refactor(frontend): 拆 types.ts(871) / ws.ts::dispatchFrame(266)(纯搬移 + barrel)
执行方案 DevDocs/audit/_plan.md 阶段 0 · G0-D: - lib/types.ts(871) → lib/types/ 11 个域文件 + 14 行 `export *` barrel(仿 lib/api.ts 先例) chat / ws / session / agent / flow / persona / api / admin(269) / adminWrite(176) / kook / notification - lib/ws.ts(494) → 203 行入口 + lib/ws/handlers/ 8 文件;dispatchFrame(266 行) 改为按组 分发链(7 个 handler 各持一组 case,命中 return true,未命中走原 default 兜底), 33 个 case 标签一一守恒 机械证明:type 导出集 86 个守恒(拆后并集 diff 为空);去掉空行与 import/export 语句后 **784=784 行、排序 diff 为空**;dispatchFrame 的 case 标签集 33=33 守恒;ws.ts 的 scheduleReconnect 至文件尾与 HEAD 字节级一致。 65 个 lib/types 消费方 + 3 个 lib/ws 消费方**零改动**通过 —— tsc 0 错误正是这一点的强判据。 验证:npx tsc --noEmit 0 错误;npm run build 成功;9 个前端逻辑测试全绿(含真实驱动 拆后 dispatchFrame 的 messageSync 54 checks 与 personaSession 23 checks); 最大文件 admin.ts 269 行、最大函数 openSocket 72 行(均达标)。
This commit is contained in:
1 parent
e9afca9b29
commit
6e0323b446
21 files changed
+1285
-1180
No files matched your search
+11
-868
@@ -1,871 +1,14 @@
|
|||||||
// Shared wire & UI model types for the Cyrene v2 user frontend (M9).
|
// Shared wire & UI model types for the Cyrene v2 user frontend (M9).
|
||||||
// Strict mode: no `any`; unknown payloads are narrowed at the parse boundary.
|
// Strict mode: no `any`; unknown payloads are narrowed at the parse boundary.
|
||||||
|
|
||||||
import type { PersonaThinking, PersonaThinkingRequest } from "./personaThinking";
|
export * from "./types/chat";
|
||||||
|
export * from "./types/ws";
|
||||||
/** The 9 behavior modes. Switching only changes the presentation layer. */
|
export * from "./types/session";
|
||||||
export type Mode =
|
export * from "./types/agent";
|
||||||
| "chat"
|
export * from "./types/flow";
|
||||||
| "code"
|
export * from "./types/persona";
|
||||||
| "home"
|
export * from "./types/api";
|
||||||
| "work"
|
export * from "./types/admin";
|
||||||
| "docs"
|
export * from "./types/adminWrite";
|
||||||
| "voice"
|
export * from "./types/kook";
|
||||||
| "vision"
|
export * from "./types/notification";
|
||||||
| "agents"
|
|
||||||
| "settings";
|
|
||||||
|
|
||||||
export const MODES: ReadonlyArray<Mode> = [
|
|
||||||
"chat",
|
|
||||||
"code",
|
|
||||||
"home",
|
|
||||||
"work",
|
|
||||||
"docs",
|
|
||||||
"voice",
|
|
||||||
"vision",
|
|
||||||
"agents",
|
|
||||||
"settings",
|
|
||||||
];
|
|
||||||
|
|
||||||
export type WsStatus =
|
|
||||||
| "idle"
|
|
||||||
| "connecting"
|
|
||||||
| "connected"
|
|
||||||
| "disconnected"
|
|
||||||
| "reconnecting"
|
|
||||||
| "expired";
|
|
||||||
|
|
||||||
export interface ToolUsed {
|
|
||||||
name: string;
|
|
||||||
summary?: string;
|
|
||||||
ok?: boolean;
|
|
||||||
detail?: string;
|
|
||||||
at: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 唤醒式能力三态(design/14 §1):静息·温柔陪伴 / 唤醒·办事 / 全能·复杂任务。 */
|
|
||||||
export type AwakenState = "quiet" | "awake" | "full";
|
|
||||||
|
|
||||||
/** capacity_awaken 下行事件 payload(design/14 §5):状态切换 + 本轮点亮的工具。
|
|
||||||
* `tool` 可能是工具名字符串,也可能是含 `name` 的对象,解析层统一为字符串。 */
|
|
||||||
export interface AwakenPayload {
|
|
||||||
fromState?: AwakenState;
|
|
||||||
toState?: AwakenState;
|
|
||||||
tool?: string | { name: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** session_busy / session_pending 下行事件 payload(design/15 §5):她是否在忙 + 挂起消息数。 */
|
|
||||||
export interface SessionStatePayload {
|
|
||||||
busy?: boolean;
|
|
||||||
pendingCount?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChatMessage {
|
|
||||||
id: string;
|
|
||||||
/** The owning session (backend `session_id`). Set when loaded via the sessions
|
|
||||||
* API; absent on a client-side optimistic bubble until it is persisted. */
|
|
||||||
sessionId?: string;
|
|
||||||
role: "user" | "assistant" | "system";
|
|
||||||
content: string;
|
|
||||||
/** Behavior mode this message was produced in — drives its bubble rendering.
|
|
||||||
* Each message carries its own mode, so a single session can mix bubble shapes. */
|
|
||||||
mode?: Mode;
|
|
||||||
streaming?: boolean;
|
|
||||||
thinking?: string;
|
|
||||||
mood?: string;
|
|
||||||
tools?: ToolUsed[];
|
|
||||||
/** Optional image attached to the turn (base64 data: URI or http(s) URL). The
|
|
||||||
* user bubble renders it as a thumb; vision-mode assistant bodies render it
|
|
||||||
* as an image card. Absent for legacy persisted messages, so renders fall back
|
|
||||||
* to plain text. */
|
|
||||||
image?: string;
|
|
||||||
/** Source-platform tag of the message (design/16 §4.4): "web" for messages that
|
|
||||||
* originate on the Web main session, or an IM channel (e.g. "telegram",
|
|
||||||
* "weixin-ilink", "qq-obv11") for messages mirrored in from an IM adapter. The
|
|
||||||
* Web client renders the full session; an IM adapter filters to its own platform.
|
|
||||||
* Absent on legacy/client-optimistic messages. */
|
|
||||||
platform?: string;
|
|
||||||
/** Concrete client/device that produced the message (design/16 §4.5), e.g.
|
|
||||||
* "web:chrome:a1b2c3" for a browser or "weixin-ilink:conf0:wx_uid" for an IM
|
|
||||||
* channel. Complements platform (which only names the platform). The Web client
|
|
||||||
* defaults to labeling the bubble source when this is present; absent on
|
|
||||||
* legacy/client-optimistic messages kept without it. */
|
|
||||||
deviceId?: string;
|
|
||||||
/** Human-readable sender display name of a third-party message (group/stranger,
|
|
||||||
* design/16 §4.6). Blank for the owner's private chat, so present only when a
|
|
||||||
* non-owner is speaking; the recipient renders it as the sender label. */
|
|
||||||
senderName?: string;
|
|
||||||
/** The role that produced this message (design/22 §7-5, `chat_messages
|
|
||||||
* .persona_id`, served by the message DTO). A session may switch roles without
|
|
||||||
* losing its context, so the authoring role is recorded per turn: a historical
|
|
||||||
* bubble keeps the role of *its own* turn instead of being re-attributed to the
|
|
||||||
* role the session speaks as now.
|
|
||||||
*
|
|
||||||
* Absent on a client-side optimistic bubble and on an older backend; `''` means
|
|
||||||
* "inherit the current global default role". Consumers fall back to the active
|
|
||||||
* session's role only in those cases — never to a name they cannot resolve. */
|
|
||||||
personaId?: string;
|
|
||||||
createdAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The two web session kinds (design/16 §2.1): the fixed main session that every
|
|
||||||
* turn falls into by default, and a user-created secondary session that shares
|
|
||||||
* the same persona memory. */
|
|
||||||
export type SessionType = "main" | "sub";
|
|
||||||
|
|
||||||
/** A web conversation session as returned by GET /api/v1/sessions. The main
|
|
||||||
* session has a fixed id; a secondary session's id is minted on creation. The
|
|
||||||
* message truth lives server-side, so this is metadata only. */
|
|
||||||
export interface SessionMeta {
|
|
||||||
id: string;
|
|
||||||
type: SessionType;
|
|
||||||
title?: string;
|
|
||||||
createdAt: number;
|
|
||||||
/** Messages newer than this account's read cursor for the session (Android
|
|
||||||
* 协同 B3/D4). Optional so an older backend stays compatible — absent means
|
|
||||||
* "unknown", which the UI and the read path both treat as 0. */
|
|
||||||
unreadCount?: number;
|
|
||||||
/** The role this session talks to (design/22 §2 option (a): the session is the
|
|
||||||
* single source of truth). `''`/absent means "inherit the global default role",
|
|
||||||
* which is the pre-multi-persona behaviour, so an older backend keeps working
|
|
||||||
* unchanged. Resolved in `lib/persona.ts#resolveSessionPersona`. */
|
|
||||||
personaId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AgentStatus =
|
|
||||||
| "idle"
|
|
||||||
| "running"
|
|
||||||
| "blocked"
|
|
||||||
| "done"
|
|
||||||
| "error";
|
|
||||||
|
|
||||||
export interface AgentInfo {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
role?: string;
|
|
||||||
mode?: string;
|
|
||||||
status: AgentStatus;
|
|
||||||
progress?: number;
|
|
||||||
thinking?: string;
|
|
||||||
tools?: ToolUsed[];
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SubagentEventPayload {
|
|
||||||
id: string;
|
|
||||||
name?: string;
|
|
||||||
state?: string;
|
|
||||||
status?: AgentStatus;
|
|
||||||
progress?: number;
|
|
||||||
message?: string;
|
|
||||||
thinking?: string;
|
|
||||||
tool?: ToolUsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A "backthink" (后台思考) item — agent reasoning done in the background. */
|
|
||||||
export interface BackthinkItem {
|
|
||||||
id: string;
|
|
||||||
trigger: string;
|
|
||||||
conclusion?: string;
|
|
||||||
shouldNotify?: boolean;
|
|
||||||
pushed?: boolean;
|
|
||||||
at: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FlowNode {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
kind?: string;
|
|
||||||
status?: AgentStatus;
|
|
||||||
/** Optional structured metadata (params/result summaries) for the node detail panel. */
|
|
||||||
props?: Record<string, unknown>;
|
|
||||||
/** Optional accumulated span events (log) for the node detail panel. */
|
|
||||||
log?: TraceEvent[];
|
|
||||||
startedAt?: string;
|
|
||||||
endedAt?: string;
|
|
||||||
}
|
|
||||||
export interface FlowEdge {
|
|
||||||
from: string;
|
|
||||||
to: string;
|
|
||||||
}
|
|
||||||
export interface FlowGraph {
|
|
||||||
id: string;
|
|
||||||
nodes: FlowNode[];
|
|
||||||
edges: FlowEdge[];
|
|
||||||
/** The graph's owning session id (backend `sessionId`). */
|
|
||||||
sessionId?: string;
|
|
||||||
/** Round start/end times (RFC3339), when the backend provides them. */
|
|
||||||
startedAt?: string;
|
|
||||||
endedAt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionRecord {
|
|
||||||
platform: string;
|
|
||||||
channelId: string;
|
|
||||||
sessionId: string;
|
|
||||||
actorRole: string;
|
|
||||||
owner: boolean;
|
|
||||||
isGroup: boolean;
|
|
||||||
memWritable: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PersonaRecord {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
character: string;
|
|
||||||
isDefault: boolean;
|
|
||||||
mode: string;
|
|
||||||
memoryNamespace: string;
|
|
||||||
/** This persona's own avatar image path. Omitted when unset (omitempty). */
|
|
||||||
avatar?: string;
|
|
||||||
/** Per-orientation chat wallpaper; a role's own, never another's. Omitted when unset. */
|
|
||||||
chatBackground?: { landscape?: string; portrait?: string };
|
|
||||||
/** The role's current mood (v0.43, role-level: design/01 §2 — emotion follows
|
|
||||||
* the role, never a session, so every session of a role shows the same value).
|
|
||||||
* `""`/absent = the role has no tracked emotion; the UI keeps its neutral
|
|
||||||
* placeholder rather than inventing one. Used to seed the mood badge on load. */
|
|
||||||
mood?: string;
|
|
||||||
/** The optional `thinking` block (v0.43). Admin list (`GET /admin/personas`)
|
|
||||||
* only; absent when the persona configures none. */
|
|
||||||
thinking?: PersonaThinking;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoginResult {
|
|
||||||
token: string;
|
|
||||||
expiresAt: number;
|
|
||||||
role: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChatSyncResult {
|
|
||||||
reply: string;
|
|
||||||
steps: number;
|
|
||||||
stopReason: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Downstream WS frame (server → client). `data` is intentionally `unknown`;
|
|
||||||
* every consumer narrows it. The backend emits `{type, data, turnId?, seq?,
|
|
||||||
* sessionId?}` and types include the structured stream events message.start/delta/end,
|
|
||||||
* thinking.start/delta/end, tool_call.start/delta/end, tool_execution.update
|
|
||||||
* (design/04 §1.1), plus the legacy stream_chunk / stream_end / thinking / mood /
|
|
||||||
* tool_used / subagent_event / graph_update / reminder / device_update / auth /
|
|
||||||
* error / task_output, and the capacity/session events from design/14 §5 + 15 §5:
|
|
||||||
* capacity_awaken (状态切换+点亮工具), session_busy / session_pending (她是否在忙+挂起消息数).
|
|
||||||
*
|
|
||||||
* `sessionId` (design/16 §4.2) scopes the frame to one conversation. The hub
|
|
||||||
* broadcasts globally — every connection of the owner sees every session — so the
|
|
||||||
* client filters by the active session: a frame belonging to another conversation
|
|
||||||
* (a concurrent stream, an edit, a delete) must never reach the rendered bubble.
|
|
||||||
* It is absent when the event is not conversation-scoped (panel/device events) or
|
|
||||||
* when the backend could not resolve the conversation; consumers then keep the old
|
|
||||||
* permissive behavior rather than dropping the frame.
|
|
||||||
*/
|
|
||||||
export interface WsDownEvent {
|
|
||||||
type: string;
|
|
||||||
data: unknown;
|
|
||||||
turnId?: string;
|
|
||||||
seq?: number;
|
|
||||||
sessionId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Upstream WS frame (client → server). */
|
|
||||||
export interface WsUpEvent {
|
|
||||||
type: "mode_switch" | "chat" | "voice_input" | "subagent_ask" | "task_output_query";
|
|
||||||
reqId?: string;
|
|
||||||
data?: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiErrorShape {
|
|
||||||
code?: string;
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** RBAC roles for the admin panel (owner > admin > viewer). */
|
|
||||||
export type Role = "owner" | "admin" | "viewer";
|
|
||||||
|
|
||||||
/** A memory entry as returned by GET /api/v1/admin/memories. */
|
|
||||||
export interface MemoryRecord {
|
|
||||||
id: string;
|
|
||||||
scope: string;
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A capability/plugin as returned by GET /api/v1/admin/plugins. */
|
|
||||||
export interface AdminPluginRecord {
|
|
||||||
name: string;
|
|
||||||
kind: string;
|
|
||||||
description: string;
|
|
||||||
allowInGroup: boolean;
|
|
||||||
allowInStranger: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One runtime-installed plugin (GET /api/v1/admin/plugins/installed, v0.28).
|
|
||||||
* A v1 plugin is an external MCP server; `live` = connected and its tools are
|
|
||||||
* registered, `toolCount` = tools it contributed. The backend masks apiKey and
|
|
||||||
* sensitive env values before they reach the client. */
|
|
||||||
export interface InstalledPlugin {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
kind: string;
|
|
||||||
transport: string;
|
|
||||||
endpoint?: string;
|
|
||||||
command?: string;
|
|
||||||
args?: string[];
|
|
||||||
apiKey?: string;
|
|
||||||
env?: Record<string, string>;
|
|
||||||
createdAt?: string;
|
|
||||||
live: boolean;
|
|
||||||
toolCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A channel adapter as returned by GET /api/v1/admin/channels. */
|
|
||||||
export interface AdminChannelRecord {
|
|
||||||
name: string;
|
|
||||||
connected: boolean;
|
|
||||||
/** Configured accounts for this channel's platform (token masked). */
|
|
||||||
accounts?: AdminChannelAccount[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One configured IM channel account (admin view, token masked). */
|
|
||||||
export interface AdminChannelAccount {
|
|
||||||
id: string;
|
|
||||||
config: Record<string, unknown>;
|
|
||||||
allowGroups?: string[];
|
|
||||||
denyGroups?: string[];
|
|
||||||
allowUsers?: string[];
|
|
||||||
denyUsers?: string[];
|
|
||||||
groupMsgPerMin?: number;
|
|
||||||
msgPerMin?: number;
|
|
||||||
maxMessages?: number;
|
|
||||||
/** The role this channel account talks as (design/22 §4.4, 批次 2). Absent/`''`
|
|
||||||
* means "跟随默认角色" — it only ever applies to the account's non-isolated
|
|
||||||
* sessions (群聊/陌生人强制默认角色). Written through the config PUT. */
|
|
||||||
personaId?: string;
|
|
||||||
/** Connected bot identity (kook resolves it from /api/v3/user/me). */
|
|
||||||
botId?: string;
|
|
||||||
botUsername?: string;
|
|
||||||
botNickname?: string;
|
|
||||||
/** Account-level admin operations the platform adapter exposes (e.g. kook's
|
|
||||||
* ["friends","user_chats","channels","guilds","message_test"]). */
|
|
||||||
capabilities?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The weixin-iLink scan-login QR payload (GET /admin/channels/{platform}/login-qr).
|
|
||||||
* qrUrl is the QR image URL; qrImage is a base64 data URL the panel can embed
|
|
||||||
* directly. status is one of idle|waiting|confirmed|failed. The qrcode scan
|
|
||||||
* identifier is never exposed. */
|
|
||||||
export interface WeixinLoginQRResponse {
|
|
||||||
/** The WeChat QR image URL (qrcode_img_content); the panel renders a QR from it. */
|
|
||||||
qrUrl?: string;
|
|
||||||
/** The raw scan identifier (qrcode), used for status polling. */
|
|
||||||
qrCode?: string;
|
|
||||||
status: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The weixin-iLink scan-login status payload (GET /admin/channels/{platform}/login-status).
|
|
||||||
* botToken is always masked (never a raw secret). */
|
|
||||||
export interface WeixinLoginStatusResponse {
|
|
||||||
status: string;
|
|
||||||
botId?: string;
|
|
||||||
botToken?: string;
|
|
||||||
err?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One message in the platform (IM channel) send/receive log. Mirrors the
|
|
||||||
* historyMessage wire shape from GET /api/v1/admin/channels/{platform}/messages. */
|
|
||||||
export interface AdminChannelMessage {
|
|
||||||
id: string;
|
|
||||||
sessionId: string;
|
|
||||||
role: string; // "user" | "assistant"
|
|
||||||
content: string;
|
|
||||||
mode?: string;
|
|
||||||
platform?: string;
|
|
||||||
deviceId?: string;
|
|
||||||
/** Adapter (platform:account) that produced the message; blank for Web. */
|
|
||||||
adapterId?: string;
|
|
||||||
/** Human sender display name for group/stranger messages; blank = owner. */
|
|
||||||
senderName?: string;
|
|
||||||
createdAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An IoT device as returned by GET /api/v1/admin/iot. */
|
|
||||||
export interface AdminIoTDevice {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
state: Record<string, unknown>;
|
|
||||||
online: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A backend user / API key record (GET /api/v1/admin/users, planned M10). */
|
|
||||||
export interface AdminUserRecord {
|
|
||||||
id: string;
|
|
||||||
username: string;
|
|
||||||
role: string;
|
|
||||||
createdAt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** LLM provider/model configuration (GET/PUT /api/v1/admin/llm, design/04 §3.1).
|
|
||||||
* API key is never returned by the backend; provider is the wire protocol. */
|
|
||||||
export interface AdminLlmProvider {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
provider: string;
|
|
||||||
baseUrl: string;
|
|
||||||
model: string;
|
|
||||||
multimodal: boolean;
|
|
||||||
enabled: boolean;
|
|
||||||
fallback?: string;
|
|
||||||
/** The group's token budget. Absent/0 = unset (the runtime falls back to
|
|
||||||
* LLM_FALLBACK_MAX_TOKENS on the last step, then the adapter default). */
|
|
||||||
maxTokens?: number;
|
|
||||||
}
|
|
||||||
export interface AdminLlmConfig {
|
|
||||||
providers: AdminLlmProvider[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** An automation / reminder entry (GET/POST /api/v1/admin/automations, planned). */
|
|
||||||
export interface AdminAutomationRecord {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
kind: string;
|
|
||||||
cron?: string;
|
|
||||||
enabled: boolean;
|
|
||||||
lastRun?: string;
|
|
||||||
nextRun?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A structured log line (GET /api/v1/admin/logs, planned). */
|
|
||||||
export interface AdminLogRecord {
|
|
||||||
ts: string;
|
|
||||||
level: string;
|
|
||||||
source: string;
|
|
||||||
msg: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Severity filter of the live log tail (GET /api/v1/admin/observability/logs).
|
|
||||||
* An absent `level` means “all”. */
|
|
||||||
export type AdminLogLevel = "debug" | "info" | "warn" | "error";
|
|
||||||
|
|
||||||
/** One line of the gateway's structured log ring buffer (newest first).
|
|
||||||
* `time` is RFC3339 on current builds but the field is typed as either that or
|
|
||||||
* epoch milliseconds so the renderer stays compatible with both; `source` is
|
|
||||||
* the `module/subfeature` tag every log line carries (never blank). */
|
|
||||||
export interface AdminLogLine {
|
|
||||||
time: string | number;
|
|
||||||
level: string;
|
|
||||||
source: string;
|
|
||||||
msg: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Query for GET /api/v1/admin/observability/logs. `limit` defaults to 200 and
|
|
||||||
* is clamped to 1000 by the backend. */
|
|
||||||
export interface AdminLogQuery {
|
|
||||||
level?: AdminLogLevel;
|
|
||||||
limit?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A knowledge-base document (GET /api/v1/admin/rag). */
|
|
||||||
export interface AdminRagDoc {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
desc?: string;
|
|
||||||
chunks: number;
|
|
||||||
embedded: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One RAG retrieval hit (GET /api/v1/admin/rag?query= / POST /rag/query). */
|
|
||||||
export interface AdminRagHit {
|
|
||||||
docId: string;
|
|
||||||
docName: string;
|
|
||||||
chapter?: string;
|
|
||||||
text: string;
|
|
||||||
score: number;
|
|
||||||
semantic: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The GET /api/v1/admin/rag response shape. */
|
|
||||||
export interface AdminRagResponse {
|
|
||||||
documents: AdminRagDoc[];
|
|
||||||
hits?: AdminRagHit[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One audit entry: who / what / when / result. `id` is a string for the local
|
|
||||||
* (client) trail but a number for backend-persisted entries. */
|
|
||||||
export interface AuditRecord {
|
|
||||||
id: string | number;
|
|
||||||
who: string;
|
|
||||||
what: string;
|
|
||||||
when: string;
|
|
||||||
result: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Gateway request metrics (GET /api/v1/admin/observability/metrics). Token/cost
|
|
||||||
* is intentionally absent: the gateway does not track spend, and a fabricated
|
|
||||||
* number would violate the "不伪造指标" red line. */
|
|
||||||
export interface AdminObservabilityMetrics {
|
|
||||||
totalRequests: number;
|
|
||||||
qps: number;
|
|
||||||
avgLatencyMs: number;
|
|
||||||
maxLatencyMs: number;
|
|
||||||
perMethod: Record<string, number>;
|
|
||||||
perPath: Record<string, number>;
|
|
||||||
statusCounts: Record<string, number>;
|
|
||||||
llmCalls: number;
|
|
||||||
subagentEvents: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One traced span (GET /api/v1/admin/observability/trace). */
|
|
||||||
export interface AdminTraceSpan {
|
|
||||||
id: string;
|
|
||||||
parentId?: string;
|
|
||||||
name: string;
|
|
||||||
kind: string;
|
|
||||||
status: string;
|
|
||||||
startedAt: string;
|
|
||||||
endedAt?: string;
|
|
||||||
durationNs: number;
|
|
||||||
props?: Record<string, unknown>;
|
|
||||||
events?: TraceEvent[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A fine-grained event attached to a span. */
|
|
||||||
export interface TraceEvent {
|
|
||||||
time: string;
|
|
||||||
type: string;
|
|
||||||
props?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The owner profile (mirrors configs/users/owner.yaml). */
|
|
||||||
export interface OwnerProfile {
|
|
||||||
id: string;
|
|
||||||
displayName: string;
|
|
||||||
defaultPersona: string;
|
|
||||||
identityFile: string;
|
|
||||||
notes: string;
|
|
||||||
accounts: OwnerAccount[];
|
|
||||||
}
|
|
||||||
export interface OwnerAccount {
|
|
||||||
platform: string;
|
|
||||||
accountId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------------
|
|
||||||
* Admin write payloads / results (POST/PUT/DELETE /api/v1/admin/*).
|
|
||||||
* The backend persists to a real store or answers honestly with a 501 / clear
|
|
||||||
* error — never a fabricated success.
|
|
||||||
* -------------------------------------------------------------------------*/
|
|
||||||
|
|
||||||
/** POST/PUT /api/v1/admin/personas body. id required on create, path on update. */
|
|
||||||
export interface PersonaUpsertRequest {
|
|
||||||
id?: string;
|
|
||||||
name: string;
|
|
||||||
characterRef?: string;
|
|
||||||
role: string;
|
|
||||||
systemPrompt?: string;
|
|
||||||
personality?: string;
|
|
||||||
speechStyle?: string;
|
|
||||||
catchphrase?: string;
|
|
||||||
background?: string;
|
|
||||||
values?: string[];
|
|
||||||
modes?: string[];
|
|
||||||
mode?: string;
|
|
||||||
memoryNamespace?: string;
|
|
||||||
/** Optional reasoning/presentation block (design/01 §2.1). **Omit the key when
|
|
||||||
* the form configures nothing**: an omitted object keeps the stored block,
|
|
||||||
* while an all-default object clears it. `showToUser` stays tri-state — omit it
|
|
||||||
* (= not configured) rather than sending `false`. */
|
|
||||||
thinking?: PersonaThinkingRequest;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /api/v1/admin/users/apikey — the plaintext key is returned exactly once. */
|
|
||||||
export interface ApiKeyIssueResult {
|
|
||||||
id: string;
|
|
||||||
key: string;
|
|
||||||
role: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /api/v1/admin/automations result. */
|
|
||||||
export interface AutomationCreateResult {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
kind: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /api/v1/admin/automations/{id}/run result. */
|
|
||||||
export interface AutomationRunResult {
|
|
||||||
id: string;
|
|
||||||
output: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /api/v1/admin/llm body — saves a provider group to models.json and
|
|
||||||
* hot-reloads the running LLM stack (persisted + active). The backend merges
|
|
||||||
* the request over the stored group (read-modify-write): an omitted field keeps
|
|
||||||
* its stored value, so a partial edit never wipes a budget or key. */
|
|
||||||
export interface LlmProviderRequest {
|
|
||||||
id?: string;
|
|
||||||
name?: string;
|
|
||||||
provider?: string;
|
|
||||||
baseUrl: string;
|
|
||||||
apiKey?: string;
|
|
||||||
model: string;
|
|
||||||
multimodal?: boolean;
|
|
||||||
fallback?: string;
|
|
||||||
/** Absent = keep the stored budget (or leave unset for a new group). */
|
|
||||||
maxTokens?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /api/v1/admin/llm/test body. */
|
|
||||||
export interface LlmTestRequest {
|
|
||||||
provider?: string;
|
|
||||||
baseUrl: string;
|
|
||||||
apiKey?: string;
|
|
||||||
model: string;
|
|
||||||
prompt?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /api/v1/admin/llm/test result. */
|
|
||||||
export interface LlmTestResult {
|
|
||||||
ok: boolean;
|
|
||||||
provider: string;
|
|
||||||
model: string;
|
|
||||||
reply: string;
|
|
||||||
tokens: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** MCP server connection block for POST /api/v1/admin/plugins/install (v0.28).
|
|
||||||
* `endpoint` is for http/sse, `command` (+ `args`) for stdio. apiKey is sent
|
|
||||||
* once and persisted but never echoed back; env is the stdio child environment. */
|
|
||||||
export interface PluginInstallConfig {
|
|
||||||
transport: string;
|
|
||||||
endpoint?: string;
|
|
||||||
command?: string;
|
|
||||||
args?: string[];
|
|
||||||
apiKey?: string;
|
|
||||||
env?: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /api/v1/admin/plugins/install body. v1 installs MCP servers only. */
|
|
||||||
export interface PluginInstallRequest {
|
|
||||||
kind: string;
|
|
||||||
name: string;
|
|
||||||
config: PluginInstallConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /api/v1/admin/plugins/install result — the live server's registered tool count. */
|
|
||||||
export interface PluginInstallResult {
|
|
||||||
installed: boolean;
|
|
||||||
name: string;
|
|
||||||
kind: string;
|
|
||||||
transport: string;
|
|
||||||
toolCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /api/v1/admin/channels/test result (structural identity verdict). */
|
|
||||||
export interface ChannelTestResult {
|
|
||||||
role: string;
|
|
||||||
owner: boolean;
|
|
||||||
isGroup: boolean;
|
|
||||||
env: string;
|
|
||||||
verdict: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /api/v1/admin/iot/{id}/control body (params keys depend on device type). */
|
|
||||||
export interface IoTControlRequest {
|
|
||||||
action: string;
|
|
||||||
params?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /api/v1/admin/memories/search body. */
|
|
||||||
export interface MemorySearchRequest {
|
|
||||||
query: string;
|
|
||||||
personaId?: string;
|
|
||||||
topK?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One memory retrieval hit. */
|
|
||||||
export interface MemorySearchHit {
|
|
||||||
id: string;
|
|
||||||
scope: string;
|
|
||||||
text: string;
|
|
||||||
score: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /api/v1/admin/memories/search result. */
|
|
||||||
export interface MemorySearchResult {
|
|
||||||
query: string;
|
|
||||||
personaId: string;
|
|
||||||
semantic: boolean;
|
|
||||||
hits: MemorySearchHit[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /api/v1/admin/memories body — the manual "添加记忆" write. Scope is
|
|
||||||
* optional and defaults to persona; it must be one of user|persona|project. */
|
|
||||||
export interface MemoryAddRequest {
|
|
||||||
personaId?: string;
|
|
||||||
content: string;
|
|
||||||
scope?: "user" | "persona" | "project";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /api/v1/admin/memories result (the stored item's identity + text). */
|
|
||||||
export interface MemoryAddResult {
|
|
||||||
id: string;
|
|
||||||
scope: string;
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /api/v1/admin/rag body — the admin "导入文档" write. */
|
|
||||||
export interface RagIndexRequest {
|
|
||||||
name: string;
|
|
||||||
desc?: string;
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- System management writes (design/11 §4 + design/10 §3 模块14; owner-only, audited).
|
|
||||||
* Field names mirror the gateway DTOs in internal/services/gateway/admin_system.go. --- */
|
|
||||||
|
|
||||||
/** POST /api/v1/admin/system/revoke-keys — how many API keys were revoked. */
|
|
||||||
export interface SystemRevokeKeysResp {
|
|
||||||
revoked: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /api/v1/admin/system/rotate-jwt — `note` warns that every existing token is now
|
|
||||||
* invalid. The new secret is never echoed back by the backend. */
|
|
||||||
export interface SystemRotateJwtResp {
|
|
||||||
rotated: true;
|
|
||||||
note: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /api/v1/admin/system/backup — the snapshot file path + byte size. */
|
|
||||||
export interface SystemBackupResp {
|
|
||||||
path: string;
|
|
||||||
size: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /api/v1/admin/system/restore — a pre-restore snapshot path + a restart notice. */
|
|
||||||
export interface SystemRestoreResp {
|
|
||||||
restored: true;
|
|
||||||
backup: string;
|
|
||||||
note: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --- KOOK per-adapter panel data (design/17 §2; GET /admin/channels/kook/view). --- */
|
|
||||||
|
|
||||||
/** One friend / blocked user in GET /admin/channels/{platform}/friends. */
|
|
||||||
export interface KookFriendEntry {
|
|
||||||
user_id: string;
|
|
||||||
username?: string;
|
|
||||||
nickname?: string;
|
|
||||||
avatar?: string;
|
|
||||||
online?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One pending friend request (has a top-level request id). */
|
|
||||||
export interface KookFriendRequestEntry {
|
|
||||||
id: number;
|
|
||||||
friend: KookFriendEntry;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GET /admin/channels/{platform}/friends parsed data. */
|
|
||||||
export interface KookFriendList {
|
|
||||||
friends?: KookFriendEntry[];
|
|
||||||
requests?: KookFriendRequestEntry[];
|
|
||||||
blocked?: KookFriendEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One private-chat session (GET /admin/channels/kook/view userChats). */
|
|
||||||
export interface KookUserChat {
|
|
||||||
code: string;
|
|
||||||
last_read_time: number;
|
|
||||||
latest_msg_time: number;
|
|
||||||
unread_count: number;
|
|
||||||
target_id: string;
|
|
||||||
target_name?: string;
|
|
||||||
is_friend?: boolean;
|
|
||||||
is_blocked?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One server in the guild list. */
|
|
||||||
export interface KookGuild {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
icon?: string;
|
|
||||||
topic?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One channel in a guild. */
|
|
||||||
export interface KookChannel {
|
|
||||||
id: string;
|
|
||||||
user_id?: string;
|
|
||||||
guild_id: string;
|
|
||||||
parent_id?: string;
|
|
||||||
name: string;
|
|
||||||
type: number;
|
|
||||||
level?: number;
|
|
||||||
is_category?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One guild member for the aggregate view. */
|
|
||||||
export interface KookGuildUser {
|
|
||||||
user_id: string;
|
|
||||||
username?: string;
|
|
||||||
nickname?: string;
|
|
||||||
avatar?: string;
|
|
||||||
online?: boolean;
|
|
||||||
bot?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One channel message (GET /admin/channels/kook/view, not currently fetched). */
|
|
||||||
export interface KookChannelMessage {
|
|
||||||
id: string;
|
|
||||||
type: number;
|
|
||||||
content: string;
|
|
||||||
author_id: string;
|
|
||||||
author_name?: string;
|
|
||||||
create_at: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /admin/channels/{platform}/message/test result. */
|
|
||||||
export interface KookMessageTestResult {
|
|
||||||
msg_id: string;
|
|
||||||
msg_timestamp: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GET /api/v1/admin/channels/{platform}/view — the KOOK aggregate panel payload.
|
|
||||||
* Sub-fetches that fail are recorded in errors rather than aborting the view. */
|
|
||||||
export interface KookViewResponse {
|
|
||||||
platform: string;
|
|
||||||
accountId?: string;
|
|
||||||
friends?: KookFriendList;
|
|
||||||
userChats?: KookUserChat[];
|
|
||||||
guilds?: KookGuild[];
|
|
||||||
channels?: KookChannel[];
|
|
||||||
members?: KookGuildUser[];
|
|
||||||
errors?: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Notification families of the unified feed (design/10 §3 #16). The endpoint
|
|
||||||
* aggregates pre-existing sources instead of a new model: audit = 系统/安全审计,
|
|
||||||
* channel = 渠道连接/断开/重连, backthink = 主动推送历史, system = 其余系统事件. */
|
|
||||||
export type AdminNotificationType = "system" | "channel" | "backthink" | "audit";
|
|
||||||
|
|
||||||
/** One item of GET /api/v1/admin/notifications (newest first). `createdAt` is
|
|
||||||
* epoch milliseconds; `source` records which source produced it (audit /
|
|
||||||
* backthink / a channel name), so the page can show provenance honestly. */
|
|
||||||
export interface AdminNotification {
|
|
||||||
id: string;
|
|
||||||
type: AdminNotificationType;
|
|
||||||
title: string;
|
|
||||||
body?: string;
|
|
||||||
source: string;
|
|
||||||
createdAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Query for GET /api/v1/admin/notifications. An absent `type` means “all”;
|
|
||||||
* `before` is a `createdAt` (ms) cursor walking toward older items; `limit`
|
|
||||||
* defaults to 50 and is clamped to 200 by the backend. */
|
|
||||||
export interface AdminNotificationQuery {
|
|
||||||
type?: AdminNotificationType;
|
|
||||||
before?: number;
|
|
||||||
limit?: number;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
/** RBAC roles for the admin panel (owner > admin > viewer). */
|
||||||
|
export type Role = "owner" | "admin" | "viewer";
|
||||||
|
|
||||||
|
/** A memory entry as returned by GET /api/v1/admin/memories. */
|
||||||
|
export interface MemoryRecord {
|
||||||
|
id: string;
|
||||||
|
scope: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A capability/plugin as returned by GET /api/v1/admin/plugins. */
|
||||||
|
export interface AdminPluginRecord {
|
||||||
|
name: string;
|
||||||
|
kind: string;
|
||||||
|
description: string;
|
||||||
|
allowInGroup: boolean;
|
||||||
|
allowInStranger: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One runtime-installed plugin (GET /api/v1/admin/plugins/installed, v0.28).
|
||||||
|
* A v1 plugin is an external MCP server; `live` = connected and its tools are
|
||||||
|
* registered, `toolCount` = tools it contributed. The backend masks apiKey and
|
||||||
|
* sensitive env values before they reach the client. */
|
||||||
|
export interface InstalledPlugin {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
kind: string;
|
||||||
|
transport: string;
|
||||||
|
endpoint?: string;
|
||||||
|
command?: string;
|
||||||
|
args?: string[];
|
||||||
|
apiKey?: string;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
createdAt?: string;
|
||||||
|
live: boolean;
|
||||||
|
toolCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A channel adapter as returned by GET /api/v1/admin/channels. */
|
||||||
|
export interface AdminChannelRecord {
|
||||||
|
name: string;
|
||||||
|
connected: boolean;
|
||||||
|
/** Configured accounts for this channel's platform (token masked). */
|
||||||
|
accounts?: AdminChannelAccount[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One configured IM channel account (admin view, token masked). */
|
||||||
|
export interface AdminChannelAccount {
|
||||||
|
id: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
allowGroups?: string[];
|
||||||
|
denyGroups?: string[];
|
||||||
|
allowUsers?: string[];
|
||||||
|
denyUsers?: string[];
|
||||||
|
groupMsgPerMin?: number;
|
||||||
|
msgPerMin?: number;
|
||||||
|
maxMessages?: number;
|
||||||
|
/** The role this channel account talks as (design/22 §4.4, 批次 2). Absent/`''`
|
||||||
|
* means "跟随默认角色" — it only ever applies to the account's non-isolated
|
||||||
|
* sessions (群聊/陌生人强制默认角色). Written through the config PUT. */
|
||||||
|
personaId?: string;
|
||||||
|
/** Connected bot identity (kook resolves it from /api/v3/user/me). */
|
||||||
|
botId?: string;
|
||||||
|
botUsername?: string;
|
||||||
|
botNickname?: string;
|
||||||
|
/** Account-level admin operations the platform adapter exposes (e.g. kook's
|
||||||
|
* ["friends","user_chats","channels","guilds","message_test"]). */
|
||||||
|
capabilities?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The weixin-iLink scan-login QR payload (GET /admin/channels/{platform}/login-qr).
|
||||||
|
* qrUrl is the QR image URL; qrImage is a base64 data URL the panel can embed
|
||||||
|
* directly. status is one of idle|waiting|confirmed|failed. The qrcode scan
|
||||||
|
* identifier is never exposed. */
|
||||||
|
export interface WeixinLoginQRResponse {
|
||||||
|
/** The WeChat QR image URL (qrcode_img_content); the panel renders a QR from it. */
|
||||||
|
qrUrl?: string;
|
||||||
|
/** The raw scan identifier (qrcode), used for status polling. */
|
||||||
|
qrCode?: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The weixin-iLink scan-login status payload (GET /admin/channels/{platform}/login-status).
|
||||||
|
* botToken is always masked (never a raw secret). */
|
||||||
|
export interface WeixinLoginStatusResponse {
|
||||||
|
status: string;
|
||||||
|
botId?: string;
|
||||||
|
botToken?: string;
|
||||||
|
err?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One message in the platform (IM channel) send/receive log. Mirrors the
|
||||||
|
* historyMessage wire shape from GET /api/v1/admin/channels/{platform}/messages. */
|
||||||
|
export interface AdminChannelMessage {
|
||||||
|
id: string;
|
||||||
|
sessionId: string;
|
||||||
|
role: string; // "user" | "assistant"
|
||||||
|
content: string;
|
||||||
|
mode?: string;
|
||||||
|
platform?: string;
|
||||||
|
deviceId?: string;
|
||||||
|
/** Adapter (platform:account) that produced the message; blank for Web. */
|
||||||
|
adapterId?: string;
|
||||||
|
/** Human sender display name for group/stranger messages; blank = owner. */
|
||||||
|
senderName?: string;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An IoT device as returned by GET /api/v1/admin/iot. */
|
||||||
|
export interface AdminIoTDevice {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
state: Record<string, unknown>;
|
||||||
|
online: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A backend user / API key record (GET /api/v1/admin/users, planned M10). */
|
||||||
|
export interface AdminUserRecord {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** LLM provider/model configuration (GET/PUT /api/v1/admin/llm, design/04 §3.1).
|
||||||
|
* API key is never returned by the backend; provider is the wire protocol. */
|
||||||
|
export interface AdminLlmProvider {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
provider: string;
|
||||||
|
baseUrl: string;
|
||||||
|
model: string;
|
||||||
|
multimodal: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
fallback?: string;
|
||||||
|
/** The group's token budget. Absent/0 = unset (the runtime falls back to
|
||||||
|
* LLM_FALLBACK_MAX_TOKENS on the last step, then the adapter default). */
|
||||||
|
maxTokens?: number;
|
||||||
|
}
|
||||||
|
export interface AdminLlmConfig {
|
||||||
|
providers: AdminLlmProvider[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An automation / reminder entry (GET/POST /api/v1/admin/automations, planned). */
|
||||||
|
export interface AdminAutomationRecord {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
kind: string;
|
||||||
|
cron?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
lastRun?: string;
|
||||||
|
nextRun?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A structured log line (GET /api/v1/admin/logs, planned). */
|
||||||
|
export interface AdminLogRecord {
|
||||||
|
ts: string;
|
||||||
|
level: string;
|
||||||
|
source: string;
|
||||||
|
msg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Severity filter of the live log tail (GET /api/v1/admin/observability/logs).
|
||||||
|
* An absent `level` means “all”. */
|
||||||
|
export type AdminLogLevel = "debug" | "info" | "warn" | "error";
|
||||||
|
|
||||||
|
/** One line of the gateway's structured log ring buffer (newest first).
|
||||||
|
* `time` is RFC3339 on current builds but the field is typed as either that or
|
||||||
|
* epoch milliseconds so the renderer stays compatible with both; `source` is
|
||||||
|
* the `module/subfeature` tag every log line carries (never blank). */
|
||||||
|
export interface AdminLogLine {
|
||||||
|
time: string | number;
|
||||||
|
level: string;
|
||||||
|
source: string;
|
||||||
|
msg: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Query for GET /api/v1/admin/observability/logs. `limit` defaults to 200 and
|
||||||
|
* is clamped to 1000 by the backend. */
|
||||||
|
export interface AdminLogQuery {
|
||||||
|
level?: AdminLogLevel;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A knowledge-base document (GET /api/v1/admin/rag). */
|
||||||
|
export interface AdminRagDoc {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
desc?: string;
|
||||||
|
chunks: number;
|
||||||
|
embedded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One RAG retrieval hit (GET /api/v1/admin/rag?query= / POST /rag/query). */
|
||||||
|
export interface AdminRagHit {
|
||||||
|
docId: string;
|
||||||
|
docName: string;
|
||||||
|
chapter?: string;
|
||||||
|
text: string;
|
||||||
|
score: number;
|
||||||
|
semantic: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The GET /api/v1/admin/rag response shape. */
|
||||||
|
export interface AdminRagResponse {
|
||||||
|
documents: AdminRagDoc[];
|
||||||
|
hits?: AdminRagHit[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One audit entry: who / what / when / result. `id` is a string for the local
|
||||||
|
* (client) trail but a number for backend-persisted entries. */
|
||||||
|
export interface AuditRecord {
|
||||||
|
id: string | number;
|
||||||
|
who: string;
|
||||||
|
what: string;
|
||||||
|
when: string;
|
||||||
|
result: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gateway request metrics (GET /api/v1/admin/observability/metrics). Token/cost
|
||||||
|
* is intentionally absent: the gateway does not track spend, and a fabricated
|
||||||
|
* number would violate the "不伪造指标" red line. */
|
||||||
|
export interface AdminObservabilityMetrics {
|
||||||
|
totalRequests: number;
|
||||||
|
qps: number;
|
||||||
|
avgLatencyMs: number;
|
||||||
|
maxLatencyMs: number;
|
||||||
|
perMethod: Record<string, number>;
|
||||||
|
perPath: Record<string, number>;
|
||||||
|
statusCounts: Record<string, number>;
|
||||||
|
llmCalls: number;
|
||||||
|
subagentEvents: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One traced span (GET /api/v1/admin/observability/trace). */
|
||||||
|
export interface AdminTraceSpan {
|
||||||
|
id: string;
|
||||||
|
parentId?: string;
|
||||||
|
name: string;
|
||||||
|
kind: string;
|
||||||
|
status: string;
|
||||||
|
startedAt: string;
|
||||||
|
endedAt?: string;
|
||||||
|
durationNs: number;
|
||||||
|
props?: Record<string, unknown>;
|
||||||
|
events?: TraceEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fine-grained event attached to a span. */
|
||||||
|
export interface TraceEvent {
|
||||||
|
time: string;
|
||||||
|
type: string;
|
||||||
|
props?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The owner profile (mirrors configs/users/owner.yaml). */
|
||||||
|
export interface OwnerProfile {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
defaultPersona: string;
|
||||||
|
identityFile: string;
|
||||||
|
notes: string;
|
||||||
|
accounts: OwnerAccount[];
|
||||||
|
}
|
||||||
|
export interface OwnerAccount {
|
||||||
|
platform: string;
|
||||||
|
accountId: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* Admin write payloads / results (POST/PUT/DELETE /api/v1/admin/*).
|
||||||
|
* The backend persists to a real store or answers honestly with a 501 / clear
|
||||||
|
* error — never a fabricated success.
|
||||||
|
* -------------------------------------------------------------------------*/
|
||||||
|
|
||||||
|
/** POST /api/v1/admin/users/apikey — the plaintext key is returned exactly once. */
|
||||||
|
export interface ApiKeyIssueResult {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/v1/admin/automations result. */
|
||||||
|
export interface AutomationCreateResult {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
kind: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/v1/admin/automations/{id}/run result. */
|
||||||
|
export interface AutomationRunResult {
|
||||||
|
id: string;
|
||||||
|
output: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/v1/admin/llm body — saves a provider group to models.json and
|
||||||
|
* hot-reloads the running LLM stack (persisted + active). The backend merges
|
||||||
|
* the request over the stored group (read-modify-write): an omitted field keeps
|
||||||
|
* its stored value, so a partial edit never wipes a budget or key. */
|
||||||
|
export interface LlmProviderRequest {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
provider?: string;
|
||||||
|
baseUrl: string;
|
||||||
|
apiKey?: string;
|
||||||
|
model: string;
|
||||||
|
multimodal?: boolean;
|
||||||
|
fallback?: string;
|
||||||
|
/** Absent = keep the stored budget (or leave unset for a new group). */
|
||||||
|
maxTokens?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/v1/admin/llm/test body. */
|
||||||
|
export interface LlmTestRequest {
|
||||||
|
provider?: string;
|
||||||
|
baseUrl: string;
|
||||||
|
apiKey?: string;
|
||||||
|
model: string;
|
||||||
|
prompt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/v1/admin/llm/test result. */
|
||||||
|
export interface LlmTestResult {
|
||||||
|
ok: boolean;
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
reply: string;
|
||||||
|
tokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MCP server connection block for POST /api/v1/admin/plugins/install (v0.28).
|
||||||
|
* `endpoint` is for http/sse, `command` (+ `args`) for stdio. apiKey is sent
|
||||||
|
* once and persisted but never echoed back; env is the stdio child environment. */
|
||||||
|
export interface PluginInstallConfig {
|
||||||
|
transport: string;
|
||||||
|
endpoint?: string;
|
||||||
|
command?: string;
|
||||||
|
args?: string[];
|
||||||
|
apiKey?: string;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/v1/admin/plugins/install body. v1 installs MCP servers only. */
|
||||||
|
export interface PluginInstallRequest {
|
||||||
|
kind: string;
|
||||||
|
name: string;
|
||||||
|
config: PluginInstallConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/v1/admin/plugins/install result — the live server's registered tool count. */
|
||||||
|
export interface PluginInstallResult {
|
||||||
|
installed: boolean;
|
||||||
|
name: string;
|
||||||
|
kind: string;
|
||||||
|
transport: string;
|
||||||
|
toolCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/v1/admin/channels/test result (structural identity verdict). */
|
||||||
|
export interface ChannelTestResult {
|
||||||
|
role: string;
|
||||||
|
owner: boolean;
|
||||||
|
isGroup: boolean;
|
||||||
|
env: string;
|
||||||
|
verdict: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/v1/admin/iot/{id}/control body (params keys depend on device type). */
|
||||||
|
export interface IoTControlRequest {
|
||||||
|
action: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/v1/admin/memories/search body. */
|
||||||
|
export interface MemorySearchRequest {
|
||||||
|
query: string;
|
||||||
|
personaId?: string;
|
||||||
|
topK?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One memory retrieval hit. */
|
||||||
|
export interface MemorySearchHit {
|
||||||
|
id: string;
|
||||||
|
scope: string;
|
||||||
|
text: string;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/v1/admin/memories/search result. */
|
||||||
|
export interface MemorySearchResult {
|
||||||
|
query: string;
|
||||||
|
personaId: string;
|
||||||
|
semantic: boolean;
|
||||||
|
hits: MemorySearchHit[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/v1/admin/memories body — the manual "添加记忆" write. Scope is
|
||||||
|
* optional and defaults to persona; it must be one of user|persona|project. */
|
||||||
|
export interface MemoryAddRequest {
|
||||||
|
personaId?: string;
|
||||||
|
content: string;
|
||||||
|
scope?: "user" | "persona" | "project";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/v1/admin/memories result (the stored item's identity + text). */
|
||||||
|
export interface MemoryAddResult {
|
||||||
|
id: string;
|
||||||
|
scope: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/v1/admin/rag body — the admin "导入文档" write. */
|
||||||
|
export interface RagIndexRequest {
|
||||||
|
name: string;
|
||||||
|
desc?: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- System management writes (design/11 §4 + design/10 §3 模块14; owner-only, audited).
|
||||||
|
* Field names mirror the gateway DTOs in internal/services/gateway/admin_system.go. --- */
|
||||||
|
|
||||||
|
/** POST /api/v1/admin/system/revoke-keys — how many API keys were revoked. */
|
||||||
|
export interface SystemRevokeKeysResp {
|
||||||
|
revoked: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/v1/admin/system/rotate-jwt — `note` warns that every existing token is now
|
||||||
|
* invalid. The new secret is never echoed back by the backend. */
|
||||||
|
export interface SystemRotateJwtResp {
|
||||||
|
rotated: true;
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/v1/admin/system/backup — the snapshot file path + byte size. */
|
||||||
|
export interface SystemBackupResp {
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/v1/admin/system/restore — a pre-restore snapshot path + a restart notice. */
|
||||||
|
export interface SystemRestoreResp {
|
||||||
|
restored: true;
|
||||||
|
backup: string;
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { ToolUsed } from "./chat";
|
||||||
|
|
||||||
|
export type AgentStatus =
|
||||||
|
| "idle"
|
||||||
|
| "running"
|
||||||
|
| "blocked"
|
||||||
|
| "done"
|
||||||
|
| "error";
|
||||||
|
|
||||||
|
export interface AgentInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
role?: string;
|
||||||
|
mode?: string;
|
||||||
|
status: AgentStatus;
|
||||||
|
progress?: number;
|
||||||
|
thinking?: string;
|
||||||
|
tools?: ToolUsed[];
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubagentEventPayload {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
state?: string;
|
||||||
|
status?: AgentStatus;
|
||||||
|
progress?: number;
|
||||||
|
message?: string;
|
||||||
|
thinking?: string;
|
||||||
|
tool?: ToolUsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A "backthink" (后台思考) item — agent reasoning done in the background. */
|
||||||
|
export interface BackthinkItem {
|
||||||
|
id: string;
|
||||||
|
trigger: string;
|
||||||
|
conclusion?: string;
|
||||||
|
shouldNotify?: boolean;
|
||||||
|
pushed?: boolean;
|
||||||
|
at: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export interface LoginResult {
|
||||||
|
token: string;
|
||||||
|
expiresAt: number;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatSyncResult {
|
||||||
|
reply: string;
|
||||||
|
steps: number;
|
||||||
|
stopReason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiErrorShape {
|
||||||
|
code?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
/** The 9 behavior modes. Switching only changes the presentation layer. */
|
||||||
|
export type Mode =
|
||||||
|
| "chat"
|
||||||
|
| "code"
|
||||||
|
| "home"
|
||||||
|
| "work"
|
||||||
|
| "docs"
|
||||||
|
| "voice"
|
||||||
|
| "vision"
|
||||||
|
| "agents"
|
||||||
|
| "settings";
|
||||||
|
|
||||||
|
export const MODES: ReadonlyArray<Mode> = [
|
||||||
|
"chat",
|
||||||
|
"code",
|
||||||
|
"home",
|
||||||
|
"work",
|
||||||
|
"docs",
|
||||||
|
"voice",
|
||||||
|
"vision",
|
||||||
|
"agents",
|
||||||
|
"settings",
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface ToolUsed {
|
||||||
|
name: string;
|
||||||
|
summary?: string;
|
||||||
|
ok?: boolean;
|
||||||
|
detail?: string;
|
||||||
|
at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
id: string;
|
||||||
|
/** The owning session (backend `session_id`). Set when loaded via the sessions
|
||||||
|
* API; absent on a client-side optimistic bubble until it is persisted. */
|
||||||
|
sessionId?: string;
|
||||||
|
role: "user" | "assistant" | "system";
|
||||||
|
content: string;
|
||||||
|
/** Behavior mode this message was produced in — drives its bubble rendering.
|
||||||
|
* Each message carries its own mode, so a single session can mix bubble shapes. */
|
||||||
|
mode?: Mode;
|
||||||
|
streaming?: boolean;
|
||||||
|
thinking?: string;
|
||||||
|
mood?: string;
|
||||||
|
tools?: ToolUsed[];
|
||||||
|
/** Optional image attached to the turn (base64 data: URI or http(s) URL). The
|
||||||
|
* user bubble renders it as a thumb; vision-mode assistant bodies render it
|
||||||
|
* as an image card. Absent for legacy persisted messages, so renders fall back
|
||||||
|
* to plain text. */
|
||||||
|
image?: string;
|
||||||
|
/** Source-platform tag of the message (design/16 §4.4): "web" for messages that
|
||||||
|
* originate on the Web main session, or an IM channel (e.g. "telegram",
|
||||||
|
* "weixin-ilink", "qq-obv11") for messages mirrored in from an IM adapter. The
|
||||||
|
* Web client renders the full session; an IM adapter filters to its own platform.
|
||||||
|
* Absent on legacy/client-optimistic messages. */
|
||||||
|
platform?: string;
|
||||||
|
/** Concrete client/device that produced the message (design/16 §4.5), e.g.
|
||||||
|
* "web:chrome:a1b2c3" for a browser or "weixin-ilink:conf0:wx_uid" for an IM
|
||||||
|
* channel. Complements platform (which only names the platform). The Web client
|
||||||
|
* defaults to labeling the bubble source when this is present; absent on
|
||||||
|
* legacy/client-optimistic messages kept without it. */
|
||||||
|
deviceId?: string;
|
||||||
|
/** Human-readable sender display name of a third-party message (group/stranger,
|
||||||
|
* design/16 §4.6). Blank for the owner's private chat, so present only when a
|
||||||
|
* non-owner is speaking; the recipient renders it as the sender label. */
|
||||||
|
senderName?: string;
|
||||||
|
/** The role that produced this message (design/22 §7-5, `chat_messages
|
||||||
|
* .persona_id`, served by the message DTO). A session may switch roles without
|
||||||
|
* losing its context, so the authoring role is recorded per turn: a historical
|
||||||
|
* bubble keeps the role of *its own* turn instead of being re-attributed to the
|
||||||
|
* role the session speaks as now.
|
||||||
|
*
|
||||||
|
* Absent on a client-side optimistic bubble and on an older backend; `''` means
|
||||||
|
* "inherit the current global default role". Consumers fall back to the active
|
||||||
|
* session's role only in those cases — never to a name they cannot resolve. */
|
||||||
|
personaId?: string;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { AgentStatus } from "./agent";
|
||||||
|
import type { TraceEvent } from "./admin";
|
||||||
|
|
||||||
|
export interface FlowNode {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
kind?: string;
|
||||||
|
status?: AgentStatus;
|
||||||
|
/** Optional structured metadata (params/result summaries) for the node detail panel. */
|
||||||
|
props?: Record<string, unknown>;
|
||||||
|
/** Optional accumulated span events (log) for the node detail panel. */
|
||||||
|
log?: TraceEvent[];
|
||||||
|
startedAt?: string;
|
||||||
|
endedAt?: string;
|
||||||
|
}
|
||||||
|
export interface FlowEdge {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
export interface FlowGraph {
|
||||||
|
id: string;
|
||||||
|
nodes: FlowNode[];
|
||||||
|
edges: FlowEdge[];
|
||||||
|
/** The graph's owning session id (backend `sessionId`). */
|
||||||
|
sessionId?: string;
|
||||||
|
/** Round start/end times (RFC3339), when the backend provides them. */
|
||||||
|
startedAt?: string;
|
||||||
|
endedAt?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/* --- KOOK per-adapter panel data (design/17 §2; GET /admin/channels/kook/view). --- */
|
||||||
|
|
||||||
|
/** One friend / blocked user in GET /admin/channels/{platform}/friends. */
|
||||||
|
export interface KookFriendEntry {
|
||||||
|
user_id: string;
|
||||||
|
username?: string;
|
||||||
|
nickname?: string;
|
||||||
|
avatar?: string;
|
||||||
|
online?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One pending friend request (has a top-level request id). */
|
||||||
|
export interface KookFriendRequestEntry {
|
||||||
|
id: number;
|
||||||
|
friend: KookFriendEntry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /admin/channels/{platform}/friends parsed data. */
|
||||||
|
export interface KookFriendList {
|
||||||
|
friends?: KookFriendEntry[];
|
||||||
|
requests?: KookFriendRequestEntry[];
|
||||||
|
blocked?: KookFriendEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One private-chat session (GET /admin/channels/kook/view userChats). */
|
||||||
|
export interface KookUserChat {
|
||||||
|
code: string;
|
||||||
|
last_read_time: number;
|
||||||
|
latest_msg_time: number;
|
||||||
|
unread_count: number;
|
||||||
|
target_id: string;
|
||||||
|
target_name?: string;
|
||||||
|
is_friend?: boolean;
|
||||||
|
is_blocked?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One server in the guild list. */
|
||||||
|
export interface KookGuild {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon?: string;
|
||||||
|
topic?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One channel in a guild. */
|
||||||
|
export interface KookChannel {
|
||||||
|
id: string;
|
||||||
|
user_id?: string;
|
||||||
|
guild_id: string;
|
||||||
|
parent_id?: string;
|
||||||
|
name: string;
|
||||||
|
type: number;
|
||||||
|
level?: number;
|
||||||
|
is_category?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One guild member for the aggregate view. */
|
||||||
|
export interface KookGuildUser {
|
||||||
|
user_id: string;
|
||||||
|
username?: string;
|
||||||
|
nickname?: string;
|
||||||
|
avatar?: string;
|
||||||
|
online?: boolean;
|
||||||
|
bot?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One channel message (GET /admin/channels/kook/view, not currently fetched). */
|
||||||
|
export interface KookChannelMessage {
|
||||||
|
id: string;
|
||||||
|
type: number;
|
||||||
|
content: string;
|
||||||
|
author_id: string;
|
||||||
|
author_name?: string;
|
||||||
|
create_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /admin/channels/{platform}/message/test result. */
|
||||||
|
export interface KookMessageTestResult {
|
||||||
|
msg_id: string;
|
||||||
|
msg_timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/v1/admin/channels/{platform}/view — the KOOK aggregate panel payload.
|
||||||
|
* Sub-fetches that fail are recorded in errors rather than aborting the view. */
|
||||||
|
export interface KookViewResponse {
|
||||||
|
platform: string;
|
||||||
|
accountId?: string;
|
||||||
|
friends?: KookFriendList;
|
||||||
|
userChats?: KookUserChat[];
|
||||||
|
guilds?: KookGuild[];
|
||||||
|
channels?: KookChannel[];
|
||||||
|
members?: KookGuildUser[];
|
||||||
|
errors?: Record<string, string>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/** Notification families of the unified feed (design/10 §3 #16). The endpoint
|
||||||
|
* aggregates pre-existing sources instead of a new model: audit = 系统/安全审计,
|
||||||
|
* channel = 渠道连接/断开/重连, backthink = 主动推送历史, system = 其余系统事件. */
|
||||||
|
export type AdminNotificationType = "system" | "channel" | "backthink" | "audit";
|
||||||
|
|
||||||
|
/** One item of GET /api/v1/admin/notifications (newest first). `createdAt` is
|
||||||
|
* epoch milliseconds; `source` records which source produced it (audit /
|
||||||
|
* backthink / a channel name), so the page can show provenance honestly. */
|
||||||
|
export interface AdminNotification {
|
||||||
|
id: string;
|
||||||
|
type: AdminNotificationType;
|
||||||
|
title: string;
|
||||||
|
body?: string;
|
||||||
|
source: string;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Query for GET /api/v1/admin/notifications. An absent `type` means “all”;
|
||||||
|
* `before` is a `createdAt` (ms) cursor walking toward older items; `limit`
|
||||||
|
* defaults to 50 and is clamped to 200 by the backend. */
|
||||||
|
export interface AdminNotificationQuery {
|
||||||
|
type?: AdminNotificationType;
|
||||||
|
before?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { PersonaThinking, PersonaThinkingRequest } from "../personaThinking";
|
||||||
|
|
||||||
|
export interface PersonaRecord {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
character: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
mode: string;
|
||||||
|
memoryNamespace: string;
|
||||||
|
/** This persona's own avatar image path. Omitted when unset (omitempty). */
|
||||||
|
avatar?: string;
|
||||||
|
/** Per-orientation chat wallpaper; a role's own, never another's. Omitted when unset. */
|
||||||
|
chatBackground?: { landscape?: string; portrait?: string };
|
||||||
|
/** The role's current mood (v0.43, role-level: design/01 §2 — emotion follows
|
||||||
|
* the role, never a session, so every session of a role shows the same value).
|
||||||
|
* `""`/absent = the role has no tracked emotion; the UI keeps its neutral
|
||||||
|
* placeholder rather than inventing one. Used to seed the mood badge on load. */
|
||||||
|
mood?: string;
|
||||||
|
/** The optional `thinking` block (v0.43). Admin list (`GET /admin/personas`)
|
||||||
|
* only; absent when the persona configures none. */
|
||||||
|
thinking?: PersonaThinking;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST/PUT /api/v1/admin/personas body. id required on create, path on update. */
|
||||||
|
export interface PersonaUpsertRequest {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
characterRef?: string;
|
||||||
|
role: string;
|
||||||
|
systemPrompt?: string;
|
||||||
|
personality?: string;
|
||||||
|
speechStyle?: string;
|
||||||
|
catchphrase?: string;
|
||||||
|
background?: string;
|
||||||
|
values?: string[];
|
||||||
|
modes?: string[];
|
||||||
|
mode?: string;
|
||||||
|
memoryNamespace?: string;
|
||||||
|
/** Optional reasoning/presentation block (design/01 §2.1). **Omit the key when
|
||||||
|
* the form configures nothing**: an omitted object keeps the stored block,
|
||||||
|
* while an all-default object clears it. `showToUser` stays tri-state — omit it
|
||||||
|
* (= not configured) rather than sending `false`. */
|
||||||
|
thinking?: PersonaThinkingRequest;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/** The two web session kinds (design/16 §2.1): the fixed main session that every
|
||||||
|
* turn falls into by default, and a user-created secondary session that shares
|
||||||
|
* the same persona memory. */
|
||||||
|
export type SessionType = "main" | "sub";
|
||||||
|
|
||||||
|
/** A web conversation session as returned by GET /api/v1/sessions. The main
|
||||||
|
* session has a fixed id; a secondary session's id is minted on creation. The
|
||||||
|
* message truth lives server-side, so this is metadata only. */
|
||||||
|
export interface SessionMeta {
|
||||||
|
id: string;
|
||||||
|
type: SessionType;
|
||||||
|
title?: string;
|
||||||
|
createdAt: number;
|
||||||
|
/** Messages newer than this account's read cursor for the session (Android
|
||||||
|
* 协同 B3/D4). Optional so an older backend stays compatible — absent means
|
||||||
|
* "unknown", which the UI and the read path both treat as 0. */
|
||||||
|
unreadCount?: number;
|
||||||
|
/** The role this session talks to (design/22 §2 option (a): the session is the
|
||||||
|
* single source of truth). `''`/absent means "inherit the global default role",
|
||||||
|
* which is the pre-multi-persona behaviour, so an older backend keeps working
|
||||||
|
* unchanged. Resolved in `lib/persona.ts#resolveSessionPersona`. */
|
||||||
|
personaId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionRecord {
|
||||||
|
platform: string;
|
||||||
|
channelId: string;
|
||||||
|
sessionId: string;
|
||||||
|
actorRole: string;
|
||||||
|
owner: boolean;
|
||||||
|
isGroup: boolean;
|
||||||
|
memWritable: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
export type WsStatus =
|
||||||
|
| "idle"
|
||||||
|
| "connecting"
|
||||||
|
| "connected"
|
||||||
|
| "disconnected"
|
||||||
|
| "reconnecting"
|
||||||
|
| "expired";
|
||||||
|
|
||||||
|
/** 唤醒式能力三态(design/14 §1):静息·温柔陪伴 / 唤醒·办事 / 全能·复杂任务。 */
|
||||||
|
export type AwakenState = "quiet" | "awake" | "full";
|
||||||
|
|
||||||
|
/** capacity_awaken 下行事件 payload(design/14 §5):状态切换 + 本轮点亮的工具。
|
||||||
|
* `tool` 可能是工具名字符串,也可能是含 `name` 的对象,解析层统一为字符串。 */
|
||||||
|
export interface AwakenPayload {
|
||||||
|
fromState?: AwakenState;
|
||||||
|
toState?: AwakenState;
|
||||||
|
tool?: string | { name: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** session_busy / session_pending 下行事件 payload(design/15 §5):她是否在忙 + 挂起消息数。 */
|
||||||
|
export interface SessionStatePayload {
|
||||||
|
busy?: boolean;
|
||||||
|
pendingCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downstream WS frame (server → client). `data` is intentionally `unknown`;
|
||||||
|
* every consumer narrows it. The backend emits `{type, data, turnId?, seq?,
|
||||||
|
* sessionId?}` and types include the structured stream events message.start/delta/end,
|
||||||
|
* thinking.start/delta/end, tool_call.start/delta/end, tool_execution.update
|
||||||
|
* (design/04 §1.1), plus the legacy stream_chunk / stream_end / thinking / mood /
|
||||||
|
* tool_used / subagent_event / graph_update / reminder / device_update / auth /
|
||||||
|
* error / task_output, and the capacity/session events from design/14 §5 + 15 §5:
|
||||||
|
* capacity_awaken (状态切换+点亮工具), session_busy / session_pending (她是否在忙+挂起消息数).
|
||||||
|
*
|
||||||
|
* `sessionId` (design/16 §4.2) scopes the frame to one conversation. The hub
|
||||||
|
* broadcasts globally — every connection of the owner sees every session — so the
|
||||||
|
* client filters by the active session: a frame belonging to another conversation
|
||||||
|
* (a concurrent stream, an edit, a delete) must never reach the rendered bubble.
|
||||||
|
* It is absent when the event is not conversation-scoped (panel/device events) or
|
||||||
|
* when the backend could not resolve the conversation; consumers then keep the old
|
||||||
|
* permissive behavior rather than dropping the frame.
|
||||||
|
*/
|
||||||
|
export interface WsDownEvent {
|
||||||
|
type: string;
|
||||||
|
data: unknown;
|
||||||
|
turnId?: string;
|
||||||
|
seq?: number;
|
||||||
|
sessionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upstream WS frame (client → server). */
|
||||||
|
export interface WsUpEvent {
|
||||||
|
type: "mode_switch" | "chat" | "voice_input" | "subagent_ask" | "task_output_query";
|
||||||
|
reqId?: string;
|
||||||
|
data?: unknown;
|
||||||
|
}
|
||||||
+21
-312
@@ -1,21 +1,15 @@
|
|||||||
import { messageEventApplies, useAppStore } from "../store/useAppStore";
|
import { useAppStore } from "../store/useAppStore";
|
||||||
import { useModeStore } from "../store/useModeStore";
|
import { useModeStore } from "../store/useModeStore";
|
||||||
import { getAuthToken, isTokenExpired, onAuthExpired, setAuthToken } from "./api";
|
import { getAuthToken, isTokenExpired, onAuthExpired, setAuthToken } from "./api";
|
||||||
import {
|
import { toRecord } from "./wsParse";
|
||||||
parseAdminChannelMessage,
|
import type { WsUpEvent } from "./types";
|
||||||
parseAgent,
|
import { handleStreamEvent } from "./ws/handlers/stream";
|
||||||
parseAwakenTool,
|
import { handleThinkingEvent } from "./ws/handlers/thinking";
|
||||||
parseBackthinkItem,
|
import { handleToolEvent } from "./ws/handlers/tool";
|
||||||
parseGraph,
|
import { handleMessageEvent } from "./ws/handlers/message";
|
||||||
parsePersistedMessage,
|
import { handleAgentEvent } from "./ws/handlers/agent";
|
||||||
parseTool,
|
import { handleSessionEvent } from "./ws/handlers/session";
|
||||||
parseToolCall,
|
import { handleDeviceEvent } from "./ws/handlers/device";
|
||||||
playVoiceAudio,
|
|
||||||
toRecord,
|
|
||||||
} from "./wsParse";
|
|
||||||
import { MODES, type AwakenState, type ChatMessage, type Mode, type WsUpEvent } from "./types";
|
|
||||||
import { sessionReadTarget } from "./sessionRead";
|
|
||||||
import { streamFrameApplies } from "./streamScope";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Realtime client for `/ws/chat`. Reconnects with exponential backoff and
|
* Realtime client for `/ws/chat`. Reconnects with exponential backoff and
|
||||||
@@ -28,11 +22,6 @@ let shouldRun = false;
|
|||||||
let reconnectTimer: number | null = null;
|
let reconnectTimer: number | null = null;
|
||||||
let attempt = 0;
|
let attempt = 0;
|
||||||
|
|
||||||
/** Accumulator for a streamed thinking segment (thinking.* events). Reset on
|
|
||||||
* thinking.start so a mid-turn reconnect can rebuild the collapsed block
|
|
||||||
* without relying on prior state. */
|
|
||||||
let pendingThinking = "";
|
|
||||||
|
|
||||||
const MAX_BACKOFF = 30_000;
|
const MAX_BACKOFF = 30_000;
|
||||||
const BASE_BACKOFF = 1_000;
|
const BASE_BACKOFF = 1_000;
|
||||||
|
|
||||||
@@ -43,307 +32,27 @@ function wsUrl(): string {
|
|||||||
return `${proto}://${window.location.host}/ws/chat${qs}`;
|
return `${proto}://${window.location.host}/ws/chat${qs}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The fields of a `message_edited` payload the chat needs: the edited message's
|
|
||||||
* id and its new content. `sessionId` is carried inside the broadcast DTO (and on
|
|
||||||
* the frame envelope) so the frame can be filtered; `mode`/`mood` are optional —
|
|
||||||
* an edit never changes them, but the DTO carries them for completeness. */
|
|
||||||
interface MessageEdited {
|
|
||||||
id: string;
|
|
||||||
content: string;
|
|
||||||
sessionId?: string;
|
|
||||||
mode?: Mode;
|
|
||||||
mood?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Narrow a `message_edited` payload (the updated message DTO), or null when it is
|
|
||||||
* unusable. `content` is required: an edit always carries the new text, and a
|
|
||||||
* frame without it must not blank out a rendered bubble. */
|
|
||||||
function parseMessageEdited(data: unknown): MessageEdited | null {
|
|
||||||
const r = toRecord(data);
|
|
||||||
if (!r || typeof r.id !== "string" || r.id === "" || typeof r.content !== "string") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: r.id,
|
|
||||||
content: r.content,
|
|
||||||
sessionId: typeof r.sessionId === "string" ? r.sessionId : undefined,
|
|
||||||
mode:
|
|
||||||
typeof r.mode === "string" && MODES.includes(r.mode as Mode)
|
|
||||||
? (r.mode as Mode)
|
|
||||||
: undefined,
|
|
||||||
mood: typeof r.mood === "string" ? r.mood : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Route one decoded downstream frame into the store. Exported (with a `frame`
|
/** Route one decoded downstream frame into the store. Exported (with a `frame`
|
||||||
* shaped first argument) so the frame→store mapping can be exercised as a pure
|
* shaped first argument) so the frame→store mapping can be exercised as a pure
|
||||||
* logic test without a browser: this project has no jsdom/test runner, so the
|
* logic test without a browser: this project has no jsdom/test runner, so the
|
||||||
* socket lifecycle around it stays untested while the mapping does not have to. */
|
* socket lifecycle around it stays untested while the mapping does not have to.
|
||||||
|
* 帧按类型分组在 lib/ws/handlers/* 中处理:任一组命中(含组内主动丢弃)即结束,
|
||||||
|
* 全部未命中走下方未知帧兜底 —— 与拆分前的单个 switch 等价。 */
|
||||||
export function dispatchFrame(ev: { type: string; sessionId?: string; data: unknown }): void {
|
export function dispatchFrame(ev: { type: string; sessionId?: string; data: unknown }): void {
|
||||||
const s = useAppStore.getState();
|
const s = useAppStore.getState();
|
||||||
const r = toRecord(ev.data);
|
const r = toRecord(ev.data);
|
||||||
|
|
||||||
switch (ev.type) {
|
if (handleStreamEvent(ev, r, s)) return;
|
||||||
case "stream_chunk": {
|
if (handleThinkingEvent(ev, r, s)) return;
|
||||||
if (!streamFrameApplies(ev, r, s.activeSessionId)) break;
|
if (handleToolEvent(ev, r, s)) return;
|
||||||
if (r && typeof r.delta === "string") s.appendChunk(r.delta);
|
if (handleMessageEvent(ev, r, s)) return;
|
||||||
break;
|
if (handleAgentEvent(ev, r, s)) return;
|
||||||
}
|
if (handleSessionEvent(ev, r, s)) return;
|
||||||
case "stream_end": {
|
if (handleDeviceEvent(ev, r, s)) return;
|
||||||
// Another session's turn ending must not close the streaming bubble on
|
|
||||||
// screen or clear this conversation's busy badge (both are single-slot
|
|
||||||
// state: the last message's flag, and one shell-wide `sessionBusy`).
|
|
||||||
if (!streamFrameApplies(ev, r, s.activeSessionId)) break;
|
|
||||||
s.endStream();
|
|
||||||
// The assistant reply stopped streaming, so the immediate turn is over:
|
|
||||||
// close the busy/pending window so the badge does not stick. If the backend
|
|
||||||
// keeps a task/subagent running it re-emits session_busy (design/15 §5).
|
|
||||||
s.setSessionBusy(false, 0);
|
|
||||||
if (r && typeof r.stopReason === "string") {
|
|
||||||
s.setContext({ lastAgentMode: s.context.lastAgentMode });
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// Structured stream events (design/04 §1.1, aligned with OpenClaw). These
|
|
||||||
// arrive in place of the legacy stream_chunk when the loop streams events.
|
|
||||||
case "message.start": {
|
|
||||||
// The assistant bubble is already opened by useChat; nothing to do here.
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "message.delta": {
|
|
||||||
if (!streamFrameApplies(ev, r, s.activeSessionId)) break;
|
|
||||||
if (r && typeof r.delta === "string") s.appendChunk(r.delta);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "message.end": {
|
|
||||||
// Body-text segment closed; the turn may continue with tool calls, so we
|
|
||||||
// do NOT end the stream here (stream_end owns that).
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "message_persisted": {
|
|
||||||
// design/16 §4.2 — a user or assistant message was persisted and broadcast
|
|
||||||
// with its backend id. Only surface messages for the active session; the
|
|
||||||
// store dedups by id and adopts the local optimistic bubble (uid → backend
|
|
||||||
// id), so both the originating and remote browsers converge on one message.
|
|
||||||
const msg = parsePersistedMessage(r?.message);
|
|
||||||
if (msg && msg.sessionId === s.activeSessionId) {
|
|
||||||
s.appendOrUpdateMessage(msg);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "message_edited": {
|
|
||||||
// design/16 §4.2 (D3 edit path) — the stored message's content was changed
|
|
||||||
// and broadcast with the updated DTO. The backend row stays the single source
|
|
||||||
// of truth; this only re-renders the bubble, and only in the session it
|
|
||||||
// belongs to (same filter as message_persisted). Patch by id — an id we do not
|
|
||||||
// hold is another session's (or an already-gone) message, never a new row.
|
|
||||||
const edit = parseMessageEdited(ev.data);
|
|
||||||
if (!edit) break;
|
|
||||||
if (messageEventApplies(edit.sessionId ?? ev.sessionId, s.activeSessionId)) {
|
|
||||||
const patch: Partial<ChatMessage> = { content: edit.content };
|
|
||||||
if (edit.mode) patch.mode = edit.mode;
|
|
||||||
if (edit.mood) patch.mood = edit.mood;
|
|
||||||
s.updateMessageById(edit.id, patch);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "message_deleted": {
|
|
||||||
// design/16 §4.2 (D3 delete path) — the message was removed server-side, so
|
|
||||||
// drop it by id instead of waiting for a manual re-read. The id is globally
|
|
||||||
// unique; sessionId is advisory and, when present, must be the active one.
|
|
||||||
const id = r && typeof r.id === "string" ? r.id : "";
|
|
||||||
if (id && messageEventApplies(ev.sessionId, s.activeSessionId)) {
|
|
||||||
s.removeMessageById(id);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "channel_persisted": {
|
|
||||||
// design/16 §4.6 §4 — an isolated (group/stranger) channel message was
|
|
||||||
// persisted and broadcast for the IM adapter log page. The web chat has no
|
|
||||||
// handler for this type, so it never reaches the active conversation; the
|
|
||||||
// admin adapter log consumes it via `channelEvents` to append in realtime.
|
|
||||||
const msg = parseAdminChannelMessage(r?.message);
|
|
||||||
if (msg) s.pushChannelEvent(msg);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "backthink": {
|
|
||||||
// design/12 §4 — a background-think pass result was persisted. Head-insert into
|
|
||||||
// the store so the admin backthink panel appends it in realtime (rows keyed by
|
|
||||||
// id → React reconciles incrementally, no whole-page re-render).
|
|
||||||
const item = parseBackthinkItem(r);
|
|
||||||
if (item) s.addBackthink(item);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "thinking.start": {
|
|
||||||
// Guarded like the deltas: only the active conversation may reset the
|
|
||||||
// accumulator (which also bounds a switched-away session's stale text).
|
|
||||||
if (!streamFrameApplies(ev, r, s.activeSessionId)) break;
|
|
||||||
pendingThinking = "";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "thinking.delta": {
|
|
||||||
if (!streamFrameApplies(ev, r, s.activeSessionId)) break;
|
|
||||||
if (r && typeof r.delta === "string") {
|
|
||||||
pendingThinking += r.delta;
|
|
||||||
s.setThinking(pendingThinking);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "thinking.end": {
|
|
||||||
if (!streamFrameApplies(ev, r, s.activeSessionId)) break;
|
|
||||||
if (pendingThinking) s.setThinking(pendingThinking);
|
|
||||||
pendingThinking = "";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "tool_call.start":
|
|
||||||
case "tool_call.end": {
|
|
||||||
if (!streamFrameApplies(ev, r, s.activeSessionId)) break;
|
|
||||||
const tool = parseToolCall(ev.data);
|
|
||||||
if (tool) s.addTool(tool);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "tool_call.delta": {
|
|
||||||
// Argument fragments stream between start/end; the card is added at the
|
|
||||||
// boundary so mid-stream reconnects do not drop it.
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "tool_execution.update": {
|
|
||||||
// Same card as tool_call.* — same guard.
|
|
||||||
if (!streamFrameApplies(ev, r, s.activeSessionId)) break;
|
|
||||||
const tool = parseToolCall(ev.data);
|
|
||||||
if (tool) s.addTool(tool);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "thinking": {
|
|
||||||
// Legacy single-shot CoT frame — renders into the same bubble.
|
|
||||||
if (!streamFrameApplies(ev, r, s.activeSessionId)) break;
|
|
||||||
if (typeof ev.data === "string") {
|
|
||||||
s.setThinking(ev.data);
|
|
||||||
} else if (r && typeof r.text === "string") {
|
|
||||||
s.setThinking(r.text);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "mood": {
|
|
||||||
// Role-scoped (design/22 §4.6): the frame names the persona the change
|
|
||||||
// belongs to and carries no sessionId, so the bucket key is personaId —
|
|
||||||
// each role's mood stays with its own conversations. A legacy string frame
|
|
||||||
// has nowhere to put it, so it lands in the unscoped "" bucket.
|
|
||||||
if (typeof ev.data === "string") {
|
|
||||||
s.setMood(ev.data);
|
|
||||||
} else if (r && typeof r.mood === "string") {
|
|
||||||
s.setMood(r.mood, typeof r.personaId === "string" ? r.personaId : "");
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "tool_used": {
|
|
||||||
// Legacy tool echo — same card as tool_call.*.
|
|
||||||
if (!streamFrameApplies(ev, r, s.activeSessionId)) break;
|
|
||||||
const tool = parseTool(ev.data);
|
|
||||||
if (tool) s.addTool(tool);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "subagent_event": {
|
|
||||||
const agent = parseAgent(ev.data ?? r);
|
|
||||||
if (agent) s.upsertAgent(agent);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "graph_update": {
|
|
||||||
const g = parseGraph(ev.data);
|
|
||||||
if (g) s.setFlowGraph(g);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "task_output": {
|
|
||||||
if (r) {
|
|
||||||
const taskId = typeof r.taskId === "string" ? r.taskId : "main";
|
|
||||||
const output =
|
|
||||||
typeof r.output === "string"
|
|
||||||
? r.output
|
|
||||||
: typeof r.line === "string"
|
|
||||||
? r.line
|
|
||||||
: typeof ev.data === "string"
|
|
||||||
? ev.data
|
|
||||||
: JSON.stringify(ev.data);
|
|
||||||
s.appendTaskLog(taskId, output + "\n");
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "device_update": {
|
|
||||||
if (typeof ev.data === "string") s.addDeviceUpdate(ev.data);
|
|
||||||
else if (r && typeof r.message === "string") s.addDeviceUpdate(r.message);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "reminder": {
|
|
||||||
if (typeof ev.data === "string") s.addDeviceUpdate(`提醒:${ev.data}`);
|
|
||||||
else if (r && typeof r.message === "string") s.addDeviceUpdate(`提醒:${r.message}`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "error": {
|
|
||||||
const msg = r && typeof r.message === "string" ? r.message : "未知错误";
|
|
||||||
s.addDeviceUpdate(`WS 错误:${msg}`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "auth": {
|
|
||||||
// handshake acknowledgement; nothing to persist yet.
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "mode_switch": {
|
|
||||||
// The agent drives the behavior mode (design/01 §3). Apply it to the shell
|
|
||||||
// so newly begun message bubbles render per that mode.
|
|
||||||
if (r && typeof r.mode === "string") {
|
|
||||||
const m = r.mode as Mode;
|
|
||||||
if (MODES.includes(m)) useModeStore.getState().setMode(m);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "voice_output": {
|
|
||||||
// Voice turn reply: the backend sends back the synthesized audio plus the
|
|
||||||
// recognized transcript and the assistant reply. Playback is best-effort
|
|
||||||
// (autoplay may be blocked); the transcript/reply is surfaced as a log line.
|
|
||||||
playVoiceAudio(ev.data);
|
|
||||||
if (r && typeof r.reply === "string") {
|
|
||||||
s.addDeviceUpdate(`语音回复:${r.reply}`);
|
|
||||||
} else if (r && typeof r.text === "string") {
|
|
||||||
s.addDeviceUpdate(`语音识别:${r.text}`);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "capacity_awaken": {
|
|
||||||
// design/14 §5 — 能力点亮:静息 → 唤醒 → 全能 + 本轮点亮工具。前端据此更新唤醒徽标/唤醒路径。
|
|
||||||
const fromState =
|
|
||||||
typeof r?.fromState === "string"
|
|
||||||
? (r.fromState as AwakenState)
|
|
||||||
: undefined;
|
|
||||||
const toState =
|
|
||||||
(typeof r?.toState === "string" ? (r.toState as AwakenState) : undefined) ??
|
|
||||||
"quiet";
|
|
||||||
s.setAwaken(fromState, toState, parseAwakenTool(r?.tool));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "session_busy":
|
|
||||||
case "session_pending": {
|
|
||||||
// design/15 §5 — 她忙/有挂起消息时的轻状态(不阻塞输入框)。两者同构:都是 {busy, pendingCount}。
|
|
||||||
const busy = typeof r?.busy === "boolean" ? r.busy : true;
|
|
||||||
const pendingCount =
|
|
||||||
typeof r?.pendingCount === "number" ? r.pendingCount : s.pendingCount;
|
|
||||||
s.setSessionBusy(busy, pendingCount);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "session_read": {
|
|
||||||
// 协同 B3/D4 — one end marked the conversation read, so the unread badge goes
|
|
||||||
// out on every end. Only the addressed session is touched (a frame naming
|
|
||||||
// another conversation must not clear the dot of the one on screen), and an
|
|
||||||
// unattributable frame is dropped rather than guessed.
|
|
||||||
const id = sessionReadTarget(ev.sessionId, r);
|
|
||||||
if (id) s.clearUnread(id);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
// Unknown future event: keep it in the context log so no frame is lost.
|
// Unknown future event: keep it in the context log so no frame is lost.
|
||||||
const text = typeof ev.data === "string" ? ev.data : JSON.stringify(ev.data);
|
const text = typeof ev.data === "string" ? ev.data : JSON.stringify(ev.data);
|
||||||
s.addDeviceUpdate(`[${ev.type}] ${text}`);
|
s.addDeviceUpdate(`[${ev.type}] ${text}`);
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleReconnect(): void {
|
function scheduleReconnect(): void {
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { parseAgent, parseBackthinkItem, parseGraph } from "../../wsParse";
|
||||||
|
import type { FrameEvent, FramePayload, StoreState } from "./shared";
|
||||||
|
|
||||||
|
/** 子代理/任务帧处理组:subagent_event / graph_update / backthink。
|
||||||
|
* 命中返回 true,未命中返回 false 交给下一组。 */
|
||||||
|
export function handleAgentEvent(ev: FrameEvent, r: FramePayload, s: StoreState): boolean {
|
||||||
|
switch (ev.type) {
|
||||||
|
case "subagent_event": {
|
||||||
|
const agent = parseAgent(ev.data ?? r);
|
||||||
|
if (agent) s.upsertAgent(agent);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "graph_update": {
|
||||||
|
const g = parseGraph(ev.data);
|
||||||
|
if (g) s.setFlowGraph(g);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "backthink": {
|
||||||
|
// design/12 §4 — a background-think pass result was persisted. Head-insert into
|
||||||
|
// the store so the admin backthink panel appends it in realtime (rows keyed by
|
||||||
|
// id → React reconciles incrementally, no whole-page re-render).
|
||||||
|
const item = parseBackthinkItem(r);
|
||||||
|
if (item) s.addBackthink(item);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { playVoiceAudio } from "../../wsParse";
|
||||||
|
import type { FrameEvent, FramePayload, StoreState } from "./shared";
|
||||||
|
|
||||||
|
/** 输出/设备侧帧处理组:task_output / device_update / reminder / error / auth /
|
||||||
|
* voice_output。命中返回 true,未命中返回 false(由 dispatchFrame 走未知帧兜底)。 */
|
||||||
|
export function handleDeviceEvent(ev: FrameEvent, r: FramePayload, s: StoreState): boolean {
|
||||||
|
switch (ev.type) {
|
||||||
|
case "task_output": {
|
||||||
|
if (r) {
|
||||||
|
const taskId = typeof r.taskId === "string" ? r.taskId : "main";
|
||||||
|
const output =
|
||||||
|
typeof r.output === "string"
|
||||||
|
? r.output
|
||||||
|
: typeof r.line === "string"
|
||||||
|
? r.line
|
||||||
|
: typeof ev.data === "string"
|
||||||
|
? ev.data
|
||||||
|
: JSON.stringify(ev.data);
|
||||||
|
s.appendTaskLog(taskId, output + "\n");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "device_update": {
|
||||||
|
if (typeof ev.data === "string") s.addDeviceUpdate(ev.data);
|
||||||
|
else if (r && typeof r.message === "string") s.addDeviceUpdate(r.message);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "reminder": {
|
||||||
|
if (typeof ev.data === "string") s.addDeviceUpdate(`提醒:${ev.data}`);
|
||||||
|
else if (r && typeof r.message === "string") s.addDeviceUpdate(`提醒:${r.message}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "error": {
|
||||||
|
const msg = r && typeof r.message === "string" ? r.message : "未知错误";
|
||||||
|
s.addDeviceUpdate(`WS 错误:${msg}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "auth": {
|
||||||
|
// handshake acknowledgement; nothing to persist yet.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "voice_output": {
|
||||||
|
// Voice turn reply: the backend sends back the synthesized audio plus the
|
||||||
|
// recognized transcript and the assistant reply. Playback is best-effort
|
||||||
|
// (autoplay may be blocked); the transcript/reply is surfaced as a log line.
|
||||||
|
playVoiceAudio(ev.data);
|
||||||
|
if (r && typeof r.reply === "string") {
|
||||||
|
s.addDeviceUpdate(`语音回复:${r.reply}`);
|
||||||
|
} else if (r && typeof r.text === "string") {
|
||||||
|
s.addDeviceUpdate(`语音识别:${r.text}`);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { messageEventApplies } from "../../../store/useAppStore";
|
||||||
|
import { MODES, type ChatMessage, type Mode } from "../../types";
|
||||||
|
import { parseAdminChannelMessage, parsePersistedMessage, toRecord } from "../../wsParse";
|
||||||
|
import type { FrameEvent, FramePayload, StoreState } from "./shared";
|
||||||
|
|
||||||
|
/** The fields of a `message_edited` payload the chat needs: the edited message's
|
||||||
|
* id and its new content. `sessionId` is carried inside the broadcast DTO (and on
|
||||||
|
* the frame envelope) so the frame can be filtered; `mode`/`mood` are optional —
|
||||||
|
* an edit never changes them, but the DTO carries them for completeness. */
|
||||||
|
interface MessageEdited {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
sessionId?: string;
|
||||||
|
mode?: Mode;
|
||||||
|
mood?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Narrow a `message_edited` payload (the updated message DTO), or null when it is
|
||||||
|
* unusable. `content` is required: an edit always carries the new text, and a
|
||||||
|
* frame without it must not blank out a rendered bubble. */
|
||||||
|
function parseMessageEdited(data: unknown): MessageEdited | null {
|
||||||
|
const r = toRecord(data);
|
||||||
|
if (!r || typeof r.id !== "string" || r.id === "" || typeof r.content !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
content: r.content,
|
||||||
|
sessionId: typeof r.sessionId === "string" ? r.sessionId : undefined,
|
||||||
|
mode:
|
||||||
|
typeof r.mode === "string" && MODES.includes(r.mode as Mode)
|
||||||
|
? (r.mode as Mode)
|
||||||
|
: undefined,
|
||||||
|
mood: typeof r.mood === "string" ? r.mood : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 消息落库/编辑/删除帧处理组:message_persisted / message_edited /
|
||||||
|
* message_deleted / channel_persisted。命中(含组内主动丢弃)返回 true,
|
||||||
|
* 未命中返回 false 交给下一组。 */
|
||||||
|
export function handleMessageEvent(ev: FrameEvent, r: FramePayload, s: StoreState): boolean {
|
||||||
|
switch (ev.type) {
|
||||||
|
case "message_persisted": {
|
||||||
|
// design/16 §4.2 — a user or assistant message was persisted and broadcast
|
||||||
|
// with its backend id. Only surface messages for the active session; the
|
||||||
|
// store dedups by id and adopts the local optimistic bubble (uid → backend
|
||||||
|
// id), so both the originating and remote browsers converge on one message.
|
||||||
|
const msg = parsePersistedMessage(r?.message);
|
||||||
|
if (msg && msg.sessionId === s.activeSessionId) {
|
||||||
|
s.appendOrUpdateMessage(msg);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "message_edited": {
|
||||||
|
// design/16 §4.2 (D3 edit path) — the stored message's content was changed
|
||||||
|
// and broadcast with the updated DTO. The backend row stays the single source
|
||||||
|
// of truth; this only re-renders the bubble, and only in the session it
|
||||||
|
// belongs to (same filter as message_persisted). Patch by id — an id we do not
|
||||||
|
// hold is another session's (or an already-gone) message, never a new row.
|
||||||
|
const edit = parseMessageEdited(ev.data);
|
||||||
|
if (!edit) return true;
|
||||||
|
if (messageEventApplies(edit.sessionId ?? ev.sessionId, s.activeSessionId)) {
|
||||||
|
const patch: Partial<ChatMessage> = { content: edit.content };
|
||||||
|
if (edit.mode) patch.mode = edit.mode;
|
||||||
|
if (edit.mood) patch.mood = edit.mood;
|
||||||
|
s.updateMessageById(edit.id, patch);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "message_deleted": {
|
||||||
|
// design/16 §4.2 (D3 delete path) — the message was removed server-side, so
|
||||||
|
// drop it by id instead of waiting for a manual re-read. The id is globally
|
||||||
|
// unique; sessionId is advisory and, when present, must be the active one.
|
||||||
|
const id = r && typeof r.id === "string" ? r.id : "";
|
||||||
|
if (id && messageEventApplies(ev.sessionId, s.activeSessionId)) {
|
||||||
|
s.removeMessageById(id);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "channel_persisted": {
|
||||||
|
// design/16 §4.6 §4 — an isolated (group/stranger) channel message was
|
||||||
|
// persisted and broadcast for the IM adapter log page. The web chat has no
|
||||||
|
// handler for this type, so it never reaches the active conversation; the
|
||||||
|
// admin adapter log consumes it via `channelEvents` to append in realtime.
|
||||||
|
const msg = parseAdminChannelMessage(r?.message);
|
||||||
|
if (msg) s.pushChannelEvent(msg);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { useModeStore } from "../../../store/useModeStore";
|
||||||
|
import { sessionReadTarget } from "../../sessionRead";
|
||||||
|
import { MODES, type AwakenState, type Mode } from "../../types";
|
||||||
|
import { parseAwakenTool } from "../../wsParse";
|
||||||
|
import type { FrameEvent, FramePayload, StoreState } from "./shared";
|
||||||
|
|
||||||
|
/** 会话/角色状态帧处理组:capacity_awaken / session_busy / session_pending /
|
||||||
|
* session_read / mode_switch / mood。命中返回 true,未命中返回 false 交给下一组。 */
|
||||||
|
export function handleSessionEvent(ev: FrameEvent, r: FramePayload, s: StoreState): boolean {
|
||||||
|
switch (ev.type) {
|
||||||
|
case "capacity_awaken": {
|
||||||
|
// design/14 §5 — 能力点亮:静息 → 唤醒 → 全能 + 本轮点亮工具。前端据此更新唤醒徽标/唤醒路径。
|
||||||
|
const fromState =
|
||||||
|
typeof r?.fromState === "string"
|
||||||
|
? (r.fromState as AwakenState)
|
||||||
|
: undefined;
|
||||||
|
const toState =
|
||||||
|
(typeof r?.toState === "string" ? (r.toState as AwakenState) : undefined) ??
|
||||||
|
"quiet";
|
||||||
|
s.setAwaken(fromState, toState, parseAwakenTool(r?.tool));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "session_busy":
|
||||||
|
case "session_pending": {
|
||||||
|
// design/15 §5 — 她忙/有挂起消息时的轻状态(不阻塞输入框)。两者同构:都是 {busy, pendingCount}。
|
||||||
|
const busy = typeof r?.busy === "boolean" ? r.busy : true;
|
||||||
|
const pendingCount =
|
||||||
|
typeof r?.pendingCount === "number" ? r.pendingCount : s.pendingCount;
|
||||||
|
s.setSessionBusy(busy, pendingCount);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "session_read": {
|
||||||
|
// 协同 B3/D4 — one end marked the conversation read, so the unread badge goes
|
||||||
|
// out on every end. Only the addressed session is touched (a frame naming
|
||||||
|
// another conversation must not clear the dot of the one on screen), and an
|
||||||
|
// unattributable frame is dropped rather than guessed.
|
||||||
|
const id = sessionReadTarget(ev.sessionId, r);
|
||||||
|
if (id) s.clearUnread(id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "mode_switch": {
|
||||||
|
// The agent drives the behavior mode (design/01 §3). Apply it to the shell
|
||||||
|
// so newly begun message bubbles render per that mode.
|
||||||
|
if (r && typeof r.mode === "string") {
|
||||||
|
const m = r.mode as Mode;
|
||||||
|
if (MODES.includes(m)) useModeStore.getState().setMode(m);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "mood": {
|
||||||
|
// Role-scoped (design/22 §4.6): the frame names the persona the change
|
||||||
|
// belongs to and carries no sessionId, so the bucket key is personaId —
|
||||||
|
// each role's mood stays with its own conversations. A legacy string frame
|
||||||
|
// has nowhere to put it, so it lands in the unscoped "" bucket.
|
||||||
|
if (typeof ev.data === "string") {
|
||||||
|
s.setMood(ev.data);
|
||||||
|
} else if (r && typeof r.mood === "string") {
|
||||||
|
s.setMood(r.mood, typeof r.personaId === "string" ? r.personaId : "");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { useAppStore } from "../../../store/useAppStore";
|
||||||
|
|
||||||
|
/** dispatchFrame 处理的单个下行帧(信封形状,与 lib/ws.ts 导出的 dispatchFrame 签名一致)。 */
|
||||||
|
export interface FrameEvent {
|
||||||
|
type: string;
|
||||||
|
sessionId?: string;
|
||||||
|
data: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** toRecord(ev.data) 解析后的帧 payload(与 lib/wsParse.ts#toRecord 的返回形状一致)。 */
|
||||||
|
export type FramePayload = Record<string, unknown> | null;
|
||||||
|
|
||||||
|
/** dispatchFrame 入口处取到的 store 快照类型,按参数传给各帧处理组。 */
|
||||||
|
export type StoreState = ReturnType<typeof useAppStore.getState>;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { streamFrameApplies } from "../../streamScope";
|
||||||
|
import type { FrameEvent, FramePayload, StoreState } from "./shared";
|
||||||
|
|
||||||
|
/** 流式正文帧处理组:stream_chunk / stream_end / message.start / message.delta /
|
||||||
|
* message.end。命中(含组内主动丢弃)返回 true,未命中返回 false 交给下一组。 */
|
||||||
|
export function handleStreamEvent(ev: FrameEvent, r: FramePayload, s: StoreState): boolean {
|
||||||
|
switch (ev.type) {
|
||||||
|
case "stream_chunk": {
|
||||||
|
if (!streamFrameApplies(ev, r, s.activeSessionId)) return true;
|
||||||
|
if (r && typeof r.delta === "string") s.appendChunk(r.delta);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "stream_end": {
|
||||||
|
// Another session's turn ending must not close the streaming bubble on
|
||||||
|
// screen or clear this conversation's busy badge (both are single-slot
|
||||||
|
// state: the last message's flag, and one shell-wide `sessionBusy`).
|
||||||
|
if (!streamFrameApplies(ev, r, s.activeSessionId)) return true;
|
||||||
|
s.endStream();
|
||||||
|
// The assistant reply stopped streaming, so the immediate turn is over:
|
||||||
|
// close the busy/pending window so the badge does not stick. If the backend
|
||||||
|
// keeps a task/subagent running it re-emits session_busy (design/15 §5).
|
||||||
|
s.setSessionBusy(false, 0);
|
||||||
|
if (r && typeof r.stopReason === "string") {
|
||||||
|
s.setContext({ lastAgentMode: s.context.lastAgentMode });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Structured stream events (design/04 §1.1, aligned with OpenClaw). These
|
||||||
|
// arrive in place of the legacy stream_chunk when the loop streams events.
|
||||||
|
case "message.start": {
|
||||||
|
// The assistant bubble is already opened by useChat; nothing to do here.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "message.delta": {
|
||||||
|
if (!streamFrameApplies(ev, r, s.activeSessionId)) return true;
|
||||||
|
if (r && typeof r.delta === "string") s.appendChunk(r.delta);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "message.end": {
|
||||||
|
// Body-text segment closed; the turn may continue with tool calls, so we
|
||||||
|
// do NOT end the stream here (stream_end owns that).
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { streamFrameApplies } from "../../streamScope";
|
||||||
|
import type { FrameEvent, FramePayload, StoreState } from "./shared";
|
||||||
|
|
||||||
|
/** Accumulator for a streamed thinking segment (thinking.* events). Reset on
|
||||||
|
* thinking.start so a mid-turn reconnect can rebuild the collapsed block
|
||||||
|
* without relying on prior state. */
|
||||||
|
let pendingThinking = "";
|
||||||
|
|
||||||
|
/** 思考流帧处理组:thinking.start / thinking.delta / thinking.end + 遗留单帧 thinking。
|
||||||
|
* 命中(含组内主动丢弃)返回 true,未命中返回 false 交给下一组。 */
|
||||||
|
export function handleThinkingEvent(ev: FrameEvent, r: FramePayload, s: StoreState): boolean {
|
||||||
|
switch (ev.type) {
|
||||||
|
case "thinking.start": {
|
||||||
|
// Guarded like the deltas: only the active conversation may reset the
|
||||||
|
// accumulator (which also bounds a switched-away session's stale text).
|
||||||
|
if (!streamFrameApplies(ev, r, s.activeSessionId)) return true;
|
||||||
|
pendingThinking = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "thinking.delta": {
|
||||||
|
if (!streamFrameApplies(ev, r, s.activeSessionId)) return true;
|
||||||
|
if (r && typeof r.delta === "string") {
|
||||||
|
pendingThinking += r.delta;
|
||||||
|
s.setThinking(pendingThinking);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "thinking.end": {
|
||||||
|
if (!streamFrameApplies(ev, r, s.activeSessionId)) return true;
|
||||||
|
if (pendingThinking) s.setThinking(pendingThinking);
|
||||||
|
pendingThinking = "";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "thinking": {
|
||||||
|
// Legacy single-shot CoT frame — renders into the same bubble.
|
||||||
|
if (!streamFrameApplies(ev, r, s.activeSessionId)) return true;
|
||||||
|
if (typeof ev.data === "string") {
|
||||||
|
s.setThinking(ev.data);
|
||||||
|
} else if (r && typeof r.text === "string") {
|
||||||
|
s.setThinking(r.text);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { streamFrameApplies } from "../../streamScope";
|
||||||
|
import { parseTool, parseToolCall } from "../../wsParse";
|
||||||
|
import type { FrameEvent, FramePayload, StoreState } from "./shared";
|
||||||
|
|
||||||
|
/** 工具帧处理组:tool_call.start / tool_call.end / tool_call.delta /
|
||||||
|
* tool_execution.update + 遗留 tool_used。命中(含组内主动丢弃)返回 true,
|
||||||
|
* 未命中返回 false 交给下一组。 */
|
||||||
|
export function handleToolEvent(ev: FrameEvent, r: FramePayload, s: StoreState): boolean {
|
||||||
|
switch (ev.type) {
|
||||||
|
case "tool_call.start":
|
||||||
|
case "tool_call.end": {
|
||||||
|
if (!streamFrameApplies(ev, r, s.activeSessionId)) return true;
|
||||||
|
const tool = parseToolCall(ev.data);
|
||||||
|
if (tool) s.addTool(tool);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "tool_call.delta": {
|
||||||
|
// Argument fragments stream between start/end; the card is added at the
|
||||||
|
// boundary so mid-stream reconnects do not drop it.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "tool_execution.update": {
|
||||||
|
// Same card as tool_call.* — same guard.
|
||||||
|
if (!streamFrameApplies(ev, r, s.activeSessionId)) return true;
|
||||||
|
const tool = parseToolCall(ev.data);
|
||||||
|
if (tool) s.addTool(tool);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case "tool_used": {
|
||||||
|
// Legacy tool echo — same card as tool_call.*.
|
||||||
|
if (!streamFrameApplies(ev, r, s.activeSessionId)) return true;
|
||||||
|
const tool = parseTool(ev.data);
|
||||||
|
if (tool) s.addTool(tool);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user