diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 1275c20..6d682e9 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,183 +1,188 @@ export type WsRequest = - | { - type: "chat"; - messages: Message[]; - config: ProviderConfig; - } - | { - type: "cancel"; - } - | { - type: "permission_response"; - request_id: string; - approved: boolean; - always_allow: boolean; - } - | { type: "ping" } - | { - type: "side_question"; - question: string; - context_messages: Message[]; - config: ProviderConfig; - }; + | { + type: "chat"; + messages: Message[]; + config: ProviderConfig; + } + | { + type: "cancel"; + } + | { + type: "permission_response"; + request_id: string; + approved: boolean; + always_allow: boolean; + } + | { type: "ping" } + | { + type: "side_question"; + question: string; + context_messages: Message[]; + config: ProviderConfig; + }; export interface AgentAssignment { - agent_name: string; - model: string | null; - status: string; + agent_name: string; + model: string | null; + status: string; } export interface PipelineStageItem { - story_id: string; - name: string | null; - error: string | null; - merge_failure: string | null; - agent: AgentAssignment | null; - review_hold: boolean | null; - qa: string | null; + story_id: string; + name: string | null; + error: string | null; + merge_failure: string | null; + agent: AgentAssignment | null; + review_hold: boolean | null; + qa: string | null; } export interface PipelineState { - backlog: PipelineStageItem[]; - current: PipelineStageItem[]; - qa: PipelineStageItem[]; - merge: PipelineStageItem[]; - done: PipelineStageItem[]; + backlog: PipelineStageItem[]; + current: PipelineStageItem[]; + qa: PipelineStageItem[]; + merge: PipelineStageItem[]; + done: PipelineStageItem[]; } export type WsResponse = - | { type: "token"; content: string } - | { type: "update"; messages: Message[] } - | { type: "session_id"; session_id: string } - | { type: "error"; message: string } - | { - type: "pipeline_state"; - backlog: PipelineStageItem[]; - current: PipelineStageItem[]; - qa: PipelineStageItem[]; - merge: PipelineStageItem[]; - done: PipelineStageItem[]; - } - | { - type: "permission_request"; - request_id: string; - tool_name: string; - tool_input: Record; - } - | { type: "tool_activity"; tool_name: string } - | { - type: "reconciliation_progress"; - story_id: string; - status: string; - message: string; - } - /** `.story_kit/project.toml` was modified; re-fetch the agent roster. */ - | { type: "agent_config_changed" } - /** An agent started, stopped, or changed state; re-fetch agent list. */ - | { type: "agent_state_changed" } - | { type: "tool_activity"; tool_name: string } - /** Heartbeat response confirming the connection is alive. */ - | { type: "pong" } - /** Sent on connect when the project still needs onboarding (specs are placeholders). */ - | { type: "onboarding_status"; needs_onboarding: boolean } - /** Streaming thinking token from an extended-thinking block, separate from regular text. */ - | { type: "thinking_token"; content: string } - /** Streaming token from a /btw side question response. */ - | { type: "side_question_token"; content: string } - /** Final signal that the /btw side question has been fully answered. */ - | { type: "side_question_done"; response: string } - /** A single server log entry (bulk on connect, then live). */ - | { type: "log_entry"; timestamp: string; level: string; message: string }; + | { type: "token"; content: string } + | { type: "update"; messages: Message[] } + | { type: "session_id"; session_id: string } + | { type: "error"; message: string } + | { + type: "pipeline_state"; + backlog: PipelineStageItem[]; + current: PipelineStageItem[]; + qa: PipelineStageItem[]; + merge: PipelineStageItem[]; + done: PipelineStageItem[]; + } + | { + type: "permission_request"; + request_id: string; + tool_name: string; + tool_input: Record; + } + | { type: "tool_activity"; tool_name: string } + | { + type: "reconciliation_progress"; + story_id: string; + status: string; + message: string; + } + /** `.story_kit/project.toml` was modified; re-fetch the agent roster. */ + | { type: "agent_config_changed" } + /** An agent started, stopped, or changed state; re-fetch agent list. */ + | { type: "agent_state_changed" } + | { type: "tool_activity"; tool_name: string } + /** Heartbeat response confirming the connection is alive. */ + | { type: "pong" } + /** Sent on connect when the project still needs onboarding (specs are placeholders). */ + | { type: "onboarding_status"; needs_onboarding: boolean } + /** Streaming thinking token from an extended-thinking block, separate from regular text. */ + | { type: "thinking_token"; content: string } + /** Streaming token from a /btw side question response. */ + | { type: "side_question_token"; content: string } + /** Final signal that the /btw side question has been fully answered. */ + | { type: "side_question_done"; response: string } + /** A single server log entry (bulk on connect, then live). */ + | { type: "log_entry"; timestamp: string; level: string; message: string }; export interface ProviderConfig { - provider: string; - model: string; - base_url?: string; - enable_tools?: boolean; - session_id?: string; + provider: string; + model: string; + base_url?: string; + enable_tools?: boolean; + session_id?: string; } export type Role = "system" | "user" | "assistant" | "tool"; export interface ToolCall { - id?: string; - type: string; - function: { - name: string; - arguments: string; - }; + id?: string; + type: string; + function: { + name: string; + arguments: string; + }; } export interface Message { - role: Role; - content: string; - tool_calls?: ToolCall[]; - tool_call_id?: string; + role: Role; + content: string; + tool_calls?: ToolCall[]; + tool_call_id?: string; +} + +export interface AnthropicModelInfo { + id: string; + context_window: number; } export interface WorkItemContent { - content: string; - stage: string; - name: string | null; - agent: string | null; + content: string; + stage: string; + name: string | null; + agent: string | null; } export interface TestCaseResult { - name: string; - status: "pass" | "fail"; - details: string | null; + name: string; + status: "pass" | "fail"; + details: string | null; } export interface TestResultsResponse { - unit: TestCaseResult[]; - integration: TestCaseResult[]; + unit: TestCaseResult[]; + integration: TestCaseResult[]; } export interface FileEntry { - name: string; - kind: "file" | "dir"; + name: string; + kind: "file" | "dir"; } export interface SearchResult { - path: string; - matches: number; + path: string; + matches: number; } export interface AgentCostEntry { - agent_name: string; - model: string | null; - input_tokens: number; - output_tokens: number; - cache_creation_input_tokens: number; - cache_read_input_tokens: number; - total_cost_usd: number; + agent_name: string; + model: string | null; + input_tokens: number; + output_tokens: number; + cache_creation_input_tokens: number; + cache_read_input_tokens: number; + total_cost_usd: number; } export interface TokenCostResponse { - total_cost_usd: number; - agents: AgentCostEntry[]; + total_cost_usd: number; + agents: AgentCostEntry[]; } export interface TokenUsageRecord { - story_id: string; - agent_name: string; - model: string | null; - timestamp: string; - input_tokens: number; - output_tokens: number; - cache_creation_input_tokens: number; - cache_read_input_tokens: number; - total_cost_usd: number; + story_id: string; + agent_name: string; + model: string | null; + timestamp: string; + input_tokens: number; + output_tokens: number; + cache_creation_input_tokens: number; + cache_read_input_tokens: number; + total_cost_usd: number; } export interface AllTokenUsageResponse { - records: TokenUsageRecord[]; + records: TokenUsageRecord[]; } export interface CommandOutput { - stdout: string; - stderr: string; - exit_code: number; + stdout: string; + stderr: string; + exit_code: number; } declare const __STORKIT_PORT__: string; @@ -186,509 +191,509 @@ const DEFAULT_API_BASE = "/api"; const DEFAULT_WS_PATH = "/ws"; export function resolveWsHost( - isDev: boolean, - envPort: string | undefined, - locationHost: string, + isDev: boolean, + envPort: string | undefined, + locationHost: string, ): string { - return isDev ? `127.0.0.1:${envPort || "3001"}` : locationHost; + return isDev ? `127.0.0.1:${envPort || "3001"}` : locationHost; } function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string { - return `${baseUrl}${path}`; + return `${baseUrl}${path}`; } async function requestJson( - path: string, - options: RequestInit = {}, - baseUrl = DEFAULT_API_BASE, + path: string, + options: RequestInit = {}, + baseUrl = DEFAULT_API_BASE, ): Promise { - const res = await fetch(buildApiUrl(path, baseUrl), { - headers: { - "Content-Type": "application/json", - ...(options.headers ?? {}), - }, - ...options, - }); + const res = await fetch(buildApiUrl(path, baseUrl), { + headers: { + "Content-Type": "application/json", + ...(options.headers ?? {}), + }, + ...options, + }); - if (!res.ok) { - const text = await res.text(); - throw new Error(text || `Request failed (${res.status})`); - } + if (!res.ok) { + const text = await res.text(); + throw new Error(text || `Request failed (${res.status})`); + } - return res.json() as Promise; + return res.json() as Promise; } export const api = { - getCurrentProject(baseUrl?: string) { - return requestJson("/project", {}, baseUrl); - }, - getKnownProjects(baseUrl?: string) { - return requestJson("/projects", {}, baseUrl); - }, - forgetKnownProject(path: string, baseUrl?: string) { - return requestJson( - "/projects/forget", - { method: "POST", body: JSON.stringify({ path }) }, - baseUrl, - ); - }, - openProject(path: string, baseUrl?: string) { - return requestJson( - "/project", - { method: "POST", body: JSON.stringify({ path }) }, - baseUrl, - ); - }, - closeProject(baseUrl?: string) { - return requestJson("/project", { method: "DELETE" }, baseUrl); - }, - getModelPreference(baseUrl?: string) { - return requestJson("/model", {}, baseUrl); - }, - setModelPreference(model: string, baseUrl?: string) { - return requestJson( - "/model", - { method: "POST", body: JSON.stringify({ model }) }, - baseUrl, - ); - }, - getOllamaModels(baseUrlParam?: string, baseUrl?: string) { - const url = new URL( - buildApiUrl("/ollama/models", baseUrl), - window.location.origin, - ); - if (baseUrlParam) { - url.searchParams.set("base_url", baseUrlParam); - } - return requestJson(url.pathname + url.search, {}, ""); - }, - getAnthropicApiKeyExists(baseUrl?: string) { - return requestJson("/anthropic/key/exists", {}, baseUrl); - }, - getAnthropicModels(baseUrl?: string) { - return requestJson("/anthropic/models", {}, baseUrl); - }, - setAnthropicApiKey(api_key: string, baseUrl?: string) { - return requestJson( - "/anthropic/key", - { method: "POST", body: JSON.stringify({ api_key }) }, - baseUrl, - ); - }, - readFile(path: string, baseUrl?: string) { - return requestJson( - "/fs/read", - { method: "POST", body: JSON.stringify({ path }) }, - baseUrl, - ); - }, - writeFile(path: string, content: string, baseUrl?: string) { - return requestJson( - "/fs/write", - { method: "POST", body: JSON.stringify({ path, content }) }, - baseUrl, - ); - }, - listDirectory(path: string, baseUrl?: string) { - return requestJson( - "/fs/list", - { method: "POST", body: JSON.stringify({ path }) }, - baseUrl, - ); - }, - listDirectoryAbsolute(path: string, baseUrl?: string) { - return requestJson( - "/io/fs/list/absolute", - { method: "POST", body: JSON.stringify({ path }) }, - baseUrl, - ); - }, - createDirectoryAbsolute(path: string, baseUrl?: string) { - return requestJson( - "/io/fs/create/absolute", - { method: "POST", body: JSON.stringify({ path }) }, - baseUrl, - ); - }, - getHomeDirectory(baseUrl?: string) { - return requestJson("/io/fs/home", {}, baseUrl); - }, - listProjectFiles(baseUrl?: string) { - return requestJson("/io/fs/files", {}, baseUrl); - }, - searchFiles(query: string, baseUrl?: string) { - return requestJson( - "/fs/search", - { method: "POST", body: JSON.stringify({ query }) }, - baseUrl, - ); - }, - execShell(command: string, args: string[], baseUrl?: string) { - return requestJson( - "/shell/exec", - { method: "POST", body: JSON.stringify({ command, args }) }, - baseUrl, - ); - }, - cancelChat(baseUrl?: string) { - return requestJson("/chat/cancel", { method: "POST" }, baseUrl); - }, - getWorkItemContent(storyId: string, baseUrl?: string) { - return requestJson( - `/work-items/${encodeURIComponent(storyId)}`, - {}, - baseUrl, - ); - }, - getTestResults(storyId: string, baseUrl?: string) { - return requestJson( - `/work-items/${encodeURIComponent(storyId)}/test-results`, - {}, - baseUrl, - ); - }, - getTokenCost(storyId: string, baseUrl?: string) { - return requestJson( - `/work-items/${encodeURIComponent(storyId)}/token-cost`, - {}, - baseUrl, - ); - }, - getAllTokenUsage(baseUrl?: string) { - return requestJson("/token-usage", {}, baseUrl); - }, - /** Trigger a server rebuild and restart. */ - rebuildAndRestart() { - return callMcpTool("rebuild_and_restart", {}); - }, - /** Approve a story in QA, moving it to merge. */ - approveQa(storyId: string) { - return callMcpTool("approve_qa", { story_id: storyId }); - }, - /** Reject a story in QA, moving it back to current with notes. */ - rejectQa(storyId: string, notes: string) { - return callMcpTool("reject_qa", { story_id: storyId, notes }); - }, - /** Launch the QA app for a story's worktree. */ - launchQaApp(storyId: string) { - return callMcpTool("launch_qa_app", { story_id: storyId }); - }, - /** Delete a story from the pipeline, stopping any running agent and removing the worktree. */ - deleteStory(storyId: string) { - return callMcpTool("delete_story", { story_id: storyId }); - }, + getCurrentProject(baseUrl?: string) { + return requestJson("/project", {}, baseUrl); + }, + getKnownProjects(baseUrl?: string) { + return requestJson("/projects", {}, baseUrl); + }, + forgetKnownProject(path: string, baseUrl?: string) { + return requestJson( + "/projects/forget", + { method: "POST", body: JSON.stringify({ path }) }, + baseUrl, + ); + }, + openProject(path: string, baseUrl?: string) { + return requestJson( + "/project", + { method: "POST", body: JSON.stringify({ path }) }, + baseUrl, + ); + }, + closeProject(baseUrl?: string) { + return requestJson("/project", { method: "DELETE" }, baseUrl); + }, + getModelPreference(baseUrl?: string) { + return requestJson("/model", {}, baseUrl); + }, + setModelPreference(model: string, baseUrl?: string) { + return requestJson( + "/model", + { method: "POST", body: JSON.stringify({ model }) }, + baseUrl, + ); + }, + getOllamaModels(baseUrlParam?: string, baseUrl?: string) { + const url = new URL( + buildApiUrl("/ollama/models", baseUrl), + window.location.origin, + ); + if (baseUrlParam) { + url.searchParams.set("base_url", baseUrlParam); + } + return requestJson(url.pathname + url.search, {}, ""); + }, + getAnthropicApiKeyExists(baseUrl?: string) { + return requestJson("/anthropic/key/exists", {}, baseUrl); + }, + getAnthropicModels(baseUrl?: string) { + return requestJson("/anthropic/models", {}, baseUrl); + }, + setAnthropicApiKey(api_key: string, baseUrl?: string) { + return requestJson( + "/anthropic/key", + { method: "POST", body: JSON.stringify({ api_key }) }, + baseUrl, + ); + }, + readFile(path: string, baseUrl?: string) { + return requestJson( + "/fs/read", + { method: "POST", body: JSON.stringify({ path }) }, + baseUrl, + ); + }, + writeFile(path: string, content: string, baseUrl?: string) { + return requestJson( + "/fs/write", + { method: "POST", body: JSON.stringify({ path, content }) }, + baseUrl, + ); + }, + listDirectory(path: string, baseUrl?: string) { + return requestJson( + "/fs/list", + { method: "POST", body: JSON.stringify({ path }) }, + baseUrl, + ); + }, + listDirectoryAbsolute(path: string, baseUrl?: string) { + return requestJson( + "/io/fs/list/absolute", + { method: "POST", body: JSON.stringify({ path }) }, + baseUrl, + ); + }, + createDirectoryAbsolute(path: string, baseUrl?: string) { + return requestJson( + "/io/fs/create/absolute", + { method: "POST", body: JSON.stringify({ path }) }, + baseUrl, + ); + }, + getHomeDirectory(baseUrl?: string) { + return requestJson("/io/fs/home", {}, baseUrl); + }, + listProjectFiles(baseUrl?: string) { + return requestJson("/io/fs/files", {}, baseUrl); + }, + searchFiles(query: string, baseUrl?: string) { + return requestJson( + "/fs/search", + { method: "POST", body: JSON.stringify({ query }) }, + baseUrl, + ); + }, + execShell(command: string, args: string[], baseUrl?: string) { + return requestJson( + "/shell/exec", + { method: "POST", body: JSON.stringify({ command, args }) }, + baseUrl, + ); + }, + cancelChat(baseUrl?: string) { + return requestJson("/chat/cancel", { method: "POST" }, baseUrl); + }, + getWorkItemContent(storyId: string, baseUrl?: string) { + return requestJson( + `/work-items/${encodeURIComponent(storyId)}`, + {}, + baseUrl, + ); + }, + getTestResults(storyId: string, baseUrl?: string) { + return requestJson( + `/work-items/${encodeURIComponent(storyId)}/test-results`, + {}, + baseUrl, + ); + }, + getTokenCost(storyId: string, baseUrl?: string) { + return requestJson( + `/work-items/${encodeURIComponent(storyId)}/token-cost`, + {}, + baseUrl, + ); + }, + getAllTokenUsage(baseUrl?: string) { + return requestJson("/token-usage", {}, baseUrl); + }, + /** Trigger a server rebuild and restart. */ + rebuildAndRestart() { + return callMcpTool("rebuild_and_restart", {}); + }, + /** Approve a story in QA, moving it to merge. */ + approveQa(storyId: string) { + return callMcpTool("approve_qa", { story_id: storyId }); + }, + /** Reject a story in QA, moving it back to current with notes. */ + rejectQa(storyId: string, notes: string) { + return callMcpTool("reject_qa", { story_id: storyId, notes }); + }, + /** Launch the QA app for a story's worktree. */ + launchQaApp(storyId: string) { + return callMcpTool("launch_qa_app", { story_id: storyId }); + }, + /** Delete a story from the pipeline, stopping any running agent and removing the worktree. */ + deleteStory(storyId: string) { + return callMcpTool("delete_story", { story_id: storyId }); + }, }; async function callMcpTool( - toolName: string, - args: Record, + toolName: string, + args: Record, ): Promise { - const res = await fetch("/mcp", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method: "tools/call", - params: { name: toolName, arguments: args }, - }), - }); - const json = await res.json(); - if (json.error) { - throw new Error(json.error.message); - } - const text = json.result?.content?.[0]?.text ?? ""; - return text; + const res = await fetch("/mcp", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: toolName, arguments: args }, + }), + }); + const json = await res.json(); + if (json.error) { + throw new Error(json.error.message); + } + const text = json.result?.content?.[0]?.text ?? ""; + return text; } export class ChatWebSocket { - private static sharedSocket: WebSocket | null = null; - private static refCount = 0; - private socket?: WebSocket; - private onToken?: (content: string) => void; - private onThinkingToken?: (content: string) => void; - private onUpdate?: (messages: Message[]) => void; - private onSessionId?: (sessionId: string) => void; - private onError?: (message: string) => void; - private onPipelineState?: (state: PipelineState) => void; - private onPermissionRequest?: ( - requestId: string, - toolName: string, - toolInput: Record, - ) => void; - private onActivity?: (toolName: string) => void; - private onReconciliationProgress?: ( - storyId: string, - status: string, - message: string, - ) => void; - private onAgentConfigChanged?: () => void; - private onAgentStateChanged?: () => void; - private onOnboardingStatus?: (needsOnboarding: boolean) => void; - private onSideQuestionToken?: (content: string) => void; - private onSideQuestionDone?: (response: string) => void; - private onLogEntry?: ( - timestamp: string, - level: string, - message: string, - ) => void; - private onConnected?: () => void; - private connected = false; - private closeTimer?: number; - private wsPath = DEFAULT_WS_PATH; - private reconnectTimer?: number; - private reconnectDelay = 1000; - private shouldReconnect = false; - private heartbeatInterval?: number; - private heartbeatTimeout?: number; - private static readonly HEARTBEAT_INTERVAL = 30_000; - private static readonly HEARTBEAT_TIMEOUT = 5_000; + private static sharedSocket: WebSocket | null = null; + private static refCount = 0; + private socket?: WebSocket; + private onToken?: (content: string) => void; + private onThinkingToken?: (content: string) => void; + private onUpdate?: (messages: Message[]) => void; + private onSessionId?: (sessionId: string) => void; + private onError?: (message: string) => void; + private onPipelineState?: (state: PipelineState) => void; + private onPermissionRequest?: ( + requestId: string, + toolName: string, + toolInput: Record, + ) => void; + private onActivity?: (toolName: string) => void; + private onReconciliationProgress?: ( + storyId: string, + status: string, + message: string, + ) => void; + private onAgentConfigChanged?: () => void; + private onAgentStateChanged?: () => void; + private onOnboardingStatus?: (needsOnboarding: boolean) => void; + private onSideQuestionToken?: (content: string) => void; + private onSideQuestionDone?: (response: string) => void; + private onLogEntry?: ( + timestamp: string, + level: string, + message: string, + ) => void; + private onConnected?: () => void; + private connected = false; + private closeTimer?: number; + private wsPath = DEFAULT_WS_PATH; + private reconnectTimer?: number; + private reconnectDelay = 1000; + private shouldReconnect = false; + private heartbeatInterval?: number; + private heartbeatTimeout?: number; + private static readonly HEARTBEAT_INTERVAL = 30_000; + private static readonly HEARTBEAT_TIMEOUT = 5_000; - private _startHeartbeat(): void { - this._stopHeartbeat(); - this.heartbeatInterval = window.setInterval(() => { - if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return; - const ping: WsRequest = { type: "ping" }; - this.socket.send(JSON.stringify(ping)); - this.heartbeatTimeout = window.setTimeout(() => { - // No pong received within timeout; close socket to trigger reconnect. - this.socket?.close(); - }, ChatWebSocket.HEARTBEAT_TIMEOUT); - }, ChatWebSocket.HEARTBEAT_INTERVAL); - } + private _startHeartbeat(): void { + this._stopHeartbeat(); + this.heartbeatInterval = window.setInterval(() => { + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return; + const ping: WsRequest = { type: "ping" }; + this.socket.send(JSON.stringify(ping)); + this.heartbeatTimeout = window.setTimeout(() => { + // No pong received within timeout; close socket to trigger reconnect. + this.socket?.close(); + }, ChatWebSocket.HEARTBEAT_TIMEOUT); + }, ChatWebSocket.HEARTBEAT_INTERVAL); + } - private _stopHeartbeat(): void { - window.clearInterval(this.heartbeatInterval); - window.clearTimeout(this.heartbeatTimeout); - this.heartbeatInterval = undefined; - this.heartbeatTimeout = undefined; - } + private _stopHeartbeat(): void { + window.clearInterval(this.heartbeatInterval); + window.clearTimeout(this.heartbeatTimeout); + this.heartbeatInterval = undefined; + this.heartbeatTimeout = undefined; + } - private _buildWsUrl(): string { - const protocol = window.location.protocol === "https:" ? "wss" : "ws"; - const wsHost = resolveWsHost( - import.meta.env.DEV, - typeof __STORKIT_PORT__ !== "undefined" ? __STORKIT_PORT__ : undefined, - window.location.host, - ); - return `${protocol}://${wsHost}${this.wsPath}`; - } + private _buildWsUrl(): string { + const protocol = window.location.protocol === "https:" ? "wss" : "ws"; + const wsHost = resolveWsHost( + import.meta.env.DEV, + typeof __STORKIT_PORT__ !== "undefined" ? __STORKIT_PORT__ : undefined, + window.location.host, + ); + return `${protocol}://${wsHost}${this.wsPath}`; + } - private _attachHandlers(): void { - if (!this.socket) return; - this.socket.onopen = () => { - this.reconnectDelay = 1000; - this._startHeartbeat(); - this.onConnected?.(); - }; - this.socket.onmessage = (event) => { - try { - const data = JSON.parse(event.data) as WsResponse; - if (data.type === "token") this.onToken?.(data.content); - if (data.type === "thinking_token") - this.onThinkingToken?.(data.content); - if (data.type === "update") this.onUpdate?.(data.messages); - if (data.type === "session_id") this.onSessionId?.(data.session_id); - if (data.type === "error") this.onError?.(data.message); - if (data.type === "pipeline_state") - this.onPipelineState?.({ - backlog: data.backlog, - current: data.current, - qa: data.qa, - merge: data.merge, - done: data.done, - }); - if (data.type === "permission_request") - this.onPermissionRequest?.( - data.request_id, - data.tool_name, - data.tool_input, - ); - if (data.type === "tool_activity") this.onActivity?.(data.tool_name); - if (data.type === "reconciliation_progress") - this.onReconciliationProgress?.( - data.story_id, - data.status, - data.message, - ); - if (data.type === "agent_config_changed") this.onAgentConfigChanged?.(); - if (data.type === "agent_state_changed") this.onAgentStateChanged?.(); - if (data.type === "onboarding_status") - this.onOnboardingStatus?.(data.needs_onboarding); - if (data.type === "side_question_token") - this.onSideQuestionToken?.(data.content); - if (data.type === "side_question_done") - this.onSideQuestionDone?.(data.response); - if (data.type === "log_entry") - this.onLogEntry?.(data.timestamp, data.level, data.message); - if (data.type === "pong") { - window.clearTimeout(this.heartbeatTimeout); - this.heartbeatTimeout = undefined; - } - } catch (err) { - this.onError?.(String(err)); - } - }; - this.socket.onerror = () => { - this.onError?.("WebSocket error"); - }; - this.socket.onclose = () => { - if (this.shouldReconnect && this.connected) { - this._scheduleReconnect(); - } - }; - } + private _attachHandlers(): void { + if (!this.socket) return; + this.socket.onopen = () => { + this.reconnectDelay = 1000; + this._startHeartbeat(); + this.onConnected?.(); + }; + this.socket.onmessage = (event) => { + try { + const data = JSON.parse(event.data) as WsResponse; + if (data.type === "token") this.onToken?.(data.content); + if (data.type === "thinking_token") + this.onThinkingToken?.(data.content); + if (data.type === "update") this.onUpdate?.(data.messages); + if (data.type === "session_id") this.onSessionId?.(data.session_id); + if (data.type === "error") this.onError?.(data.message); + if (data.type === "pipeline_state") + this.onPipelineState?.({ + backlog: data.backlog, + current: data.current, + qa: data.qa, + merge: data.merge, + done: data.done, + }); + if (data.type === "permission_request") + this.onPermissionRequest?.( + data.request_id, + data.tool_name, + data.tool_input, + ); + if (data.type === "tool_activity") this.onActivity?.(data.tool_name); + if (data.type === "reconciliation_progress") + this.onReconciliationProgress?.( + data.story_id, + data.status, + data.message, + ); + if (data.type === "agent_config_changed") this.onAgentConfigChanged?.(); + if (data.type === "agent_state_changed") this.onAgentStateChanged?.(); + if (data.type === "onboarding_status") + this.onOnboardingStatus?.(data.needs_onboarding); + if (data.type === "side_question_token") + this.onSideQuestionToken?.(data.content); + if (data.type === "side_question_done") + this.onSideQuestionDone?.(data.response); + if (data.type === "log_entry") + this.onLogEntry?.(data.timestamp, data.level, data.message); + if (data.type === "pong") { + window.clearTimeout(this.heartbeatTimeout); + this.heartbeatTimeout = undefined; + } + } catch (err) { + this.onError?.(String(err)); + } + }; + this.socket.onerror = () => { + this.onError?.("WebSocket error"); + }; + this.socket.onclose = () => { + if (this.shouldReconnect && this.connected) { + this._scheduleReconnect(); + } + }; + } - private _scheduleReconnect(): void { - window.clearTimeout(this.reconnectTimer); - const delay = this.reconnectDelay; - this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000); - this.reconnectTimer = window.setTimeout(() => { - this.reconnectTimer = undefined; - const wsUrl = this._buildWsUrl(); - ChatWebSocket.sharedSocket = new WebSocket(wsUrl); - this.socket = ChatWebSocket.sharedSocket; - this._attachHandlers(); - }, delay); - } + private _scheduleReconnect(): void { + window.clearTimeout(this.reconnectTimer); + const delay = this.reconnectDelay; + this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000); + this.reconnectTimer = window.setTimeout(() => { + this.reconnectTimer = undefined; + const wsUrl = this._buildWsUrl(); + ChatWebSocket.sharedSocket = new WebSocket(wsUrl); + this.socket = ChatWebSocket.sharedSocket; + this._attachHandlers(); + }, delay); + } - connect( - handlers: { - onToken?: (content: string) => void; - onThinkingToken?: (content: string) => void; - onUpdate?: (messages: Message[]) => void; - onSessionId?: (sessionId: string) => void; - onError?: (message: string) => void; - onPipelineState?: (state: PipelineState) => void; - onPermissionRequest?: ( - requestId: string, - toolName: string, - toolInput: Record, - ) => void; - onActivity?: (toolName: string) => void; - onReconciliationProgress?: ( - storyId: string, - status: string, - message: string, - ) => void; - onAgentConfigChanged?: () => void; - onAgentStateChanged?: () => void; - onOnboardingStatus?: (needsOnboarding: boolean) => void; - onSideQuestionToken?: (content: string) => void; - onSideQuestionDone?: (response: string) => void; - onLogEntry?: (timestamp: string, level: string, message: string) => void; - onConnected?: () => void; - }, - wsPath = DEFAULT_WS_PATH, - ) { - this.onToken = handlers.onToken; - this.onThinkingToken = handlers.onThinkingToken; - this.onUpdate = handlers.onUpdate; - this.onSessionId = handlers.onSessionId; - this.onError = handlers.onError; - this.onPipelineState = handlers.onPipelineState; - this.onPermissionRequest = handlers.onPermissionRequest; - this.onActivity = handlers.onActivity; - this.onReconciliationProgress = handlers.onReconciliationProgress; - this.onAgentConfigChanged = handlers.onAgentConfigChanged; - this.onAgentStateChanged = handlers.onAgentStateChanged; - this.onOnboardingStatus = handlers.onOnboardingStatus; - this.onSideQuestionToken = handlers.onSideQuestionToken; - this.onSideQuestionDone = handlers.onSideQuestionDone; - this.onLogEntry = handlers.onLogEntry; - this.onConnected = handlers.onConnected; - this.wsPath = wsPath; - this.shouldReconnect = true; + connect( + handlers: { + onToken?: (content: string) => void; + onThinkingToken?: (content: string) => void; + onUpdate?: (messages: Message[]) => void; + onSessionId?: (sessionId: string) => void; + onError?: (message: string) => void; + onPipelineState?: (state: PipelineState) => void; + onPermissionRequest?: ( + requestId: string, + toolName: string, + toolInput: Record, + ) => void; + onActivity?: (toolName: string) => void; + onReconciliationProgress?: ( + storyId: string, + status: string, + message: string, + ) => void; + onAgentConfigChanged?: () => void; + onAgentStateChanged?: () => void; + onOnboardingStatus?: (needsOnboarding: boolean) => void; + onSideQuestionToken?: (content: string) => void; + onSideQuestionDone?: (response: string) => void; + onLogEntry?: (timestamp: string, level: string, message: string) => void; + onConnected?: () => void; + }, + wsPath = DEFAULT_WS_PATH, + ) { + this.onToken = handlers.onToken; + this.onThinkingToken = handlers.onThinkingToken; + this.onUpdate = handlers.onUpdate; + this.onSessionId = handlers.onSessionId; + this.onError = handlers.onError; + this.onPipelineState = handlers.onPipelineState; + this.onPermissionRequest = handlers.onPermissionRequest; + this.onActivity = handlers.onActivity; + this.onReconciliationProgress = handlers.onReconciliationProgress; + this.onAgentConfigChanged = handlers.onAgentConfigChanged; + this.onAgentStateChanged = handlers.onAgentStateChanged; + this.onOnboardingStatus = handlers.onOnboardingStatus; + this.onSideQuestionToken = handlers.onSideQuestionToken; + this.onSideQuestionDone = handlers.onSideQuestionDone; + this.onLogEntry = handlers.onLogEntry; + this.onConnected = handlers.onConnected; + this.wsPath = wsPath; + this.shouldReconnect = true; - if (this.connected) { - return; - } - this.connected = true; - ChatWebSocket.refCount += 1; + if (this.connected) { + return; + } + this.connected = true; + ChatWebSocket.refCount += 1; - if ( - !ChatWebSocket.sharedSocket || - ChatWebSocket.sharedSocket.readyState === WebSocket.CLOSED || - ChatWebSocket.sharedSocket.readyState === WebSocket.CLOSING - ) { - const wsUrl = this._buildWsUrl(); - ChatWebSocket.sharedSocket = new WebSocket(wsUrl); - } - this.socket = ChatWebSocket.sharedSocket; - this._attachHandlers(); - } + if ( + !ChatWebSocket.sharedSocket || + ChatWebSocket.sharedSocket.readyState === WebSocket.CLOSED || + ChatWebSocket.sharedSocket.readyState === WebSocket.CLOSING + ) { + const wsUrl = this._buildWsUrl(); + ChatWebSocket.sharedSocket = new WebSocket(wsUrl); + } + this.socket = ChatWebSocket.sharedSocket; + this._attachHandlers(); + } - sendChat(messages: Message[], config: ProviderConfig) { - this.send({ type: "chat", messages, config }); - } + sendChat(messages: Message[], config: ProviderConfig) { + this.send({ type: "chat", messages, config }); + } - sendSideQuestion( - question: string, - contextMessages: Message[], - config: ProviderConfig, - ) { - this.send({ - type: "side_question", - question, - context_messages: contextMessages, - config, - }); - } + sendSideQuestion( + question: string, + contextMessages: Message[], + config: ProviderConfig, + ) { + this.send({ + type: "side_question", + question, + context_messages: contextMessages, + config, + }); + } - cancel() { - this.send({ type: "cancel" }); - } + cancel() { + this.send({ type: "cancel" }); + } - sendPermissionResponse( - requestId: string, - approved: boolean, - alwaysAllow = false, - ) { - this.send({ - type: "permission_response", - request_id: requestId, - approved, - always_allow: alwaysAllow, - }); - } + sendPermissionResponse( + requestId: string, + approved: boolean, + alwaysAllow = false, + ) { + this.send({ + type: "permission_response", + request_id: requestId, + approved, + always_allow: alwaysAllow, + }); + } - close() { - this.shouldReconnect = false; - this._stopHeartbeat(); - window.clearTimeout(this.reconnectTimer); - this.reconnectTimer = undefined; + close() { + this.shouldReconnect = false; + this._stopHeartbeat(); + window.clearTimeout(this.reconnectTimer); + this.reconnectTimer = undefined; - if (!this.connected) return; - this.connected = false; - ChatWebSocket.refCount = Math.max(0, ChatWebSocket.refCount - 1); + if (!this.connected) return; + this.connected = false; + ChatWebSocket.refCount = Math.max(0, ChatWebSocket.refCount - 1); - if (import.meta.env.DEV) { - if (this.closeTimer) { - window.clearTimeout(this.closeTimer); - } - this.closeTimer = window.setTimeout(() => { - if (ChatWebSocket.refCount === 0) { - ChatWebSocket.sharedSocket?.close(); - ChatWebSocket.sharedSocket = null; - } - this.socket = ChatWebSocket.sharedSocket ?? undefined; - this.closeTimer = undefined; - }, 250); - return; - } + if (import.meta.env.DEV) { + if (this.closeTimer) { + window.clearTimeout(this.closeTimer); + } + this.closeTimer = window.setTimeout(() => { + if (ChatWebSocket.refCount === 0) { + ChatWebSocket.sharedSocket?.close(); + ChatWebSocket.sharedSocket = null; + } + this.socket = ChatWebSocket.sharedSocket ?? undefined; + this.closeTimer = undefined; + }, 250); + return; + } - if (ChatWebSocket.refCount === 0) { - ChatWebSocket.sharedSocket?.close(); - ChatWebSocket.sharedSocket = null; - } - this.socket = ChatWebSocket.sharedSocket ?? undefined; - } + if (ChatWebSocket.refCount === 0) { + ChatWebSocket.sharedSocket?.close(); + ChatWebSocket.sharedSocket = null; + } + this.socket = ChatWebSocket.sharedSocket ?? undefined; + } - private send(payload: WsRequest) { - if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { - this.onError?.("WebSocket is not connected"); - return; - } - this.socket.send(JSON.stringify(payload)); - } + private send(payload: WsRequest) { + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) { + this.onError?.("WebSocket is not connected"); + return; + } + this.socket.send(JSON.stringify(payload)); + } } diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index 5666478..74f4113 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -4,7 +4,7 @@ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; import type { AgentConfigInfo } from "../api/agents"; import { agentsApi } from "../api/agents"; -import type { PipelineState } from "../api/client"; +import type { AnthropicModelInfo, PipelineState } from "../api/client"; import { api, ChatWebSocket } from "../api/client"; import { useChatHistory } from "../hooks/useChatHistory"; import type { Message, ProviderConfig } from "../types"; @@ -143,8 +143,13 @@ function formatToolActivity(toolName: string): string { const estimateTokens = (text: string): number => Math.ceil(text.length / 4); -const getContextWindowSize = (modelName: string): number => { - if (modelName.startsWith("claude-")) return 200000; +const getContextWindowSize = ( + modelName: string, + claudeContextWindows?: Map, +): number => { + if (modelName.startsWith("claude-")) { + return claudeContextWindows?.get(modelName) ?? 200000; + } if (modelName.includes("llama3")) return 8192; if (modelName.includes("qwen2.5")) return 32768; if (modelName.includes("deepseek")) return 16384; @@ -163,6 +168,9 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { const [enableTools, setEnableTools] = useState(true); const [availableModels, setAvailableModels] = useState([]); const [claudeModels, setClaudeModels] = useState([]); + const [claudeContextWindowMap, setClaudeContextWindowMap] = useState< + Map + >(new Map()); const [streamingContent, setStreamingContent] = useState(""); const [streamingThinking, setStreamingThinking] = useState(""); const [showApiKeyDialog, setShowApiKeyDialog] = useState(false); @@ -285,7 +293,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { totalTokens += estimateTokens(streamingContent); } - const contextWindow = getContextWindowSize(model); + const contextWindow = getContextWindowSize(model, claudeContextWindowMap); const percentage = Math.round((totalTokens / contextWindow) * 100); return { @@ -293,7 +301,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { total: contextWindow, percentage, }; - }, [messages, streamingContent, model]); + }, [messages, streamingContent, model, claudeContextWindowMap]); useEffect(() => { try { @@ -337,14 +345,18 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { .then((exists) => { setHasAnthropicKey(exists); if (!exists) return; - return api.getAnthropicModels().then((models) => { + return api.getAnthropicModels().then((models: AnthropicModelInfo[]) => { if (models.length > 0) { const sortedModels = models.sort((a, b) => - a.toLowerCase().localeCompare(b.toLowerCase()), + a.id.toLowerCase().localeCompare(b.id.toLowerCase()), + ); + setClaudeModels(sortedModels.map((m) => m.id)); + setClaudeContextWindowMap( + new Map(sortedModels.map((m) => [m.id, m.context_window])), ); - setClaudeModels(sortedModels); } else { setClaudeModels([]); + setClaudeContextWindowMap(new Map()); } }); }) diff --git a/server/src/http/anthropic.rs b/server/src/http/anthropic.rs index 8213e00..10f3b08 100644 --- a/server/src/http/anthropic.rs +++ b/server/src/http/anthropic.rs @@ -3,7 +3,7 @@ use crate::llm::chat; use crate::store::StoreOps; use poem_openapi::{Object, OpenApi, Tags, payload::Json}; use reqwest::header::{HeaderMap, HeaderValue}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::sync::Arc; const ANTHROPIC_MODELS_URL: &str = "https://api.anthropic.com/v1/models"; @@ -18,6 +18,13 @@ struct AnthropicModelsResponse { #[derive(Deserialize)] struct AnthropicModelInfo { id: String, + context_window: u64, +} + +#[derive(Serialize, Object)] +struct AnthropicModelSummary { + id: String, + context_window: u64, } fn get_anthropic_api_key(ctx: &AppContext) -> Result { @@ -84,7 +91,7 @@ impl AnthropicApi { /// List available Anthropic models. #[oai(path = "/anthropic/models", method = "get")] - async fn list_anthropic_models(&self) -> OpenApiResult>> { + async fn list_anthropic_models(&self) -> OpenApiResult>> { self.list_anthropic_models_from(ANTHROPIC_MODELS_URL).await } } @@ -93,7 +100,7 @@ impl AnthropicApi { async fn list_anthropic_models_from( &self, url: &str, - ) -> OpenApiResult>> { + ) -> OpenApiResult>> { let api_key = get_anthropic_api_key(self.ctx.as_ref()).map_err(bad_request)?; let client = reqwest::Client::new(); let mut headers = HeaderMap::new(); @@ -128,7 +135,14 @@ impl AnthropicApi { .json::() .await .map_err(|e| bad_request(e.to_string()))?; - let models = body.data.into_iter().map(|m| m.id).collect(); + let models = body + .data + .into_iter() + .map(|m| AnthropicModelSummary { + id: m.id, + context_window: m.context_window, + }) + .collect(); Ok(Json(models)) } @@ -276,4 +290,29 @@ mod tests { let dir = TempDir::new().unwrap(); let _api = make_api(&dir); } + + #[test] + fn anthropic_model_info_deserializes_context_window() { + let json = json!({ + "id": "claude-opus-4-5", + "context_window": 200000 + }); + let info: AnthropicModelInfo = serde_json::from_value(json).unwrap(); + assert_eq!(info.id, "claude-opus-4-5"); + assert_eq!(info.context_window, 200000); + } + + #[test] + fn anthropic_models_response_deserializes_multiple_models() { + let json = json!({ + "data": [ + { "id": "claude-opus-4-5", "context_window": 200000 }, + { "id": "claude-haiku-4-5-20251001", "context_window": 100000 } + ] + }); + let response: AnthropicModelsResponse = serde_json::from_value(json).unwrap(); + assert_eq!(response.data.len(), 2); + assert_eq!(response.data[0].context_window, 200000); + assert_eq!(response.data[1].context_window, 100000); + } }