feat(web): 前端与后端同步一批(帧壳 turnId / 并行六态与两级分桶 / documents 渲染 / System 键名 / 主人档案分组)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 帧壳 `turnId` 在 socket 层不再被丢(`lib/ws.ts` 的 dispatchFrame 补 turnId/seq)—— 按路分桶的前提
- 并行帧组接入:`turn_begin` / `steer_ack` / `pending_ack` / `pending_drain` / `pending_full` /
  `chat_duplicate` ⇒ 六态 + 灰态 + `(sessionId, turnId)` 两级分桶;`appendChunk` 不再永远写尾气泡
- 多路忙态:`session_interrupted` 不再无条件当空闲(看 mode/activeTurns);`stream_end` 不再只按会话过滤
- `documents` 渲染(含 status/chars/truncated 与**逐字中文 reason**);`turnId` 历史行分组小标
- `System.tsx` 键名改正(真键 HOST/PORT/ADMIN_USERNAME/ADMIN_PASSWORD,避免照抄静默失效)
- 主人档案**按平台分组** + 平台下拉 + 账号候选;**写盘仍展平数组**(后端契约不变)
- 验证:tsc --noEmit ✅、npm run build ✅、22 个前端测试文件全通过(本次新增 3 文件 108 条断言)
- 未做(如实):握手 history handler 与断线补拉、上行 interrupt 按钮、proactive_message/subagent_result
  等帧落点、文档上传入口与图片预检、voice 管线、System 的 env 读端点
This commit is contained in:
AskaEth committed 2026-10-05 11:09:24 +08:00
1 parent 6a545badbd
commit eaada3af5d
34 files changed
+2457 -223

No files matched your search

