feat(web): 前端与后端同步一批(帧壳 turnId / 并行六态与两级分桶 / documents 渲染 / System 键名 / 主人档案分组)
- 帧壳 `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:
1 parent
6a545badbd
commit
eaada3af5d
34 files changed
+2457
-223
No files matched your search
@@ -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
|
||||
)
|
||||
);
|
||||
Reference in new issue
Block a user