2026-08-12 21:07:06 +09:00
|
|
|
export type ConnectionStatus = "connecting" | "connected" | "closed" | "error"
|
|
|
|
|
|
2026-08-12 23:12:44 +09:00
|
|
|
export interface ToolProgressEvent {
|
|
|
|
|
version?: number
|
|
|
|
|
phase?: "start" | "end" | "error" | string
|
|
|
|
|
call_id?: string
|
|
|
|
|
name?: string
|
|
|
|
|
arguments?: unknown
|
|
|
|
|
result?: unknown
|
|
|
|
|
error?: unknown
|
|
|
|
|
files?: unknown[]
|
|
|
|
|
embeds?: unknown[]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface FileEditEvent {
|
|
|
|
|
version?: number
|
|
|
|
|
call_id?: string
|
|
|
|
|
tool?: string
|
|
|
|
|
path?: string
|
2026-08-14 18:59:50 +08:00
|
|
|
absolute_path?: string
|
2026-08-12 23:12:44 +09:00
|
|
|
phase?: "start" | "end" | "error" | string
|
|
|
|
|
added?: number
|
|
|
|
|
deleted?: number
|
2026-08-14 18:59:50 +08:00
|
|
|
approximate?: boolean
|
2026-08-12 23:12:44 +09:00
|
|
|
status?: "editing" | "done" | "error" | string
|
2026-08-14 18:59:50 +08:00
|
|
|
operation?: "edit" | "delete" | string
|
|
|
|
|
binary?: boolean
|
2026-08-12 23:12:44 +09:00
|
|
|
error?: string
|
2026-08-14 18:59:50 +08:00
|
|
|
diff?: FileDiff
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface FileDiff {
|
|
|
|
|
format: "unified" | string
|
|
|
|
|
context?: number
|
|
|
|
|
truncated?: boolean
|
|
|
|
|
text?: string
|
2026-08-12 23:12:44 +09:00
|
|
|
}
|
|
|
|
|
|
2026-08-12 21:07:06 +09:00
|
|
|
export type InboundEvent =
|
|
|
|
|
| { event: "ready"; chat_id: string; client_id: string }
|
2026-08-16 16:34:11 +08:00
|
|
|
| {
|
|
|
|
|
event: "attached"
|
|
|
|
|
chat_id: string
|
|
|
|
|
model_preset?: string | null
|
|
|
|
|
usage?: TokenUsage
|
|
|
|
|
}
|
2026-08-12 21:07:06 +09:00
|
|
|
| { event: "message_accepted"; chat_id: string; turn_id: string }
|
|
|
|
|
| {
|
|
|
|
|
event: "message"
|
|
|
|
|
chat_id: string
|
|
|
|
|
text: string
|
|
|
|
|
kind?: "tool_hint" | "progress" | "reasoning"
|
2026-08-12 23:12:44 +09:00
|
|
|
tool_events?: ToolProgressEvent[]
|
2026-08-12 21:07:06 +09:00
|
|
|
turn_id?: string
|
|
|
|
|
}
|
2026-08-12 23:12:44 +09:00
|
|
|
| { event: "file_edit"; chat_id: string; edits: FileEditEvent[]; turn_id?: string }
|
2026-08-12 21:07:06 +09:00
|
|
|
| { event: "delta"; chat_id: string; text: string; stream_id?: string; turn_id?: string }
|
|
|
|
|
| {
|
|
|
|
|
event: "stream_end"
|
|
|
|
|
chat_id: string
|
|
|
|
|
text?: string
|
|
|
|
|
stream_id?: string
|
|
|
|
|
resuming?: boolean
|
|
|
|
|
merge_next?: boolean
|
|
|
|
|
turn_id?: string
|
|
|
|
|
}
|
|
|
|
|
| { event: "reasoning_delta"; chat_id: string; text: string; turn_id?: string }
|
|
|
|
|
| { event: "reasoning_end"; chat_id: string; turn_id?: string }
|
2026-08-16 16:34:11 +08:00
|
|
|
| {
|
|
|
|
|
event: "turn_end"
|
|
|
|
|
chat_id: string
|
|
|
|
|
latency_ms?: number
|
|
|
|
|
turn_id?: string
|
|
|
|
|
usage?: TokenUsage
|
|
|
|
|
context_window_tokens?: number
|
|
|
|
|
}
|
2026-08-12 23:12:44 +09:00
|
|
|
| {
|
|
|
|
|
event: "goal_status"
|
|
|
|
|
chat_id: string
|
|
|
|
|
status: "running" | "idle"
|
|
|
|
|
started_at?: number
|
|
|
|
|
turn_id?: string
|
|
|
|
|
}
|
|
|
|
|
| { event: "goal_state"; chat_id: string; goal_state: Record<string, unknown> }
|
2026-08-15 01:34:15 +08:00
|
|
|
| { event: "session_updated"; chat_id: string; scope?: string }
|
2026-08-12 21:07:06 +09:00
|
|
|
| { event: "runtime_model_updated"; model_name: string; model_preset?: string | null }
|
2026-08-16 12:17:46 +08:00
|
|
|
| {
|
|
|
|
|
event: "turn_model_updated"
|
|
|
|
|
chat_id: string
|
|
|
|
|
model_name: string
|
|
|
|
|
model_preset?: string | null
|
2026-08-16 16:34:11 +08:00
|
|
|
context_window_tokens?: number
|
2026-08-16 12:17:46 +08:00
|
|
|
}
|
2026-08-12 21:07:06 +09:00
|
|
|
| { event: "error"; chat_id?: string; detail?: string; reason?: string; turn_id?: string }
|
|
|
|
|
|
|
|
|
|
type OutboundEvent =
|
|
|
|
|
| { type: "new_chat" }
|
2026-08-16 16:34:11 +08:00
|
|
|
| { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
|
2026-08-12 21:07:06 +09:00
|
|
|
| { type: "attach"; chat_id: string }
|
2026-08-16 16:34:11 +08:00
|
|
|
| {
|
|
|
|
|
type: "message"
|
|
|
|
|
chat_id: string
|
|
|
|
|
content: string
|
|
|
|
|
turn_id: string
|
|
|
|
|
webui: true
|
|
|
|
|
cli_apps?: Array<{ name: string }>
|
|
|
|
|
mcp_presets?: Array<{ name: string }>
|
|
|
|
|
session_mentions?: SessionMention[]
|
|
|
|
|
}
|
2026-08-12 21:07:06 +09:00
|
|
|
|
|
|
|
|
export interface ClientOptions {
|
|
|
|
|
url: string
|
|
|
|
|
chatId?: string
|
2026-08-12 23:12:44 +09:00
|
|
|
reconnectDelayMs?: number
|
2026-08-12 21:07:06 +09:00
|
|
|
onEvent: (event: InboundEvent) => void
|
|
|
|
|
onStatus: (status: ConnectionStatus, detail?: string) => void
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface HistoryMessage {
|
2026-08-12 23:12:44 +09:00
|
|
|
role: "user" | "assistant" | "activity"
|
2026-08-12 21:07:06 +09:00
|
|
|
content: string
|
2026-08-12 23:12:44 +09:00
|
|
|
toolEvents?: ToolProgressEvent[]
|
|
|
|
|
fileEdits?: FileEditEvent[]
|
2026-08-16 16:34:11 +08:00
|
|
|
forkIndex?: number
|
2026-08-12 23:12:44 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface HistorySnapshot {
|
|
|
|
|
messages: HistoryMessage[]
|
2026-08-13 15:22:06 +09:00
|
|
|
hasMoreBefore: boolean
|
|
|
|
|
beforeCursor: string | null
|
2026-08-16 16:34:11 +08:00
|
|
|
userMessageOffset: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface TokenUsage {
|
|
|
|
|
prompt_tokens?: number
|
|
|
|
|
completion_tokens?: number
|
|
|
|
|
cached_tokens?: number
|
|
|
|
|
total_tokens?: number
|
|
|
|
|
provider_tokens?: number
|
|
|
|
|
estimated_tokens?: number
|
|
|
|
|
cost_usd?: number
|
2026-08-13 15:22:06 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface SessionContextSnapshot {
|
|
|
|
|
totalMessages: number
|
|
|
|
|
archivedMessages: number
|
|
|
|
|
replayMessages: number
|
|
|
|
|
estimatedReplayTokens: number
|
|
|
|
|
estimatedSummaryTokens: number
|
|
|
|
|
estimatedSessionTokens: number
|
|
|
|
|
archivedSummary: string | null
|
|
|
|
|
archivedSummaryAt: string | null
|
2026-08-16 16:34:11 +08:00
|
|
|
lastUsage: TokenUsage | null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface SessionMention {
|
|
|
|
|
name: string
|
|
|
|
|
session_key: string
|
|
|
|
|
title?: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface MentionCandidate {
|
|
|
|
|
kind: "session" | "cli" | "mcp"
|
|
|
|
|
name: string
|
|
|
|
|
targetName?: string
|
|
|
|
|
displayName: string
|
|
|
|
|
description: string
|
|
|
|
|
session?: SessionMention
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface MessageOptions {
|
|
|
|
|
cliApps?: Array<{ name: string }>
|
|
|
|
|
mcpPresets?: Array<{ name: string }>
|
|
|
|
|
sessionMentions?: SessionMention[]
|
2026-08-12 23:12:44 +09:00
|
|
|
}
|
|
|
|
|
|
2026-08-13 12:55:26 +09:00
|
|
|
export interface SlashCommand {
|
|
|
|
|
command: string
|
|
|
|
|
title: string
|
|
|
|
|
description: string
|
|
|
|
|
argHint: string
|
2026-08-13 14:35:26 +09:00
|
|
|
lifecycle: SlashCommandLifecycle
|
2026-08-13 12:55:26 +09:00
|
|
|
acceptsArgs: boolean
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-13 14:35:26 +09:00
|
|
|
export type SlashCommandLifecycle =
|
|
|
|
|
| "side_channel"
|
|
|
|
|
| "finalize_active_turn"
|
|
|
|
|
| "stop_active_turn"
|
|
|
|
|
| "agent_turn"
|
|
|
|
|
| "agent_turn_with_args"
|
|
|
|
|
|
2026-08-13 13:46:53 +09:00
|
|
|
export interface SessionSummary {
|
|
|
|
|
chatId: string
|
|
|
|
|
title: string
|
|
|
|
|
preview: string
|
|
|
|
|
createdAt: string | null
|
|
|
|
|
updatedAt: string | null
|
|
|
|
|
runStartedAt: number | null
|
2026-08-16 12:17:46 +08:00
|
|
|
modelPreset: string | null
|
2026-08-13 15:22:06 +09:00
|
|
|
pinned: boolean
|
|
|
|
|
archived: boolean
|
2026-08-13 13:46:53 +09:00
|
|
|
}
|
|
|
|
|
|
2026-08-13 12:55:26 +09:00
|
|
|
const SLASH_COMMAND_LIFECYCLES = new Set([
|
|
|
|
|
"side_channel",
|
|
|
|
|
"finalize_active_turn",
|
|
|
|
|
"stop_active_turn",
|
|
|
|
|
"agent_turn",
|
|
|
|
|
"agent_turn_with_args",
|
|
|
|
|
])
|
|
|
|
|
|
2026-08-12 23:12:44 +09:00
|
|
|
const CHAT_EVENTS = new Set([
|
|
|
|
|
"attached",
|
|
|
|
|
"message_accepted",
|
|
|
|
|
"message",
|
|
|
|
|
"file_edit",
|
|
|
|
|
"delta",
|
|
|
|
|
"stream_end",
|
|
|
|
|
"reasoning_delta",
|
|
|
|
|
"reasoning_end",
|
|
|
|
|
"turn_end",
|
|
|
|
|
"goal_status",
|
|
|
|
|
"goal_state",
|
2026-08-15 01:34:15 +08:00
|
|
|
"session_updated",
|
2026-08-12 23:12:44 +09:00
|
|
|
"turn_model_updated",
|
|
|
|
|
"error",
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
|
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function optional(value: unknown, type: "boolean" | "number" | "string"): boolean {
|
|
|
|
|
return value === undefined || typeof value === type
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isToolEvent(value: unknown): value is ToolProgressEvent {
|
|
|
|
|
if (!isRecord(value)) return false
|
|
|
|
|
return optional(value.version, "number")
|
|
|
|
|
&& optional(value.phase, "string")
|
|
|
|
|
&& optional(value.call_id, "string")
|
|
|
|
|
&& optional(value.name, "string")
|
|
|
|
|
&& (value.files === undefined || Array.isArray(value.files))
|
|
|
|
|
&& (value.embeds === undefined || Array.isArray(value.embeds))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isFileEdit(value: unknown): value is FileEditEvent {
|
|
|
|
|
if (!isRecord(value)) return false
|
|
|
|
|
return optional(value.version, "number")
|
|
|
|
|
&& optional(value.call_id, "string")
|
|
|
|
|
&& optional(value.tool, "string")
|
|
|
|
|
&& optional(value.path, "string")
|
2026-08-14 18:59:50 +08:00
|
|
|
&& optional(value.absolute_path, "string")
|
2026-08-12 23:12:44 +09:00
|
|
|
&& optional(value.phase, "string")
|
|
|
|
|
&& optional(value.status, "string")
|
|
|
|
|
&& optional(value.added, "number")
|
|
|
|
|
&& optional(value.deleted, "number")
|
2026-08-14 18:59:50 +08:00
|
|
|
&& optional(value.approximate, "boolean")
|
|
|
|
|
&& optional(value.operation, "string")
|
|
|
|
|
&& optional(value.binary, "boolean")
|
2026-08-12 23:12:44 +09:00
|
|
|
&& optional(value.error, "string")
|
2026-08-14 18:59:50 +08:00
|
|
|
&& (value.diff === undefined || isFileDiff(value.diff))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isFileDiff(value: unknown): value is FileDiff {
|
|
|
|
|
if (!isRecord(value) || typeof value.format !== "string") return false
|
|
|
|
|
return optional(value.context, "number")
|
|
|
|
|
&& optional(value.truncated, "boolean")
|
|
|
|
|
&& optional(value.text, "string")
|
2026-08-12 23:12:44 +09:00
|
|
|
}
|
|
|
|
|
|
2026-08-16 16:34:11 +08:00
|
|
|
function isTokenUsage(value: unknown): value is TokenUsage {
|
|
|
|
|
if (!isRecord(value)) return false
|
|
|
|
|
return [
|
|
|
|
|
"prompt_tokens",
|
|
|
|
|
"completion_tokens",
|
|
|
|
|
"cached_tokens",
|
|
|
|
|
"total_tokens",
|
|
|
|
|
"provider_tokens",
|
|
|
|
|
"estimated_tokens",
|
|
|
|
|
"cost_usd",
|
|
|
|
|
].every((key) => optional(value[key], "number"))
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-12 23:12:44 +09:00
|
|
|
function decodeInboundEvent(value: unknown): InboundEvent | null | undefined {
|
|
|
|
|
if (!isRecord(value)) return null
|
|
|
|
|
const record = value
|
|
|
|
|
const name = record.event
|
|
|
|
|
if (typeof name !== "string") return null
|
|
|
|
|
if (name === "ready") {
|
|
|
|
|
return typeof record.chat_id === "string" && typeof record.client_id === "string"
|
|
|
|
|
? value as InboundEvent
|
|
|
|
|
: null
|
|
|
|
|
}
|
|
|
|
|
if (name === "runtime_model_updated") {
|
|
|
|
|
return typeof record.model_name === "string"
|
|
|
|
|
&& (record.model_preset === undefined
|
|
|
|
|
|| record.model_preset === null
|
|
|
|
|
|| typeof record.model_preset === "string")
|
|
|
|
|
? value as InboundEvent
|
|
|
|
|
: null
|
|
|
|
|
}
|
|
|
|
|
if (name === "error" && (record.chat_id === undefined || typeof record.chat_id === "string")) {
|
|
|
|
|
return optional(record.detail, "string") && optional(record.reason, "string")
|
|
|
|
|
? value as InboundEvent
|
|
|
|
|
: null
|
|
|
|
|
}
|
|
|
|
|
if (!CHAT_EVENTS.has(name)) return undefined // Forward-compatible additive event.
|
|
|
|
|
if (typeof record.chat_id !== "string") return null
|
2026-08-16 12:17:46 +08:00
|
|
|
if (
|
|
|
|
|
name === "attached"
|
2026-08-16 16:34:11 +08:00
|
|
|
&& ((record.model_preset !== undefined
|
|
|
|
|
&& record.model_preset !== null
|
|
|
|
|
&& typeof record.model_preset !== "string")
|
|
|
|
|
|| (record.usage !== undefined && !isTokenUsage(record.usage)))
|
2026-08-16 12:17:46 +08:00
|
|
|
) return null
|
2026-08-12 23:12:44 +09:00
|
|
|
if (["message", "delta", "reasoning_delta"].includes(name) && typeof record.text !== "string") {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
if (
|
|
|
|
|
name === "message"
|
|
|
|
|
&& record.tool_events !== undefined
|
|
|
|
|
&& (!Array.isArray(record.tool_events) || !record.tool_events.every(isToolEvent))
|
|
|
|
|
) return null
|
|
|
|
|
if (name === "file_edit" && (!Array.isArray(record.edits) || !record.edits.every(isFileEdit))) {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
if (
|
|
|
|
|
name === "stream_end"
|
|
|
|
|
&& (!optional(record.text, "string")
|
|
|
|
|
|| !optional(record.resuming, "boolean")
|
|
|
|
|
|| !optional(record.merge_next, "boolean"))
|
|
|
|
|
) return null
|
2026-08-16 16:34:11 +08:00
|
|
|
if (
|
|
|
|
|
name === "turn_end"
|
|
|
|
|
&& (!optional(record.latency_ms, "number")
|
|
|
|
|
|| !optional(record.context_window_tokens, "number")
|
|
|
|
|
|| (record.usage !== undefined && !isTokenUsage(record.usage)))
|
|
|
|
|
) return null
|
2026-08-12 23:12:44 +09:00
|
|
|
if (name === "goal_status" && record.status !== "running" && record.status !== "idle") return null
|
|
|
|
|
if (name === "goal_state" && (!record.goal_state || typeof record.goal_state !== "object")) return null
|
2026-08-15 01:34:15 +08:00
|
|
|
if (name === "session_updated" && !optional(record.scope, "string")) return null
|
2026-08-16 12:17:46 +08:00
|
|
|
if (
|
|
|
|
|
name === "turn_model_updated"
|
|
|
|
|
&& (typeof record.model_name !== "string"
|
|
|
|
|
|| (record.model_preset !== undefined
|
|
|
|
|
&& record.model_preset !== null
|
2026-08-16 16:34:11 +08:00
|
|
|
&& typeof record.model_preset !== "string")
|
|
|
|
|
|| !optional(record.context_window_tokens, "number"))
|
2026-08-16 12:17:46 +08:00
|
|
|
) return null
|
2026-08-12 23:12:44 +09:00
|
|
|
return value as InboundEvent
|
2026-08-12 21:07:06 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function fetchHistory(
|
|
|
|
|
apiUrl: string,
|
|
|
|
|
apiToken: string,
|
|
|
|
|
chatId: string,
|
2026-08-13 15:22:06 +09:00
|
|
|
beforeCursor?: string | null,
|
2026-08-12 23:12:44 +09:00
|
|
|
): Promise<HistorySnapshot> {
|
2026-08-13 15:22:06 +09:00
|
|
|
if (!apiUrl || !apiToken) {
|
2026-08-16 16:34:11 +08:00
|
|
|
return { messages: [], hasMoreBefore: false, beforeCursor: null, userMessageOffset: 0 }
|
2026-08-13 15:22:06 +09:00
|
|
|
}
|
2026-08-12 21:07:06 +09:00
|
|
|
const key = encodeURIComponent(`websocket:${chatId}`)
|
2026-08-13 15:22:06 +09:00
|
|
|
const params = new URLSearchParams({ limit: "120", direction: "latest" })
|
|
|
|
|
if (beforeCursor) params.set("before", beforeCursor)
|
|
|
|
|
const response = await fetch(`${apiUrl}/api/sessions/${key}/webui-thread?${params}`, {
|
2026-08-12 21:07:06 +09:00
|
|
|
headers: { Authorization: `Bearer ${apiToken}` },
|
|
|
|
|
})
|
2026-08-13 15:22:06 +09:00
|
|
|
if (response.status === 404) {
|
2026-08-16 16:34:11 +08:00
|
|
|
return { messages: [], hasMoreBefore: false, beforeCursor: null, userMessageOffset: 0 }
|
2026-08-13 15:22:06 +09:00
|
|
|
}
|
2026-08-12 21:07:06 +09:00
|
|
|
if (!response.ok) throw new Error(`history request failed: HTTP ${response.status}`)
|
2026-08-12 23:12:44 +09:00
|
|
|
const payload = (await response.json()) as {
|
|
|
|
|
messages?: Array<Record<string, unknown>>
|
2026-08-16 16:34:11 +08:00
|
|
|
page?: { has_more_before?: boolean; before_cursor?: string; user_message_offset?: number }
|
2026-08-12 23:12:44 +09:00
|
|
|
}
|
2026-08-16 16:34:11 +08:00
|
|
|
let userIndex = typeof payload.page?.user_message_offset === "number"
|
|
|
|
|
? Math.max(0, payload.page.user_message_offset)
|
|
|
|
|
: 0
|
|
|
|
|
const messages: HistoryMessage[] = []
|
|
|
|
|
for (const message of payload.messages || []) {
|
2026-08-12 21:07:06 +09:00
|
|
|
const role = message.role
|
|
|
|
|
const content = message.content
|
2026-08-12 23:12:44 +09:00
|
|
|
if (role === "tool" && message.kind === "trace") {
|
|
|
|
|
const traces = Array.isArray(message.traces)
|
|
|
|
|
? message.traces.filter((value): value is string => typeof value === "string")
|
|
|
|
|
: []
|
|
|
|
|
const toolEvents = Array.isArray(message.toolEvents)
|
2026-08-14 18:59:50 +08:00
|
|
|
? message.toolEvents.filter(isToolEvent)
|
2026-08-12 23:12:44 +09:00
|
|
|
: undefined
|
|
|
|
|
const fileEdits = Array.isArray(message.fileEdits)
|
2026-08-14 18:59:50 +08:00
|
|
|
? message.fileEdits.filter(isFileEdit)
|
2026-08-12 23:12:44 +09:00
|
|
|
: undefined
|
|
|
|
|
const activity = traces.join("\n") || (typeof content === "string" ? content : "")
|
2026-08-16 16:34:11 +08:00
|
|
|
messages.push({
|
|
|
|
|
role: "activity",
|
|
|
|
|
content: activity,
|
|
|
|
|
...(toolEvents?.length ? { toolEvents } : {}),
|
|
|
|
|
...(fileEdits?.length ? { fileEdits } : {}),
|
|
|
|
|
})
|
|
|
|
|
continue
|
2026-08-12 23:12:44 +09:00
|
|
|
}
|
|
|
|
|
if (
|
|
|
|
|
(role !== "user" && role !== "assistant")
|
|
|
|
|
|| message.kind === "reasoning"
|
|
|
|
|
|| typeof content !== "string"
|
|
|
|
|
|| !content.trim()
|
|
|
|
|
) {
|
2026-08-16 16:34:11 +08:00
|
|
|
continue
|
2026-08-12 21:07:06 +09:00
|
|
|
}
|
2026-08-16 16:34:11 +08:00
|
|
|
if (role === "user") {
|
|
|
|
|
userIndex += 1
|
|
|
|
|
messages.push({ role: "user", content })
|
|
|
|
|
} else {
|
|
|
|
|
messages.push({ role: "assistant", content, forkIndex: userIndex })
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-13 15:22:06 +09:00
|
|
|
return {
|
|
|
|
|
messages,
|
|
|
|
|
hasMoreBefore: payload.page?.has_more_before === true,
|
|
|
|
|
beforeCursor: typeof payload.page?.before_cursor === "string"
|
|
|
|
|
? payload.page.before_cursor
|
|
|
|
|
: null,
|
2026-08-16 16:34:11 +08:00
|
|
|
userMessageOffset: typeof payload.page?.user_message_offset === "number"
|
|
|
|
|
? Math.max(0, payload.page.user_message_offset)
|
|
|
|
|
: 0,
|
2026-08-13 15:22:06 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function fetchSessionContext(
|
|
|
|
|
apiUrl: string,
|
|
|
|
|
apiToken: string,
|
|
|
|
|
chatId: string,
|
|
|
|
|
): Promise<SessionContextSnapshot | null> {
|
|
|
|
|
if (!apiUrl || !apiToken) return null
|
|
|
|
|
const key = encodeURIComponent(`websocket:${chatId}`)
|
|
|
|
|
const response = await fetch(`${apiUrl}/api/sessions/${key}/context`, {
|
|
|
|
|
headers: { Authorization: `Bearer ${apiToken}` },
|
|
|
|
|
})
|
|
|
|
|
if (response.status === 404) return null
|
|
|
|
|
if (!response.ok) throw new Error(`context request failed: HTTP ${response.status}`)
|
|
|
|
|
const value = await response.json() as Record<string, unknown>
|
|
|
|
|
const number = (key: string) => typeof value[key] === "number" ? value[key] as number : 0
|
|
|
|
|
return {
|
|
|
|
|
totalMessages: number("total_messages"),
|
|
|
|
|
archivedMessages: number("archived_messages"),
|
|
|
|
|
replayMessages: number("replay_messages"),
|
|
|
|
|
estimatedReplayTokens: number("estimated_replay_tokens"),
|
|
|
|
|
estimatedSummaryTokens: number("estimated_summary_tokens"),
|
|
|
|
|
estimatedSessionTokens: number("estimated_session_tokens"),
|
|
|
|
|
archivedSummary: typeof value.archived_summary === "string" ? value.archived_summary : null,
|
|
|
|
|
archivedSummaryAt: typeof value.archived_summary_at === "string"
|
|
|
|
|
? value.archived_summary_at
|
|
|
|
|
: null,
|
2026-08-16 16:34:11 +08:00
|
|
|
lastUsage: isTokenUsage(value.last_usage) ? value.last_usage : null,
|
2026-08-13 15:22:06 +09:00
|
|
|
}
|
2026-08-12 21:07:06 +09:00
|
|
|
}
|
|
|
|
|
|
2026-08-13 12:55:26 +09:00
|
|
|
export async function fetchSlashCommands(
|
|
|
|
|
apiUrl: string,
|
|
|
|
|
apiToken: string,
|
|
|
|
|
): Promise<SlashCommand[]> {
|
|
|
|
|
if (!apiUrl || !apiToken) return []
|
|
|
|
|
const response = await fetch(`${apiUrl}/api/commands`, {
|
|
|
|
|
headers: { Authorization: `Bearer ${apiToken}` },
|
|
|
|
|
})
|
|
|
|
|
if (!response.ok) throw new Error(`command request failed: HTTP ${response.status}`)
|
|
|
|
|
const payload = await response.json() as { commands?: unknown[] }
|
|
|
|
|
return (payload.commands || []).flatMap((value) => {
|
|
|
|
|
if (
|
|
|
|
|
!isRecord(value)
|
|
|
|
|
|| typeof value.command !== "string"
|
|
|
|
|
|| typeof value.lifecycle !== "string"
|
|
|
|
|
|| !SLASH_COMMAND_LIFECYCLES.has(value.lifecycle)
|
|
|
|
|
) return []
|
|
|
|
|
return [{
|
|
|
|
|
command: value.command,
|
|
|
|
|
title: typeof value.title === "string" ? value.title : value.command,
|
|
|
|
|
description: typeof value.description === "string" ? value.description : "",
|
|
|
|
|
argHint: typeof value.arg_hint === "string" ? value.arg_hint : "",
|
2026-08-13 14:35:26 +09:00
|
|
|
lifecycle: value.lifecycle as SlashCommandLifecycle,
|
2026-08-13 12:55:26 +09:00
|
|
|
acceptsArgs: value.accepts_args === true,
|
|
|
|
|
}]
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-13 13:46:53 +09:00
|
|
|
export async function fetchSessions(
|
|
|
|
|
apiUrl: string,
|
|
|
|
|
apiToken: string,
|
|
|
|
|
): Promise<SessionSummary[]> {
|
|
|
|
|
if (!apiUrl || !apiToken) return []
|
2026-08-13 15:22:06 +09:00
|
|
|
const headers = { Authorization: `Bearer ${apiToken}` }
|
|
|
|
|
const [response, sidebarResponse] = await Promise.all([
|
|
|
|
|
fetch(`${apiUrl}/api/sessions`, { headers }),
|
|
|
|
|
fetch(`${apiUrl}/api/webui/sidebar-state`, { headers }).catch(() => null),
|
|
|
|
|
])
|
2026-08-13 13:46:53 +09:00
|
|
|
if (!response.ok) throw new Error(`session request failed: HTTP ${response.status}`)
|
|
|
|
|
const payload = await response.json() as { sessions?: unknown[] }
|
2026-08-13 15:22:06 +09:00
|
|
|
let sidebar: Record<string, unknown> = {}
|
|
|
|
|
if (sidebarResponse?.ok) {
|
|
|
|
|
try {
|
|
|
|
|
const value: unknown = await sidebarResponse.json()
|
|
|
|
|
if (isRecord(value)) sidebar = value
|
|
|
|
|
} catch {
|
|
|
|
|
// Session navigation remains available against older or damaged sidebar state.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const pinned = new Set(Array.isArray(sidebar.pinned_keys) ? sidebar.pinned_keys : [])
|
|
|
|
|
const archived = new Set(Array.isArray(sidebar.archived_keys) ? sidebar.archived_keys : [])
|
|
|
|
|
const titles = isRecord(sidebar.title_overrides) ? sidebar.title_overrides : {}
|
2026-08-13 13:46:53 +09:00
|
|
|
return (payload.sessions || []).flatMap((value) => {
|
|
|
|
|
if (!isRecord(value) || typeof value.key !== "string" || !value.key.startsWith("websocket:")) {
|
|
|
|
|
return []
|
|
|
|
|
}
|
|
|
|
|
const chatId = value.key.slice("websocket:".length)
|
|
|
|
|
if (!chatId) return []
|
2026-08-13 15:22:06 +09:00
|
|
|
const titleOverride = titles[value.key]
|
2026-08-13 13:46:53 +09:00
|
|
|
return [{
|
|
|
|
|
chatId,
|
2026-08-13 15:22:06 +09:00
|
|
|
title: typeof titleOverride === "string"
|
|
|
|
|
? titleOverride
|
|
|
|
|
: typeof value.title === "string" ? value.title : "",
|
2026-08-13 13:46:53 +09:00
|
|
|
preview: typeof value.preview === "string" ? value.preview : "",
|
|
|
|
|
createdAt: typeof value.created_at === "string" ? value.created_at : null,
|
|
|
|
|
updatedAt: typeof value.updated_at === "string" ? value.updated_at : null,
|
|
|
|
|
runStartedAt: typeof value.run_started_at === "number" ? value.run_started_at : null,
|
2026-08-16 12:17:46 +08:00
|
|
|
modelPreset: typeof value.model_preset === "string" && value.model_preset.trim()
|
|
|
|
|
? value.model_preset.trim()
|
|
|
|
|
: null,
|
2026-08-13 15:22:06 +09:00
|
|
|
pinned: pinned.has(value.key),
|
|
|
|
|
archived: archived.has(value.key),
|
2026-08-13 13:46:53 +09:00
|
|
|
}]
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-16 16:34:11 +08:00
|
|
|
function sessionMentionName(session: SessionSummary): string {
|
|
|
|
|
const label = (session.title || session.preview || "session")
|
|
|
|
|
.normalize("NFKC")
|
|
|
|
|
.replace(/\s+/gu, "-")
|
|
|
|
|
.replace(/[^\p{L}\p{N}_-]+/gu, "")
|
|
|
|
|
.replace(/-+/gu, "-")
|
|
|
|
|
.replace(/^-|-$/gu, "")
|
|
|
|
|
return Array.from(label || "session").slice(0, 40).join("")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Installed capabilities and saved chats share one mention namespace. */
|
|
|
|
|
export async function fetchMentionCandidates(
|
|
|
|
|
apiUrl: string,
|
|
|
|
|
apiToken: string,
|
|
|
|
|
): Promise<MentionCandidate[]> {
|
|
|
|
|
if (!apiUrl || !apiToken) return []
|
|
|
|
|
const headers = { Authorization: `Bearer ${apiToken}` }
|
|
|
|
|
const [sessions, appsResponse, mcpResponse] = await Promise.all([
|
|
|
|
|
fetchSessions(apiUrl, apiToken),
|
|
|
|
|
fetch(`${apiUrl}/api/settings/cli-apps?installed_only=1`, { headers }).catch(() => null),
|
|
|
|
|
fetch(`${apiUrl}/api/settings/mcp-presets`, { headers }).catch(() => null),
|
|
|
|
|
])
|
|
|
|
|
const used = new Set<string>()
|
|
|
|
|
const uniqueName = (raw: string) => {
|
|
|
|
|
const base = raw || "session"
|
|
|
|
|
let name = base
|
|
|
|
|
let suffix = 2
|
|
|
|
|
while (used.has(name.toLocaleLowerCase())) name = `${base}-${suffix++}`
|
|
|
|
|
used.add(name.toLocaleLowerCase())
|
|
|
|
|
return name
|
|
|
|
|
}
|
|
|
|
|
const candidates: MentionCandidate[] = []
|
|
|
|
|
if (appsResponse?.ok) {
|
|
|
|
|
const payload = await appsResponse.json() as { apps?: unknown[] }
|
|
|
|
|
for (const value of payload.apps || []) {
|
|
|
|
|
if (!isRecord(value) || value.installed !== true || typeof value.name !== "string") continue
|
|
|
|
|
const name = uniqueName(value.name)
|
|
|
|
|
candidates.push({
|
|
|
|
|
kind: "cli",
|
|
|
|
|
name,
|
|
|
|
|
...(name === value.name ? {} : { targetName: value.name }),
|
|
|
|
|
displayName: typeof value.display_name === "string" ? value.display_name : name,
|
|
|
|
|
description: typeof value.description === "string" ? value.description : "CLI app",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (mcpResponse?.ok) {
|
|
|
|
|
const payload = await mcpResponse.json() as { presets?: unknown[] }
|
|
|
|
|
for (const value of payload.presets || []) {
|
|
|
|
|
if (
|
|
|
|
|
!isRecord(value)
|
|
|
|
|
|| value.installed !== true
|
|
|
|
|
|| value.configured !== true
|
|
|
|
|
|| typeof value.name !== "string"
|
|
|
|
|
) continue
|
|
|
|
|
const name = uniqueName(value.name)
|
|
|
|
|
candidates.push({
|
|
|
|
|
kind: "mcp",
|
|
|
|
|
name,
|
|
|
|
|
...(name === value.name ? {} : { targetName: value.name }),
|
|
|
|
|
displayName: typeof value.display_name === "string" ? value.display_name : name,
|
|
|
|
|
description: typeof value.description === "string" ? value.description : "MCP server",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for (const session of sessions) {
|
|
|
|
|
const name = uniqueName(sessionMentionName(session))
|
|
|
|
|
candidates.push({
|
|
|
|
|
kind: "session",
|
|
|
|
|
name,
|
|
|
|
|
displayName: sessionLabelForMention(session),
|
|
|
|
|
description: session.preview || "Saved session",
|
|
|
|
|
session: {
|
|
|
|
|
name,
|
|
|
|
|
session_key: `websocket:${session.chatId}`,
|
|
|
|
|
title: session.title || undefined,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return candidates
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sessionLabelForMention(session: SessionSummary): string {
|
|
|
|
|
return (session.title || session.preview || "Untitled chat").replace(/\s+/gu, " ").trim()
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-12 21:07:06 +09:00
|
|
|
export class NanobotClient {
|
|
|
|
|
private socket: WebSocket | null = null
|
|
|
|
|
private chatId = ""
|
2026-08-12 23:12:44 +09:00
|
|
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
|
private reconnectAttempt = 0
|
|
|
|
|
private closedByClient = false
|
2026-08-12 21:07:06 +09:00
|
|
|
|
|
|
|
|
constructor(private readonly options: ClientOptions) {}
|
|
|
|
|
|
|
|
|
|
get activeChatId(): string {
|
|
|
|
|
return this.chatId
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
connect(): void {
|
2026-08-12 23:12:44 +09:00
|
|
|
this.closedByClient = false
|
|
|
|
|
this.open()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private open(): void {
|
|
|
|
|
if (this.socket) return
|
2026-08-12 21:07:06 +09:00
|
|
|
this.options.onStatus("connecting")
|
|
|
|
|
const socket = new WebSocket(this.options.url)
|
|
|
|
|
this.socket = socket
|
2026-08-12 23:12:44 +09:00
|
|
|
socket.addEventListener("open", () => {
|
|
|
|
|
if (this.socket !== socket) return
|
|
|
|
|
this.reconnectAttempt = 0
|
|
|
|
|
this.options.onStatus("connected")
|
|
|
|
|
})
|
|
|
|
|
socket.addEventListener("message", (message) => {
|
|
|
|
|
if (this.socket === socket) this.handleMessage(String(message.data))
|
|
|
|
|
})
|
|
|
|
|
socket.addEventListener("error", () => {
|
|
|
|
|
if (this.socket === socket) this.options.onStatus("error", "connection failed")
|
|
|
|
|
})
|
|
|
|
|
socket.addEventListener("close", () => {
|
|
|
|
|
if (this.socket !== socket) return
|
|
|
|
|
this.socket = null
|
|
|
|
|
if (this.closedByClient) {
|
|
|
|
|
this.options.onStatus("closed")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
this.scheduleReconnect()
|
|
|
|
|
})
|
2026-08-12 21:07:06 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
close(): void {
|
2026-08-12 23:12:44 +09:00
|
|
|
this.closedByClient = true
|
|
|
|
|
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
|
|
|
|
|
this.reconnectTimer = null
|
|
|
|
|
const socket = this.socket
|
2026-08-12 21:07:06 +09:00
|
|
|
this.socket = null
|
2026-08-12 23:12:44 +09:00
|
|
|
socket?.close()
|
2026-08-12 21:07:06 +09:00
|
|
|
}
|
|
|
|
|
|
2026-08-16 16:34:11 +08:00
|
|
|
send(content: string, options: MessageOptions = {}): string {
|
2026-08-12 21:07:06 +09:00
|
|
|
if (!this.chatId) throw new Error("chat is not ready")
|
|
|
|
|
const turnId = crypto.randomUUID()
|
|
|
|
|
this.write({
|
|
|
|
|
type: "message",
|
|
|
|
|
chat_id: this.chatId,
|
|
|
|
|
content,
|
|
|
|
|
turn_id: turnId,
|
|
|
|
|
webui: true,
|
2026-08-16 16:34:11 +08:00
|
|
|
...(options.cliApps?.length ? { cli_apps: options.cliApps } : {}),
|
|
|
|
|
...(options.mcpPresets?.length ? { mcp_presets: options.mcpPresets } : {}),
|
|
|
|
|
...(options.sessionMentions?.length
|
|
|
|
|
? { session_mentions: options.sessionMentions }
|
|
|
|
|
: {}),
|
2026-08-12 21:07:06 +09:00
|
|
|
})
|
|
|
|
|
return turnId
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-13 13:46:53 +09:00
|
|
|
attach(chatId: string): void {
|
|
|
|
|
if (!chatId) throw new Error("chat id is required")
|
|
|
|
|
this.write({ type: "attach", chat_id: chatId })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
newChat(): void {
|
|
|
|
|
this.write({ type: "new_chat" })
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-16 16:34:11 +08:00
|
|
|
forkChat(sourceChatId: string, beforeUserIndex: number, title?: string): void {
|
|
|
|
|
this.write({
|
|
|
|
|
type: "fork_chat",
|
|
|
|
|
source_chat_id: sourceChatId,
|
|
|
|
|
before_user_index: beforeUserIndex,
|
|
|
|
|
...(title?.trim() ? { title: title.trim() } : {}),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-12 21:07:06 +09:00
|
|
|
private handleMessage(raw: string): void {
|
|
|
|
|
let value: unknown
|
|
|
|
|
try {
|
|
|
|
|
value = JSON.parse(raw) as unknown
|
|
|
|
|
} catch {
|
|
|
|
|
this.options.onStatus("error", "gateway sent invalid JSON")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-12 23:12:44 +09:00
|
|
|
const event = decodeInboundEvent(value)
|
|
|
|
|
if (event === undefined) return
|
|
|
|
|
if (event === null) {
|
2026-08-12 21:07:06 +09:00
|
|
|
this.options.onStatus("error", "gateway sent an invalid event")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (event.event === "ready") {
|
2026-08-12 23:12:44 +09:00
|
|
|
const requestedChatId = this.chatId || this.options.chatId
|
|
|
|
|
if (requestedChatId) {
|
|
|
|
|
this.chatId = requestedChatId
|
2026-08-12 21:07:06 +09:00
|
|
|
this.write({ type: "attach", chat_id: this.chatId })
|
|
|
|
|
} else {
|
|
|
|
|
this.write({ type: "new_chat" })
|
|
|
|
|
}
|
|
|
|
|
} else if (event.event === "attached") {
|
|
|
|
|
this.chatId = event.chat_id
|
|
|
|
|
}
|
|
|
|
|
this.options.onEvent(event)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-12 23:12:44 +09:00
|
|
|
private scheduleReconnect(): void {
|
|
|
|
|
if (this.reconnectTimer || this.closedByClient) return
|
|
|
|
|
const base = this.options.reconnectDelayMs ?? 500
|
|
|
|
|
const delay = Math.min(8_000, base * 2 ** Math.min(this.reconnectAttempt++, 4))
|
|
|
|
|
this.options.onStatus("connecting", `reconnecting in ${delay}ms`)
|
|
|
|
|
this.reconnectTimer = setTimeout(() => {
|
|
|
|
|
this.reconnectTimer = null
|
|
|
|
|
this.open()
|
|
|
|
|
}, delay)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-12 21:07:06 +09:00
|
|
|
private write(event: OutboundEvent): void {
|
|
|
|
|
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
|
|
|
throw new Error("gateway connection is not open")
|
|
|
|
|
}
|
|
|
|
|
this.socket.send(JSON.stringify(event))
|
|
|
|
|
}
|
|
|
|
|
}
|