+58 -66
View File
@@ -28,6 +28,9 @@ import {
writeSessionById,
type SessionMessages,
} from "./sessionCache";
import { deliveryActions, turnActions, turnState, type TurnSlice } from "./turnSlice";
import { patchAssistantForTurn } from "./turnCache";
import { persistOptions } from "./persistOptions";
interface ChatContext {
topic?: string;
@@ -35,7 +38,7 @@ interface ChatContext {
notes?: string[];
}
export interface AppStore {
export interface AppStore extends TurnSlice {
/** Session metadata (main + secondary) keyed by session id. The message truth
* lives server-side, so this is a small list — safe to persist. */
sessions: Record<string, SessionMeta>;
@@ -80,7 +83,9 @@ export interface AppStore {
awakenState: AwakenState;
awakenFrom: AwakenState | null;
awakenTool: string | null;
/** 会话编排(design/15 §5)——她是否在忙 + 挂起消息数(不阻塞输入框)。 */
/** 会话编排(design/15 §5)——她是否在忙(不阻塞输入框)。挂起数 `pendingCount`、
* 在途路数 `activeTurns`、在途回合绑定 `turnBindings` 与六态写入动作由
* `store/turnSlice.ts` 提供(AGENTS §10 文件 ≤500 行)。 */
sessionBusy: boolean;
pendingCount: number;
/** 最近一次主代理代审小字(design/11 §4;`approval_notice` 帧,裁决 13.4.3):
@@ -149,14 +154,16 @@ export interface AppStore {
* duplicate frame). */
removeMessageById: (id: string, sessionId?: string) => void;
addUserMessage: (text: string, mode?: Mode, image?: string, deviceId?: string) => void;
addUserMessage: (text: string, mode?: Mode, image?: string, deviceId?: string, clientMsgId?: string) => void;
beginAssistant: (mode?: Mode, deviceId?: string) => string;
/** Append a streamed delta to the assistant bubble of `sessionId` (default: the
* active session) — a background turn keeps streaming into its own bucket. */
appendChunk: (delta: string, sessionId?: string) => void;
endStream: (sessionId?: string) => void;
setThinking: (text: string, sessionId?: string) => void;
addTool: (tool: ToolUsed, sessionId?: string) => void;
* active session) — a background turn keeps streaming into its own bucket.
* 给了 `turnId` 就按**二级分桶**写进那一路的气泡(忙时并行下两条回复交错到达,
* 按列表尾写会把它们糊成一条);没给则沿用旧的「列表尾气泡」行为。 */
appendChunk: (delta: string, sessionId?: string, turnId?: string) => void;
endStream: (sessionId?: string, turnId?: string) => void;
setThinking: (text: string, sessionId?: string, turnId?: string) => void;
addTool: (tool: ToolUsed, sessionId?: string, turnId?: string) => void;
setAgents: (agents: AgentInfo[]) => void;
upsertAgent: (agent: AgentInfo) => void;
@@ -172,7 +179,6 @@ export interface AppStore {
to: AwakenState,
tool?: string | null
) => void;
setSessionBusy: (busy: boolean, pendingCount?: number) => void;
/** 记录一条主代理代审小字(`approval_notice` WS 帧)。null 表示清除。 */
setApprovalNotice: (notice: ApprovalNotice | null) => void;
@@ -210,6 +216,11 @@ export const useAppStore = create<AppStore>()(
taskLogs: {},
channelEvents: [],
// 忙时并行切片(在途路数 / 绑定表 / 队列提示 + 六态与回合生命周期动作)。
...turnState(),
...turnActions(set, get),
...deliveryActions(set, get),
setWsStatus: (status) => set({ wsStatus: status }),
setPersonas: (list) => set({ personas: list }),
setContext: (patch) =>
@@ -277,7 +288,7 @@ export const useAppStore = create<AppStore>()(
removeMessageById: (id, sessionId) =>
set((s) => writeSessionById(s, sessionId, (list) => dropMessage(list, id))),
addUserMessage: (text, mode, image, deviceId) =>
addUserMessage: (text, mode, image, deviceId, clientMsgId) =>
set((s) => ({
messages: [
...s.messages,
@@ -288,6 +299,10 @@ export const useAppStore = create<AppStore>()(
mode,
image,
deviceId,
clientMsgId,
/** ① 发送中:本地乐观气泡,落库(`message_persisted`)或幂等命中
* (`chat_duplicate`)后归一为服务端 id 并转「已送达」。 */
delivery: "sending",
createdAt: now(),
},
],
@@ -313,34 +328,46 @@ export const useAppStore = create<AppStore>()(
return id;
},
appendChunk: (delta, sessionId) =>
appendChunk: (delta, sessionId, turnId) =>
set((s) =>
writeSession(s, sessionId ?? s.activeSessionId, (list) =>
patchAssistantTail(list, (last) => ({
...last,
content: last.content + delta,
}))
turnId
? patchAssistantForTurn(list, turnId, (m) => ({
...m,
content: m.content + delta,
}))
: patchAssistantTail(list, (last) => ({
...last,
content: last.content + delta,
}))
)
),
endStream: (sessionId) =>
endStream: (sessionId, turnId) =>
set((s) =>
writeSession(s, sessionId ?? s.activeSessionId, (list) =>
patchAssistantTail(list, (last) => ({ ...last, streaming: false }))
turnId
? patchAssistantForTurn(list, turnId, (m) => ({
...m,
streaming: false,
}))
: patchAssistantTail(list, (last) => ({ ...last, streaming: false }))
)
),
setThinking: (text, sessionId) =>
setThinking: (text, sessionId, turnId) =>
set((s) =>
writeSession(s, sessionId ?? s.activeSessionId, (list) =>
patchAssistantTail(list, (last) => ({ ...last, thinking: text }))
turnId
? patchAssistantForTurn(list, turnId, (m) => ({ ...m, thinking: text }))
: patchAssistantTail(list, (last) => ({ ...last, thinking: text }))
)
),
addTool: (tool, sessionId) =>
addTool: (tool, sessionId, turnId) =>
set((s) =>
writeSession(s, sessionId ?? s.activeSessionId, (list) =>
patchAssistantTail(list, (last) => {
writeSession(s, sessionId ?? s.activeSessionId, (list) => {
const add = (last: ChatMessage): ChatMessage => {
const existing = last.tools ?? [];
const idx = existing.findIndex((t) => t.name === tool.name);
const tools =
@@ -348,8 +375,11 @@ export const useAppStore = create<AppStore>()(
? [...existing, tool]
: existing.map((t, i) => (i === idx ? { ...t, ...tool } : t));
return { ...last, tools };
})
)
};
return turnId
? patchAssistantForTurn(list, turnId, add)
: patchAssistantTail(list, add);
})
),
setAgents: (agents) => set({ agents }),
@@ -393,11 +423,6 @@ export const useAppStore = create<AppStore>()(
awakenFrom: from ?? s.awakenFrom,
awakenTool: tool ?? s.awakenTool,
})),
setSessionBusy: (busy, pendingCount) =>
set((s) => ({
sessionBusy: busy,
pendingCount: pendingCount ?? s.pendingCount,
})),
setApprovalNotice: (notice) => set({ approvalNotice: notice }),
appendTaskLog: (taskId, text) =>
@@ -434,45 +459,12 @@ export const useAppStore = create<AppStore>()(
awakenTool: null,
sessionBusy: false,
pendingCount: 0,
activeTurns: 0,
turnBindings: {},
queueNotice: null,
approvalNotice: null,
})),
}),
{
name: "cyrene.app",
version: 3,
// v1 persisted the whole chat (`messages` + a single `sessionId`); v2 added
// one resolved `currentPersona`, which can no longer describe the active role
// now that the role belongs to a *session*. Both shapes are dropped:
// `personas` is refilled from `GET /personas` and the role is re-derived from
// the session list. `moods` is transient by nature (a restart resets a role's
// mood), so it is not carried across either. A v1 state was never trusted for
// the session metadata, so those users simply restart on the main session.
migrate: (persisted, version) => {
const p = (persisted ?? {}) as Partial<AppStore>;
const fromV1 = version < 2;
return {
activeSessionId: fromV1 ? "" : p.activeSessionId ?? "",
sessions: fromV1 ? {} : p.sessions ?? {},
personas: p.personas ?? [],
moods: {},
context: p.context ?? {},
backthink: p.backthink ?? [],
};
},
partialize: (s) => ({
// messages deliberately excluded: the backend is the message truth
// (design/16 §1), so we never persist the whole chat into localStorage —
// and neither is `sessionMessages` (the per-session buckets of 裁决 16.4.5).
// We keep the (small) session metadata + the focused session id so a
// refresh lands on the same conversation, plus the role catalog so the
// avatar/name/wallpaper resolve before the first fetch returns.
activeSessionId: s.activeSessionId,
sessions: s.sessions,
personas: s.personas,
moods: s.moods,
context: s.context,
backthink: s.backthink,
}),
}
persistOptions
)
);