refactor: replace any types with proper TypeScript types across gateway client, hooks, and components
- Add GatewayMessage and JsonPayload interfaces to gateway.ts - Type WebSocket message parsing with GatewayMessage instead of any - Use ReturnType<typeof setTimeout> for timer refs - Type chat event payload destructuring explicitly - Add ChatPayloadMessage interface for extractText() - Replace any casts in loadSessions/loadHistory with typed assertions - Add str() helper in ToolCall.tsx for safe unknown→string extraction - Use Extract<MessageBlock, ...> type guards instead of `as any` casts - Type CodeBlock children prop properly
This commit is contained in:
@@ -148,7 +148,7 @@ function renderTextBlocks(blocks: MessageBlock[]) {
|
|||||||
return getTextBlocks(blocks).map((block, i) => (
|
return getTextBlocks(blocks).map((block, i) => (
|
||||||
<div key={`text-${i}`} className="markdown-body">
|
<div key={`text-${i}`} className="markdown-body">
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={[rehypeHighlight]} components={markdownComponents}>
|
<ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={[rehypeHighlight]} components={markdownComponents}>
|
||||||
{autoFormatText((block as any).text)}
|
{autoFormatText((block as Extract<MessageBlock, { type: 'text' }>).text)}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
));
|
));
|
||||||
@@ -238,7 +238,7 @@ function CopyButton({ text }: { text: string }) {
|
|||||||
/** 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) {
|
||||||
return getTextBlocks(message.blocks).map(b => (b as any).text).join('\n\n');
|
return getTextBlocks(message.blocks).map(b => (b as Extract<MessageBlock, { type: 'text' }>).text).join('\n\n');
|
||||||
}
|
}
|
||||||
return message.content;
|
return message.content;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export function CodeBlock(props: HTMLAttributes<HTMLPreElement>) {
|
|||||||
|
|
||||||
const handleCopy = useCallback(() => {
|
const handleCopy = useCallback(() => {
|
||||||
// Extract text from the nested <code> element
|
// Extract text from the nested <code> element
|
||||||
const code = (props.children as any)?.props?.children;
|
const code = (props.children as React.ReactElement<{ children?: string }> | undefined)?.props?.children;
|
||||||
if (typeof code === 'string') {
|
if (typeof code === 'string') {
|
||||||
navigator.clipboard.writeText(code).then(() => {
|
navigator.clipboard.writeText(code).then(() => {
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
|
|||||||
@@ -122,33 +122,40 @@ export function HighlightedPre({ text, className }: { text: string; className: s
|
|||||||
return <pre className={className}>{text}</pre>;
|
return <pre className={className}>{text}</pre>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getContextHint(name: string, input: any): string | null {
|
function str(v: unknown): string | null {
|
||||||
|
return typeof v === 'string' ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContextHint(name: string, input: Record<string, unknown> | undefined): string | null {
|
||||||
if (!input || typeof input !== 'object') return null;
|
if (!input || typeof input !== 'object') return null;
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case 'exec':
|
case 'exec':
|
||||||
return input.command ? truncate(input.command, 60) : null;
|
return str(input.command) ? truncate(str(input.command)!, 60) : null;
|
||||||
case 'Read': case 'read':
|
case 'Read': case 'read':
|
||||||
case 'Write': case 'write':
|
case 'Write': case 'write':
|
||||||
case 'Edit': case 'edit':
|
case 'Edit': case 'edit':
|
||||||
return input.file_path || input.path || null;
|
return str(input.file_path) || str(input.path) || null;
|
||||||
case 'web_search':
|
case 'web_search':
|
||||||
return input.query ? truncate(input.query, 50) : null;
|
return str(input.query) ? truncate(str(input.query)!, 50) : null;
|
||||||
case 'web_fetch':
|
case 'web_fetch':
|
||||||
return input.url ? truncate(input.url, 60) : null;
|
return str(input.url) ? truncate(str(input.url)!, 60) : null;
|
||||||
case 'browser':
|
case 'browser':
|
||||||
return input.action || null;
|
return str(input.action) || null;
|
||||||
case 'message':
|
case 'message': {
|
||||||
return input.action ? `${input.action}${input.target ? ' → ' + input.target : ''}` : null;
|
const action = str(input.action);
|
||||||
|
const target = str(input.target);
|
||||||
|
return action ? `${action}${target ? ' → ' + target : ''}` : null;
|
||||||
|
}
|
||||||
case 'memory_search':
|
case 'memory_search':
|
||||||
return input.query ? truncate(input.query, 50) : null;
|
return str(input.query) ? truncate(str(input.query)!, 50) : null;
|
||||||
case 'memory_get':
|
case 'memory_get':
|
||||||
return input.path || null;
|
return str(input.path) || null;
|
||||||
case 'cron':
|
case 'cron':
|
||||||
return input.action || null;
|
return str(input.action) || null;
|
||||||
case 'sessions_spawn':
|
case 'sessions_spawn':
|
||||||
return input.task ? truncate(input.task, 50) : null;
|
return str(input.task) ? truncate(str(input.task)!, 50) : null;
|
||||||
case 'image':
|
case 'image':
|
||||||
return input.prompt ? truncate(input.prompt, 50) : null;
|
return str(input.prompt) ? truncate(str(input.prompt)!, 50) : null;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -184,7 +191,7 @@ function extractImageFromResult(result: string): { src: string; remaining: strin
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ToolCall({ name, input, result }: { name: string; input?: any; result?: string }) {
|
export function ToolCall({ name, input, result }: { name: string; input?: Record<string, unknown>; result?: string }) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const c = getColor(name);
|
const c = getColor(name);
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { GatewayClient } 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 '../components/LoginScreen';
|
||||||
import type { ChatMessage, MessageBlock, ConnectionStatus, Session } from '../types';
|
import type { ChatMessage, MessageBlock, ConnectionStatus, Session } from '../types';
|
||||||
|
|
||||||
function extractText(message: any): string {
|
interface ChatPayloadMessage {
|
||||||
|
content?: string | Array<{ type: string; text?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractText(message: ChatPayloadMessage | undefined): string {
|
||||||
if (!message) return '';
|
if (!message) return '';
|
||||||
const content = message.content;
|
const content = message.content;
|
||||||
if (typeof content === 'string') return content;
|
if (typeof content === 'string') return content;
|
||||||
if (Array.isArray(content)) {
|
if (Array.isArray(content)) {
|
||||||
return content
|
return content
|
||||||
.filter((b: any) => b.type === 'text' && typeof b.text === 'string')
|
.filter((b) => b.type === 'text' && typeof b.text === 'string')
|
||||||
.map((b: any) => b.text)
|
.map((b) => b.text as string)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
@@ -72,7 +76,11 @@ export function useGateway() {
|
|||||||
}
|
}
|
||||||
if (event !== 'chat') return;
|
if (event !== 'chat') return;
|
||||||
|
|
||||||
const { state, runId, message, errorMessage, sessionKey: evtSession } = payload;
|
const state = payload.state as string | undefined;
|
||||||
|
const runId = payload.runId as string;
|
||||||
|
const message = payload.message as ChatPayloadMessage | undefined;
|
||||||
|
const errorMessage = payload.errorMessage as string | undefined;
|
||||||
|
const evtSession = payload.sessionKey as string | undefined;
|
||||||
|
|
||||||
if (evtSession) {
|
if (evtSession) {
|
||||||
if (state === 'delta') {
|
if (state === 'delta') {
|
||||||
@@ -167,12 +175,12 @@ export function useGateway() {
|
|||||||
}
|
}
|
||||||
}, [setupClient]);
|
}, [setupClient]);
|
||||||
|
|
||||||
const handleAgentEvent = useCallback((payload: any) => {
|
const handleAgentEvent = useCallback((payload: JsonPayload) => {
|
||||||
if (payload?.stream !== 'tool') return;
|
if (payload?.stream !== 'tool') return;
|
||||||
const data = payload.data ?? {};
|
const data = (payload.data ?? {}) as Record<string, unknown>;
|
||||||
const phase = data.phase;
|
const phase = data.phase as string | undefined;
|
||||||
const toolCallId = data.toolCallId;
|
const toolCallId = data.toolCallId as string | undefined;
|
||||||
const name = data.name || 'tool';
|
const name = (data.name as string) || 'tool';
|
||||||
if (!toolCallId) return;
|
if (!toolCallId) return;
|
||||||
|
|
||||||
setMessages(prev => {
|
setMessages(prev => {
|
||||||
@@ -185,11 +193,12 @@ export function useGateway() {
|
|||||||
updated.blocks.push({
|
updated.blocks.push({
|
||||||
type: 'tool_use' as const,
|
type: 'tool_use' as const,
|
||||||
name,
|
name,
|
||||||
input: data.args,
|
input: (data.args as Record<string, unknown>) ?? {},
|
||||||
id: toolCallId,
|
id: toolCallId,
|
||||||
});
|
});
|
||||||
} else if (phase === 'result') {
|
} else if (phase === 'result') {
|
||||||
const result = typeof data.result === 'string' ? data.result : JSON.stringify(data.result, null, 2);
|
const rawResult = data.result;
|
||||||
|
const result = typeof rawResult === 'string' ? rawResult : JSON.stringify(rawResult, null, 2);
|
||||||
updated.blocks.push({
|
updated.blocks.push({
|
||||||
type: 'tool_result' as const,
|
type: 'tool_result' as const,
|
||||||
content: result?.slice(0, 500) || '',
|
content: result?.slice(0, 500) || '',
|
||||||
@@ -205,15 +214,16 @@ export function useGateway() {
|
|||||||
const loadSessions = useCallback(async () => {
|
const loadSessions = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await clientRef.current?.send('sessions.list', {});
|
const res = await clientRef.current?.send('sessions.list', {});
|
||||||
if (res?.sessions) {
|
const sessionList = res?.sessions as Array<Record<string, unknown>> | undefined;
|
||||||
setSessions(res.sessions.map((s: any) => ({
|
if (sessionList) {
|
||||||
key: s.key || s.sessionKey,
|
setSessions(sessionList.map((s) => ({
|
||||||
label: s.label || s.key || s.sessionKey,
|
key: (s.key || s.sessionKey) as string,
|
||||||
messageCount: s.messageCount,
|
label: (s.label || s.key || s.sessionKey) as string,
|
||||||
totalTokens: s.totalTokens,
|
messageCount: s.messageCount as number | undefined,
|
||||||
contextTokens: s.contextTokens,
|
totalTokens: s.totalTokens as number | undefined,
|
||||||
inputTokens: s.inputTokens,
|
contextTokens: s.contextTokens as number | undefined,
|
||||||
outputTokens: s.outputTokens,
|
inputTokens: s.inputTokens as number | undefined,
|
||||||
|
outputTokens: s.outputTokens as number | undefined,
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
@@ -222,9 +232,10 @@ export function useGateway() {
|
|||||||
const loadHistory = useCallback(async (sessionKey: string) => {
|
const loadHistory = useCallback(async (sessionKey: string) => {
|
||||||
try {
|
try {
|
||||||
const res = await clientRef.current?.send('chat.history', { sessionKey, limit: 100 });
|
const res = await clientRef.current?.send('chat.history', { sessionKey, limit: 100 });
|
||||||
if (res?.messages) {
|
const rawMsgs = res?.messages as Array<Record<string, unknown>> | undefined;
|
||||||
const rawMsgs: any[] = res.messages;
|
if (rawMsgs) {
|
||||||
const msgs: ChatMessage[] = rawMsgs.map((m: any, i: number) => {
|
/* 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[] = [];
|
const blocks: MessageBlock[] = [];
|
||||||
if (m.content) {
|
if (m.content) {
|
||||||
if (Array.isArray(m.content)) {
|
if (Array.isArray(m.content)) {
|
||||||
@@ -269,19 +280,20 @@ export function useGateway() {
|
|||||||
return {
|
return {
|
||||||
id: m.id || `hist-${i}`,
|
id: m.id || `hist-${i}`,
|
||||||
role,
|
role,
|
||||||
content: blocks.filter(b => b.type === 'text').map(b => (b as any).text).join(''),
|
content: blocks.filter((b): b is Extract<MessageBlock, { type: 'text' }> => b.type === 'text').map(b => b.text).join(''),
|
||||||
timestamp: m.timestamp || Date.now(),
|
timestamp: m.timestamp || Date.now(),
|
||||||
blocks,
|
blocks,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const merged: ChatMessage[] = [];
|
const merged: ChatMessage[] = [];
|
||||||
for (const msg of msgs) {
|
for (const msg of msgs) {
|
||||||
if ((msg as any).isToolResult && merged.length > 0 && merged[merged.length - 1].role === 'assistant') {
|
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] = {
|
||||||
...merged[merged.length - 1],
|
...merged[merged.length - 1],
|
||||||
blocks: [...merged[merged.length - 1].blocks, ...msg.blocks],
|
blocks: [...merged[merged.length - 1].blocks, ...msg.blocks],
|
||||||
};
|
};
|
||||||
} else if ((msg as any).isToolResult) {
|
} else if (isToolResult) {
|
||||||
// skip orphan
|
// skip orphan
|
||||||
} else {
|
} else {
|
||||||
merged.push(msg);
|
merged.push(msg);
|
||||||
|
|||||||
@@ -1,14 +1,29 @@
|
|||||||
import { genId } from './utils';
|
import { genId } from './utils';
|
||||||
|
|
||||||
export type GatewayEventHandler = (event: string, payload: any) => void;
|
/** JSON-safe payload type used for gateway messages. */
|
||||||
export type GatewayResponseHandler = (id: string, ok: boolean, payload: any) => void;
|
export type JsonPayload = Record<string, unknown>;
|
||||||
|
|
||||||
|
export type GatewayEventHandler = (event: string, payload: JsonPayload) => void;
|
||||||
|
export type GatewayResponseHandler = (id: string, ok: boolean, payload: JsonPayload) => void;
|
||||||
|
|
||||||
|
/** Shape of an incoming WebSocket message from the gateway. */
|
||||||
|
interface GatewayMessage {
|
||||||
|
type: 'event' | 'res';
|
||||||
|
// event fields
|
||||||
|
event?: string;
|
||||||
|
payload?: JsonPayload;
|
||||||
|
// response fields
|
||||||
|
id?: string;
|
||||||
|
ok?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class GatewayClient {
|
export class GatewayClient {
|
||||||
private ws: WebSocket | null = null;
|
private ws: WebSocket | null = null;
|
||||||
private pendingRequests = new Map<string, { resolve: (v: any) => void; reject: (e: any) => void }>();
|
private pendingRequests = new Map<string, { resolve: (v: JsonPayload) => void; reject: (e: unknown) => void }>();
|
||||||
private eventHandlers: GatewayEventHandler[] = [];
|
private eventHandlers: GatewayEventHandler[] = [];
|
||||||
private _onStatus: (s: 'disconnected' | 'connecting' | 'connected') => void = () => {};
|
private _onStatus: (s: 'disconnected' | 'connecting' | 'connected') => void = () => {};
|
||||||
private reconnectTimer: any = null;
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private connected = false;
|
private connected = false;
|
||||||
private autoReconnect = true;
|
private autoReconnect = true;
|
||||||
|
|
||||||
@@ -44,22 +59,22 @@ export class GatewayClient {
|
|||||||
this.ws.onopen = () => { console.log('[GW] WS open'); };
|
this.ws.onopen = () => { console.log('[GW] WS open'); };
|
||||||
|
|
||||||
this.ws.onmessage = (ev) => {
|
this.ws.onmessage = (ev) => {
|
||||||
let msg: any;
|
let msg: GatewayMessage;
|
||||||
try { msg = JSON.parse(ev.data); } catch { console.log('[GW] parse error', ev.data); return; }
|
try { msg = JSON.parse(ev.data as string) as GatewayMessage; } catch { console.log('[GW] parse error', ev.data); return; }
|
||||||
console.log('[GW] msg:', msg.type, msg.event || msg.id || '', msg.ok);
|
console.log('[GW] msg:', msg.type, msg.event || msg.id || '', msg.ok);
|
||||||
|
|
||||||
if (msg.type === 'event') {
|
if (msg.type === 'event') {
|
||||||
if (msg.event === 'connect.challenge') {
|
if (msg.event === 'connect.challenge') {
|
||||||
this.handleChallenge();
|
this.handleChallenge();
|
||||||
} else {
|
} else {
|
||||||
for (const h of this.eventHandlers) h(msg.event, msg.payload);
|
for (const h of this.eventHandlers) h(msg.event ?? '', msg.payload ?? {});
|
||||||
}
|
}
|
||||||
} else if (msg.type === 'res') {
|
} else if (msg.type === 'res' && msg.id) {
|
||||||
const pending = this.pendingRequests.get(msg.id);
|
const pending = this.pendingRequests.get(msg.id);
|
||||||
if (pending) {
|
if (pending) {
|
||||||
this.pendingRequests.delete(msg.id);
|
this.pendingRequests.delete(msg.id);
|
||||||
if (msg.ok) pending.resolve(msg.payload);
|
if (msg.ok) pending.resolve(msg.payload ?? {});
|
||||||
else pending.reject(msg.payload || msg.error);
|
else pending.reject(msg.payload ?? msg.error ?? 'unknown error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -118,7 +133,7 @@ export class GatewayClient {
|
|||||||
this._onStatus('disconnected');
|
this._onStatus('disconnected');
|
||||||
}
|
}
|
||||||
|
|
||||||
request(id: string, method: string, params: any): Promise<any> {
|
request(id: string, method: string, params: JsonPayload): Promise<JsonPayload> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||||
return reject(new Error('not connected'));
|
return reject(new Error('not connected'));
|
||||||
@@ -134,7 +149,7 @@ export class GatewayClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async send(method: string, params: any): Promise<any> {
|
async send(method: string, params: JsonPayload): Promise<JsonPayload> {
|
||||||
const id = genId('req');
|
const id = genId('req');
|
||||||
return this.request(id, method, params);
|
return this.request(id, method, params);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export interface ChatMessage {
|
|||||||
export type MessageBlock =
|
export type MessageBlock =
|
||||||
| { type: 'text'; text: string }
|
| { type: 'text'; text: string }
|
||||||
| { type: 'thinking'; text: string }
|
| { type: 'thinking'; text: string }
|
||||||
| { type: 'tool_use'; name: string; input: any; id?: string }
|
| { type: 'tool_use'; name: string; input: Record<string, unknown>; id?: string }
|
||||||
| { type: 'tool_result'; content: string; toolUseId?: string; name?: string }
|
| { type: 'tool_result'; content: string; toolUseId?: string; name?: string }
|
||||||
| { type: 'image'; mediaType: string; data?: string; url?: string };
|
| { type: 'image'; mediaType: string; data?: string; url?: string };
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user