When an assistant message sits status='streaming' with no token activity for 60+ seconds, the chat shows a banner above the input offering Retry or Discard. Both clear the stale row via a new backend endpoint POST /api/chats/:id/discard_stale that updates status='failed' and publishes chat_status='idle'. Closes the UX gap that caused the 2026-05-21 debugging spiral — slow streams and dead streams now look different to the user. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
305 lines
11 KiB
TypeScript
305 lines
11 KiB
TypeScript
import type {
|
|
Project,
|
|
AvailableProject,
|
|
Session,
|
|
Chat,
|
|
Message,
|
|
ModelInfo,
|
|
SidebarResponse,
|
|
ListDirResult,
|
|
ViewFileResult,
|
|
AgentsResponse,
|
|
GitMeta,
|
|
Skill,
|
|
AskUserAnswer,
|
|
} from './types';
|
|
|
|
export class ApiError extends Error {
|
|
constructor(
|
|
public status: number,
|
|
public body: unknown
|
|
) {
|
|
super(typeof body === 'object' && body && 'error' in body ? String((body as { error: unknown }).error) : `HTTP ${status}`);
|
|
}
|
|
}
|
|
|
|
async function request<T>(
|
|
path: string,
|
|
init: RequestInit = {}
|
|
): Promise<T> {
|
|
const res = await fetch(path, {
|
|
...init,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...(init.headers ?? {}),
|
|
},
|
|
});
|
|
if (res.status === 204) return undefined as T;
|
|
const text = await res.text();
|
|
const data = text ? JSON.parse(text) : undefined;
|
|
if (!res.ok) throw new ApiError(res.status, data);
|
|
return data as T;
|
|
}
|
|
|
|
export const api = {
|
|
health: () => request<{ status: string; db: boolean }>('/api/health'),
|
|
|
|
projects: {
|
|
list: (params?: { status?: 'open' | 'archived' }) =>
|
|
request<Project[]>(`/api/projects${params?.status ? `?status=${params.status}` : ''}`),
|
|
available: () => request<AvailableProject[]>('/api/projects/available'),
|
|
add: (body: { path: string; name?: string }) =>
|
|
request<Project>('/api/projects', {
|
|
method: 'POST',
|
|
body: JSON.stringify(body),
|
|
}),
|
|
update: (
|
|
id: string,
|
|
body: Partial<Pick<Project, 'name' | 'default_system_prompt' | 'default_web_search_enabled'>>,
|
|
) =>
|
|
request<Project>(`/api/projects/${id}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(body),
|
|
}),
|
|
get: (id: string) => request<Project>(`/api/projects/${id}`),
|
|
archive: (id: string) =>
|
|
request<void>(`/api/projects/${id}/archive`, { method: 'POST' }),
|
|
unarchive: (id: string) =>
|
|
request<Project>(`/api/projects/${id}/unarchive`, { method: 'POST' }),
|
|
// v1.9: bulk-archive every open session in this project. Server publishes
|
|
// one session_archived frame per affected id, so the sidebar reducer
|
|
// updates incrementally rather than waiting for a refetch.
|
|
archiveAllSessions: (id: string) =>
|
|
request<{ archived: number; ids: string[] }>(
|
|
`/api/projects/${id}/sessions/archive-all`,
|
|
{ method: 'POST' },
|
|
),
|
|
openSessionsCount: (id: string) =>
|
|
request<{ count: number }>(`/api/projects/${id}/sessions/open-count`),
|
|
create: (body: {
|
|
name: string;
|
|
commit_message?: string;
|
|
visibility?: 'private' | 'public';
|
|
create_gitea_remote?: boolean;
|
|
}) =>
|
|
request<{
|
|
project: Project;
|
|
bootstrap: {
|
|
folder_created: boolean;
|
|
git_initialized: boolean;
|
|
first_commit: boolean;
|
|
gitea_remote_created: boolean;
|
|
gitea_pushed: boolean;
|
|
warnings: string[];
|
|
};
|
|
}>(`/api/projects/create`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(body),
|
|
}),
|
|
remove: (id: string) =>
|
|
request<void>(`/api/projects/${id}`, { method: 'DELETE' }),
|
|
listDir: (id: string, path: string) =>
|
|
request<ListDirResult>(`/api/projects/${id}/list_dir?path=${encodeURIComponent(path)}`),
|
|
viewFile: (id: string, path: string) =>
|
|
request<ViewFileResult>(`/api/projects/${id}/view_file?path=${encodeURIComponent(path)}`),
|
|
files: (id: string) =>
|
|
request<{ files: string[] }>(`/api/projects/${id}/files`),
|
|
git: (id: string) =>
|
|
request<GitMeta>(`/api/projects/${id}/git`),
|
|
},
|
|
|
|
sessions: {
|
|
listForProject: (projectId: string, status?: 'open' | 'archived') =>
|
|
request<Session[]>(`/api/projects/${projectId}/sessions${status ? `?status=${status}` : ''}`),
|
|
create: (
|
|
projectId: string,
|
|
body: { name?: string; model?: string; system_prompt?: string; agent_id?: string | null }
|
|
) =>
|
|
request<Session>(`/api/projects/${projectId}/sessions`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(body),
|
|
}),
|
|
get: (id: string) => request<Session>(`/api/sessions/${id}`),
|
|
update: (
|
|
id: string,
|
|
body: Partial<Pick<Session, 'name' | 'model' | 'system_prompt' | 'agent_id' | 'web_search_enabled'>>
|
|
) =>
|
|
request<Session>(`/api/sessions/${id}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(body),
|
|
}),
|
|
remove: (id: string) =>
|
|
request<void>(`/api/sessions/${id}`, { method: 'DELETE' }),
|
|
archive: (id: string) =>
|
|
request<void>(`/api/sessions/${id}/archive`, { method: 'POST' }),
|
|
unarchive: (id: string) =>
|
|
request<Session>(`/api/sessions/${id}/unarchive`, { method: 'POST' }),
|
|
// v1.9: bulk-archive every open chat in this session. Same pattern as
|
|
// archiveAllSessions — server publishes one chat_archived per id.
|
|
archiveAllChats: (id: string) =>
|
|
request<{ archived: number; ids: string[] }>(
|
|
`/api/sessions/${id}/chats/archive-all`,
|
|
{ method: 'POST' },
|
|
),
|
|
openChatsCount: (id: string) =>
|
|
request<{ count: number }>(`/api/sessions/${id}/chats/open-count`),
|
|
updateWorkspacePanes: (id: string, panes: Session['workspace_panes']) =>
|
|
request<Session>(`/api/sessions/${id}/workspace`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ workspace_panes: panes }),
|
|
}),
|
|
},
|
|
|
|
chats: {
|
|
listForSession: (sessionId: string, params?: { status?: 'open' | 'archived' }) =>
|
|
request<Chat[]>(
|
|
`/api/sessions/${sessionId}/chats${params?.status ? `?status=${params.status}` : ''}`
|
|
),
|
|
create: (sessionId: string, body?: { name?: string }) =>
|
|
request<Chat>(`/api/sessions/${sessionId}/chats`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(body ?? {}),
|
|
}),
|
|
update: (chatId: string, body: { name: string }) =>
|
|
request<Chat>(`/api/chats/${chatId}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(body),
|
|
}),
|
|
archive: (chatId: string) =>
|
|
request<void>(`/api/chats/${chatId}/archive`, { method: 'POST' }),
|
|
unarchive: (chatId: string) =>
|
|
request<Chat>(`/api/chats/${chatId}/unarchive`, { method: 'POST' }),
|
|
remove: (chatId: string) =>
|
|
request<void>(`/api/chats/${chatId}`, { method: 'DELETE' }),
|
|
messages: (chatId: string) =>
|
|
request<Message[]>(`/api/chats/${chatId}/messages`),
|
|
// v1.11: anchored-rolling compaction. POST awaits the LLM call inside
|
|
// the route's lifecycle; the new summary row arrives via the 'compacted'
|
|
// WS frame (useSessionStream refetches + toasts).
|
|
compact: (chatId: string) =>
|
|
request<{ ok: true }>(`/api/chats/${chatId}/compact`, { method: 'POST' }),
|
|
stop: (chatId: string) =>
|
|
request<{ stopped: boolean }>(`/api/chats/${chatId}/stop`, { method: 'POST' }),
|
|
discardStale: (chatId: string, messageId: string) =>
|
|
request<Message>(`/api/chats/${chatId}/discard_stale`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ message_id: messageId }),
|
|
}),
|
|
forceSend: (chatId: string, content: string) =>
|
|
request<{ user_message_id: string; assistant_message_id: string }>(
|
|
`/api/chats/${chatId}/force_send`,
|
|
{ method: 'POST', body: JSON.stringify({ content }) }
|
|
),
|
|
// v1.8.2: extend an inference that hit the tool budget. `sentinelMessageId`
|
|
// is the cap-hit sentinel message the user clicked Continue on.
|
|
continue: (chatId: string, sentinelMessageId: string) =>
|
|
request<{ assistant_message_id: string }>(
|
|
`/api/chats/${chatId}/continue`,
|
|
{ method: 'POST', body: JSON.stringify({ sentinel_message_id: sentinelMessageId }) }
|
|
),
|
|
fork: (chatId: string, body: { messageId: string; name?: string }) =>
|
|
request<Chat>(`/api/chats/${chatId}/fork`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ message_id: body.messageId, name: body.name }),
|
|
}),
|
|
// Batch 9.6: slash-command invocation. Server loads the skill body
|
|
// authoritatively (client doesn't get to forge file contents), persists
|
|
// a synthetic skill_use tool_use + tool_result + user message + streaming
|
|
// assistant, and enqueues inference. Returns all 4 new message IDs.
|
|
skillInvoke: (chatId: string, skillName: string, userMessage: string | null) =>
|
|
request<{
|
|
synth_assistant_id: string;
|
|
tool_message_id: string;
|
|
user_message_id: string;
|
|
assistant_message_id: string;
|
|
}>(`/api/chats/${chatId}/skill_invoke`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ skill_name: skillName, user_message: userMessage }),
|
|
}),
|
|
// Batch 9.7: submit answers for a paused ask_user_input call. Server
|
|
// validates against the question shape, UPDATEs the pending tool row,
|
|
// publishes the deferred tool_result frame, and enqueues the next turn.
|
|
answerUserInput: (chatId: string, toolCallId: string, answers: AskUserAnswer[]) =>
|
|
request<{ tool_message_id: string; assistant_message_id: string }>(
|
|
`/api/chats/${chatId}/answer_user_input`,
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify({ tool_call_id: toolCallId, answers }),
|
|
},
|
|
),
|
|
},
|
|
|
|
messages: {
|
|
list: (sessionId: string) =>
|
|
request<Message[]>(`/api/sessions/${sessionId}/messages`),
|
|
send: (chatId: string, content: string) =>
|
|
request<{ user_message_id: string; assistant_message_id: string }>(
|
|
`/api/chats/${chatId}/messages`,
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify({ content }),
|
|
}
|
|
),
|
|
regenerate: (chatId: string, messageId: string) =>
|
|
request<{ assistant_message_id: string }>(
|
|
`/api/chats/${chatId}/messages/${messageId}/regenerate`,
|
|
{ method: 'POST' }
|
|
),
|
|
remove: (chatId: string, messageId: string) =>
|
|
request<void>(`/api/chats/${chatId}/messages/${messageId}`, {
|
|
method: 'DELETE',
|
|
}),
|
|
},
|
|
|
|
models: () => request<ModelInfo[]>('/api/models'),
|
|
|
|
agents: {
|
|
list: (projectId: string) =>
|
|
request<AgentsResponse>(`/api/projects/${projectId}/agents`),
|
|
},
|
|
|
|
skills: {
|
|
list: () => request<{ skills: Skill[] }>('/api/skills'),
|
|
},
|
|
|
|
settings: {
|
|
get: () => request<Record<string, unknown>>('/api/settings'),
|
|
patch: (body: Record<string, unknown>) =>
|
|
request<Record<string, unknown>>('/api/settings', {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(body),
|
|
}),
|
|
},
|
|
|
|
sidebar: {
|
|
get: () => request<SidebarResponse>('/api/sidebar'),
|
|
},
|
|
|
|
// v1.10 booterm: REST control plane for terminal panes. WebSocket attach
|
|
// lives at /ws/term/sessions/:sid/panes/:pid (handled directly by
|
|
// TerminalPane). v1.10.8c: resize moved in-band onto the WebSocket as a
|
|
// `{type:"resize",cols,rows}` text frame — the old /resize HTTP endpoint is
|
|
// gone, eliminating the race between WS attach and PTY-map registration.
|
|
terminals: {
|
|
// cols/rows are optional. When passed, booterm sizes the per-pane tmux
|
|
// session at creation time so the inner bash (and any TUI it spawns) is
|
|
// born with the correct PTY dimensions instead of tmux's 80x24 default.
|
|
start: (sessionId: string, paneId: string, cols?: number, rows?: number) =>
|
|
request<{ tmux_session: string }>(
|
|
`/api/term/sessions/${sessionId}/panes/${paneId}/start`,
|
|
{
|
|
method: 'POST',
|
|
body:
|
|
cols !== undefined && rows !== undefined
|
|
? JSON.stringify({ cols, rows })
|
|
: undefined,
|
|
},
|
|
),
|
|
kill: (sessionId: string, paneId: string) =>
|
|
request<{ ok: true }>(
|
|
`/api/term/sessions/${sessionId}/panes/${paneId}/kill`,
|
|
{ method: 'POST' },
|
|
),
|
|
},
|
|
};
|