Compare commits
10 Commits
5a6179aa8b
...
a03e8db621
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a03e8db621 | ||
|
|
246953963b | ||
|
|
7c50b8a77e | ||
|
|
bc3e507a77 | ||
|
|
c4eb7dd844 | ||
|
|
d3d04540c0 | ||
|
|
c092ab9a00 | ||
|
|
e223804777 | ||
|
|
a129d22007 | ||
|
|
daeac3d631 |
@@ -10,7 +10,7 @@ RUN npm run build
|
|||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
EXPOSE 80
|
EXPOSE 3000
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
CMD wget -qO /dev/null http://localhost:80/ || exit 1
|
CMD wget -qO /dev/null http://localhost:3000/ || exit 1
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
services:
|
services:
|
||||||
pinchchat:
|
pinchchat:
|
||||||
image: ghcr.io/marlburrow/pinchchat:latest
|
# image: ghcr.io/marlburrow/pinchchat:latest
|
||||||
# Or build locally:
|
build: .
|
||||||
# build: .
|
network_mode: host
|
||||||
ports:
|
|
||||||
- "3000:80"
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO", "/dev/null", "http://localhost:80/"]
|
test: ["CMD", "wget", "-qO", "/dev/null", "http://localhost:3000/"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
start_period: 5s
|
start_period: 5s
|
||||||
|
|||||||
19
nginx.conf
19
nginx.conf
@@ -1,5 +1,5 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 3000;
|
||||||
server_name _;
|
server_name _;
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
@@ -10,24 +10,35 @@ server {
|
|||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
|
||||||
|
# WebSocket reverse proxy — relay to remote gateway with correct Origin
|
||||||
|
location ~ ^/gwproxy/(.+) {
|
||||||
|
resolver 8.8.8.8 valid=30s;
|
||||||
|
proxy_pass https://api.routin.ai/$1;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host api.routin.ai;
|
||||||
|
proxy_set_header Origin "https://api.routin.ai";
|
||||||
|
proxy_ssl_server_name on;
|
||||||
|
proxy_read_timeout 86400;
|
||||||
|
proxy_send_timeout 86400;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Cache static assets (hashed filenames — safe to cache forever)
|
|
||||||
location /assets/ {
|
location /assets/ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
}
|
}
|
||||||
|
|
||||||
# Don't cache index.html (SPA entry point must always be fresh)
|
|
||||||
location = /index.html {
|
location = /index.html {
|
||||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
add_header Pragma "no-cache";
|
add_header Pragma "no-cache";
|
||||||
add_header Expires "0";
|
add_header Expires "0";
|
||||||
}
|
}
|
||||||
|
|
||||||
# Gzip
|
|
||||||
gzip on;
|
gzip on;
|
||||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
|
||||||
gzip_min_length 256;
|
gzip_min_length 256;
|
||||||
|
|||||||
768
package-lock.json
generated
768
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
26
package.json
26
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pinchchat",
|
"name": "pinchchat",
|
||||||
"version": "1.69.1",
|
"version": "1.69.4",
|
||||||
"description": "A sleek, dark-themed webchat UI for OpenClaw — monitor sessions, stream responses, and inspect tool calls in real-time.",
|
"description": "A sleek, dark-themed webchat UI for OpenClaw — monitor sessions, stream responses, and inspect tool calls in real-time.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -37,36 +37,36 @@
|
|||||||
"lint:fix": "eslint . --fix"
|
"lint:fix": "eslint . --fix"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.564.0",
|
"lucide-react": "^0.577.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"rehype-highlight": "^7.0.2",
|
"rehype-highlight": "^7.0.2",
|
||||||
"remark-breaks": "^4.0.0",
|
"remark-breaks": "^4.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss": "^4.1.18"
|
"tailwindcss": "^4.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.39.4",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/node": "^24.10.13",
|
"@types/node": "^24.12.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.4",
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.1.0",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.4",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.0",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^28.1.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.55.0",
|
"typescript-eslint": "^8.57.0",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
"vitest": "^4.0.18"
|
"vitest": "^4.1.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.19"
|
"node": ">=20.19"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useCallback, useState, useMemo } from 'react';
|
import { useEffect, useRef, useCallback, useState, useMemo } from 'react';
|
||||||
import { ChatMessageComponent } from './ChatMessage';
|
import { ChatMessageComponent } from './ChatMessage';
|
||||||
import { ChatInput } from './ChatInput';
|
import { ChatInput, type ComposerInsertRequest } from './ChatInput';
|
||||||
import { TypingIndicator } from './TypingIndicator';
|
import { TypingIndicator } from './TypingIndicator';
|
||||||
import type { ChatMessage, ConnectionStatus } from '../types';
|
import type { ChatMessage, ConnectionStatus } from '../types';
|
||||||
import { Bot, ArrowDown, Loader2, ChevronsDownUp, ChevronsUpDown, Sparkles, Bookmark, Download } from 'lucide-react';
|
import { Bot, ArrowDown, Loader2, ChevronsDownUp, ChevronsUpDown, Sparkles, Bookmark, Download } from 'lucide-react';
|
||||||
@@ -81,9 +81,14 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
|
|||||||
const [showScrollBtn, setShowScrollBtn] = useState(false);
|
const [showScrollBtn, setShowScrollBtn] = useState(false);
|
||||||
const [newMessageCount, setNewMessageCount] = useState(0);
|
const [newMessageCount, setNewMessageCount] = useState(0);
|
||||||
const [replyTo, setReplyTo] = useState<{ preview: string } | null>(null);
|
const [replyTo, setReplyTo] = useState<{ preview: string } | null>(null);
|
||||||
|
const [insertRequest, setInsertRequest] = useState<ComposerInsertRequest | null>(null);
|
||||||
|
|
||||||
// Clear reply context on session switch
|
// Clear reply context on session switch
|
||||||
useEffect(() => { setReplyTo(null); }, [sessionKey]); // eslint-disable-line react-hooks/set-state-in-effect
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- reset transient composer UI when session changes
|
||||||
|
setReplyTo(null);
|
||||||
|
setInsertRequest(null);
|
||||||
|
}, [sessionKey]);
|
||||||
const prevMessageCountRef = useRef(messages.length);
|
const prevMessageCountRef = useRef(messages.length);
|
||||||
|
|
||||||
const checkIfNearBottom = useCallback(() => {
|
const checkIfNearBottom = useCallback(() => {
|
||||||
@@ -343,7 +348,19 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={`${isActiveMatch ? 'ring-1 ring-pc-accent-light/40 rounded-lg' : ''} ${msg.isArchived ? 'opacity-60' : ''}`}>
|
<div className={`${isActiveMatch ? 'ring-1 ring-pc-accent-light/40 rounded-lg' : ''} ${msg.isArchived ? 'opacity-60' : ''}`}>
|
||||||
<ChatMessageComponent message={msg} onRetry={!isGenerating ? handleSend : undefined} onReply={(preview) => { setReplyTo({ preview }); document.getElementById('chat-input')?.focus(); }} agentAvatarUrl={agentAvatarUrl} isFirstInGroup={isFirstInGroup} isBookmarked={isBookmarked(msg.id)} onToggleBookmark={sessionKey ? () => toggleBookmark(msg.id, sessionKey, (msg.content || '').slice(0, 120), msg.timestamp) : undefined} />
|
<ChatMessageComponent
|
||||||
|
message={msg}
|
||||||
|
onRetry={!isGenerating ? handleSend : undefined}
|
||||||
|
onReply={(preview) => { setReplyTo({ preview }); document.getElementById('chat-input')?.focus(); }}
|
||||||
|
onUseSelection={(text) => {
|
||||||
|
setInsertRequest({ id: `${msg.id}:${Date.now()}`, text });
|
||||||
|
document.getElementById('chat-input')?.focus();
|
||||||
|
}}
|
||||||
|
agentAvatarUrl={agentAvatarUrl}
|
||||||
|
isFirstInGroup={isFirstInGroup}
|
||||||
|
isBookmarked={isBookmarked(msg.id)}
|
||||||
|
onToggleBookmark={sessionKey ? () => toggleBookmark(msg.id, sessionKey, (msg.content || '').slice(0, 120), msg.timestamp) : undefined}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -426,7 +443,7 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ChatInput onSend={handleSend} onNewSession={onNewSession} onAbort={onAbort} isGenerating={isGenerating} disabled={status !== 'connected'} sessionKey={sessionKey} replyTo={replyTo} onCancelReply={() => setReplyTo(null)} />
|
<ChatInput onSend={handleSend} onNewSession={onNewSession} onAbort={onAbort} isGenerating={isGenerating} disabled={status !== 'connected'} sessionKey={sessionKey} replyTo={replyTo} onCancelReply={() => setReplyTo(null)} insertRequest={insertRequest} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export interface ReplyContext {
|
|||||||
preview: string;
|
preview: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ComposerInsertRequest {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onSend: (text: string, attachments?: Array<{ mimeType: string; fileName: string; content: string }>) => void;
|
onSend: (text: string, attachments?: Array<{ mimeType: string; fileName: string; content: string }>) => void;
|
||||||
onNewSession?: () => Promise<void>;
|
onNewSession?: () => Promise<void>;
|
||||||
@@ -27,24 +32,12 @@ interface Props {
|
|||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
replyTo?: ReplyContext | null;
|
replyTo?: ReplyContext | null;
|
||||||
onCancelReply?: () => void;
|
onCancelReply?: () => void;
|
||||||
|
insertRequest?: ComposerInsertRequest | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_BASE64_CHARS = 300 * 1024; // ~225KB real, well under 512KB WS limit (JSON overhead + base64 bloat)
|
const MAX_BASE64_CHARS = 300 * 1024; // ~225KB real, well under 512KB WS limit (JSON overhead + base64 bloat)
|
||||||
const MAX_IMAGE_PIXELS = 1280; // Max dimension for resize
|
const MAX_IMAGE_PIXELS = 1280; // Max dimension for resize
|
||||||
|
|
||||||
function fileToBase64(file: File): Promise<string> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = () => {
|
|
||||||
const dataUrl = reader.result as string;
|
|
||||||
const base64 = dataUrl.split(',')[1] || '';
|
|
||||||
resolve(base64);
|
|
||||||
};
|
|
||||||
reader.onerror = reject;
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function compressImage(file: File, maxBase64Chars: number): Promise<{ base64: string; mimeType: string }> {
|
function compressImage(file: File, maxBase64Chars: number): Promise<{ base64: string; mimeType: string }> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
@@ -91,7 +84,15 @@ function formatSize(bytes: number): string {
|
|||||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInput({ onSend, onNewSession, onAbort, isGenerating, disabled, sessionKey, replyTo, onCancelReply }: Props) {
|
function toQuotedContext(text: string): string {
|
||||||
|
return text
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.map(line => `> ${line}`)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatInput({ onSend, onNewSession, onAbort, isGenerating, disabled, sessionKey, replyTo, onCancelReply, insertRequest }: Props) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const { sendOnEnter, toggle: toggleSendShortcut } = useSendShortcut();
|
const { sendOnEnter, toggle: toggleSendShortcut } = useSendShortcut();
|
||||||
const [text, setText] = useState('');
|
const [text, setText] = useState('');
|
||||||
@@ -106,6 +107,7 @@ export function ChatInput({ onSend, onNewSession, onAbort, isGenerating, disable
|
|||||||
// Per-session draft storage
|
// Per-session draft storage
|
||||||
const draftsRef = useRef<Map<string, string>>(new Map());
|
const draftsRef = useRef<Map<string, string>>(new Map());
|
||||||
const prevSessionRef = useRef<string | undefined>(sessionKey);
|
const prevSessionRef = useRef<string | undefined>(sessionKey);
|
||||||
|
const lastInsertIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// Save draft to previous session and restore draft for new session
|
// Save draft to previous session and restore draft for new session
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -141,28 +143,46 @@ export function ChatInput({ onSend, onNewSession, onAbort, isGenerating, disable
|
|||||||
}
|
}
|
||||||
}, [sessionKey, disabled]);
|
}, [sessionKey, disabled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!insertRequest?.id || lastInsertIdRef.current === insertRequest.id) return;
|
||||||
|
lastInsertIdRef.current = insertRequest.id;
|
||||||
|
|
||||||
|
const quoted = toQuotedContext(insertRequest.text);
|
||||||
|
if (!quoted) return;
|
||||||
|
|
||||||
|
setText(prev => {
|
||||||
|
const next = prev.trim()
|
||||||
|
? `${prev.replace(/\s*$/, '')}\n\n${quoted}\n\n`
|
||||||
|
: `${quoted}\n\n`;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) return;
|
||||||
|
textarea.focus();
|
||||||
|
const pos = next.length;
|
||||||
|
textarea.setSelectionRange(pos, pos);
|
||||||
|
});
|
||||||
|
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [insertRequest]);
|
||||||
|
|
||||||
const addFiles = useCallback(async (fileList: FileList | File[]) => {
|
const addFiles = useCallback(async (fileList: FileList | File[]) => {
|
||||||
const newFiles: FileAttachment[] = [];
|
const newFiles: FileAttachment[] = [];
|
||||||
for (const file of Array.from(fileList)) {
|
for (const file of Array.from(fileList)) {
|
||||||
if (file.size > 20 * 1024 * 1024) continue; // 20MB max
|
if (file.size > 20 * 1024 * 1024) continue; // 20MB max
|
||||||
const isImage = file.type.startsWith('image/');
|
// Only images are supported — the OpenClaw gateway drops non-image attachments
|
||||||
let base64: string;
|
if (!file.type.startsWith('image/')) continue;
|
||||||
let mimeType: string;
|
// Compress images to fit WS payload limit
|
||||||
if (isImage) {
|
const compressed = await compressImage(file, MAX_BASE64_CHARS);
|
||||||
// Compress images to fit WS payload limit
|
const base64 = compressed.base64;
|
||||||
const compressed = await compressImage(file, MAX_BASE64_CHARS);
|
const mimeType = compressed.mimeType;
|
||||||
base64 = compressed.base64;
|
|
||||||
mimeType = compressed.mimeType;
|
|
||||||
} else {
|
|
||||||
base64 = await fileToBase64(file);
|
|
||||||
mimeType = file.type || 'application/octet-stream';
|
|
||||||
}
|
|
||||||
newFiles.push({
|
newFiles.push({
|
||||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
file,
|
file,
|
||||||
base64,
|
base64,
|
||||||
mimeType,
|
mimeType,
|
||||||
preview: isImage ? `data:${mimeType};base64,${base64}` : undefined,
|
preview: `data:${mimeType};base64,${base64}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setFiles(prev => [...prev, ...newFiles]);
|
setFiles(prev => [...prev, ...newFiles]);
|
||||||
@@ -209,7 +229,9 @@ export function ChatInput({ onSend, onNewSession, onAbort, isGenerating, disable
|
|||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
// Prevent sending when IME is composing (e.g., Chinese/Japanese input)
|
// Prevent sending when IME is composing (e.g., Chinese/Japanese input)
|
||||||
if (isComposing) return;
|
// Check both React state and native event property — on some browsers
|
||||||
|
// compositionend fires before keydown, making isComposing stale
|
||||||
|
if (isComposing || e.nativeEvent.isComposing || e.keyCode === 229) return;
|
||||||
if (sendOnEnter) {
|
if (sendOnEnter) {
|
||||||
// Enter sends, Shift+Enter for newline
|
// Enter sends, Shift+Enter for newline
|
||||||
if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
if (!e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
||||||
@@ -377,7 +399,7 @@ export function ChatInput({ onSend, onNewSession, onAbort, isGenerating, disable
|
|||||||
multiple
|
multiple
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={(e) => { if (e.target.files) addFiles(e.target.files); e.target.value = ''; }}
|
onChange={(e) => { if (e.target.files) addFiles(e.target.files); e.target.value = ''; }}
|
||||||
accept="image/*,.pdf,.txt,.md,.json,.csv,.log,.py,.js,.ts,.tsx,.jsx,.html,.css,.yaml,.yml,.xml,.sql,.sh,.env,.toml"
|
accept="image/*"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -313,6 +313,12 @@ function RawJsonPanel({ message }: { message: ChatMessageType }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SelectionActionState {
|
||||||
|
text: string;
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** Extract plain text from message blocks for clipboard copy */
|
/** Extract plain text from message blocks for clipboard copy */
|
||||||
function getPlainText(message: ChatMessageType): string {
|
function getPlainText(message: ChatMessageType): string {
|
||||||
if (message.blocks.length > 0) {
|
if (message.blocks.length > 0) {
|
||||||
@@ -353,11 +359,14 @@ function SystemEventMessage({ message }: { message: ChatMessageType }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChatMessageComponent = memo(function ChatMessageComponent({ message: rawMessage, onRetry, onReply, agentAvatarUrl, isFirstInGroup = true, isBookmarked = false, onToggleBookmark }: { message: ChatMessageType; onRetry?: (text: string) => void; onReply?: (preview: string) => void; agentAvatarUrl?: string; isFirstInGroup?: boolean; isBookmarked?: boolean; onToggleBookmark?: () => void }) {
|
export const ChatMessageComponent = memo(function ChatMessageComponent({ message: rawMessage, onRetry, onReply, onUseSelection, agentAvatarUrl, isFirstInGroup = true, isBookmarked = false, onToggleBookmark }: { message: ChatMessageType; onRetry?: (text: string) => void; onReply?: (preview: string) => void; onUseSelection?: (text: string) => void; agentAvatarUrl?: string; isFirstInGroup?: boolean; isBookmarked?: boolean; onToggleBookmark?: () => void }) {
|
||||||
useLocale(); // re-render on locale change
|
useLocale(); // re-render on locale change
|
||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
const isLight = resolvedTheme === 'light';
|
const isLight = resolvedTheme === 'light';
|
||||||
const [showRawJson, setShowRawJson] = useState(false);
|
const [showRawJson, setShowRawJson] = useState(false);
|
||||||
|
const [selectionAction, setSelectionAction] = useState<SelectionActionState | null>(null);
|
||||||
|
const bubbleRef = useRef<HTMLDivElement>(null);
|
||||||
|
const selectionButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
// Strip webhook/hook scaffolding and webchat envelope from user messages before rendering
|
// Strip webhook/hook scaffolding and webchat envelope from user messages before rendering
|
||||||
const message = useMemo(() => {
|
const message = useMemo(() => {
|
||||||
@@ -402,6 +411,69 @@ export const ChatMessageComponent = memo(function ChatMessageComponent({ message
|
|||||||
|
|
||||||
const isUser = message.role === 'user';
|
const isUser = message.role === 'user';
|
||||||
|
|
||||||
|
const clearSelectionAction = useCallback(() => {
|
||||||
|
setSelectionAction(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateSelectionAction = useCallback(() => {
|
||||||
|
if (isUser || message.isStreaming || !onUseSelection) {
|
||||||
|
setSelectionAction(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection = window.getSelection();
|
||||||
|
const bubble = bubbleRef.current;
|
||||||
|
if (!selection || !bubble || selection.rangeCount === 0 || selection.isCollapsed) {
|
||||||
|
setSelectionAction(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = selection.toString().trim();
|
||||||
|
if (!text || text.length > 1500) {
|
||||||
|
setSelectionAction(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
const common = range.commonAncestorContainer;
|
||||||
|
if (!bubble.contains(common.nodeType === Node.TEXT_NODE ? common.parentNode : common)) {
|
||||||
|
setSelectionAction(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rect = range.getBoundingClientRect();
|
||||||
|
if (!rect.width && !rect.height) {
|
||||||
|
setSelectionAction(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectionAction({
|
||||||
|
text,
|
||||||
|
top: Math.max(12, rect.top - 40),
|
||||||
|
left: rect.left + (rect.width / 2),
|
||||||
|
});
|
||||||
|
}, [isUser, message.isStreaming, onUseSelection]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onUseSelection || isUser) return;
|
||||||
|
|
||||||
|
const handleSelectionChange = () => {
|
||||||
|
requestAnimationFrame(updateSelectionAction);
|
||||||
|
};
|
||||||
|
const handlePointerDown = (e: MouseEvent) => {
|
||||||
|
if (selectionButtonRef.current?.contains(e.target as Node)) return;
|
||||||
|
if (bubbleRef.current?.contains(e.target as Node)) return;
|
||||||
|
clearSelectionAction();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('selectionchange', handleSelectionChange);
|
||||||
|
document.addEventListener('mousedown', handlePointerDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('selectionchange', handleSelectionChange);
|
||||||
|
document.removeEventListener('mousedown', handlePointerDown);
|
||||||
|
};
|
||||||
|
}, [clearSelectionAction, isUser, onUseSelection, updateSelectionAction]);
|
||||||
|
|
||||||
// System events render as subtle inline notifications
|
// System events render as subtle inline notifications
|
||||||
if (message.isSystemEvent) {
|
if (message.isSystemEvent) {
|
||||||
return <SystemEventMessage message={rawMessage} />;
|
return <SystemEventMessage message={rawMessage} />;
|
||||||
@@ -432,13 +504,18 @@ export const ChatMessageComponent = memo(function ChatMessageComponent({ message
|
|||||||
|
|
||||||
{/* Bubble */}
|
{/* Bubble */}
|
||||||
<div className={`min-w-0 max-w-[80%] ${isUser ? 'text-right' : ''}`}>
|
<div className={`min-w-0 max-w-[80%] ${isUser ? 'text-right' : ''}`}>
|
||||||
<div className={`group relative inline-block text-left rounded-3xl px-4 py-3 text-sm leading-relaxed max-w-full overflow-hidden ${
|
<div
|
||||||
|
ref={bubbleRef}
|
||||||
|
onMouseUp={updateSelectionAction}
|
||||||
|
onKeyUp={updateSelectionAction}
|
||||||
|
className={`group relative inline-block text-left rounded-3xl px-4 py-3 text-sm leading-relaxed max-w-full overflow-hidden ${
|
||||||
isUser
|
isUser
|
||||||
? (isLight
|
? (isLight
|
||||||
? 'bg-[rgba(var(--pc-accent-rgb),0.12)] text-pc-text border border-[rgba(var(--pc-accent-rgb),0.3)]'
|
? 'bg-[rgba(var(--pc-accent-rgb),0.12)] text-pc-text border border-[rgba(var(--pc-accent-rgb),0.3)]'
|
||||||
: 'bg-[rgba(var(--pc-accent-rgb),0.08)] text-pc-text border border-[rgba(var(--pc-accent-rgb),0.2)]')
|
: 'bg-[rgba(var(--pc-accent-rgb),0.08)] text-pc-text border border-[rgba(var(--pc-accent-rgb),0.2)]')
|
||||||
: 'bg-pc-elevated/40 text-pc-text border border-pc-border shadow-[0_0_0_1px_rgba(255,255,255,0.03)]'
|
: 'bg-pc-elevated/40 text-pc-text border border-pc-border shadow-[0_0_0_1px_rgba(255,255,255,0.03)]'
|
||||||
}`}>
|
}`}
|
||||||
|
>
|
||||||
{/* User-visible text */}
|
{/* User-visible text */}
|
||||||
{!isUser ? (
|
{!isUser ? (
|
||||||
<CollapsibleContent content={message.content || ''} isStreaming={message.isStreaming}>
|
<CollapsibleContent content={message.content || ''} isStreaming={message.isStreaming}>
|
||||||
@@ -530,6 +607,30 @@ export const ChatMessageComponent = memo(function ChatMessageComponent({ message
|
|||||||
<RawJsonToggle isOpen={showRawJson} onToggle={() => setShowRawJson(o => !o)} />
|
<RawJsonToggle isOpen={showRawJson} onToggle={() => setShowRawJson(o => !o)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{!isUser && selectionAction && onUseSelection && createPortal(
|
||||||
|
<button
|
||||||
|
ref={selectionButtonRef}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onUseSelection(selectionAction.text);
|
||||||
|
window.getSelection()?.removeAllRanges();
|
||||||
|
clearSelectionAction();
|
||||||
|
}}
|
||||||
|
className="fixed z-[9999] -translate-x-1/2 inline-flex items-center gap-2 rounded-2xl border border-white/8 bg-[rgba(26,26,29,0.96)] px-3.5 py-2 text-[13px] font-medium text-white shadow-[0_12px_28px_rgba(0,0,0,0.38)] backdrop-blur-xl transition-all hover:bg-[rgba(36,36,40,0.98)]"
|
||||||
|
style={{ top: selectionAction.top, left: selectionAction.left }}
|
||||||
|
aria-label={t('message.askInChat')}
|
||||||
|
title={t('message.askInChat')}
|
||||||
|
>
|
||||||
|
<span className="text-base leading-none text-white/90">❞</span>
|
||||||
|
<span>{t('message.askInChat')}</span>
|
||||||
|
</button>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
{(message.timestamp || wasWebhookMessage || isBookmarked) && (
|
{(message.timestamp || wasWebhookMessage || isBookmarked) && (
|
||||||
<div className={`mt-1 flex items-center gap-1.5 text-[11px] text-pc-text-muted ${isUser ? 'justify-end pr-2' : 'pl-2'}`}>
|
<div className={`mt-1 flex items-center gap-1.5 text-[11px] text-pc-text-muted ${isUser ? 'justify-end pr-2' : 'pl-2'}`}>
|
||||||
{isBookmarked && (
|
{isBookmarked && (
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ function getInitialUrl(): string {
|
|||||||
if (import.meta.env.VITE_GATEWAY_WS_URL) return import.meta.env.VITE_GATEWAY_WS_URL;
|
if (import.meta.env.VITE_GATEWAY_WS_URL) return import.meta.env.VITE_GATEWAY_WS_URL;
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
const host = window.location.hostname;
|
const host = window.location.hostname;
|
||||||
|
const pathname = window.location.pathname;
|
||||||
|
|
||||||
|
// Detect path-based proxy deployment (e.g. routin.ai sandbox proxy)
|
||||||
|
// Browser auto-sends correct Origin (same domain), so direct connection works
|
||||||
|
const proxyMatch = pathname.match(/^(.*\/proxy\/)\d+(\/.*)?$/);
|
||||||
|
if (proxyMatch) {
|
||||||
|
return `${protocol}://${host}${proxyMatch[1]}18789`;
|
||||||
|
}
|
||||||
// When served over HTTPS, assume gateway is on a sibling subdomain (e.g. marlbot-gw.example.com)
|
// When served over HTTPS, assume gateway is on a sibling subdomain (e.g. marlbot-gw.example.com)
|
||||||
if (protocol === 'wss') {
|
if (protocol === 'wss') {
|
||||||
const parts = host.split('.');
|
const parts = host.split('.');
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { GatewayClient } from '../gateway';
|
import { GatewayClient } from '../gateway';
|
||||||
|
import type { DeviceIdentity } from '../deviceIdentity';
|
||||||
|
import * as deviceIdentityModule from '../deviceIdentity';
|
||||||
|
|
||||||
|
vi.mock('../deviceIdentity', () => ({
|
||||||
|
buildDeviceAuthPayload: vi.fn(),
|
||||||
|
signPayload: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Minimal WebSocket mock */
|
/* Minimal WebSocket mock */
|
||||||
@@ -50,6 +57,7 @@ beforeEach(() => {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
(globalThis as any).WebSocket = MockWebSocket;
|
(globalThis as any).WebSocket = MockWebSocket;
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -297,6 +305,44 @@ describe('GatewayClient', () => {
|
|||||||
gw.disconnect();
|
gw.disconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('password mode with deviceIdentity: signs with token:null and sends auth.password', async () => {
|
||||||
|
const buildPayload = vi.mocked(deviceIdentityModule.buildDeviceAuthPayload);
|
||||||
|
const sign = vi.mocked(deviceIdentityModule.signPayload);
|
||||||
|
buildPayload.mockReturnValue('mock-device-payload');
|
||||||
|
sign.mockResolvedValue('mock-sig');
|
||||||
|
|
||||||
|
// In password mode authToken holds the password string, not a JWT/token.
|
||||||
|
// buildDeviceAuthPayload must receive token:null so the gateway signature
|
||||||
|
// verification matches (gateway sees no token segment in the connect request).
|
||||||
|
const gw = new GatewayClient('ws://test:1234', 'my-secret-password', 'password');
|
||||||
|
const mockIdentity: DeviceIdentity = {
|
||||||
|
id: 'device-id-abc',
|
||||||
|
publicKeyRaw: 'pubkey-raw-abc',
|
||||||
|
keyPair: { privateKey: {} as CryptoKey, publicKey: {} as CryptoKey },
|
||||||
|
};
|
||||||
|
gw.setDeviceIdentity(mockIdentity);
|
||||||
|
|
||||||
|
gw.connect();
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
|
||||||
|
const ws = MockWebSocket.instances[0]!;
|
||||||
|
ws._receive({ type: 'event', event: 'connect.challenge', payload: { nonce: 'nonce-xyz' } });
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
|
||||||
|
// buildDeviceAuthPayload must be called with token: null (not the password)
|
||||||
|
expect(buildPayload).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ token: null }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The connect request must use auth.password, not auth.token
|
||||||
|
const req = JSON.parse(ws.sent[0]!);
|
||||||
|
expect(req.method).toBe('connect');
|
||||||
|
expect(req.params.auth.password).toBe('my-secret-password');
|
||||||
|
expect(req.params.auth.token).toBeUndefined();
|
||||||
|
|
||||||
|
gw.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
it('emits pairing status on NOT_PAIRED error', async () => {
|
it('emits pairing status on NOT_PAIRED error', async () => {
|
||||||
const gw = new GatewayClient('ws://test:1234', 'tok');
|
const gw = new GatewayClient('ws://test:1234', 'tok');
|
||||||
const statuses: string[] = [];
|
const statuses: string[] = [];
|
||||||
|
|||||||
@@ -79,7 +79,18 @@ export class GatewayClient {
|
|||||||
this.autoReconnect = true;
|
this.autoReconnect = true;
|
||||||
this.connectNonce = null;
|
this.connectNonce = null;
|
||||||
this._onStatus('connecting');
|
this._onStatus('connecting');
|
||||||
this.ws = new WebSocket(this.wsUrl);
|
|
||||||
|
// If wsUrl points to a different host, route through local /gwproxy/ to bypass Origin restriction
|
||||||
|
let url = this.wsUrl;
|
||||||
|
try {
|
||||||
|
const target = new URL(this.wsUrl);
|
||||||
|
if (target.hostname !== window.location.hostname) {
|
||||||
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
url = `${proto}//${window.location.host}/gwproxy${target.pathname}`;
|
||||||
|
log('cross-origin detected, proxying via', url);
|
||||||
|
}
|
||||||
|
} catch { /* use original url */ }
|
||||||
|
this.ws = new WebSocket(url);
|
||||||
|
|
||||||
this.ws.onopen = () => { log('WS open'); };
|
this.ws.onopen = () => { log('WS open'); };
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ const en = {
|
|||||||
'chat.loadingHistory': 'Loading messages…',
|
'chat.loadingHistory': 'Loading messages…',
|
||||||
'chat.inputPlaceholder': 'Type a message…',
|
'chat.inputPlaceholder': 'Type a message…',
|
||||||
'chat.inputLabel': 'Message',
|
'chat.inputLabel': 'Message',
|
||||||
'chat.attachFile': 'Attach file',
|
'chat.attachFile': 'Attach image',
|
||||||
'chat.send': 'Send',
|
'chat.send': 'Send',
|
||||||
'chat.stop': 'Stop',
|
'chat.stop': 'Stop',
|
||||||
'chat.showPreview': 'Preview markdown',
|
'chat.showPreview': 'Preview markdown',
|
||||||
@@ -105,6 +105,7 @@ const en = {
|
|||||||
'message.metadata': 'Message details',
|
'message.metadata': 'Message details',
|
||||||
'message.rawJson': 'Raw JSON',
|
'message.rawJson': 'Raw JSON',
|
||||||
'message.hideRawJson': 'Hide raw JSON',
|
'message.hideRawJson': 'Hide raw JSON',
|
||||||
|
'message.askInChat': 'Ask in Chat',
|
||||||
|
|
||||||
// Timestamps
|
// Timestamps
|
||||||
'time.yesterday': 'Yesterday',
|
'time.yesterday': 'Yesterday',
|
||||||
@@ -244,7 +245,7 @@ const fr: Record<keyof typeof en, string> = {
|
|||||||
'chat.loadingHistory': 'Chargement des messages…',
|
'chat.loadingHistory': 'Chargement des messages…',
|
||||||
'chat.inputPlaceholder': 'Tapez un message…',
|
'chat.inputPlaceholder': 'Tapez un message…',
|
||||||
'chat.inputLabel': 'Message',
|
'chat.inputLabel': 'Message',
|
||||||
'chat.attachFile': 'Joindre un fichier',
|
'chat.attachFile': 'Joindre une image',
|
||||||
'chat.send': 'Envoyer',
|
'chat.send': 'Envoyer',
|
||||||
'chat.stop': 'Arrêter',
|
'chat.stop': 'Arrêter',
|
||||||
'chat.showPreview': 'Aperçu markdown',
|
'chat.showPreview': 'Aperçu markdown',
|
||||||
@@ -295,6 +296,7 @@ const fr: Record<keyof typeof en, string> = {
|
|||||||
'message.metadata': 'Détails du message',
|
'message.metadata': 'Détails du message',
|
||||||
'message.rawJson': 'JSON brut',
|
'message.rawJson': 'JSON brut',
|
||||||
'message.hideRawJson': 'Masquer le JSON brut',
|
'message.hideRawJson': 'Masquer le JSON brut',
|
||||||
|
'message.askInChat': 'Demander dans le chat',
|
||||||
|
|
||||||
'time.yesterday': 'Hier',
|
'time.yesterday': 'Hier',
|
||||||
'time.today': "Aujourd'hui",
|
'time.today': "Aujourd'hui",
|
||||||
@@ -426,7 +428,7 @@ const es: Record<keyof typeof en, string> = {
|
|||||||
'chat.loadingHistory': 'Cargando mensajes…',
|
'chat.loadingHistory': 'Cargando mensajes…',
|
||||||
'chat.inputPlaceholder': 'Escribe un mensaje…',
|
'chat.inputPlaceholder': 'Escribe un mensaje…',
|
||||||
'chat.inputLabel': 'Mensaje',
|
'chat.inputLabel': 'Mensaje',
|
||||||
'chat.attachFile': 'Adjuntar archivo',
|
'chat.attachFile': 'Adjuntar imagen',
|
||||||
'chat.send': 'Enviar',
|
'chat.send': 'Enviar',
|
||||||
'chat.stop': 'Detener',
|
'chat.stop': 'Detener',
|
||||||
'chat.showPreview': 'Vista previa markdown',
|
'chat.showPreview': 'Vista previa markdown',
|
||||||
@@ -477,6 +479,7 @@ const es: Record<keyof typeof en, string> = {
|
|||||||
'message.metadata': 'Detalles del mensaje',
|
'message.metadata': 'Detalles del mensaje',
|
||||||
'message.rawJson': 'JSON sin formato',
|
'message.rawJson': 'JSON sin formato',
|
||||||
'message.hideRawJson': 'Ocultar JSON sin formato',
|
'message.hideRawJson': 'Ocultar JSON sin formato',
|
||||||
|
'message.askInChat': 'Preguntar en el chat',
|
||||||
|
|
||||||
'time.yesterday': 'Ayer',
|
'time.yesterday': 'Ayer',
|
||||||
'time.today': 'Hoy',
|
'time.today': 'Hoy',
|
||||||
@@ -610,7 +613,7 @@ const de: Record<keyof typeof en, string> = {
|
|||||||
'chat.loadingHistory': 'Nachrichten werden geladen…',
|
'chat.loadingHistory': 'Nachrichten werden geladen…',
|
||||||
'chat.inputPlaceholder': 'Nachricht eingeben…',
|
'chat.inputPlaceholder': 'Nachricht eingeben…',
|
||||||
'chat.inputLabel': 'Nachricht',
|
'chat.inputLabel': 'Nachricht',
|
||||||
'chat.attachFile': 'Datei anhängen',
|
'chat.attachFile': 'Bild anhängen',
|
||||||
'chat.send': 'Senden',
|
'chat.send': 'Senden',
|
||||||
'chat.stop': 'Stoppen',
|
'chat.stop': 'Stoppen',
|
||||||
'chat.showPreview': 'Markdown-Vorschau',
|
'chat.showPreview': 'Markdown-Vorschau',
|
||||||
@@ -661,6 +664,7 @@ const de: Record<keyof typeof en, string> = {
|
|||||||
'message.metadata': 'Nachrichtendetails',
|
'message.metadata': 'Nachrichtendetails',
|
||||||
'message.rawJson': 'Roh-JSON',
|
'message.rawJson': 'Roh-JSON',
|
||||||
'message.hideRawJson': 'Roh-JSON ausblenden',
|
'message.hideRawJson': 'Roh-JSON ausblenden',
|
||||||
|
'message.askInChat': 'Im Chat fragen',
|
||||||
|
|
||||||
'time.yesterday': 'Gestern',
|
'time.yesterday': 'Gestern',
|
||||||
'time.today': 'Heute',
|
'time.today': 'Heute',
|
||||||
@@ -792,7 +796,7 @@ const ja: Record<keyof typeof en, string> = {
|
|||||||
'chat.loadingHistory': 'メッセージを読み込み中…',
|
'chat.loadingHistory': 'メッセージを読み込み中…',
|
||||||
'chat.inputPlaceholder': 'メッセージを入力…',
|
'chat.inputPlaceholder': 'メッセージを入力…',
|
||||||
'chat.inputLabel': 'メッセージ',
|
'chat.inputLabel': 'メッセージ',
|
||||||
'chat.attachFile': 'ファイルを添付',
|
'chat.attachFile': '画像を添付',
|
||||||
'chat.send': '送信',
|
'chat.send': '送信',
|
||||||
'chat.stop': '停止',
|
'chat.stop': '停止',
|
||||||
'chat.showPreview': 'Markdownプレビュー',
|
'chat.showPreview': 'Markdownプレビュー',
|
||||||
@@ -843,6 +847,7 @@ const ja: Record<keyof typeof en, string> = {
|
|||||||
'message.metadata': 'メッセージの詳細',
|
'message.metadata': 'メッセージの詳細',
|
||||||
'message.rawJson': '生JSON',
|
'message.rawJson': '生JSON',
|
||||||
'message.hideRawJson': '生JSONを非表示',
|
'message.hideRawJson': '生JSONを非表示',
|
||||||
|
'message.askInChat': 'チャットで質問',
|
||||||
|
|
||||||
'time.yesterday': '昨日',
|
'time.yesterday': '昨日',
|
||||||
'time.today': '今日',
|
'time.today': '今日',
|
||||||
@@ -974,7 +979,7 @@ const pt: Record<keyof typeof en, string> = {
|
|||||||
'chat.loadingHistory': 'Carregando mensagens…',
|
'chat.loadingHistory': 'Carregando mensagens…',
|
||||||
'chat.inputPlaceholder': 'Digite uma mensagem…',
|
'chat.inputPlaceholder': 'Digite uma mensagem…',
|
||||||
'chat.inputLabel': 'Mensagem',
|
'chat.inputLabel': 'Mensagem',
|
||||||
'chat.attachFile': 'Anexar arquivo',
|
'chat.attachFile': 'Anexar imagem',
|
||||||
'chat.send': 'Enviar',
|
'chat.send': 'Enviar',
|
||||||
'chat.stop': 'Parar',
|
'chat.stop': 'Parar',
|
||||||
'chat.showPreview': 'Pré-visualizar markdown',
|
'chat.showPreview': 'Pré-visualizar markdown',
|
||||||
@@ -1025,6 +1030,7 @@ const pt: Record<keyof typeof en, string> = {
|
|||||||
'message.metadata': 'Detalhes da mensagem',
|
'message.metadata': 'Detalhes da mensagem',
|
||||||
'message.rawJson': 'JSON bruto',
|
'message.rawJson': 'JSON bruto',
|
||||||
'message.hideRawJson': 'Ocultar JSON bruto',
|
'message.hideRawJson': 'Ocultar JSON bruto',
|
||||||
|
'message.askInChat': 'Perguntar no chat',
|
||||||
|
|
||||||
'time.yesterday': 'Ontem',
|
'time.yesterday': 'Ontem',
|
||||||
'time.today': 'Hoje',
|
'time.today': 'Hoje',
|
||||||
@@ -1156,7 +1162,7 @@ const zh: Record<keyof typeof en, string> = {
|
|||||||
'chat.loadingHistory': '加载消息中…',
|
'chat.loadingHistory': '加载消息中…',
|
||||||
'chat.inputPlaceholder': '输入消息…',
|
'chat.inputPlaceholder': '输入消息…',
|
||||||
'chat.inputLabel': '消息',
|
'chat.inputLabel': '消息',
|
||||||
'chat.attachFile': '添加附件',
|
'chat.attachFile': '添加图片',
|
||||||
'chat.send': '发送',
|
'chat.send': '发送',
|
||||||
'chat.stop': '停止',
|
'chat.stop': '停止',
|
||||||
'chat.showPreview': '预览 Markdown',
|
'chat.showPreview': '预览 Markdown',
|
||||||
@@ -1207,6 +1213,7 @@ const zh: Record<keyof typeof en, string> = {
|
|||||||
'message.metadata': '消息详情',
|
'message.metadata': '消息详情',
|
||||||
'message.rawJson': '原始 JSON',
|
'message.rawJson': '原始 JSON',
|
||||||
'message.hideRawJson': '隐藏原始 JSON',
|
'message.hideRawJson': '隐藏原始 JSON',
|
||||||
|
'message.askInChat': '在聊天中提问',
|
||||||
|
|
||||||
'time.yesterday': '昨天',
|
'time.yesterday': '昨天',
|
||||||
'time.today': '今天',
|
'time.today': '今天',
|
||||||
@@ -1338,7 +1345,7 @@ const it: Record<keyof typeof en, string> = {
|
|||||||
'chat.loadingHistory': 'Caricamento messaggi…',
|
'chat.loadingHistory': 'Caricamento messaggi…',
|
||||||
'chat.inputPlaceholder': 'Scrivi un messaggio…',
|
'chat.inputPlaceholder': 'Scrivi un messaggio…',
|
||||||
'chat.inputLabel': 'Messaggio',
|
'chat.inputLabel': 'Messaggio',
|
||||||
'chat.attachFile': 'Allega file',
|
'chat.attachFile': 'Allega immagine',
|
||||||
'chat.send': 'Invia',
|
'chat.send': 'Invia',
|
||||||
'chat.stop': 'Ferma',
|
'chat.stop': 'Ferma',
|
||||||
'chat.showPreview': 'Anteprima markdown',
|
'chat.showPreview': 'Anteprima markdown',
|
||||||
@@ -1389,6 +1396,7 @@ const it: Record<keyof typeof en, string> = {
|
|||||||
'message.metadata': 'Dettagli messaggio',
|
'message.metadata': 'Dettagli messaggio',
|
||||||
'message.rawJson': 'JSON grezzo',
|
'message.rawJson': 'JSON grezzo',
|
||||||
'message.hideRawJson': 'Nascondi JSON grezzo',
|
'message.hideRawJson': 'Nascondi JSON grezzo',
|
||||||
|
'message.askInChat': 'Chiedi in chat',
|
||||||
|
|
||||||
'time.yesterday': 'Ieri',
|
'time.yesterday': 'Ieri',
|
||||||
'time.today': 'Oggi',
|
'time.today': 'Oggi',
|
||||||
|
|||||||
Reference in New Issue
Block a user