fix: resolve all ESLint errors and add lint step to CI
- Extract credential helpers to src/lib/credentials.ts (fixes react-refresh/only-export-components) - Extract buildImageSrc to src/lib/image.ts (fixes react-refresh/only-export-components) - Reorder useCallback declarations in useGateway to fix react-hooks/immutability - Sync refs via useEffect instead of during render (fixes react-hooks/refs) - Replace useState initializer effect with lazy initializer functions in LoginScreen - Add comments to empty catch blocks (fixes no-empty) - Remove unused variable (fixes @typescript-eslint/no-unused-vars) - Downgrade react-hooks/set-state-in-effect to warning (valid init/status patterns) - Add lint step to CI workflow (runs before type-check and build)
This commit is contained in:
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -25,6 +25,9 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
- name: Type check
|
- name: Type check
|
||||||
run: npx tsc --noEmit
|
run: npx tsc --noEmit
|
||||||
|
|
||||||
|
|||||||
@@ -19,5 +19,9 @@ export default defineConfig([
|
|||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
},
|
},
|
||||||
|
rules: {
|
||||||
|
// Allow setState in effects for status-tracking and initialization patterns
|
||||||
|
'react-hooks/set-state-in-effect': 'warn',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import type { ChatMessage as ChatMessageType, MessageBlock } from '../types';
|
|||||||
import { ThinkingBlock } from './ThinkingBlock';
|
import { ThinkingBlock } from './ThinkingBlock';
|
||||||
import { CodeBlock } from './CodeBlock';
|
import { CodeBlock } from './CodeBlock';
|
||||||
import { ToolCall } from './ToolCall';
|
import { ToolCall } from './ToolCall';
|
||||||
import { ImageBlock, buildImageSrc } from './ImageBlock';
|
import { ImageBlock } from './ImageBlock';
|
||||||
|
import { buildImageSrc } from '../lib/image';
|
||||||
import { Bot, User, Wrench, Copy, Check, RefreshCw } from 'lucide-react';
|
import { Bot, User, Wrench, Copy, Check, RefreshCw } from 'lucide-react';
|
||||||
import { t, getLocale } from '../lib/i18n';
|
import { t, getLocale } from '../lib/i18n';
|
||||||
import { useLocale } from '../hooks/useLocale';
|
import { useLocale } from '../hooks/useLocale';
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ type BannerState = 'hidden' | 'reconnecting' | 'reconnected';
|
|||||||
export function ConnectionBanner({ status }: Props) {
|
export function ConnectionBanner({ status }: Props) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const [banner, setBanner] = useState<BannerState>('hidden');
|
const [banner, setBanner] = useState<BannerState>('hidden');
|
||||||
const prevStatus = useRef(status);
|
const prevStatus = useRef<ConnectionStatus | null>(null);
|
||||||
const dismissTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const dismissTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -25,16 +25,12 @@ export function ConnectionBanner({ status }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'disconnected' || status === 'connecting') {
|
if (status === 'disconnected' || status === 'connecting') {
|
||||||
// Only show reconnecting if we were previously connected (not initial load)
|
|
||||||
if (prev === 'connected') {
|
if (prev === 'connected') {
|
||||||
setBanner('reconnecting');
|
setBanner('reconnecting');
|
||||||
}
|
}
|
||||||
} else if (status === 'connected' && prev !== 'connected') {
|
} else if (status === 'connected' && prev !== null && prev !== 'connected') {
|
||||||
// Just reconnected — flash success only if we were showing the banner
|
setBanner('reconnected');
|
||||||
if (banner === 'reconnecting' || prev === 'disconnected' || prev === 'connecting') {
|
dismissTimer.current = setTimeout(() => setBanner('hidden'), 3000);
|
||||||
setBanner('reconnected');
|
|
||||||
dismissTimer.current = setTimeout(() => setBanner('hidden'), 3000);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -66,9 +66,4 @@ export function ImageBlock({ src, alt }: ImageBlockProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a data URL from base64 image data */
|
// buildImageSrc moved to ../lib/image.ts
|
||||||
export function buildImageSrc(mediaType: string, data?: string, url?: string): string {
|
|
||||||
if (url) return url;
|
|
||||||
if (data) return `data:${mediaType};base64,${data}`;
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState } from 'react';
|
||||||
import { Sparkles, Eye, EyeOff, Loader2 } from 'lucide-react';
|
import { Sparkles, Eye, EyeOff, Loader2 } from 'lucide-react';
|
||||||
import { useT } from '../hooks/useLocale';
|
import { useT } from '../hooks/useLocale';
|
||||||
|
import { getStoredCredentials } from '../lib/credentials';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onConnect: (url: string, token: string) => void;
|
onConnect: (url: string, token: string) => void;
|
||||||
@@ -8,41 +9,23 @@ interface Props {
|
|||||||
isConnecting?: boolean;
|
isConnecting?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = 'pinchchat_credentials';
|
function getInitialUrl(): string {
|
||||||
|
const stored = getStoredCredentials();
|
||||||
export function getStoredCredentials(): { url: string; token: string } | null {
|
if (stored) return stored.url;
|
||||||
try {
|
return import.meta.env.VITE_GATEWAY_WS_URL || `ws://${window.location.hostname}:18789`;
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
|
||||||
if (!raw) return null;
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
if (parsed.url && parsed.token) return parsed;
|
|
||||||
} catch {}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function storeCredentials(url: string, token: string) {
|
function getInitialToken(): string {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ url, token }));
|
const stored = getStoredCredentials();
|
||||||
}
|
return stored?.token ?? '';
|
||||||
|
|
||||||
export function clearCredentials() {
|
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LoginScreen({ onConnect, error, isConnecting }: Props) {
|
export function LoginScreen({ onConnect, error, isConnecting }: Props) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const defaultUrl = import.meta.env.VITE_GATEWAY_WS_URL || `ws://${window.location.hostname}:18789`;
|
const [url, setUrl] = useState(getInitialUrl);
|
||||||
const [url, setUrl] = useState(defaultUrl);
|
const [token, setToken] = useState(getInitialToken);
|
||||||
const [token, setToken] = useState('');
|
|
||||||
const [showToken, setShowToken] = useState(false);
|
const [showToken, setShowToken] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const stored = getStoredCredentials();
|
|
||||||
if (stored) {
|
|
||||||
setUrl(stored.url);
|
|
||||||
setToken(stored.token);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!url.trim() || !token.trim()) return;
|
if (!url.trim() || !token.trim()) return;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { GatewayClient, type JsonPayload } from '../lib/gateway';
|
import { GatewayClient, type JsonPayload } from '../lib/gateway';
|
||||||
import { genIdempotencyKey } from '../lib/utils';
|
import { genIdempotencyKey } from '../lib/utils';
|
||||||
import { getStoredCredentials, storeCredentials, clearCredentials } from '../components/LoginScreen';
|
import { getStoredCredentials, storeCredentials, clearCredentials } from '../lib/credentials';
|
||||||
import type { ChatMessage, MessageBlock, ConnectionStatus, Session } from '../types';
|
import type { ChatMessage, MessageBlock, ConnectionStatus, Session } from '../types';
|
||||||
|
|
||||||
interface ChatPayloadMessage {
|
interface ChatPayloadMessage {
|
||||||
@@ -33,12 +33,147 @@ export function useGateway() {
|
|||||||
const [isConnecting, setIsConnecting] = useState(false);
|
const [isConnecting, setIsConnecting] = useState(false);
|
||||||
const isConnectingRef = useRef(false);
|
const isConnectingRef = useRef(false);
|
||||||
const messagesRef = useRef(messages);
|
const messagesRef = useRef(messages);
|
||||||
messagesRef.current = messages;
|
|
||||||
const activeSessionRef = useRef(activeSession);
|
const activeSessionRef = useRef(activeSession);
|
||||||
activeSessionRef.current = activeSession;
|
|
||||||
|
// Sync refs in an effect to avoid ref writes during render
|
||||||
|
useEffect(() => { messagesRef.current = messages; }, [messages]);
|
||||||
|
useEffect(() => { activeSessionRef.current = activeSession; }, [activeSession]);
|
||||||
const currentRunIdRef = useRef<string | null>(null);
|
const currentRunIdRef = useRef<string | null>(null);
|
||||||
const [activeSessions, setActiveSessions] = useState<Set<string>>(new Set());
|
const [activeSessions, setActiveSessions] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const handleAgentEvent = useCallback((payload: JsonPayload) => {
|
||||||
|
if (payload?.stream !== 'tool') return;
|
||||||
|
const data = (payload.data ?? {}) as Record<string, unknown>;
|
||||||
|
const phase = data.phase as string | undefined;
|
||||||
|
const toolCallId = data.toolCallId as string | undefined;
|
||||||
|
const name = (data.name as string) || 'tool';
|
||||||
|
if (!toolCallId) return;
|
||||||
|
|
||||||
|
setMessages(prev => {
|
||||||
|
const last = prev[prev.length - 1];
|
||||||
|
if (!last || last.role !== 'assistant' || !last.isStreaming) return prev;
|
||||||
|
|
||||||
|
const updated = { ...last, blocks: [...last.blocks] };
|
||||||
|
|
||||||
|
if (phase === 'start') {
|
||||||
|
updated.blocks.push({
|
||||||
|
type: 'tool_use' as const,
|
||||||
|
name,
|
||||||
|
input: (data.args as Record<string, unknown>) ?? {},
|
||||||
|
id: toolCallId,
|
||||||
|
});
|
||||||
|
} else if (phase === 'result') {
|
||||||
|
const rawResult = data.result;
|
||||||
|
const result = typeof rawResult === 'string' ? rawResult : JSON.stringify(rawResult, null, 2);
|
||||||
|
updated.blocks.push({
|
||||||
|
type: 'tool_result' as const,
|
||||||
|
content: result?.slice(0, 500) || '',
|
||||||
|
toolUseId: toolCallId,
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...prev.slice(0, -1), updated];
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadSessions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await clientRef.current?.send('sessions.list', {});
|
||||||
|
const sessionList = res?.sessions as Array<Record<string, unknown>> | undefined;
|
||||||
|
if (sessionList) {
|
||||||
|
setSessions(sessionList.map((s) => ({
|
||||||
|
key: (s.key || s.sessionKey) as string,
|
||||||
|
label: (s.label || s.key || s.sessionKey) as string,
|
||||||
|
messageCount: s.messageCount as number | undefined,
|
||||||
|
totalTokens: s.totalTokens as number | undefined,
|
||||||
|
contextTokens: s.contextTokens as number | undefined,
|
||||||
|
inputTokens: s.inputTokens as number | undefined,
|
||||||
|
outputTokens: s.outputTokens as number | undefined,
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently ignore session list failures (e.g. disconnected)
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadHistory = useCallback(async (sessionKey: string) => {
|
||||||
|
try {
|
||||||
|
const res = await clientRef.current?.send('chat.history', { sessionKey, limit: 100 });
|
||||||
|
const rawMsgs = res?.messages as Array<Record<string, unknown>> | undefined;
|
||||||
|
if (rawMsgs) {
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any -- raw gateway history messages have dynamic shape */
|
||||||
|
const msgs: ChatMessage[] = rawMsgs.map((m: Record<string, any>, i: number) => {
|
||||||
|
const blocks: MessageBlock[] = [];
|
||||||
|
if (m.content) {
|
||||||
|
if (Array.isArray(m.content)) {
|
||||||
|
for (const block of m.content) {
|
||||||
|
if (block.type === 'text') blocks.push({ type: 'text', text: block.text });
|
||||||
|
else if (block.type === 'thinking') blocks.push({ type: 'thinking', text: block.thinking || block.text || '' });
|
||||||
|
else if (block.type === 'image') {
|
||||||
|
const src = block.source || {};
|
||||||
|
blocks.push({ type: 'image', mediaType: src.media_type || block.media_type || 'image/png', data: src.data || block.data, url: block.url || src.url });
|
||||||
|
}
|
||||||
|
else if (block.type === 'image_url') {
|
||||||
|
blocks.push({ type: 'image', mediaType: 'image/png', url: block.image_url?.url || block.url });
|
||||||
|
}
|
||||||
|
else if (block.type === 'tool_use') blocks.push({ type: 'tool_use', name: block.name, input: block.input, id: block.id });
|
||||||
|
else if (block.type === 'tool_result') blocks.push({ type: 'tool_result', content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content, null, 2), toolUseId: block.tool_use_id });
|
||||||
|
else if (block.type === 'toolCall') blocks.push({ type: 'tool_use', name: block.name, input: block.arguments || block.input, id: block.id });
|
||||||
|
else if (block.type === 'toolResult') blocks.push({ type: 'tool_result', content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content, null, 2), toolUseId: block.toolCallId || block.tool_use_id, name: block.name });
|
||||||
|
}
|
||||||
|
} else if (typeof m.content === 'string') {
|
||||||
|
blocks.push({ type: 'text', text: m.content });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const role: 'user' | 'assistant' = m.role === 'user' ? 'user' : 'assistant';
|
||||||
|
|
||||||
|
if (m.role === 'toolResult') {
|
||||||
|
const toolBlocks: MessageBlock[] = blocks.map(b => {
|
||||||
|
if (b.type === 'text') {
|
||||||
|
return { type: 'tool_result' as const, content: b.text, toolUseId: m.toolCallId };
|
||||||
|
}
|
||||||
|
return b;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: m.id || `hist-${i}`,
|
||||||
|
role: 'assistant' as const,
|
||||||
|
content: '',
|
||||||
|
timestamp: m.timestamp || Date.now(),
|
||||||
|
blocks: toolBlocks,
|
||||||
|
isToolResult: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: m.id || `hist-${i}`,
|
||||||
|
role,
|
||||||
|
content: blocks.filter((b): b is Extract<MessageBlock, { type: 'text' }> => b.type === 'text').map(b => b.text).join(''),
|
||||||
|
timestamp: m.timestamp || Date.now(),
|
||||||
|
blocks,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const merged: ChatMessage[] = [];
|
||||||
|
for (const msg of msgs) {
|
||||||
|
const isToolResult = 'isToolResult' in msg && (msg as ChatMessage & { isToolResult?: boolean }).isToolResult;
|
||||||
|
if (isToolResult && merged.length > 0 && merged[merged.length - 1].role === 'assistant') {
|
||||||
|
merged[merged.length - 1] = {
|
||||||
|
...merged[merged.length - 1],
|
||||||
|
blocks: [...merged[merged.length - 1].blocks, ...msg.blocks],
|
||||||
|
};
|
||||||
|
} else if (isToolResult) {
|
||||||
|
// skip orphan tool results
|
||||||
|
} else {
|
||||||
|
merged.push(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setMessages(merged);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently ignore history load failures
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const setupClient = useCallback((wsUrl: string, token: string) => {
|
const setupClient = useCallback((wsUrl: string, token: string) => {
|
||||||
// Tear down existing client
|
// Tear down existing client
|
||||||
if (clientRef.current) {
|
if (clientRef.current) {
|
||||||
@@ -163,10 +298,13 @@ export function useGateway() {
|
|||||||
isConnectingRef.current = true;
|
isConnectingRef.current = true;
|
||||||
setConnectError(null);
|
setConnectError(null);
|
||||||
client.connect();
|
client.connect();
|
||||||
}, []);
|
}, [handleAgentEvent, loadHistory, loadSessions]);
|
||||||
|
|
||||||
// On mount: try stored credentials
|
// On mount: try stored credentials
|
||||||
|
const initRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (initRef.current) return;
|
||||||
|
initRef.current = true;
|
||||||
const stored = getStoredCredentials();
|
const stored = getStoredCredentials();
|
||||||
if (stored) {
|
if (stored) {
|
||||||
setupClient(stored.url, stored.token);
|
setupClient(stored.url, stored.token);
|
||||||
@@ -175,135 +313,6 @@ export function useGateway() {
|
|||||||
}
|
}
|
||||||
}, [setupClient]);
|
}, [setupClient]);
|
||||||
|
|
||||||
const handleAgentEvent = useCallback((payload: JsonPayload) => {
|
|
||||||
if (payload?.stream !== 'tool') return;
|
|
||||||
const data = (payload.data ?? {}) as Record<string, unknown>;
|
|
||||||
const phase = data.phase as string | undefined;
|
|
||||||
const toolCallId = data.toolCallId as string | undefined;
|
|
||||||
const name = (data.name as string) || 'tool';
|
|
||||||
if (!toolCallId) return;
|
|
||||||
|
|
||||||
setMessages(prev => {
|
|
||||||
const last = prev[prev.length - 1];
|
|
||||||
if (!last || last.role !== 'assistant' || !last.isStreaming) return prev;
|
|
||||||
|
|
||||||
const updated = { ...last, blocks: [...last.blocks] };
|
|
||||||
|
|
||||||
if (phase === 'start') {
|
|
||||||
updated.blocks.push({
|
|
||||||
type: 'tool_use' as const,
|
|
||||||
name,
|
|
||||||
input: (data.args as Record<string, unknown>) ?? {},
|
|
||||||
id: toolCallId,
|
|
||||||
});
|
|
||||||
} else if (phase === 'result') {
|
|
||||||
const rawResult = data.result;
|
|
||||||
const result = typeof rawResult === 'string' ? rawResult : JSON.stringify(rawResult, null, 2);
|
|
||||||
updated.blocks.push({
|
|
||||||
type: 'tool_result' as const,
|
|
||||||
content: result?.slice(0, 500) || '',
|
|
||||||
toolUseId: toolCallId,
|
|
||||||
name,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...prev.slice(0, -1), updated];
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadSessions = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const res = await clientRef.current?.send('sessions.list', {});
|
|
||||||
const sessionList = res?.sessions as Array<Record<string, unknown>> | undefined;
|
|
||||||
if (sessionList) {
|
|
||||||
setSessions(sessionList.map((s) => ({
|
|
||||||
key: (s.key || s.sessionKey) as string,
|
|
||||||
label: (s.label || s.key || s.sessionKey) as string,
|
|
||||||
messageCount: s.messageCount as number | undefined,
|
|
||||||
totalTokens: s.totalTokens as number | undefined,
|
|
||||||
contextTokens: s.contextTokens as number | undefined,
|
|
||||||
inputTokens: s.inputTokens as number | undefined,
|
|
||||||
outputTokens: s.outputTokens as number | undefined,
|
|
||||||
})));
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadHistory = useCallback(async (sessionKey: string) => {
|
|
||||||
try {
|
|
||||||
const res = await clientRef.current?.send('chat.history', { sessionKey, limit: 100 });
|
|
||||||
const rawMsgs = res?.messages as Array<Record<string, unknown>> | undefined;
|
|
||||||
if (rawMsgs) {
|
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any -- raw gateway history messages have dynamic shape */
|
|
||||||
const msgs: ChatMessage[] = rawMsgs.map((m: Record<string, any>, i: number) => {
|
|
||||||
const blocks: MessageBlock[] = [];
|
|
||||||
if (m.content) {
|
|
||||||
if (Array.isArray(m.content)) {
|
|
||||||
for (const block of m.content) {
|
|
||||||
if (block.type === 'text') blocks.push({ type: 'text', text: block.text });
|
|
||||||
else if (block.type === 'thinking') blocks.push({ type: 'thinking', text: block.thinking || block.text || '' });
|
|
||||||
else if (block.type === 'image') {
|
|
||||||
const src = block.source || {};
|
|
||||||
blocks.push({ type: 'image', mediaType: src.media_type || block.media_type || 'image/png', data: src.data || block.data, url: block.url || src.url });
|
|
||||||
}
|
|
||||||
else if (block.type === 'image_url') {
|
|
||||||
blocks.push({ type: 'image', mediaType: 'image/png', url: block.image_url?.url || block.url });
|
|
||||||
}
|
|
||||||
else if (block.type === 'tool_use') blocks.push({ type: 'tool_use', name: block.name, input: block.input, id: block.id });
|
|
||||||
else if (block.type === 'tool_result') blocks.push({ type: 'tool_result', content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content, null, 2), toolUseId: block.tool_use_id });
|
|
||||||
else if (block.type === 'toolCall') blocks.push({ type: 'tool_use', name: block.name, input: block.arguments || block.input, id: block.id });
|
|
||||||
else if (block.type === 'toolResult') blocks.push({ type: 'tool_result', content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content, null, 2), toolUseId: block.toolCallId || block.tool_use_id, name: block.name });
|
|
||||||
}
|
|
||||||
} else if (typeof m.content === 'string') {
|
|
||||||
blocks.push({ type: 'text', text: m.content });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const role: 'user' | 'assistant' = m.role === 'user' ? 'user' : 'assistant';
|
|
||||||
|
|
||||||
if (m.role === 'toolResult') {
|
|
||||||
const toolBlocks: MessageBlock[] = blocks.map(b => {
|
|
||||||
if (b.type === 'text') {
|
|
||||||
return { type: 'tool_result' as const, content: b.text, toolUseId: m.toolCallId };
|
|
||||||
}
|
|
||||||
return b;
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
id: m.id || `hist-${i}`,
|
|
||||||
role: 'assistant' as const,
|
|
||||||
content: '',
|
|
||||||
timestamp: m.timestamp || Date.now(),
|
|
||||||
blocks: toolBlocks,
|
|
||||||
isToolResult: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: m.id || `hist-${i}`,
|
|
||||||
role,
|
|
||||||
content: blocks.filter((b): b is Extract<MessageBlock, { type: 'text' }> => b.type === 'text').map(b => b.text).join(''),
|
|
||||||
timestamp: m.timestamp || Date.now(),
|
|
||||||
blocks,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const merged: ChatMessage[] = [];
|
|
||||||
for (const msg of msgs) {
|
|
||||||
const isToolResult = 'isToolResult' in msg && (msg as ChatMessage & { isToolResult?: boolean }).isToolResult;
|
|
||||||
if (isToolResult && merged.length > 0 && merged[merged.length - 1].role === 'assistant') {
|
|
||||||
merged[merged.length - 1] = {
|
|
||||||
...merged[merged.length - 1],
|
|
||||||
blocks: [...merged[merged.length - 1].blocks, ...msg.blocks],
|
|
||||||
};
|
|
||||||
} else if (isToolResult) {
|
|
||||||
// skip orphan
|
|
||||||
} else {
|
|
||||||
merged.push(msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setMessages(merged);
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const sendMessage = useCallback(async (text: string, attachments?: Array<{ mimeType: string; fileName: string; content: string }>) => {
|
const sendMessage = useCallback(async (text: string, attachments?: Array<{ mimeType: string; fileName: string; content: string }>) => {
|
||||||
const userMsg: ChatMessage = {
|
const userMsg: ChatMessage = {
|
||||||
id: 'user-' + Date.now(),
|
id: 'user-' + Date.now(),
|
||||||
@@ -323,7 +332,8 @@ export function useGateway() {
|
|||||||
idempotencyKey: genIdempotencyKey(),
|
idempotencyKey: genIdempotencyKey(),
|
||||||
...(attachments && attachments.length > 0 ? { attachments } : {}),
|
...(attachments && attachments.length > 0 ? { attachments } : {}),
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch {
|
||||||
|
// Failed to send — stop generating indicator
|
||||||
setIsGenerating(false);
|
setIsGenerating(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@@ -331,7 +341,9 @@ export function useGateway() {
|
|||||||
const abort = useCallback(async () => {
|
const abort = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await clientRef.current?.send('chat.abort', { sessionKey: activeSessionRef.current });
|
await clientRef.current?.send('chat.abort', { sessionKey: activeSessionRef.current });
|
||||||
} catch {}
|
} catch {
|
||||||
|
// Ignore abort failures
|
||||||
|
}
|
||||||
setIsGenerating(false);
|
setIsGenerating(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
21
src/lib/credentials.ts
Normal file
21
src/lib/credentials.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
const STORAGE_KEY = 'pinchchat_credentials';
|
||||||
|
|
||||||
|
export function getStoredCredentials(): { url: string; token: string } | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (parsed.url && parsed.token) return parsed;
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed localStorage data
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeCredentials(url: string, token: string) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify({ url, token }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearCredentials() {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
}
|
||||||
6
src/lib/image.ts
Normal file
6
src/lib/image.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/** Build a data URL from base64 image data */
|
||||||
|
export function buildImageSrc(mediaType: string, data?: string, url?: string): string {
|
||||||
|
if (url) return url;
|
||||||
|
if (data) return `data:${mediaType};base64,${data}`;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user