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
@@ -0,0 +1,330 @@
|
||||
import { useMemo } from "react";
|
||||
import type { AdminChannelRecord, EnumItem, OwnerAccount } from "../../lib/types";
|
||||
import { Button } from "../../components/primitives";
|
||||
import {
|
||||
accountCandidates,
|
||||
flattenAccounts,
|
||||
groupAccounts,
|
||||
platformIsConfigured,
|
||||
platformOptions,
|
||||
type OwnerAccountGroup,
|
||||
} from "./ownerAccounts";
|
||||
|
||||
/**
|
||||
* 渠道账号白名单编辑器(主人 2026-10-05 要求):**一个平台一条**,该平台下用
|
||||
* 「+ 添加账号 ID」加多行;平台是权威枚举**下拉**;账号 ID 旁给「从该平台已配置
|
||||
* 账号里选」的候选。改动经 `onChange` 回到草稿时**立即展平**为
|
||||
* `accounts:[{platform,accountId}…]` —— 后端契约与 `owner.yaml` 格式完全不变。
|
||||
*
|
||||
* 为什么不在这里直接保存:档案的读取/草稿/保存状态由 `Users.tsx` 的
|
||||
* `OwnerProfileSection` 统一持有(一次真实 PUT),本组件只管呈现与展平。
|
||||
*/
|
||||
export function OwnerAccountsEditor({
|
||||
accounts,
|
||||
disabled,
|
||||
platforms,
|
||||
channels,
|
||||
onChange,
|
||||
}: {
|
||||
accounts: OwnerAccount[];
|
||||
disabled: boolean;
|
||||
/** 权威平台枚举(`GET /api/v1/meta/enums` 的 `platforms`);读取失败可为空。 */
|
||||
platforms: EnumItem[] | undefined;
|
||||
/** 已配置渠道账号(`GET /api/v1/admin/channels`),用于给出账号 id 候选。 */
|
||||
channels: AdminChannelRecord[] | null;
|
||||
onChange: (next: OwnerAccount[]) => void;
|
||||
}): React.ReactNode {
|
||||
const groups = useMemo(() => groupAccounts(accounts), [accounts]);
|
||||
const options = useMemo(
|
||||
() => platformOptions(platforms, groups.map((g) => g.platform)),
|
||||
[platforms, groups]
|
||||
);
|
||||
|
||||
// 每次改动都「分组 → 展平 → 回草稿」:草稿里始终是后端要的扁平形态,
|
||||
// 界面上的分组只是它的投影,两者不会各说各话。
|
||||
const emit = (next: OwnerAccountGroup[]): void => onChange(flattenAccounts(next));
|
||||
|
||||
const patchGroup = (i: number, patch: Partial<OwnerAccountGroup>): void =>
|
||||
emit(groups.map((g, idx) => (idx === i ? { ...g, ...patch } : g)));
|
||||
const addAccount = (i: number): void =>
|
||||
patchGroup(i, { accountIds: [...groups[i].accountIds, ""] });
|
||||
const removeAccount = (i: number, j: number): void =>
|
||||
patchGroup(i, { accountIds: groups[i].accountIds.filter((_, k) => k !== j) });
|
||||
const patchAccount = (i: number, j: number, value: string): void =>
|
||||
patchGroup(i, {
|
||||
accountIds: groups[i].accountIds.map((id, k) => (k === j ? value : id)),
|
||||
});
|
||||
const removeGroup = (i: number): void => emit(groups.filter((_, idx) => idx !== i));
|
||||
const addGroup = (): void => emit([...groups, { platform: "", accountIds: [""] }]);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<WhitelistHeader disabled={disabled} onAddGroup={addGroup} />
|
||||
{groups.length === 0 ? (
|
||||
<p className="text-xs text-[var(--mu-text-lo)]">
|
||||
尚未配置:现状为「无人是主人」(默认拒绝,fail-safe)。
|
||||
</p>
|
||||
) : null}
|
||||
{groups.map((g, i) => (
|
||||
<PlatformGroupRow
|
||||
key={`platform-${i}`}
|
||||
group={g}
|
||||
index={i}
|
||||
options={options}
|
||||
candidates={accountCandidates(channels, g.platform)}
|
||||
known={platformIsConfigured(channels, g.platform)}
|
||||
disabled={disabled}
|
||||
onPlatform={(v) => patchGroup(i, { platform: v })}
|
||||
onAccount={(j, v) => patchAccount(i, j, v)}
|
||||
onAddAccount={() => addAccount(i)}
|
||||
onRemoveAccount={(j) => removeAccount(i, j)}
|
||||
onRemoveGroup={() => removeGroup(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 头部:标题 + 说明(写盘格式不变)+「+ 添加平台」,以及「一组都没有」的诚实提示。 */
|
||||
function WhitelistHeader({
|
||||
disabled,
|
||||
onAddGroup,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
onAddGroup: () => void;
|
||||
}): React.ReactNode {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">
|
||||
渠道账号白名单(命中即走 owner 车道)· 一个平台一条,可加多个账号 ID
|
||||
</span>
|
||||
<Button size="sm" variant="ghost" disabled={disabled} onClick={onAddGroup}>
|
||||
+ 添加平台
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--mu-text-lo)]">
|
||||
保存时仍是扁平数组{' '}
|
||||
<span className="font-mono">{'accounts: [{platform, accountId}, …]'}</span>
|
||||
(<span className="font-mono">owner.yaml</span> 格式不变);平台与账号 ID 都填了的条目才会写盘。
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
"w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)] disabled:opacity-60";
|
||||
|
||||
interface PlatformGroupRowProps {
|
||||
group: OwnerAccountGroup;
|
||||
index: number;
|
||||
options: EnumItem[];
|
||||
candidates: string[];
|
||||
known: boolean;
|
||||
disabled: boolean;
|
||||
onPlatform: (v: string) => void;
|
||||
onAccount: (j: number, v: string) => void;
|
||||
onAddAccount: () => void;
|
||||
onRemoveAccount: (j: number) => void;
|
||||
onRemoveGroup: () => void;
|
||||
}
|
||||
|
||||
/** 一个平台分组:平台下拉 + 该平台下的账号 ID 多行(每行带候选,可删)。 */
|
||||
function PlatformGroupRow(props: PlatformGroupRowProps): React.ReactNode {
|
||||
const { group, index, options, candidates, known, disabled } = props;
|
||||
const emptyCount = group.accountIds.filter((id) => id.trim() === "").length;
|
||||
return (
|
||||
<div
|
||||
className="space-y-2 rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]/50 p-3"
|
||||
data-platform={group.platform}
|
||||
data-group-index={index}
|
||||
>
|
||||
<PlatformPicker
|
||||
group={group}
|
||||
options={options}
|
||||
disabled={disabled}
|
||||
onPlatform={props.onPlatform}
|
||||
onRemoveGroup={props.onRemoveGroup}
|
||||
/>
|
||||
|
||||
{group.accountIds.length === 0 ? (
|
||||
<p className="text-xs text-[var(--mu-warn)]">
|
||||
该平台还没有账号 ID:保存时这一组会被忽略(不会写入 owner.yaml)。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{group.accountIds.map((accountId, j) => (
|
||||
<AccountIdRow
|
||||
key={`acct-${index}-${j}`}
|
||||
listId={`acct-${index}-${j}-candidates`}
|
||||
accountId={accountId}
|
||||
candidates={candidates}
|
||||
platform={group.platform}
|
||||
known={known}
|
||||
disabled={disabled}
|
||||
onValue={(v) => props.onAccount(j, v)}
|
||||
onRemove={() => props.onRemoveAccount(j)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="soft" disabled={disabled} onClick={props.onAddAccount}>
|
||||
+ 添加账号 ID
|
||||
</Button>
|
||||
{emptyCount > 0 ? (
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">
|
||||
有 {emptyCount} 条空白账号 ID:保存时会被忽略。
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 分组头:平台下拉(权威枚举)+ 该平台的账号条数 + 「删除该平台」。 */
|
||||
function PlatformPicker({
|
||||
group,
|
||||
options,
|
||||
disabled,
|
||||
onPlatform,
|
||||
onRemoveGroup,
|
||||
}: {
|
||||
group: OwnerAccountGroup;
|
||||
options: EnumItem[];
|
||||
disabled: boolean;
|
||||
onPlatform: (v: string) => void;
|
||||
onRemoveGroup: () => void;
|
||||
}): React.ReactNode {
|
||||
return (
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<label className="block w-48 space-y-1">
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">平台</span>
|
||||
<select
|
||||
value={group.platform}
|
||||
onChange={(e) => onPlatform(e.target.value)}
|
||||
disabled={disabled}
|
||||
className={inputCls}
|
||||
>
|
||||
<option value="">请选择平台…</option>
|
||||
{options.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.label}({p.id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">
|
||||
{group.platform === ""
|
||||
? "未选平台:这组不会被写盘"
|
||||
: `该平台下的账号 ID(${group.accountIds.length} 条)`}
|
||||
</span>
|
||||
<Button size="sm" variant="ghost" className="ml-auto" disabled={disabled} onClick={onRemoveGroup}>
|
||||
删除该平台
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AccountIdRowProps {
|
||||
listId: string;
|
||||
accountId: string;
|
||||
candidates: string[];
|
||||
platform: string;
|
||||
known: boolean;
|
||||
disabled: boolean;
|
||||
onValue: (v: string) => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
/** 一行账号 ID:手填 + 该平台已配置账号的候选(点一下就填入)。 */
|
||||
function AccountIdRow({
|
||||
listId,
|
||||
accountId,
|
||||
candidates,
|
||||
platform,
|
||||
known,
|
||||
disabled,
|
||||
onValue,
|
||||
onRemove,
|
||||
}: AccountIdRowProps): React.ReactNode {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<label className="block min-w-56 flex-1 space-y-1">
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">账号 ID</span>
|
||||
<input
|
||||
value={accountId}
|
||||
onChange={(e) => onValue(e.target.value)}
|
||||
disabled={disabled}
|
||||
list={candidates.length ? listId : undefined}
|
||||
className={`${inputCls} font-mono`}
|
||||
placeholder={candidates.length ? "可手填,或从下方候选里选" : "如 9616b27a…"}
|
||||
/>
|
||||
</label>
|
||||
<Button size="sm" variant="ghost" disabled={disabled} onClick={onRemove}>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
{candidates.length ? (
|
||||
<AccountCandidates
|
||||
listId={listId}
|
||||
candidates={candidates}
|
||||
disabled={disabled}
|
||||
onPick={onValue}
|
||||
/>
|
||||
) : (
|
||||
<CandidateHint platform={platform} known={known} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 候选账号:`<datalist>`(键盘可筛)+ 可点的标签(看得见、点一下就填)。 */
|
||||
function AccountCandidates({
|
||||
listId,
|
||||
candidates,
|
||||
disabled,
|
||||
onPick,
|
||||
}: {
|
||||
listId: string;
|
||||
candidates: string[];
|
||||
disabled: boolean;
|
||||
onPick: (v: string) => void;
|
||||
}): React.ReactNode {
|
||||
return (
|
||||
<>
|
||||
<datalist id={listId}>
|
||||
{candidates.map((c) => (
|
||||
<option key={c} value={c} />
|
||||
))}
|
||||
</datalist>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">从该平台已配置账号里选:</span>
|
||||
{candidates.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onPick(c)}
|
||||
className="rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-4)] px-2 py-0.5 font-mono text-xs text-[var(--mu-text-mid)] transition-colors duration-200 hover:bg-[var(--mu-surface-5)] disabled:opacity-60"
|
||||
title={`填入已配置账号 ${c}`}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 没有候选时给一句诚实的解释(而不是假装有候选或静静空着)。 */
|
||||
function CandidateHint({ platform, known }: { platform: string; known: boolean }): React.ReactNode {
|
||||
if (!platform) return null;
|
||||
return (
|
||||
<p className="text-xs text-[var(--mu-text-lo)]">
|
||||
{known
|
||||
? "该平台暂无已配置账号:先在「渠道管理」里配好账号,这里就能直接选。"
|
||||
: "该平台未在渠道管理里注册:账号 ID 只能手填。"}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -157,9 +157,17 @@ export function SystemModule(): React.ReactNode {
|
||||
|
||||
<Surface className="space-y-1">
|
||||
<SectionTitle icon={<Icon name="package" size={18} />} title="环境变量" />
|
||||
<KeyValueRow label="SERVER_HOST" value="0.0.0.0" mono />
|
||||
<KeyValueRow label="SERVER_PORT" value="8080" mono />
|
||||
<KeyValueRow label="AUTH_ADMIN_USER/PASS" value="••••••••" mono />
|
||||
{/* 键名必须与 `.env` / `.env.example` **逐字一致**:面板是只读事实卡,
|
||||
用户会照着这里抄键名去改 .env —— 写错一个字母就是静默不生效(AGENTS §10 E6)。
|
||||
真键:HOST / PORT / ADMIN_USERNAME / ADMIN_PASSWORD(见 .env.example)。 */}
|
||||
<KeyValueRow label="HOST" value="0.0.0.0" mono />
|
||||
<KeyValueRow label="PORT" value="8080" mono />
|
||||
<KeyValueRow label="ADMIN_USERNAME" value="admin" mono />
|
||||
<KeyValueRow label="ADMIN_PASSWORD" value="••••••••" mono />
|
||||
<p className="pt-1 text-xs text-[var(--mu-text-lo)]">
|
||||
以上为 <span className="font-mono">.env.example</span> 的默认值(面板无 env 读取端点,不是运行实例的实时值);
|
||||
改值请编辑 <span className="font-mono">.env</span> 后重启服务。
|
||||
</p>
|
||||
</Surface>
|
||||
|
||||
<Surface className="space-y-1">
|
||||
|
||||
@@ -3,15 +3,23 @@ import { useAsyncData } from "../../lib/useAsyncData";
|
||||
import {
|
||||
adminCreateUser,
|
||||
adminGetOwnerProfile,
|
||||
adminListChannels,
|
||||
adminListUsers,
|
||||
adminIssueApiKey,
|
||||
adminRevokeApiKey,
|
||||
adminSaveOwnerProfile,
|
||||
fetchEnums,
|
||||
getStoredRole,
|
||||
} from "../../lib/api";
|
||||
import type { AdminUserRecord, OwnerAccount, OwnerProfile } from "../../lib/types";
|
||||
import type {
|
||||
AdminChannelRecord,
|
||||
AdminUserRecord,
|
||||
EnumItem,
|
||||
OwnerProfile,
|
||||
} from "../../lib/types";
|
||||
import { useAuditStore } from "../audit";
|
||||
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
|
||||
import { OwnerAccountsEditor } from "./OwnerAccountsEditor";
|
||||
import { Button, Surface, Tag } from "../../components/primitives";
|
||||
|
||||
const roleTone: Record<string, "accent" | "ok" | "neutral" | "warn"> = {
|
||||
@@ -138,6 +146,10 @@ function UsersTableSection({
|
||||
* 是一次真实 PUT:成功回读服务端档案,失败如实报错 —— 绝不假装保存成功。 */
|
||||
function OwnerProfileSection({ isOwner }: { isOwner: boolean }): React.ReactNode {
|
||||
const { data, loading, error } = useAsyncData(adminGetOwnerProfile, []);
|
||||
// 平台下拉取权威枚举(未接线/失败时回落本仓镜像的标签表,取值仍原样呈现);
|
||||
// 账号 id 候选取该平台已配置的渠道账号,避免手打错一位就静默失效。
|
||||
const enums = useAsyncData(fetchEnums, []);
|
||||
const channels = useAsyncData(adminListChannels, []);
|
||||
const log = useAuditStore((s) => s.log);
|
||||
const [profile, setProfile] = useState<OwnerProfile | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
@@ -181,7 +193,15 @@ function OwnerProfileSection({ isOwner }: { isOwner: boolean }): React.ReactNode
|
||||
{fail ? <Notice tone="err">{fail}</Notice> : null}
|
||||
{notice ? <Notice tone="ok">{notice}</Notice> : null}
|
||||
{profile ? (
|
||||
<OwnerProfileForm profile={profile} disabled={!isOwner} busy={busy} onChange={setProfile} onSave={save} />
|
||||
<OwnerProfileForm
|
||||
profile={profile}
|
||||
disabled={!isOwner}
|
||||
busy={busy}
|
||||
platforms={enums.data?.platforms}
|
||||
channels={channels.data}
|
||||
onChange={setProfile}
|
||||
onSave={save}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-xs text-[var(--mu-text-lo)]">{loading ? "读取中…" : "未加载到主人档案"}</p>
|
||||
)}
|
||||
@@ -193,17 +213,23 @@ interface OwnerProfileFormProps {
|
||||
profile: OwnerProfile;
|
||||
disabled: boolean;
|
||||
busy: boolean;
|
||||
platforms: EnumItem[] | undefined;
|
||||
channels: AdminChannelRecord[] | null;
|
||||
onChange: (next: OwnerProfile) => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
/** 档案表单本体(纯受控:所有改动都经 onChange 回到草稿)。 */
|
||||
function OwnerProfileForm({ profile, disabled, busy, onChange, onSave }: OwnerProfileFormProps): React.ReactNode {
|
||||
function OwnerProfileForm({
|
||||
profile,
|
||||
disabled,
|
||||
busy,
|
||||
platforms,
|
||||
channels,
|
||||
onChange,
|
||||
onSave,
|
||||
}: OwnerProfileFormProps): React.ReactNode {
|
||||
const patch = (p: Partial<OwnerProfile>): void => onChange({ ...profile, ...p });
|
||||
const patchAccount = (i: number, p: Partial<OwnerAccount>): void =>
|
||||
patch({ accounts: profile.accounts.map((a, idx) => (idx === i ? { ...a, ...p } : a)) });
|
||||
const removeAccount = (i: number): void => patch({ accounts: profile.accounts.filter((_, idx) => idx !== i) });
|
||||
const appendAccount = (): void => patch({ accounts: [...profile.accounts, { platform: "", accountId: "" }] });
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -213,12 +239,12 @@ function OwnerProfileForm({ profile, disabled, busy, onChange, onSave }: OwnerPr
|
||||
<Field label="默认人格" value={profile.defaultPersona} onChange={(v) => patch({ defaultPersona: v })} disabled={disabled} />
|
||||
<Field label="身份说明书" value={profile.identityFile} onChange={(v) => patch({ identityFile: v })} disabled={disabled} />
|
||||
</div>
|
||||
<AccountWhitelistEditor
|
||||
<OwnerAccountsEditor
|
||||
accounts={profile.accounts}
|
||||
disabled={disabled}
|
||||
onPatch={patchAccount}
|
||||
onRemove={removeAccount}
|
||||
onAppend={appendAccount}
|
||||
platforms={platforms}
|
||||
channels={channels}
|
||||
onChange={(accounts) => patch({ accounts })}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">关系备注</span>
|
||||
@@ -242,46 +268,6 @@ function OwnerProfileForm({ profile, disabled, busy, onChange, onSave }: OwnerPr
|
||||
);
|
||||
}
|
||||
|
||||
interface AccountWhitelistEditorProps {
|
||||
accounts: OwnerAccount[];
|
||||
disabled: boolean;
|
||||
onPatch: (index: number, patch: Partial<OwnerAccount>) => void;
|
||||
onRemove: (index: number) => void;
|
||||
onAppend: () => void;
|
||||
}
|
||||
|
||||
/** 渠道账号白名单行编辑器:命中此列表的账号走 owner 车道(元数据判定,非提示词)。 */
|
||||
function AccountWhitelistEditor({
|
||||
accounts,
|
||||
disabled,
|
||||
onPatch,
|
||||
onRemove,
|
||||
onAppend,
|
||||
}: AccountWhitelistEditorProps): React.ReactNode {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">渠道账号白名单(命中即走 owner 车道)</span>
|
||||
<Button size="sm" variant="ghost" disabled={disabled} onClick={onAppend}>
|
||||
添加账号
|
||||
</Button>
|
||||
</div>
|
||||
{accounts.length === 0 ? (
|
||||
<p className="text-xs text-[var(--mu-text-lo)]">尚未配置:现状为「无人是主人」(默认拒绝,fail-safe)。</p>
|
||||
) : null}
|
||||
{accounts.map((a, i) => (
|
||||
<div key={`acct-${i}`} className="flex flex-wrap items-end gap-2">
|
||||
<Field label="平台" value={a.platform} onChange={(v) => onPatch(i, { platform: v })} disabled={disabled} />
|
||||
<Field label="账号 id" value={a.accountId} onChange={(v) => onPatch(i, { accountId: v })} disabled={disabled} />
|
||||
<Button size="sm" variant="ghost" disabled={disabled} onClick={() => onRemove(i)}>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** API key 签发块(裁决 15.4.3):不选角色 —— 所有 key 一律超管。 */
|
||||
function ApiKeySection({ isOwner, onIssued }: { isOwner: boolean; onIssued: () => void }): React.ReactNode {
|
||||
const log = useAuditStore((s) => s.log);
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
// 主人档案「渠道账号白名单」编辑器的测试(主人 2026-10-05 要求)。
|
||||
//
|
||||
// 为什么需要它:此前一行 = `{platform, accountId}`,同一平台多个账号要**重复填平台名**,
|
||||
// 且平台是纯文本输入框 —— 填错(`qq_obv11` / `QQ-OBV11`)会**静默失效**(主人被当陌生人,
|
||||
// 界面无任何提示)。改法:界面**一个平台一条** + 该平台下「+ 添加账号 ID」;平台是权威
|
||||
// 枚举**下拉**;账号 ID 给出「从该平台已配置账号里选」的候选;**写盘时仍是扁平数组**。
|
||||
//
|
||||
// 锁三件事:
|
||||
// ① 分组/展平是纯函数且**展平后就是后端契约的扁平数组**(`owner.yaml` 格式不变);
|
||||
// ② 下拉选项 = 权威枚举 + 存量里出现过的平台(未列出的旧值不被下拉吃掉);
|
||||
// ③ 渲染:一个平台一条(两个平台 ⇒ 两块)、有「+ 添加账号 ID」、有候选可点。
|
||||
//
|
||||
// 跑法(react-dom/server 的 CJS 构建需要 require,所以必须打成 cjs):
|
||||
//
|
||||
// cd frontend/web \
|
||||
// && ./node_modules/.bin/esbuild src/admin/modules/ownerAccounts.test.ts \
|
||||
// --bundle --platform=node --format=cjs \
|
||||
// --outfile=/tmp/cyrene-owner-accounts.test.cjs \
|
||||
// && node /tmp/cyrene-owner-accounts.test.cjs
|
||||
//
|
||||
// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内导入、
|
||||
// 不 import node:*。
|
||||
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type { AdminChannelRecord, EnumItem, OwnerAccount } from "../../lib/types";
|
||||
import { OwnerAccountsEditor } from "./OwnerAccountsEditor";
|
||||
import {
|
||||
accountCandidates,
|
||||
flattenAccounts,
|
||||
groupAccounts,
|
||||
platformIsConfigured,
|
||||
platformOptions,
|
||||
} from "./ownerAccounts";
|
||||
|
||||
let checks = 0;
|
||||
|
||||
function ok(cond: boolean, what: string): void {
|
||||
checks += 1;
|
||||
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||
}
|
||||
|
||||
function same<T>(got: T, want: T, what: string): void {
|
||||
ok(
|
||||
JSON.stringify(got) === JSON.stringify(want),
|
||||
`${what} — got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`
|
||||
);
|
||||
}
|
||||
|
||||
const PUBLISHED: EnumItem[] = [
|
||||
{ id: "web", label: "网页" },
|
||||
{ id: "kook", label: "KOOK" },
|
||||
{ id: "qq-obv11", label: "QQ" },
|
||||
{ id: "weixin-ilink", label: "微信" },
|
||||
{ id: "telegram", label: "Telegram" },
|
||||
{ id: "webhook", label: "Webhook" },
|
||||
];
|
||||
|
||||
// ── ① 分组 / 展平(纯函数) ─────────────────────────────────────────────────────
|
||||
{
|
||||
const flat: OwnerAccount[] = [
|
||||
{ platform: "qq-obv11", accountId: "9616b27a" },
|
||||
{ platform: "web", accountId: "web-user" },
|
||||
{ platform: "qq-obv11", accountId: "aaaa1111" },
|
||||
];
|
||||
const groups = groupAccounts(flat);
|
||||
same(groups.length, 2, "两个平台 ⇒ 两块(同一平台的多个账号不重复出现平台)");
|
||||
same(groups[0], { platform: "qq-obv11", accountIds: ["9616b27a", "aaaa1111"] }, "同平台的账号收进一块");
|
||||
same(groups[1], { platform: "web", accountIds: ["web-user"] }, "顺序 = 首次出现顺序");
|
||||
|
||||
// 展平回扁平数组。注意:分组天然把**同一平台的账号排到相邻位置** ⇒ 保存后文件的
|
||||
// 行序会变成「按平台聚集」。白名单是一个**集合**(命中即 owner,与顺序无关),所以
|
||||
// 这里按集合断言;顺序变化在面板上一眼可见,不是隐藏改动。
|
||||
const sorted = (list: OwnerAccount[]): OwnerAccount[] =>
|
||||
[...list].sort((a, b) => `${a.platform}/${a.accountId}`.localeCompare(`${b.platform}/${b.accountId}`));
|
||||
same(sorted(flattenAccounts(groups)), sorted(flat), "分组 → 展平 回到同一组白名单条目");
|
||||
const shape = Object.keys(flattenAccounts(groups)[0]).sort();
|
||||
same(shape, ["accountId", "platform"], "写盘条目只有 {platform, accountId}(owner.yaml 格式不变)");
|
||||
|
||||
// 空值:平台或账号 id 任一为空都不写(写了也匹配不上 actor 元数据,是噪声)。
|
||||
same(
|
||||
flattenAccounts([
|
||||
{ platform: "qq-obv11", accountIds: ["9616b27a", " ", ""] },
|
||||
{ platform: "", accountIds: ["orphan"] },
|
||||
{ platform: "kook", accountIds: [] },
|
||||
]),
|
||||
[{ platform: "qq-obv11", accountId: "9616b27a" }],
|
||||
"空白账号 / 空白平台 / 空平台组都不写盘"
|
||||
);
|
||||
same(
|
||||
flattenAccounts([{ platform: " kook ", accountIds: [" 42 "] }]),
|
||||
[{ platform: "kook", accountId: "42" }],
|
||||
"两侧空白被去掉(避免写出匹配不上的键)"
|
||||
);
|
||||
}
|
||||
|
||||
// ── ② 下拉选项:权威枚举 + 存量旧值(display-raw) ───────────────────────────────
|
||||
{
|
||||
const options = platformOptions(PUBLISHED, ["qq-obv11", "discord"]);
|
||||
ok(options.length === 7, "权威 6 项 + 1 个存量里的未知平台(不重复)");
|
||||
same(options[0], { id: "web", label: "网页" }, "权威枚举在最前");
|
||||
same(options[6], { id: "discord", label: "discord" }, "未列出的旧值原样保留(display-raw,不被下拉吃掉)");
|
||||
ok(
|
||||
platformOptions(undefined, ["qq-obv11"]).length === 1,
|
||||
"取不到枚举(未接线/失败)时仍能列出存量平台,不至于把下拉掏空"
|
||||
);
|
||||
ok(
|
||||
platformOptions(PUBLISHED, []).some((o) => o.id === "qq-obv11" && o.label === "QQ"),
|
||||
"枚举项用后端给的标签"
|
||||
);
|
||||
}
|
||||
|
||||
// ── ③ 候选账号:来自该平台已配置的渠道账号 ──────────────────────────────────────
|
||||
{
|
||||
const channels: AdminChannelRecord[] = [
|
||||
{ name: "qq-obv11", connected: true, accounts: [{ id: "9616b27a", config: {} }, { id: "aaaa1111", config: {} }] },
|
||||
{ name: "kook", connected: false, accounts: [{ id: "", config: {} }] },
|
||||
];
|
||||
same(accountCandidates(channels, "qq-obv11"), ["9616b27a", "aaaa1111"], "候选取该平台已配置账号的 id");
|
||||
same(accountCandidates(channels, "kook"), [], "空 id 的历史条目不作候选(它没有可用 id)");
|
||||
same(accountCandidates(channels, "telegram"), [], "未配置的平台没有候选");
|
||||
same(accountCandidates(null, "qq-obv11"), [], "渠道列表没读到时不编候选");
|
||||
ok(platformIsConfigured(channels, "qq-obv11"), "已注册平台");
|
||||
ok(!platformIsConfigured(channels, "telegram"), "未注册平台");
|
||||
}
|
||||
|
||||
// ── ④ 渲染:一个平台一条 + 子按钮 + 候选 ────────────────────────────────────────
|
||||
{
|
||||
const accounts: OwnerAccount[] = [
|
||||
{ platform: "qq-obv11", accountId: "9616b27a" },
|
||||
{ platform: "qq-obv11", accountId: "aaaa1111" },
|
||||
{ platform: "kook", accountId: "" },
|
||||
];
|
||||
const channels: AdminChannelRecord[] = [
|
||||
{ name: "qq-obv11", connected: true, accounts: [{ id: "9616b27a", config: {} }] },
|
||||
];
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(OwnerAccountsEditor, {
|
||||
accounts,
|
||||
disabled: false,
|
||||
platforms: PUBLISHED,
|
||||
channels,
|
||||
onChange: () => {},
|
||||
})
|
||||
);
|
||||
|
||||
const groupCount = html.split("data-platform=").length - 1;
|
||||
same(groupCount, 2, "界面「一个平台一条」:两个平台 ⇒ 两块(同一平台的账号合在一块)");
|
||||
ok(html.includes('data-platform="qq-obv11"'), "分组带平台标记,便于核对与测试定位");
|
||||
ok(html.includes("<select"), "平台是下拉选择,不再是纯文本输入框(消灭拼错平台导致的静默失效)");
|
||||
ok(html.includes('value="qq-obv11"') && html.includes("QQ(qq-obv11)"), "下拉里有权威枚举项(含 id,抄不错)");
|
||||
ok(html.includes('value="discord"') === false, "存量里没有的平台不凭空塞进下拉");
|
||||
ok(html.includes("+ 添加账号 ID"), "每个平台下都有「+ 添加账号 ID」子按钮");
|
||||
ok(html.includes("从该平台已配置账号里选:"), "账号 ID 旁给出「从已配置账号里选」的候选");
|
||||
ok(html.includes("9616b27a"), "候选里出现该平台已配置的账号 id");
|
||||
ok(html.includes("owner.yaml"), "明写保存后仍是 owner.yaml 的既有格式");
|
||||
ok(html.includes("有 1 条空白账号 ID"), "空账号 ID 有显式提示(保存时会被忽略,不是静默丢弃)");
|
||||
ok(html.includes("该平台下的账号 ID(1 条)"), "分组标题报出该平台的账号条数");
|
||||
|
||||
// 未注册的平台(没有候选)给出诚实的解释,而不是假装有候选。
|
||||
const other = renderToStaticMarkup(
|
||||
createElement(OwnerAccountsEditor, {
|
||||
accounts: [{ platform: "telegram", accountId: "" }],
|
||||
disabled: true,
|
||||
platforms: PUBLISHED,
|
||||
channels,
|
||||
onChange: () => {},
|
||||
})
|
||||
);
|
||||
ok(other.includes("该平台未在渠道管理里注册"), "未注册平台说明「账号 ID 只能手填」");
|
||||
ok(other.includes('disabled=""'), "只读角色下控件被禁用(不假装能改)");
|
||||
}
|
||||
|
||||
console.log(`owner accounts editor test: ${checks} checks passed`);
|
||||
@@ -0,0 +1,97 @@
|
||||
import { labelFor } from "../../lib/enums";
|
||||
import type { AdminChannelRecord, EnumItem, OwnerAccount } from "../../lib/types";
|
||||
|
||||
/**
|
||||
* 主人档案「渠道账号白名单」的呈现层(主人 2026-10-05 要求):界面**按平台分组**
|
||||
* (一个平台一条 + 该平台下「+ 添加账号 ID」),**写盘时展平成扁平数组**
|
||||
* `accounts:[{platform,accountId}…]` —— 后端契约与 `configs/users/owner.yaml` 的
|
||||
* 文件格式**一个字都不改**,这里纯粹是呈现层。
|
||||
*
|
||||
* 为什么值得改:此前一行 = `{platform, accountId}`,同一平台多个账号要**重复填平台名**,
|
||||
* 且平台是纯文本输入框 —— 填错(`qq_obv11` / `QQ-OBV11`)会**静默失效**
|
||||
* (`owner.IsOwner` 匹配不上 actor 元数据 ⇒ 主人被当陌生人,界面上没有任何提示)。
|
||||
* 平台改为权威枚举下拉 + 账号 id 可从该平台已配置账号里选,消灭这类静默失效。
|
||||
*
|
||||
* 本模块是纯函数(无 DOM、无请求),因此分组/展平/下拉选项/候选都能在 node 下逐条测。
|
||||
*/
|
||||
|
||||
/** 一个平台分组:界面上的一条。 */
|
||||
export interface OwnerAccountGroup {
|
||||
platform: string;
|
||||
accountIds: string[];
|
||||
}
|
||||
|
||||
/** 把扁平数组按平台分组(保持首次出现顺序,同一平台的多条账号收进一组)。 */
|
||||
export function groupAccounts(accounts: OwnerAccount[]): OwnerAccountGroup[] {
|
||||
const groups: OwnerAccountGroup[] = [];
|
||||
const byPlatform = new Map<string, OwnerAccountGroup>();
|
||||
for (const a of accounts) {
|
||||
const platform = a?.platform ?? "";
|
||||
let g = byPlatform.get(platform);
|
||||
if (!g) {
|
||||
g = { platform, accountIds: [] };
|
||||
byPlatform.set(platform, g);
|
||||
groups.push(g);
|
||||
}
|
||||
g.accountIds.push(a?.accountId ?? "");
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** 展平成后端契约要的扁平数组。**平台与账号 id 都非空才写**:任一为空都匹配不上任何
|
||||
* actor 元数据(写了也是噪声,还会让主人误以为已配置)。空的账号行/空平台组被忽略,
|
||||
* 界面上有对应提示,不是静默丢弃。 */
|
||||
export function flattenAccounts(groups: OwnerAccountGroup[]): OwnerAccount[] {
|
||||
const out: OwnerAccount[] = [];
|
||||
for (const g of groups) {
|
||||
const platform = g.platform.trim();
|
||||
for (const raw of g.accountIds) {
|
||||
const accountId = raw.trim();
|
||||
if (!platform || !accountId) continue;
|
||||
out.push({ platform, accountId });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 下拉选项:后端权威枚举(`GET /api/v1/meta/enums` 的 `platforms`)在前,随后补上
|
||||
* 存量里出现过的平台 —— 未列出的旧值**原样保留**(display-raw:绝不下拉吃掉它,
|
||||
* 否则一次保存就把主人已经生效的配置删了)。 */
|
||||
export function platformOptions(
|
||||
published: readonly EnumItem[] | undefined,
|
||||
used: readonly string[]
|
||||
): EnumItem[] {
|
||||
const out: EnumItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
const push = (id: string, label?: string) => {
|
||||
if (!id || seen.has(id)) return;
|
||||
seen.add(id);
|
||||
out.push({ id, label: label || labelFor("platform", id) });
|
||||
};
|
||||
for (const p of published ?? []) push(p.id, p.label);
|
||||
for (const id of used) push(id);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 该平台**已配置账号**的候选(数据来自 `GET /api/v1/admin/channels` 的 accounts):
|
||||
* 让主人从真凭据里选,不再手打 id(手打错一个字符同样是静默失效)。 */
|
||||
export function accountCandidates(
|
||||
channels: AdminChannelRecord[] | null | undefined,
|
||||
platform: string
|
||||
): string[] {
|
||||
if (!platform) return [];
|
||||
const record = (channels ?? []).find((c) => c.name === platform);
|
||||
const ids = (record?.accounts ?? [])
|
||||
.map((a) => a?.id ?? "")
|
||||
.filter((id) => id !== "");
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
|
||||
/** 该平台是否在「已配置账号」里出现过(用于给「候选为空」一句诚实的解释)。 */
|
||||
export function platformIsConfigured(
|
||||
channels: AdminChannelRecord[] | null | undefined,
|
||||
platform: string
|
||||
): boolean {
|
||||
if (!platform) return false;
|
||||
return (channels ?? []).some((c) => c.name === platform);
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import { Tag } from "./primitives";
|
||||
import { Icon, type IconName } from "./Icon";
|
||||
import { WakePath } from "./status";
|
||||
import { modeBody } from "./messages";
|
||||
import { DocumentList } from "./messages/Documents";
|
||||
import { DeliveryChip, TurnTag, userBubbleClass } from "./messages/TurnMeta";
|
||||
import { UserMessageActions } from "./messages/MessageActions";
|
||||
import { renderContent } from "../lib/renderContent";
|
||||
import { splitBubble, type ContentPart } from "../lib/parseContent";
|
||||
@@ -169,6 +171,22 @@ function PersonaLabel({ name }: { name?: string }): React.ReactNode {
|
||||
);
|
||||
}
|
||||
|
||||
/** 用户气泡下面那行小字:回合小标 + 投递态角标 + 来源。两个用户气泡(单块 / 分段)
|
||||
* 共用一处,避免「四份渲染各写一遍、日后漂移」。 */
|
||||
function UserMeta({ message }: { message: ChatMessage }): React.ReactNode {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||
<TurnTag turnId={message.turnId} align="right" />
|
||||
<DeliveryChip
|
||||
delivery={message.delivery}
|
||||
steerStep={message.steerStep}
|
||||
rejectReason={message.rejectReason}
|
||||
/>
|
||||
<SourceLabel deviceId={message.deviceId} role={message.role} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A single message bubble. Per design/07 §1 the bubble shape is driven by the
|
||||
* message's own `mode`: the same role renders differently under different modes,
|
||||
* and a single session may mix shapes. Falls back to `chat` when mode is absent
|
||||
@@ -288,6 +306,7 @@ function AssistantParaBubble({
|
||||
) : null}
|
||||
{full ? <SourceLabel deviceId={message.deviceId} role={message.role} /> : null}
|
||||
{full && switched ? <PersonaLabel name={persona?.name} /> : null}
|
||||
{full ? <TurnTag turnId={message.turnId} /> : null}
|
||||
{full ? <WakePath active={active} /> : null}
|
||||
{full ? <ThinkingBlock text={message.thinking} streaming={message.streaming} /> : null}
|
||||
{full ? message.tools?.map((t, i) => <ToolCard key={i} tool={t} />) : null}
|
||||
@@ -309,7 +328,9 @@ function UserParaBubble({
|
||||
}): React.ReactNode {
|
||||
return (
|
||||
<div className="mu-rise-in flex flex-col items-end gap-0.5">
|
||||
<div className="liquid-glass-accent max-w-[80%] rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm text-white shadow-[var(--mu-shadow-1)]">
|
||||
<div
|
||||
className={`${userBubbleClass(message.delivery)} max-w-[80%] rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm shadow-[var(--mu-shadow-1)]`}
|
||||
>
|
||||
{full && message.image ? (
|
||||
<img
|
||||
src={message.image}
|
||||
@@ -317,9 +338,10 @@ function UserParaBubble({
|
||||
className="mb-1.5 block max-h-56 rounded-[var(--mu-radius-sm)] object-cover"
|
||||
/>
|
||||
) : null}
|
||||
{full ? <DocumentList documents={message.documents} /> : null}
|
||||
<span className="whitespace-pre-wrap">{renderContent(text)}</span>
|
||||
</div>
|
||||
{full ? <SourceLabel deviceId={message.deviceId} role={message.role} /> : null}
|
||||
{full ? <UserMeta message={message} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -391,7 +413,9 @@ function UserBubble({ message, mode }: { message: ChatMessage; mode: Mode }): Re
|
||||
) : null;
|
||||
return (
|
||||
<div className="mu-rise-in group relative flex flex-col items-end gap-0.5">
|
||||
<div className="liquid-glass-accent max-w-[80%] rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm text-white shadow-[var(--mu-shadow-1)]">
|
||||
<div
|
||||
className={`${userBubbleClass(message.delivery)} max-w-[80%] rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm shadow-[var(--mu-shadow-1)]`}
|
||||
>
|
||||
{message.image ? (
|
||||
<img
|
||||
src={message.image}
|
||||
@@ -399,9 +423,10 @@ function UserBubble({ message, mode }: { message: ChatMessage; mode: Mode }): Re
|
||||
className="mb-1.5 block max-h-56 rounded-[var(--mu-radius-sm)] object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<DocumentList documents={message.documents} />
|
||||
{body}
|
||||
</div>
|
||||
<SourceLabel deviceId={message.deviceId} role={message.role} />
|
||||
<UserMeta message={message} />
|
||||
<SenderLabel name={message.senderName} />
|
||||
<UserMessageActions message={message} />
|
||||
</div>
|
||||
@@ -430,6 +455,7 @@ function AssistantBubble({
|
||||
) : null}
|
||||
<SourceLabel deviceId={message.deviceId} role={message.role} />
|
||||
{switched ? <PersonaLabel name={persona?.name} /> : null}
|
||||
<TurnTag turnId={message.turnId} />
|
||||
<WakePath active={active} />
|
||||
<ThinkingBlock text={message.thinking} streaming={message.streaming} />
|
||||
{message.tools?.map((t, i) => <ToolCard key={i} tool={t} />)}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { MessageDocument } from "../../lib/types";
|
||||
import { Icon } from "../Icon";
|
||||
import { Tag } from "../primitives";
|
||||
|
||||
/**
|
||||
* 一条消息携带的文档附件条(契约 §4.4,design/16)。放在用户气泡里(附件是**用户**
|
||||
* 这一回合带进来的),逐条显示:文件名、状态、注入侧统计,以及 `status:"unreadable"`
|
||||
* 时后端给的那句**逐字**中文原因。
|
||||
*
|
||||
* 两条红线:
|
||||
* - `chars`/`truncated` 是**注入侧**语义(本回合注入模型上下文的字符数 / 是否被预算
|
||||
* 截断),不是文件字数与文件截断,所以文案写「注入 N 字」而不是「N 字」;
|
||||
* - `status` 允许后端新增取值:未知值**原样显示**(display-raw),绝不丢附件、也绝不
|
||||
* 把未知当错误。`reason` 只由后端产出,前端不自己拼一句。
|
||||
*/
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
extracted: "已读入",
|
||||
listed: "仅清单",
|
||||
unreadable: "读不到正文",
|
||||
};
|
||||
|
||||
const STATUS_TITLE: Record<string, string> = {
|
||||
extracted: "正文已注入本轮上下文",
|
||||
listed: "只给了模型附件清单,正文未进上下文",
|
||||
unreadable: "读不到正文:原因见下方(服务端原话)",
|
||||
};
|
||||
|
||||
export function DocumentList({
|
||||
documents,
|
||||
}: {
|
||||
documents?: MessageDocument[];
|
||||
}): React.ReactNode {
|
||||
if (!documents || documents.length === 0) return null;
|
||||
return (
|
||||
<div className="mb-1.5 space-y-1">
|
||||
{documents.map((doc, i) => (
|
||||
<DocumentRow key={`${doc.name}-${i}`} doc={doc} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 状态标签色:读不到正文用告警色(用户需要知道这条附件没被读到),其余中性/强调。 */
|
||||
function statusTone(status: string): "accent" | "warn" | "neutral" {
|
||||
if (status === "unreadable") return "warn";
|
||||
if (status === "extracted") return "accent";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function DocumentRow({ doc }: { doc: MessageDocument }): React.ReactNode {
|
||||
const status = doc.status ?? "";
|
||||
const label = STATUS_LABEL[status] ?? (status || "附件");
|
||||
return (
|
||||
<div className="rounded-[var(--mu-radius-sm)] bg-black/15 px-2.5 py-1.5 text-xs">
|
||||
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5">
|
||||
<Icon name="docs" size={12} className="shrink-0 text-white/70" />
|
||||
<span className="min-w-0 max-w-[16rem] truncate font-medium text-white/90">
|
||||
{doc.name}
|
||||
</span>
|
||||
<Tag tone={statusTone(status)} title={STATUS_TITLE[status]}>
|
||||
{label}
|
||||
</Tag>
|
||||
{typeof doc.chars === "number" ? (
|
||||
<span className="text-white/60">注入 {doc.chars} 字</span>
|
||||
) : null}
|
||||
{doc.truncated ? <span className="text-white/60">注入已截断</span> : null}
|
||||
{typeof doc.size === "number" ? (
|
||||
<span className="text-white/60">{formatSize(doc.size)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{doc.reason ? (
|
||||
<div className="mt-0.5 text-[11px] leading-tight text-white/80">{doc.reason}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 文件大小的紧凑展示(后端给的是字节)。 */
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MiB`;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { MessageDelivery } from "../../lib/types";
|
||||
import { useAppStore } from "../../store/useAppStore";
|
||||
import { Icon } from "../Icon";
|
||||
import { Tag } from "../primitives";
|
||||
|
||||
/**
|
||||
* 六态投递态与回合分组小标(忙时并行规范草案 §1.1 / §1.4)。纯展示、无副作用:
|
||||
* `delivery` 缺席(老后端 / REST 历史行)时不渲染任何角标,旧行为逐字不变。
|
||||
*
|
||||
* 为什么要有它:忙时一条消息可能处在「排队中」或「已受理·回答中(灰态)」——两者
|
||||
* 语义不同(③ 她还没开始理你这条;④ 这条已经在被处理的那一轮里了),用户必须能
|
||||
* 一眼分清;被拒时还要明确说「未送达」而不是让气泡静静躺着。
|
||||
*/
|
||||
export function DeliveryChip({
|
||||
delivery,
|
||||
steerStep,
|
||||
rejectReason,
|
||||
}: {
|
||||
delivery?: MessageDelivery;
|
||||
steerStep?: number;
|
||||
rejectReason?: string;
|
||||
}): React.ReactNode {
|
||||
switch (delivery) {
|
||||
case "sending":
|
||||
return (
|
||||
<Tag tone="neutral">
|
||||
<Icon name="clock" size={11} />
|
||||
发送中
|
||||
</Tag>
|
||||
);
|
||||
case "queued":
|
||||
return (
|
||||
<Tag tone="warn" title="会话正忙,这条已排队;轮到她处理时会自动开始">
|
||||
排队中
|
||||
</Tag>
|
||||
);
|
||||
case "answering":
|
||||
// 灰态:「已读、未答」。执行到第几步由 steer_ack 的 atStep 给出(后端没给就不写)。
|
||||
return (
|
||||
<Tag tone="neutral" title="已被本轮吃进,正在回答;该回合结束时自动收口">
|
||||
{typeof steerStep === "number"
|
||||
? `已读 · 回答中(第 ${steerStep} 步)`
|
||||
: "已读 · 回答中"}
|
||||
</Tag>
|
||||
);
|
||||
case "rejected":
|
||||
// ⑥ 被拒:文案优先用后端给的(如「排队已满(上限 N 条)」),没给就说清后果。
|
||||
return <Tag tone="err">{rejectReason ?? "未送达 · 会话正忙"}</Tag>;
|
||||
default:
|
||||
// sent / answered / 缺省:正常气泡,不加角标(不制造噪声)。
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 灰态时用户气泡的底色:正文区转灰表示「已读、未答」,收口后回到正常的强调色。 */
|
||||
export function userBubbleClass(delivery?: MessageDelivery): string {
|
||||
return delivery === "answering"
|
||||
? "bg-[var(--mu-surface-4)] text-[var(--mu-text-mid)] ring-1 ring-[var(--mu-glass-border)]"
|
||||
: "liquid-glass-accent text-white";
|
||||
}
|
||||
|
||||
/** 回合分组小标:显示这一行属于哪个回合(有绑定记录时补上「并行路 N」)。
|
||||
* 它让「回复 ↔ 提问」在真并行下可核对,也是重连后按 `turnId` 分组的落点
|
||||
* (规范草案 §1.4 呈现 A / §1.6)。 */
|
||||
export function TurnTag({
|
||||
turnId,
|
||||
align = "left",
|
||||
}: {
|
||||
turnId?: string;
|
||||
align?: "left" | "right";
|
||||
}): React.ReactNode {
|
||||
// 该路仍在绑定表里(还在飞)时能拿到会话内路序号;已收口的回合退化成只显示 id。
|
||||
const leg = useAppStore((s) => (turnId ? s.turnBindings[turnId]?.leg : undefined));
|
||||
if (!turnId) return null;
|
||||
const short = turnId.length > 12 ? `${turnId.slice(0, 12)}…` : turnId;
|
||||
return (
|
||||
<div
|
||||
className={`text-[10px] leading-tight text-[var(--mu-text-lo)] ${align === "right" ? "text-right" : ""}`}
|
||||
>
|
||||
{leg !== undefined && leg > 1 ? `并行路 ${leg} · ` : ""}回合 {short}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// 气泡渲染测试(契约 §4.4 `documents` + 忙时并行规范草案 §1.1 六态 / §1.4 分组小标)。
|
||||
//
|
||||
// 为什么需要它:`documents` 此前在**全链**缺席(DTO → parser → ChatMessage → 气泡),
|
||||
// IM 附件在 Web 上完全看不见;而「已受理·回答中」的灰态与「排队中」「未送达」的区分
|
||||
// 是画出来才算数的事 —— 纯逻辑测试证明不了它们真的出现在气泡上。
|
||||
//
|
||||
// 锁三件事:
|
||||
// ① `documents` 渲染:文件名、状态、**注入侧**语义(`chars`=注入字符数、`truncated`=
|
||||
// 注入被预算截断,不能写成「文件 N 字」)、以及 `status:"unreadable"` 时后端给的
|
||||
// 那句**逐字**中文原因;
|
||||
// ② 六态角标:发送中 / 排队中 / 已读·回答中(含 atStep)/ 未送达(含原因);缺省与
|
||||
// 已答不加角标,灰态时气泡底色不再是强调色;
|
||||
// ③ 回合小标:历史行显示所属 `turnId`(有绑定记录时补「并行路 N」)。
|
||||
//
|
||||
// 跑法(react-dom/server 的 CJS 构建需要 require,所以必须打成 cjs):
|
||||
//
|
||||
// cd frontend/web \
|
||||
// && ./node_modules/.bin/esbuild src/components/messages/bubble.render.test.ts \
|
||||
// --bundle --platform=node --format=cjs \
|
||||
// --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \
|
||||
// --outfile=/tmp/cyrene-bubble.test.cjs \
|
||||
// && node /tmp/cyrene-bubble.test.cjs
|
||||
//
|
||||
// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内导入、
|
||||
// 不 import node:*。
|
||||
|
||||
import { createElement } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type { ChatMessage } from "../../lib/types";
|
||||
import { SplitChatBubble } from "../chat";
|
||||
|
||||
let checks = 0;
|
||||
|
||||
function ok(cond: boolean, what: string): void {
|
||||
checks += 1;
|
||||
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||
}
|
||||
|
||||
function has(html: string, needle: string): boolean {
|
||||
return html.includes(needle);
|
||||
}
|
||||
|
||||
function render(m: ChatMessage): string {
|
||||
return renderToStaticMarkup(createElement(SplitChatBubble, { message: m }));
|
||||
}
|
||||
|
||||
const T = "turn-9f3c1a2b3c4d5e6f";
|
||||
|
||||
function user(extra: Partial<ChatMessage> = {}): ChatMessage {
|
||||
return { id: "m1", role: "user", content: "看看这个附件", createdAt: 1, ...extra };
|
||||
}
|
||||
|
||||
// ── ① documents 全链渲染 ────────────────────────────────────────────────────────
|
||||
{
|
||||
const html = render(
|
||||
user({
|
||||
documents: [
|
||||
{ name: "年度报告.pdf", mime: "application/pdf", size: 2 * 1024 * 1024, chars: 8000, truncated: true, status: "extracted" },
|
||||
{ name: "扫描件.pdf", status: "unreadable", reason: "无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)" },
|
||||
{ name: "只进了清单.docx", status: "listed" },
|
||||
],
|
||||
})
|
||||
);
|
||||
ok(has(html, "年度报告.pdf"), "渲染附件文件名");
|
||||
ok(has(html, "扫描件.pdf"), "渲染第二个附件(≤5 条按发送顺序)");
|
||||
ok(has(html, "已读入"), "extracted 显示「已读入」");
|
||||
ok(has(html, "仅清单"), "listed 显示「仅清单」(正文没进上下文)");
|
||||
ok(has(html, "读不到正文"), "unreadable 显示「读不到正文」");
|
||||
ok(has(html, "注入 8000 字"), "chars 按**注入侧**语义呈现(不是文件字数)");
|
||||
ok(has(html, "注入已截断"), "truncated 按**注入**口径呈现(不是文件被截断)");
|
||||
ok(has(html, "2.0 MiB"), "size 渲染成可读体积");
|
||||
ok(
|
||||
has(html, "无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)"),
|
||||
"unreadable 的 reason **逐字**渲染(后端给的文案,前端不自己拼)"
|
||||
);
|
||||
}
|
||||
|
||||
// 未知 status 原样呈现(display-raw:后端新增取值时客户端不丢附件、不报错)。
|
||||
{
|
||||
const html = render(user({ documents: [{ name: "x.bin", status: "quarantined" }] }));
|
||||
ok(has(html, "x.bin"), "未知 status 的附件仍然渲染");
|
||||
ok(has(html, "quarantined"), "未知 status 原样显示");
|
||||
}
|
||||
|
||||
// 没有附件时不出现附件条(0 个附件时契约里键就缺席)。
|
||||
{
|
||||
const html = render(user());
|
||||
ok(!has(html, "注入 "), "无附件时不渲染附件条");
|
||||
}
|
||||
|
||||
// ── ② 六态角标(缺省 = 已送达/已答,不加角标) ──────────────────────────────────
|
||||
{
|
||||
const plain = render(user());
|
||||
ok(!has(plain, "发送中") && !has(plain, "排队中") && !has(plain, "已读"), "缺省投递态不加任何角标");
|
||||
ok(has(plain, "liquid-glass-accent"), "正常用户气泡仍是强调色");
|
||||
|
||||
ok(has(render(user({ delivery: "sending" })), "发送中"), "① sending 显示「发送中」");
|
||||
ok(has(render(user({ delivery: "queued" })), "排队中"), "③ queued 显示「排队中」(不是灰态)");
|
||||
|
||||
const answering = render(user({ delivery: "answering" }));
|
||||
ok(has(answering, "已读 · 回答中"), "④ answering 显示「已读 · 回答中」");
|
||||
ok(!has(answering, "liquid-glass-accent"), "灰态时气泡不再是强调色(正文区转灰)");
|
||||
|
||||
ok(
|
||||
has(render(user({ delivery: "answering", steerStep: 2 })), "已读 · 回答中(第 2 步)"),
|
||||
"steer_ack 的 atStep 作为灰态小字"
|
||||
);
|
||||
|
||||
const rejected = render(user({ delivery: "rejected", rejectReason: "会话正忙:排队已满(上限 5 条),这条未送达" }));
|
||||
ok(has(rejected, "未送达"), "⑥ rejected 说清「未送达」");
|
||||
ok(has(rejected, "排队已满(上限 5 条)"), "⑥ 被拒原因用后端给的文案");
|
||||
|
||||
ok(has(render(user({ delivery: "answered" })), "看看这个附件"), "⑤ answered 是正常气泡(内容照常)");
|
||||
ok(!has(render(user({ delivery: "answered" })), "回答中"), "⑤ answered 不再显示「回答中」");
|
||||
}
|
||||
|
||||
// ── ③ 回合小标(turnId)+ 并行路序号 ────────────────────────────────────────────
|
||||
{
|
||||
const msg = user({ turnId: T });
|
||||
const html = render(msg);
|
||||
ok(has(html, "回合 turn-9f3c1a2…"), "用户气泡带回合小标(可与 turn_begin 配对)");
|
||||
|
||||
const assistant = render({ id: "a1", role: "assistant", content: "回答", createdAt: 2, turnId: T });
|
||||
ok(has(assistant, "回合 turn-9f3c1a2…"), "助手气泡同样带回合小标");
|
||||
ok(!has(assistant, "并行路"), "无绑定记录时退回只显示回合 id");
|
||||
ok(!has(render(user()), "回合 "), "没有 turnId 的历史行不渲染小标(旧行为不变)");
|
||||
|
||||
// ⚠️ 诚实边界:「并行路 N」取自 store 的 turnBindings,而 `renderToStaticMarkup` 用的是
|
||||
// zustand 的 **server snapshot(初始状态)**,所以这里读不到绑定,无法在静态渲染里断言。
|
||||
// 绑定与路序号本身由 parallel.test.ts 驱动真实 store 覆盖(turn_begin 的 leg 落表)。
|
||||
}
|
||||
|
||||
console.log(`bubble render test: ${checks} checks passed`);
|
||||
@@ -109,16 +109,21 @@ export function WakePath({
|
||||
}
|
||||
|
||||
/** 会话编排 busy/pending 轻状态提醒(design/15 §5)。她忙时显示在聊天区顶部;
|
||||
* **不阻塞输入框**(输入框可继续打字),挂起消息数可见。 */
|
||||
* **不阻塞输入框**(输入框可继续打字),挂起消息数与在途路数可见。
|
||||
* 忙时并行(design/15 §2.5)后:`activeTurns > 1` 时显式写「并行 N 路」,
|
||||
* 排队/额度已满这类后端给的原因(`queueNotice`)优先显示 —— 用户才知道「为什么还在等」。 */
|
||||
export function SessionStatusHint(): React.ReactNode | null {
|
||||
const busy = useAppStore((s) => s.sessionBusy);
|
||||
const pendingCount = useAppStore((s) => s.pendingCount);
|
||||
const activeTurns = useAppStore((s) => s.activeTurns);
|
||||
const queueNotice = useAppStore((s) => s.queueNotice);
|
||||
if (!busy) return null;
|
||||
|
||||
return (
|
||||
<div className="mu-rise-in flex items-center gap-2 rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]/70 px-3 py-2 text-xs text-[var(--mu-text-lo)]">
|
||||
<Icon name="clock" size={14} className="shrink-0 text-[var(--mu-warn)]" />
|
||||
<span className="min-w-0 truncate">她正在忙,有新消息稍后回</span>
|
||||
<span className="min-w-0 truncate">{queueNotice ?? "她正在忙,有新消息稍后回"}</span>
|
||||
{activeTurns > 1 ? <Tag tone="accent">并行 {activeTurns} 路</Tag> : null}
|
||||
{pendingCount > 0 ? <Tag tone="warn">{pendingCount} 条待回</Tag> : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -32,7 +32,11 @@ export {
|
||||
|
||||
export { health, login } from "./api/auth";
|
||||
|
||||
export { fetchEnums } from "./api/meta";
|
||||
export type { MetaEnums } from "./api/meta";
|
||||
|
||||
export { chatStream, chatSync } from "./api/chat";
|
||||
export type { ChatTriggerResult } from "./api/chat";
|
||||
|
||||
export {
|
||||
createSubSession,
|
||||
|
||||
@@ -15,19 +15,42 @@ export function chatSync(
|
||||
});
|
||||
}
|
||||
|
||||
/** `POST /api/v1/chat`(`stream:true`)的响应面(契约 §4.6)。202/200 都属成功,
|
||||
* 载荷必须**如实读出**:`queued` 表示被排队(不是流式),`parallel`/`turnId` 给出
|
||||
* 这一路的权威回合标识(`turn_begin` 帧也会带同一个值)。 */
|
||||
export interface ChatTriggerResult {
|
||||
stream?: true;
|
||||
/** 显式声明 `parallel:true` 且服务端照准 ⇒ 独立一路并行回合。 */
|
||||
parallel?: boolean;
|
||||
/** 会话忙且落回排队(HTTP 202):`queueDepth` = 仍在 pending 的条数。 */
|
||||
queued?: boolean;
|
||||
queueDepth?: number;
|
||||
/** 本回合的权威标识(`turn_begin` 帧的帧壳 `turnId` 同值)。 */
|
||||
turnId?: string;
|
||||
/** `clientMsgId` 判重命中(HTTP 200):本条没有新回合,`messageId` 是既有消息的
|
||||
* 权威 id(与 WS 的 `chat_duplicate` 帧同一条事实)。 */
|
||||
duplicate?: boolean;
|
||||
messageId?: string;
|
||||
}
|
||||
|
||||
/** POST /api/v1/chat with stream:true — triggers streaming over the WS channel.
|
||||
* `image` (a base64 data: URI or URL) is attached for the vision path; the
|
||||
* gateway's chat body currently has no image field, so an unconfigured backend
|
||||
* simply ignores it. `deviceId` mirrors the client's per-browser id onto the
|
||||
* persisted message (design/16 §4.5). `mode` is the behavior mode this turn is
|
||||
* sent in (design/01 §3); omitted when empty so the backend falls back to chat. */
|
||||
* sent in (design/01 §3); omitted when empty so the backend falls back to chat.
|
||||
* `clientMsgId` 是本条的幂等键(契约 §4.3):服务端判重命中时回
|
||||
* `chat_duplicate{clientMsgId,messageId}`,本端据此把乐观气泡对齐到既有 id。
|
||||
* `parallel` 显式请求独立一路(忙时并行规范草案 §2.2);省略 = 行为与今天逐字相同。 */
|
||||
export function chatStream(
|
||||
sessionId: string,
|
||||
text: string,
|
||||
image?: string,
|
||||
mode?: Mode
|
||||
): Promise<{ stream: true }> {
|
||||
return request<{ stream: true }>("/chat", {
|
||||
mode?: Mode,
|
||||
clientMsgId?: string,
|
||||
parallel?: boolean
|
||||
): Promise<ChatTriggerResult> {
|
||||
return request<ChatTriggerResult>("/chat", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
sessionId,
|
||||
@@ -36,6 +59,8 @@ export function chatStream(
|
||||
stream: true,
|
||||
deviceId: getClientId(),
|
||||
...(mode ? { mode } : {}),
|
||||
...(clientMsgId ? { clientMsgId } : {}),
|
||||
...(parallel ? { parallel: true } : {}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { EnumItem } from "../types";
|
||||
import { request } from "./core";
|
||||
|
||||
/** GET /api/v1/meta/enums 的载荷(design/07 §6,Android 协作 B4):后端发布的权威
|
||||
* 枚举表 + 未列出取值的回退策略(`unknown: "display-raw"`)。
|
||||
*
|
||||
* 客户端优先读这里而不是硬编码映射:表由后端一处维护,来源徽标/情绪标签才不会逐
|
||||
* 客户端漂移。取不到(未接线 / 离线)时调用方回落到本仓镜像的 `lib/enums.ts`
|
||||
* 标签表 —— 回落也只影响标签文字,不影响取值的原样呈现。 */
|
||||
export interface MetaEnums {
|
||||
platforms?: EnumItem[];
|
||||
moods?: EnumItem[];
|
||||
modes?: EnumItem[];
|
||||
unknown?: string;
|
||||
}
|
||||
|
||||
export function fetchEnums(): Promise<MetaEnums> {
|
||||
return request<MetaEnums>("/meta/enums");
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ChatMessage, Mode, SessionMeta } from "../types";
|
||||
import type { ChatMessage, MessageDocument, Mode, SessionMeta } from "../types";
|
||||
import { request } from "./core";
|
||||
|
||||
/** One chat message as returned by GET /api/v1/sessions/{id}/messages. Mirrors
|
||||
@@ -11,6 +11,11 @@ export interface MessageDto {
|
||||
mode?: Mode;
|
||||
mood?: string;
|
||||
image?: string;
|
||||
/** 文档附件(契约 §4.4):`{name,mime,size,chars,truncated,status,reason?}`,
|
||||
* ≤5 条/回合,0 个附件时键缺席。**不含正文**;`status:"unreadable"` 时带逐字
|
||||
* 中文 `reason`。`chars`/`truncated` 是**注入侧**语义(注入上下文的字符数与是否
|
||||
* 被预算截断),不是文件字数/截断。 */
|
||||
documents?: MessageDocument[];
|
||||
thinking?: string;
|
||||
/** Source-platform tag (design/16 §4.4): "web" or an IM channel platform. */
|
||||
platform?: string;
|
||||
@@ -18,6 +23,13 @@ export interface MessageDto {
|
||||
deviceId?: string;
|
||||
/** Sender display name of a third-party message (design/16 §4.6). Blank for the owner. */
|
||||
senderName?: string;
|
||||
/** IM 来源适配器(`platform:account`);Web 来源为空。 */
|
||||
adapterId?: string;
|
||||
/** 本回合的权威回合标识(`chat_messages.turn_id`):用户行 = 吃进/起它的那一路,
|
||||
* 助手行 = 产出它的那一路。旧行为空缺席。 */
|
||||
turnId?: string;
|
||||
/** 幂等键(契约 §4.3);空则省略。 */
|
||||
clientMsgId?: string;
|
||||
/** The role that spoke this message (design/22 §7-5, backing column
|
||||
* `chat_messages.persona_id`); `''` means "inherit the current default role". */
|
||||
personaId?: string;
|
||||
@@ -34,10 +46,13 @@ function toChatMessage(m: MessageDto): ChatMessage {
|
||||
mode: m.mode,
|
||||
mood: m.mood,
|
||||
image: m.image,
|
||||
documents: m.documents,
|
||||
thinking: m.thinking,
|
||||
platform: m.platform,
|
||||
deviceId: m.deviceId,
|
||||
senderName: m.senderName,
|
||||
turnId: m.turnId,
|
||||
clientMsgId: m.clientMsgId,
|
||||
personaId: m.personaId,
|
||||
createdAt: m.createdAt,
|
||||
};
|
||||
|
||||
@@ -14,3 +14,12 @@ export interface ApiErrorShape {
|
||||
code?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/** 一条客户端枚举项(`GET /api/v1/meta/enums` 的 `platforms`/`moods`/`modes` 元素):
|
||||
* `id` 是客户端拿来比对的机器可读值,`label` 是可直接显示的中文默认标签
|
||||
* (有 i18n 的客户端可覆盖)。契约保证「表外取值原样展示」,所以消费方绝不能把
|
||||
* 未列出的 id 当错误丢掉。 */
|
||||
export interface EnumItem {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
@@ -30,6 +30,40 @@ export interface ToolUsed {
|
||||
at: number;
|
||||
}
|
||||
|
||||
/** 一条用户消息的投递态(忙时并行规范草案 §1.1 的六态)。缺省 = 旧行为
|
||||
* (已送达 / 已答),因此老后端下渲染逐字不变。
|
||||
*
|
||||
* sending ①本地乐观气泡 → sent ②已落库 → queued ③排队中(pending_ack)→
|
||||
* answering ④已受理·回答中(灰态:turn_begin / steer_ack)→ answered ⑤已答
|
||||
* (该 turnId 的 turn.end,兜底 = 该 turnId 的助手 message_persisted);
|
||||
* rejected ⑥被拒(chat_busy / pending_full)。 */
|
||||
export type MessageDelivery =
|
||||
| "sending"
|
||||
| "sent"
|
||||
| "queued"
|
||||
| "answering"
|
||||
| "answered"
|
||||
| "rejected";
|
||||
|
||||
/** 一条消息携带的文档附件(契约 §4.4)。**不含正文**:只给名字与「注入侧」的
|
||||
* 统计。`chars`/`truncated` 讲的是**本回合注入模型上下文的字符数 / 是否被预算
|
||||
* 截断**,不是文件的真实字数与截断 —— 渲染时不能写成「文件 N 字」。 */
|
||||
export interface MessageDocument {
|
||||
name: string;
|
||||
mime?: string;
|
||||
size?: number;
|
||||
/** 本回合注入上下文的字符数(截断时为截断后的值)。 */
|
||||
chars?: number;
|
||||
/** 注入是否被预算截断。 */
|
||||
truncated?: boolean;
|
||||
/** extracted(正文已进上下文)/ listed(只给了清单)/ unreadable(读不到正文)。
|
||||
* 契约允许后端新增取值,未知值按 display-raw 原样呈现,绝不当作错误。 */
|
||||
status?: string;
|
||||
/** **只在 `status:"unreadable"` 时下发**的逐字中文原因(服务端常量),客户端
|
||||
* 原样渲染、不自己拼。 */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
/** The owning session (backend `session_id`). Set when loaded via the sessions
|
||||
@@ -49,6 +83,23 @@ export interface ChatMessage {
|
||||
* as an image card. Absent for legacy persisted messages, so renders fall back
|
||||
* to plain text. */
|
||||
image?: string;
|
||||
/** 文档附件列表(契约 §4.4;0 个附件时键缺席,不是 `[]`)。渲染在用户气泡里;
|
||||
* `status:"unreadable"` 时同屏显示后端给的逐字中文 `reason`。 */
|
||||
documents?: MessageDocument[];
|
||||
/** 这条消息所属的回合(`chat_messages.turn_id` / 帧壳 `turnId`)。用户行 = 吃进
|
||||
* 或起它的那一路;助手行 = 产出它的那一路。忙时并行下靠它把「回复 ↔ 提问」配对、
|
||||
* 把两条交错到达的回复分进两个气泡(规范草案 §1.4/§1.7)。旧行为空缺席。 */
|
||||
turnId?: string;
|
||||
/** 本端乐观气泡的幂等键(契约 §4.3;随 `chat.clientMsgId` 上行)。服务端判重命中
|
||||
* 时回 `chat_duplicate{clientMsgId,messageId}`,本端据此把气泡对齐到既有 id。 */
|
||||
clientMsgId?: string;
|
||||
/** 六态投递态(规范草案 §1.1);缺省 = 已送达/已答。 */
|
||||
delivery?: MessageDelivery;
|
||||
/** steer_ack 的 `atStep`:这条消息被在飞回合吃进时的模型步序号(1 基)。
|
||||
* 只作灰态小字用,不参与判定。 */
|
||||
steerStep?: number;
|
||||
/** ⑥ 被拒的原因小字(队列满 / `chat_busy`),后端文案或码,原样展示。 */
|
||||
rejectReason?: 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
|
||||
|
||||
@@ -17,10 +17,64 @@ export interface AwakenPayload {
|
||||
tool?: string | { name: string };
|
||||
}
|
||||
|
||||
/** session_busy / session_pending 下行事件 payload(design/15 §5):她是否在忙 + 挂起消息数。 */
|
||||
/** session_busy / session_pending 下行事件 payload(design/15 §5):她是否在忙 + 挂起消息数。
|
||||
*
|
||||
* 忙时并行(design/15 §2.5,契约 v0.65 §3.4.1)后这些帧还带 `activeTurns`(在途路数)、
|
||||
* `mode`(忙碌通道)、`queueDepth` / `reason` / `dropped` / `cancelledTurns` 等计数键。
|
||||
* 全部可选:老后端不发则逐字沿用旧行为。`reason` 目前唯一取值 `"parallel_limit"`
|
||||
* (声明并行却落回排队)。 */
|
||||
export interface SessionStatePayload {
|
||||
busy?: boolean;
|
||||
pendingCount?: number;
|
||||
/** 该帧发出时的在途路数(含正在收尾的路)。忙 = activeTurns > 0。 */
|
||||
activeTurns?: number;
|
||||
/** 忙碌通道:thinking / executing / subagent;`session_idle` 为 "idle"。 */
|
||||
mode?: string;
|
||||
/** 仍在 pending、尚未被任何一路接手的条数(已 steer / 已升并行路的不计)。 */
|
||||
queueDepth?: number;
|
||||
/** `pending_ack` 的落回原因,目前仅 `"parallel_limit"`。其它情况键缺席。 */
|
||||
reason?: string;
|
||||
/** `session_interrupted`:scope:"all" 时清空的 pending 数;scope:"current" 恒 0。 */
|
||||
dropped?: number;
|
||||
/** `session_interrupted`:本次真的取消了几路。 */
|
||||
cancelledTurns?: number;
|
||||
/** `pending_full`:队列上限(队列满 ⇒ 深度 = max)。 */
|
||||
max?: number;
|
||||
/** `pending_full`:被拒消息此前已落库/已认领时的权威 id(服务端绝不在此新铸 id)。 */
|
||||
messageId?: string;
|
||||
/** `pending_full`:被拒消息的幂等键(未携带则键缺席)。 */
|
||||
clientMsgId?: string;
|
||||
}
|
||||
|
||||
/** `turn_begin` 下行帧 payload(契约 §3.4.3 / 规范草案 §2.4):每起一路(含续跑路)
|
||||
* 恰好一帧,把「服务端刚起的这一路 ↔ 它归属的用户消息 id」钉死。帧壳带 `turnId`。 */
|
||||
export interface TurnBeginPayload {
|
||||
/** 服务端**实际**如何起这条路:新回合/续跑 = "turn";声明并行且照准 = "parallel"。
|
||||
* 仅供展示,客户端状态机一律用 `messageIds` 是否含本气泡的 id 判定。 */
|
||||
kind?: string;
|
||||
/** 本次启动归属本路的用户消息 id 列表(可为空数组,恒为数组不为 null)。 */
|
||||
messageIds?: string[];
|
||||
/** 该路的会话内序号(1 基;1 = 唯一/第一路)。 */
|
||||
leg?: number;
|
||||
/** 本次启动后该会话在途路数。 */
|
||||
activeTurns?: number;
|
||||
}
|
||||
|
||||
/** 一路在途回合的客户端绑定记录(由 `turn_begin` 建立,`turn.end` 收口时丢弃)。
|
||||
* 它是「消息 ↔ 回合」的唯一真源:灰态靠它把 `messageIds` 置 answering,待落库的
|
||||
* 用户行靠它在 `message_persisted` 到达时补上绑定。只存内存,不持久化。 */
|
||||
export interface TurnBinding {
|
||||
turnId: string;
|
||||
sessionId: string;
|
||||
/** 该路归属的用户消息 id(`turn_begin.messageIds`,可为空数组)。 */
|
||||
messageIds: string[];
|
||||
/** "turn"(新回合/续跑)或 "parallel"(并行照准);仅供标注。 */
|
||||
kind: string;
|
||||
/** 会话内路序号(1 基)。 */
|
||||
leg: number;
|
||||
/** 建立时的在途路数。 */
|
||||
activeTurns: number;
|
||||
at: number;
|
||||
}
|
||||
|
||||
/** approval_notice 下行事件 payload(design/11 §4 主代理代审,裁决 13.4.3):
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { chatStream } from "./api";
|
||||
import { ApiError, chatStream } from "./api";
|
||||
import { connectWs, isWsOpen } from "./ws";
|
||||
import { useAppStore } from "../store/useAppStore";
|
||||
import { useModeStore } from "../store/useModeStore";
|
||||
import { getClientId } from "./client";
|
||||
import { now } from "./util";
|
||||
import { now, uid } from "./util";
|
||||
|
||||
/** Drives one user turn: push the user bubble, blanket a streaming assistant
|
||||
* bubble, trigger the backend loop over REST (`stream:true`), and let the WS
|
||||
@@ -16,7 +16,7 @@ import { now } from "./util";
|
||||
* renders per that message's mode. We read the current UI mode here and pass it
|
||||
* on the turn so the backend persists it alongside the message (A3). */
|
||||
export function useSendChat(): {
|
||||
send: (text: string, image?: string) => Promise<void>;
|
||||
send: (text: string, image?: string, opts?: { parallel?: boolean }) => Promise<void>;
|
||||
sending: boolean;
|
||||
} {
|
||||
const [sending, setSending] = useState(false);
|
||||
@@ -27,10 +27,12 @@ export function useSendChat(): {
|
||||
const appendChunk = useAppStore((s) => s.appendChunk);
|
||||
const addTool = useAppStore((s) => s.addTool);
|
||||
const appendTaskLog = useAppStore((s) => s.appendTaskLog);
|
||||
const markLocalDelivery = useAppStore((s) => s.markLocalDelivery);
|
||||
const alignDuplicateMessage = useAppStore((s) => s.alignDuplicateMessage);
|
||||
const mode = useModeStore((s) => s.mode);
|
||||
|
||||
const send = useCallback(
|
||||
async (text: string, image?: string) => {
|
||||
async (text: string, image?: string, opts: { parallel?: boolean } = {}) => {
|
||||
const trimmed = text.trim();
|
||||
if ((!trimmed && !image) || sending) return;
|
||||
setSending(true);
|
||||
@@ -42,15 +44,41 @@ export function useSendChat(): {
|
||||
// Tag this browser's persistent client id onto the optimistic bubbles so the
|
||||
// source label shows immediately and matches the persisted turn (design/16 §4.5).
|
||||
const clientId = getClientId();
|
||||
addUserMessage(trimmed, undefined, image, clientId);
|
||||
// 幂等键(契约 §4.3):随上行一起发,服务端判重命中会回 `chat_duplicate` 帧或
|
||||
// `{duplicate:true,messageId}`(REST 面),本端据此把本地气泡对齐到既有 id ——
|
||||
// 重发/双发不会多出一条气泡。
|
||||
const clientMsgId = uid();
|
||||
addUserMessage(trimmed, undefined, image, clientId, clientMsgId);
|
||||
beginAssistant(mode, clientId);
|
||||
|
||||
try {
|
||||
await chatStream(sessionId, prompt, image, mode);
|
||||
const res = await chatStream(
|
||||
sessionId,
|
||||
prompt,
|
||||
image,
|
||||
mode,
|
||||
clientMsgId,
|
||||
opts.parallel
|
||||
);
|
||||
// 响应面得如实消费(契约 §4.6):202 排队不是错误。
|
||||
// `pending_ack` 帧是队列深度的真源,这里只做「万一那条帧没到」的兜底标注;
|
||||
// `duplicate` 同理(同一条事实的 REST 面)。
|
||||
if (res.duplicate && res.messageId) {
|
||||
alignDuplicateMessage(clientMsgId, res.messageId, sessionId);
|
||||
} else if (res.queued) {
|
||||
markLocalDelivery("queued");
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
endStream();
|
||||
// ⑥ 被拒:`409 chat_busy`(队列满)—— 这不是「触发失败」,气泡要明确说
|
||||
// 「未送达」,让人知道这句话没进去(而不是只看到一行错误日志)。
|
||||
if (err instanceof ApiError && err.code === "chat_busy") {
|
||||
markLocalDelivery("rejected", { rejectReason: "会话正忙:排队已满,这条未送达" });
|
||||
appendTaskLog("main", "[chat_busy] 排队已满,本条未送达\n");
|
||||
return;
|
||||
}
|
||||
// Backend loop not wired or gateway unreachable: show the failure inline
|
||||
// rather than leaving a permanently-streaming bubble.
|
||||
endStream();
|
||||
const msg = err instanceof Error ? err.message : "触发失败";
|
||||
appendChunk(`[stream 触发失败] ${msg}`);
|
||||
appendTaskLog("main", `[error] ${msg}\n`);
|
||||
@@ -59,7 +87,7 @@ export function useSendChat(): {
|
||||
setSending(false);
|
||||
}
|
||||
},
|
||||
[sending, sessionId, mode, addUserMessage, beginAssistant, endStream, appendChunk, addTool, appendTaskLog]
|
||||
[sending, sessionId, mode, addUserMessage, beginAssistant, endStream, appendChunk, addTool, appendTaskLog, markLocalDelivery, alignDuplicateMessage]
|
||||
);
|
||||
|
||||
return { send, sending };
|
||||
|
||||
@@ -9,6 +9,8 @@ import { handleToolEvent } from "./ws/handlers/tool";
|
||||
import { handleMessageEvent } from "./ws/handlers/message";
|
||||
import { handleAgentEvent } from "./ws/handlers/agent";
|
||||
import { handleSessionEvent } from "./ws/handlers/session";
|
||||
import { handlePersonaEvent } from "./ws/handlers/persona";
|
||||
import { handleParallelEvent } from "./ws/handlers/parallel";
|
||||
import { handleDeviceEvent } from "./ws/handlers/device";
|
||||
import { handleApprovalEvent } from "./ws/handlers/approval";
|
||||
|
||||
@@ -72,8 +74,18 @@ function notifyObservability(type: string, data: unknown): void {
|
||||
* 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.
|
||||
* 帧按类型分组在 lib/ws/handlers/* 中处理:任一组命中(含组内主动丢弃)即结束,
|
||||
* 全部未命中走下方未知帧兜底 —— 与拆分前的单个 switch 等价。 */
|
||||
export function dispatchFrame(ev: { type: string; sessionId?: string; data: unknown }): void {
|
||||
* 全部未命中走下方未知帧兜底 —— 与拆分前的单个 switch 等价。
|
||||
*
|
||||
* `turnId`/`seq` 是帧壳字段,**必须原样传进 handler**:忙时并行下同一会话的两条
|
||||
* 回复靠 `(sessionId, turnId)` 两级分桶区分,此前签名只收 `{type,sessionId,data}`,
|
||||
* `turnId` 在 socket 层就被丢掉 ⇒ 所有按路分桶都不可能实现。 */
|
||||
export function dispatchFrame(ev: {
|
||||
type: string;
|
||||
sessionId?: string;
|
||||
turnId?: string;
|
||||
seq?: number;
|
||||
data: unknown;
|
||||
}): void {
|
||||
const s = useAppStore.getState();
|
||||
const r = toRecord(ev.data);
|
||||
|
||||
@@ -91,6 +103,10 @@ export function dispatchFrame(ev: { type: string; sessionId?: string; data: unkn
|
||||
if (handleMessageEvent(ev, r, s)) return;
|
||||
if (handleAgentEvent(ev, r, s)) return;
|
||||
if (handleSessionEvent(ev, r, s)) return;
|
||||
if (handlePersonaEvent(ev, r, s)) return;
|
||||
// 忙时并行帧组在 device 组之前:`error` 除 `chat_busy` 外一律放行,仍由 device.ts
|
||||
// 记一行日志,所以顺序只影响谁先处理 chat_busy,不影响原有日志行为。
|
||||
if (handleParallelEvent(ev, r, s)) return;
|
||||
if (handleDeviceEvent(ev, r, s)) return;
|
||||
if (handleApprovalEvent(ev, r, s)) return;
|
||||
|
||||
@@ -161,6 +177,9 @@ function openSocket(): void {
|
||||
// The frame envelope's sessionId is what scopes a payload that does not
|
||||
// carry its own (see the `message_deleted` case in dispatch).
|
||||
sessionId: typeof r.sessionId === "string" ? r.sessionId : undefined,
|
||||
// 帧壳的 turnId/seq(契约 §3.4):分桶键必须传下去,不能在这里丢。
|
||||
turnId: typeof r.turnId === "string" && r.turnId ? r.turnId : undefined,
|
||||
seq: typeof r.seq === "number" ? r.seq : undefined,
|
||||
data: r.data,
|
||||
});
|
||||
} catch {
|
||||
|
||||
@@ -48,7 +48,17 @@ export function handleMessageEvent(ev: FrameEvent, r: FramePayload, s: StoreStat
|
||||
// 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) s.appendOrUpdateMessage(msg);
|
||||
if (msg) {
|
||||
s.appendOrUpdateMessage(msg);
|
||||
// `turn_begin` 可能早于用户行落库到达(那时 messageIds 在列表里还没有落点,
|
||||
// 绑定只记在 turnBindings 里):这里补一次落地,让气泡及时进灰态。
|
||||
s.rebindSessionTurns(msg.sessionId ?? s.activeSessionId);
|
||||
// 灰态的**兜底**收口(规范草案 §1.2):该 `turnId` 的助手回复落库同样收口,
|
||||
// 它与 `turn.end` 顺序不定,任一到即收。
|
||||
if (msg.role === "assistant" && msg.turnId) {
|
||||
s.clearGrey(msg.turnId, msg.sessionId ?? s.activeSessionId);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case "message_edited": {
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
// 忙时并行帧组的逻辑测试(design/15 §2.5 / `DevDocs/_specs/忙时并行-规范草案.md`;契约 v0.65 §3.4.1–§3.4.3)。
|
||||
//
|
||||
// 为什么需要它:这组帧此前在前端 **grep 命中 0**(全部落进未知帧兜底),更根本的是
|
||||
// 帧壳 `turnId` 在 socket 层就被丢掉 ⇒ 任何「按路分桶」都不可能实现。本测试锁六件事:
|
||||
// ① `turnId` 从帧壳一路传进 handler 与按路气泡(分桶键不能在中途丢);
|
||||
// ② `(sessionId, turnId)` 两级分桶:两路增量**不会糊进同一个气泡**;
|
||||
// ③ 灰态(已读·未答)入口 = `turn_begin`/`steer_ack`,出口 = 该 `turnId` 的 `turn.end`,
|
||||
// 兜底 = 该 `turnId` 的助手 `message_persisted`;
|
||||
// ④ 六态里的 ③ 排队 / ⑥ 被拒:`pending_ack` 不是灰态,`pending_full`/`chat_busy` 说清后果;
|
||||
// ⑤ 多路忙态:`session_interrupted`(scope:"current")不能当空闲;`stream_end` 不能把
|
||||
// 另一路还在跑的徽标关掉;
|
||||
// ⑥ `chat_duplicate` 把本地乐观气泡对齐到既有 id(不重复渲染);
|
||||
// ⑦ `tool_execution.start/end` 的 `{tool, phase}` 真的落成工具卡(此前用错解析器 ⇒ 卡片永不出现)。
|
||||
//
|
||||
// 项目没有前端测试框架/jsdom,所以这是零依赖的纯 node 检查:它驱动真实的帧解码器
|
||||
// (lib/ws.ts 的 dispatchFrame)与真实的 store,并在第一个失败处抛出。
|
||||
//
|
||||
// 跑法(用 vite 自带的 esbuild,不新增依赖、不改 package.json;banner 补上 store 的
|
||||
// persist 中间件在 node 下探测的 window.localStorage):
|
||||
//
|
||||
// cd frontend/web \
|
||||
// && ./node_modules/.bin/esbuild src/lib/ws/handlers/parallel.test.ts \
|
||||
// --bundle --platform=node --format=esm \
|
||||
// --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \
|
||||
// --outfile=/tmp/cyrene-parallel.test.mjs \
|
||||
// && node /tmp/cyrene-parallel.test.mjs
|
||||
//
|
||||
// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内导入、
|
||||
// 不 import node:*。
|
||||
|
||||
import { dispatchFrame } from "../../ws";
|
||||
import { useAppStore } from "../../../store/useAppStore";
|
||||
import type { ChatMessage } from "../../types";
|
||||
|
||||
let checks = 0;
|
||||
|
||||
function ok(cond: boolean, what: string): void {
|
||||
checks += 1;
|
||||
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||
}
|
||||
|
||||
function same<T>(got: T, want: T, what: string): void {
|
||||
ok(
|
||||
JSON.stringify(got) === JSON.stringify(want),
|
||||
`${what} — got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`
|
||||
);
|
||||
}
|
||||
|
||||
const MAIN = "web";
|
||||
const T1 = "turn-aaaa";
|
||||
const T2 = "turn-bbbb";
|
||||
|
||||
/** 每个场景前把 store 复位成「新连接、无在途路」。 */
|
||||
function reset(): void {
|
||||
useAppStore.setState({
|
||||
activeSessionId: MAIN,
|
||||
messages: [],
|
||||
sessionMessages: {},
|
||||
turnBindings: {},
|
||||
sessionBusy: false,
|
||||
pendingCount: 0,
|
||||
activeTurns: 0,
|
||||
queueNotice: null,
|
||||
deviceUpdates: [],
|
||||
});
|
||||
}
|
||||
|
||||
function msgs(): ChatMessage[] {
|
||||
return useAppStore.getState().messages;
|
||||
}
|
||||
|
||||
/** 下行帧(帧壳 turnId/seq 与真实 socket 层一致:onmessage 就是这么填的)。 */
|
||||
function down(type: string, turnId: string | undefined, data: unknown, sessionId = MAIN): void {
|
||||
dispatchFrame({ type, sessionId, turnId, data });
|
||||
}
|
||||
|
||||
/** 落库一条消息(`message_persisted`,帧里带 DTO)。 */
|
||||
function persist(m: Record<string, unknown>): void {
|
||||
down("message_persisted", undefined, { message: { sessionId: MAIN, createdAt: 1, ...m } });
|
||||
}
|
||||
|
||||
/** 起一路(`turn_begin` 的帧壳带 turnId)。 */
|
||||
function turnBegin(turnId: string, kind: string, messageIds: string[], leg: number, activeTurns: number): void {
|
||||
down("turn_begin", turnId, { kind, messageIds, leg, activeTurns });
|
||||
}
|
||||
|
||||
// ── ① 帧壳 turnId 真的传进 handler(此前在 socket 层被丢) ────────────────────────
|
||||
{
|
||||
reset();
|
||||
turnBegin(T1, "turn", ["msg-A"], 1, 1);
|
||||
const bindings = useAppStore.getState().turnBindings;
|
||||
same(Object.keys(bindings), [T1], "turn_begin 建立了以帧壳 turnId 为键的绑定");
|
||||
same(bindings[T1].messageIds, ["msg-A"], "绑定记住了本路归属的用户消息 id");
|
||||
same(bindings[T1].leg, 1, "绑定记住了路序号");
|
||||
}
|
||||
|
||||
// ── ② 两级分桶:两条回复交错到达,各写自己的气泡(不糊成一条) ──────────────────
|
||||
{
|
||||
reset();
|
||||
persist({ id: "msg-A", role: "user", content: "问题一" });
|
||||
turnBegin(T1, "turn", ["msg-A"], 1, 1);
|
||||
persist({ id: "msg-B", role: "user", content: "问题二" });
|
||||
turnBegin(T2, "parallel", ["msg-B"], 2, 2);
|
||||
|
||||
same(msgs().length, 4, "两路各自有自己的用户行与助手气泡");
|
||||
same(
|
||||
msgs().map((m) => [m.id, m.role, m.turnId]),
|
||||
[
|
||||
["msg-A", "user", T1],
|
||||
[msgs()[1].id, "assistant", T1],
|
||||
["msg-B", "user", T2],
|
||||
[msgs()[3].id, "assistant", T2],
|
||||
],
|
||||
"用户行与助手气泡各自绑到自己的 turnId(顺序 = 历史顺序)"
|
||||
);
|
||||
|
||||
// 交错到达:T1 → T2 → T1 → T2,若按「列表尾气泡」写,四个片段会全落进最后一条。
|
||||
down("message.delta", T1, { index: 0, delta: "甲一" });
|
||||
down("message.delta", T2, { index: 0, delta: "乙一" });
|
||||
down("message.delta", T1, { index: 0, delta: "甲二" });
|
||||
down("message.delta", T2, { index: 0, delta: "乙二" });
|
||||
same(msgs()[1].content, "甲一甲二", "T1 的增量只进 T1 的气泡");
|
||||
same(msgs()[3].content, "乙一乙二", "T2 的增量只进 T2 的气泡(没被糊在一起)");
|
||||
}
|
||||
|
||||
// ── ③ 灰态:入口 turn_begin/steer_ack,出口 turn.end,兜底 = 助手 message_persisted ──
|
||||
{
|
||||
reset();
|
||||
persist({ id: "msg-A", role: "user", content: "问题" });
|
||||
turnBegin(T1, "turn", ["msg-A"], 1, 1);
|
||||
same(msgs()[0].delivery, "answering", "turn_begin 把归属本路的用户气泡置灰态(已受理·回答中)");
|
||||
|
||||
down("message.delta", T1, { index: 0, delta: "答" });
|
||||
down("turn.end", T1, undefined);
|
||||
same(msgs()[0].delivery, "answered", "该 turnId 的 turn.end 收口灰态");
|
||||
same(msgs()[1].streaming, false, "该路助手气泡停止流式");
|
||||
same(useAppStore.getState().turnBindings, {}, "turn.end 丢弃该路绑定");
|
||||
|
||||
// 兜底口径:助手 message_persisted 也收口(两个事件顺序不定)。
|
||||
reset();
|
||||
persist({ id: "msg-C", role: "user", content: "问题" });
|
||||
turnBegin(T1, "turn", ["msg-C"], 1, 1);
|
||||
persist({ id: "msg-reply", role: "assistant", content: "回答", turnId: T1 });
|
||||
same(
|
||||
msgs().find((m) => m.id === "msg-C")?.delivery,
|
||||
"answered",
|
||||
"该 turnId 的助手回复落库同样收口灰态(兜底)"
|
||||
);
|
||||
|
||||
// 两路并行时:T2 收口不得把 T1 的灰态一起收掉。
|
||||
reset();
|
||||
persist({ id: "msg-A", role: "user", content: "一" });
|
||||
turnBegin(T1, "turn", ["msg-A"], 1, 1);
|
||||
persist({ id: "msg-B", role: "user", content: "二" });
|
||||
turnBegin(T2, "parallel", ["msg-B"], 2, 2);
|
||||
down("turn.end", T2, undefined);
|
||||
same(msgs()[0].delivery, "answering", "T2 收口不影响 T1 的灰态");
|
||||
same(msgs()[2].delivery, "answered", "T2 的灰态按自己的 turnId 收口");
|
||||
|
||||
// steer_ack:被在飞回合吃进 ⇒ 灰态 + 步序号(帧壳 turnId 决定与哪一路配对)。
|
||||
reset();
|
||||
persist({ id: "msg-S", role: "user", content: "忙时发的" });
|
||||
down("steer_ack", T1, { messageId: "msg-S", atStep: 2 });
|
||||
same(msgs()[0].delivery, "answering", "steer_ack 把该气泡置灰态");
|
||||
same(msgs()[0].turnId, T1, "steer_ack 的帧壳 turnId 绑到该气泡(并行下必需)");
|
||||
same(msgs()[0].steerStep, 2, "steer_ack 的 atStep 作为进度小字");
|
||||
down("turn.end", T1, undefined);
|
||||
same(msgs()[0].delivery, "answered", "steer_ack 的灰态由该 turnId 的 turn.end 收口");
|
||||
}
|
||||
|
||||
// ── ④ 六态:③ 排队(pending_ack 不是灰态)/ ④ 已受理 / ⑥ 被拒 ─────────────────────
|
||||
{
|
||||
reset();
|
||||
useAppStore.setState({ messages: [{ id: "local-1", role: "user", content: "忙时发的", delivery: "sending", createdAt: 1 }] });
|
||||
down("pending_ack", undefined, { sessionId: MAIN, mode: "executing", queueDepth: 2 });
|
||||
same(useAppStore.getState().pendingCount, 2, "pending_ack 的 queueDepth 是队列深度的真源");
|
||||
same(useAppStore.getState().sessionBusy, true, "排队 ⇒ 会话忙");
|
||||
same(msgs()[0].delivery, "queued", "本端刚发的那条标为「排队中」");
|
||||
same(typeof useAppStore.getState().queueNotice, "string", "排队给出可见的解释文案");
|
||||
|
||||
down("pending_ack", undefined, { sessionId: MAIN, mode: "executing", queueDepth: 3, reason: "parallel_limit" });
|
||||
ok(
|
||||
(useAppStore.getState().queueNotice ?? "").includes("并行额度已满"),
|
||||
"reason:parallel_limit 明说「声明并行但额度已满」"
|
||||
);
|
||||
|
||||
// 排队的消息被弹出 ⇒ 刷新深度与在途路数(随后会有该路的 turn_begin)。
|
||||
down("pending_drain", undefined, { sessionId: MAIN, mode: "thinking", queueDepth: 1, activeTurns: 2 });
|
||||
same(useAppStore.getState().pendingCount, 1, "pending_drain 刷新队列深度");
|
||||
same(useAppStore.getState().activeTurns, 2, "pending_drain 刷新在途路数");
|
||||
|
||||
// 队列满:定向 chat_busy 与广播 pending_full 都算 ⑥ 被拒。
|
||||
reset();
|
||||
useAppStore.setState({ messages: [{ id: "local-2", role: "user", content: "满了", delivery: "queued", createdAt: 1 }] });
|
||||
down("pending_full", undefined, { sessionId: MAIN, mode: "executing", max: 5 });
|
||||
same(msgs()[0].delivery, "rejected", "pending_full 把本端待处理的那条标为未送达");
|
||||
ok((msgs()[0].rejectReason ?? "").includes("排队已满"), "被拒原因写清「排队已满(上限 N 条)」");
|
||||
|
||||
reset();
|
||||
useAppStore.setState({ messages: [{ id: "local-3", role: "user", content: "满了", delivery: "queued", createdAt: 1 }] });
|
||||
down("error", undefined, { code: "chat_busy", message: "会话正忙", sessionId: MAIN, messageId: "local-3" });
|
||||
same(msgs()[0].delivery, "rejected", "定向 error{code:chat_busy} 同样标为被拒");
|
||||
|
||||
// 其它 error 码仍交给 device 组记一行日志(不被本组吞掉)。
|
||||
reset();
|
||||
down("error", undefined, { message: "voice unavailable" });
|
||||
same(useAppStore.getState().deviceUpdates.length, 1, "非 chat_busy 的 error 仍落设备日志");
|
||||
}
|
||||
|
||||
// ── ⑤ 多路忙态:interrupted(scope:current) 不能当空闲;stream_end 不能关掉别的路 ──
|
||||
{
|
||||
reset();
|
||||
down("session_busy", undefined, { sessionId: MAIN, mode: "thinking", activeTurns: 2 });
|
||||
same(useAppStore.getState().sessionBusy, true, "session_busy{activeTurns:2} 置忙");
|
||||
same(useAppStore.getState().activeTurns, 2, "在途路数被读出来(状态栏可显示「并行 2 路」)");
|
||||
|
||||
// scope:"current":只取消了当时忙碌的那条通道,另一路仍在答 ⇒ 不能显示「她闲了」。
|
||||
down("session_interrupted", undefined, { sessionId: MAIN, mode: "executing", dropped: 0, cancelledTurns: 1, activeTurns: 1 });
|
||||
same(useAppStore.getState().sessionBusy, true, "scope:current 被打断后仍忙(mode 非 idle)");
|
||||
same(useAppStore.getState().activeTurns, 1, "被打断后刷新在途路数");
|
||||
|
||||
// scope:"all":mode:"idle" ⇒ 确实空闲。
|
||||
down("session_interrupted", undefined, { sessionId: MAIN, mode: "idle", dropped: 2, cancelledTurns: 2, activeTurns: 0 });
|
||||
same(useAppStore.getState().sessionBusy, false, "scope:all(mode idle)收口为空闲");
|
||||
same(useAppStore.getState().pendingCount, 0, "scope:all 清空挂起数");
|
||||
|
||||
// stream_end:并行另一路还在跑时,先收尾的那一路不能把徽标关掉。
|
||||
reset();
|
||||
persist({ id: "msg-A", role: "user", content: "一" });
|
||||
turnBegin(T1, "turn", ["msg-A"], 1, 1);
|
||||
persist({ id: "msg-B", role: "user", content: "二" });
|
||||
turnBegin(T2, "parallel", ["msg-B"], 2, 2);
|
||||
down("session_busy", undefined, { sessionId: MAIN, mode: "thinking", activeTurns: 2 });
|
||||
down("stream_end", T1, { stopReason: "end_turn" });
|
||||
same(useAppStore.getState().sessionBusy, true, "T1 收尾时 T2 仍在跑 ⇒ 不回空闲");
|
||||
same(msgs()[1].streaming, false, "T1 的气泡确实停止流式");
|
||||
same(msgs()[3].streaming, true, "T2 的气泡继续生长");
|
||||
|
||||
down("turn.end", T1, undefined);
|
||||
same(useAppStore.getState().sessionBusy, true, "T1 的 turn.end 之后(T2 仍在途)仍忙");
|
||||
down("turn.end", T2, undefined);
|
||||
same(useAppStore.getState().sessionBusy, false, "全部在途路收尾且队列空 ⇒ 忙态置闲");
|
||||
same(useAppStore.getState().activeTurns, 0, "全部收尾后在途路数为 0");
|
||||
|
||||
// 单路(无 turnId 的老帧)逐字沿用旧行为:stream_end 直接收口。
|
||||
reset();
|
||||
down("session_busy", undefined, { sessionId: MAIN, mode: "thinking" });
|
||||
down("stream_end", undefined, { stopReason: "end_turn" });
|
||||
same(useAppStore.getState().sessionBusy, false, "无 turnId 的 stream_end 照旧收口忙态");
|
||||
}
|
||||
|
||||
// ── ⑥ chat_duplicate:本地乐观气泡对齐到既有 id(不重复渲染) ─────────────────────
|
||||
{
|
||||
reset();
|
||||
useAppStore.setState({
|
||||
messages: [
|
||||
{ id: "local-9", role: "user", content: "重发的话", delivery: "sending", clientMsgId: "c-1", createdAt: 1 },
|
||||
],
|
||||
});
|
||||
down("chat_duplicate", undefined, { sessionId: MAIN, clientMsgId: "c-1", messageId: "msg-server-1" });
|
||||
same(msgs().length, 1, "判重命中不新增气泡");
|
||||
same(msgs()[0].id, "msg-server-1", "气泡对齐到服务端既有 messageId");
|
||||
same(msgs()[0].delivery, "sent", "对齐后投递态回到「已送达」");
|
||||
|
||||
// 没有匹配的 clientMsgId 时不改任何东西(不误改别的气泡)。
|
||||
down("chat_duplicate", undefined, { sessionId: MAIN, clientMsgId: "c-unknown", messageId: "msg-x" });
|
||||
same(msgs()[0].id, "msg-server-1", "认不出的幂等键不动状态");
|
||||
}
|
||||
|
||||
// ── ⑦ 工具的**执行阶段**帧(契约 §3.4.2;键名与 tool_call.* 不同) ────────────────
|
||||
// 此前只接了 `tool_execution.update`(后端暂无生产者),真正的 start/end 落到未知帧兜底;
|
||||
// 而用 `parseToolCall` 读 `{tool, phase}` 只会得到 null(键名不同)⇒ 卡片永远不出现。
|
||||
{
|
||||
reset();
|
||||
persist({ id: "msg-A", role: "user", content: "跑个命令" });
|
||||
down("message_persisted", undefined, { message: { id: "a-T1", sessionId: MAIN, role: "assistant", content: "", turnId: T1, createdAt: 2 } });
|
||||
down("turn_begin", T1, { kind: "turn", messageIds: ["msg-A"], leg: 1, activeTurns: 1 });
|
||||
down("tool_execution.start", T1, { tool: "terminal", phase: "running" });
|
||||
const running = msgs().find((m) => m.role === "assistant")?.tools ?? [];
|
||||
same(running.map((t) => [t.name, t.ok]), [["terminal", undefined]], "tool_execution.start 落成「运行中」工具卡");
|
||||
down("tool_execution.end", T1, { tool: "terminal", phase: "error" });
|
||||
const failed = msgs().find((m) => m.role === "assistant")?.tools ?? [];
|
||||
same(failed.map((t) => [t.name, t.ok]), [["terminal", false]], "tool_execution.end 的 phase:error 落成失败(不是一次新调用)");
|
||||
same(useAppStore.getState().deviceUpdates.length, 0, "tool_execution.* 不进未知帧兜底");
|
||||
}
|
||||
|
||||
// ── ⑧ 这些帧必须被消费(不得落进未知帧兜底,往 deviceUpdates 里堆垃圾) ──────────
|
||||
{
|
||||
reset();
|
||||
turnBegin(T1, "turn", ["msg-A"], 1, 1);
|
||||
down("turn.start", T1, undefined);
|
||||
down("pending_ack", undefined, { sessionId: MAIN, mode: "thinking", queueDepth: 1 });
|
||||
down("pending_drain", undefined, { sessionId: MAIN, mode: "thinking", queueDepth: 0, activeTurns: 1 });
|
||||
down("steer_ack", T1, { messageId: "msg-A", atStep: 1 });
|
||||
down("chat_duplicate", undefined, { sessionId: MAIN, clientMsgId: "c-x", messageId: "m-x" });
|
||||
down("turn.end", T1, undefined);
|
||||
same(useAppStore.getState().deviceUpdates.length, 0, "并行帧组全部被消费,不进未知帧兜底");
|
||||
}
|
||||
|
||||
console.log(`parallel frame logic test: ${checks} checks passed`);
|
||||
@@ -0,0 +1,181 @@
|
||||
import { now } from "../../util";
|
||||
import type { TurnBinding } from "../../types";
|
||||
import {
|
||||
frameIsActive,
|
||||
frameNumber,
|
||||
frameSessionId,
|
||||
frameText,
|
||||
frameTurnId,
|
||||
type FrameEvent,
|
||||
type FramePayload,
|
||||
type StoreState,
|
||||
} from "./shared";
|
||||
|
||||
/**
|
||||
* 忙时并行(多路)帧处理组(design/15 §2.5 / `DevDocs/_specs/忙时并行-规范草案.md`):
|
||||
* `turn_begin` / `turn.end` / `turn.start` / `steer_ack` / `pending_ack` /
|
||||
* `pending_drain` / `pending_full` / `chat_duplicate`,以及 `error{code:"chat_busy"}`。
|
||||
*
|
||||
* 接入这组的三个必须(规范草案 §1.7):
|
||||
* ① 结构化流帧按 `(sessionId, turnId)` 两级分桶 —— 分桶写入在 store 的
|
||||
* `appendChunk`/`setThinking`/`addTool` 里,本组负责把 `turnId` 送进去并把
|
||||
* `turn_begin` 的「消息 ↔ 回合」绑定建起来;
|
||||
* ② 灰态(已读·未答)入口只有 `turn_begin` / `steer_ack`,出口只有该 `turnId` 的
|
||||
* `turn.end`(兜底 = 该 `turnId` 的助手 `message_persisted`,见 message.ts);
|
||||
* ③ `queued`(排队)**不是**灰态:它由 `pending_ack` 标注,收到 `turn_begin` 时
|
||||
* 直接换态,绝不把排队渲染成灰态。
|
||||
*
|
||||
* 命中(含组内主动丢弃)返回 true,未命中返回 false 交给下一组。
|
||||
*/
|
||||
export function handleParallelEvent(
|
||||
ev: FrameEvent,
|
||||
r: FramePayload,
|
||||
s: StoreState
|
||||
): boolean {
|
||||
switch (ev.type) {
|
||||
case "turn_begin":
|
||||
return onTurnBegin(ev, r, s);
|
||||
case "turn.end":
|
||||
return onTurnEnd(ev, r, s);
|
||||
case "turn.start":
|
||||
// 回合起始的边界帧:气泡与灰态已由 `turn_begin` 处理(它带 messageIds),
|
||||
// 这里消费掉即可,不再堆进未知帧兜底。
|
||||
return true;
|
||||
case "steer_ack":
|
||||
return onSteerAck(ev, r, s);
|
||||
case "pending_ack":
|
||||
return onPendingAck(ev, r, s);
|
||||
case "pending_drain":
|
||||
return onPendingDrain(ev, r, s);
|
||||
case "pending_full":
|
||||
return onPendingFull(ev, r, s);
|
||||
case "chat_duplicate":
|
||||
return onDuplicate(ev, r, s);
|
||||
case "error":
|
||||
return onUpstreamError(r, s);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** `turn_begin`:每起一路恰好一帧,是「消息 ↔ 回合」的唯一权威来源。
|
||||
* 帧壳 `turnId` 缺了就无处分桶 —— 契约要求它必带,所以这种帧只消费不猜。 */
|
||||
function onTurnBegin(ev: FrameEvent, r: FramePayload, s: StoreState): boolean {
|
||||
const turnId = frameTurnId(ev, r);
|
||||
if (!turnId) return true;
|
||||
const raw = r && Array.isArray(r.messageIds) ? r.messageIds : [];
|
||||
const binding: TurnBinding = {
|
||||
turnId,
|
||||
sessionId: frameSessionId(ev, r) ?? s.activeSessionId,
|
||||
messageIds: raw.filter((x): x is string => typeof x === "string" && x !== ""),
|
||||
kind: frameText(r?.kind) ?? "turn",
|
||||
leg: frameNumber(r?.leg) ?? 1,
|
||||
activeTurns: frameNumber(r?.activeTurns) ?? s.activeTurns,
|
||||
at: now(),
|
||||
};
|
||||
s.beginTurn(binding);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** `turn.end`:整轮结束(覆盖最终答案 / 工具循环终止 / 预算耗尽 / 出错四种出口),
|
||||
* 是灰态的**唯一**收口事件。缺 `turnId` 时无从知道收的是哪一路,交给
|
||||
* `session_idle` 收尾,绝不错关别人的灰态。 */
|
||||
function onTurnEnd(ev: FrameEvent, r: FramePayload, s: StoreState): boolean {
|
||||
const turnId = frameTurnId(ev, r);
|
||||
if (turnId) s.endTurn(turnId, frameSessionId(ev, r));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** `steer_ack`:忙时新消息被在飞回合吃进同一回合 ⇒ 该气泡进灰态;`turnId` 是把灰态
|
||||
* 与正确 `turn.end` 配对的键(并行下必需)。`atStep` 只是进度小字。 */
|
||||
function onSteerAck(ev: FrameEvent, r: FramePayload, s: StoreState): boolean {
|
||||
const messageId = frameText(r?.messageId);
|
||||
if (!messageId) return true;
|
||||
const turnId = frameTurnId(ev, r);
|
||||
const atStep = frameNumber(r?.atStep);
|
||||
s.markDelivery(
|
||||
messageId,
|
||||
{
|
||||
delivery: "answering",
|
||||
...(turnId ? { turnId } : {}),
|
||||
...(atStep === undefined ? {} : { steerStep: atStep }),
|
||||
},
|
||||
frameSessionId(ev, r)
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** `pending_ack`:忙时消息入队(唯一真源是这里的 `queueDepth`)。
|
||||
* ③ 排队 ≠ 灰态:只把**本端刚发的那条**(还没被任何一路接手)标成 queued。
|
||||
* `reason:"parallel_limit"` 时明说「声明并行但额度已满,落回排队」。 */
|
||||
function onPendingAck(ev: FrameEvent, r: FramePayload, s: StoreState): boolean {
|
||||
if (!frameIsActive(frameSessionId(ev, r), s.activeSessionId)) return true;
|
||||
const depth = frameNumber(r?.queueDepth);
|
||||
s.setSessionBusy(true, depth ?? s.pendingCount);
|
||||
if (depth !== undefined && depth > 0) s.markLocalDelivery("queued");
|
||||
if (frameText(r?.reason) === "parallel_limit") {
|
||||
s.setQueueNotice(
|
||||
depth === undefined
|
||||
? "并行额度已满:这条已转入排队"
|
||||
: `并行额度已满:这条已转入排队(前面还有 ${depth} 条)`
|
||||
);
|
||||
} else if (depth !== undefined && depth > 0) {
|
||||
s.setQueueNotice(`已排队:前面还有 ${depth} 条`);
|
||||
} else {
|
||||
s.setQueueNotice(null);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** `pending_drain`:续跑弹出队首 ⇒ 深度与在途路数都刷新(随后会有该路的 `turn_begin`)。 */
|
||||
function onPendingDrain(ev: FrameEvent, r: FramePayload, s: StoreState): boolean {
|
||||
if (!frameIsActive(frameSessionId(ev, r), s.activeSessionId)) return true;
|
||||
const depth = frameNumber(r?.queueDepth);
|
||||
s.setSessionBusy(true, depth, frameNumber(r?.activeTurns));
|
||||
if (depth === 0) s.setQueueNotice(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** `pending_full`:队列满(⑥ 被拒)。`messageId` 只在服务端已认领该消息时给出,
|
||||
* 没有就落到本端刚发的那条气泡上 —— 不新铸 id、不假装送达。 */
|
||||
function onPendingFull(ev: FrameEvent, r: FramePayload, s: StoreState): boolean {
|
||||
const sid = frameSessionId(ev, r);
|
||||
if (!frameIsActive(sid, s.activeSessionId)) return true;
|
||||
const max = frameNumber(r?.max);
|
||||
const reason =
|
||||
max === undefined
|
||||
? "会话正忙,这条未送达"
|
||||
: `会话正忙:排队已满(上限 ${max} 条),这条未送达`;
|
||||
s.setSessionBusy(true, max ?? s.pendingCount);
|
||||
s.setQueueNotice(reason);
|
||||
const messageId = frameText(r?.messageId);
|
||||
if (messageId) s.markDelivery(messageId, { delivery: "rejected", rejectReason: reason }, sid);
|
||||
else s.markLocalDelivery("rejected", { rejectReason: reason });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** `chat_duplicate`:`clientMsgId` 判重命中 —— 没有新回合、没有新内容帧,只把本地
|
||||
* 乐观气泡对齐到既有 `messageId`(否则同一句话会渲染两遍)。 */
|
||||
function onDuplicate(ev: FrameEvent, r: FramePayload, s: StoreState): boolean {
|
||||
const clientMsgId = frameText(r?.clientMsgId);
|
||||
const messageId = frameText(r?.messageId);
|
||||
if (clientMsgId && messageId) {
|
||||
s.alignDuplicateMessage(clientMsgId, messageId, frameSessionId(ev, r));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 上行 `error` 里的 `chat_busy`(定向回发送者,同时可能广播 `pending_full`):
|
||||
* 把这条气泡标成 ⑥ 被拒。**其它 error 码一律放行**给 device.ts 记一行日志。 */
|
||||
function onUpstreamError(r: FramePayload, s: StoreState): boolean {
|
||||
if (frameText(r?.code) !== "chat_busy") return false;
|
||||
const reason = frameText(r?.message) ?? "会话正忙,这条未送达";
|
||||
s.setQueueNotice(reason);
|
||||
const messageId = frameText(r?.messageId);
|
||||
if (messageId) {
|
||||
s.markDelivery(messageId, { delivery: "rejected", rejectReason: reason }, frameText(r?.sessionId));
|
||||
} else {
|
||||
s.markLocalDelivery("rejected", { rejectReason: reason });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useModeStore } from "../../../store/useModeStore";
|
||||
import { MODES, type AwakenState, type Mode } from "../../types";
|
||||
import { parseAwakenTool } from "../../wsParse";
|
||||
import type { FrameEvent, FramePayload, StoreState } from "./shared";
|
||||
|
||||
/** 角色 / 呈现状态帧处理组:capacity_awaken / mode_switch / mood。命中返回 true,
|
||||
* 未命中返回 false 交给下一组(与拆分前的单个 switch 等价)。
|
||||
*
|
||||
* 为什么从 session.ts 拆出来(AGENTS §10 函数 ≤80 行):这三帧讲的是**角色与呈现**
|
||||
* (唤醒三态 design/14 §5、呈现模式 design/01 §3、角色情绪 design/22 §4.6),
|
||||
* 而 session.ts 讲的是**会话编排**(忙闲/在途路/已读游标)。两件事的改动理由不同。 */
|
||||
export function handlePersonaEvent(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 "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;
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,71 @@
|
||||
import { useModeStore } from "../../../store/useModeStore";
|
||||
import { sessionReadTarget } from "../../sessionRead";
|
||||
import { MODES, type AwakenState, type Mode } from "../../types";
|
||||
import { parseAwakenTool } from "../../wsParse";
|
||||
import { frameIsActive, frameSessionId, type FrameEvent, type FramePayload, type StoreState } from "./shared";
|
||||
import {
|
||||
frameIsActive,
|
||||
frameNumber,
|
||||
frameSessionId,
|
||||
frameText,
|
||||
type FrameEvent,
|
||||
type FramePayload,
|
||||
type StoreState,
|
||||
} from "./shared";
|
||||
|
||||
/** 会话/角色状态帧处理组:capacity_awaken / 会话编排忙闲(session_busy /
|
||||
* session_pending / session_idle / session_interrupted)/ session_read /
|
||||
* mode_switch / mood。命中返回 true,未命中返回 false 交给下一组。 */
|
||||
/** 会话编排忙闲帧处理组:session_busy / session_pending / session_idle /
|
||||
* session_interrupted / session_read。命中返回 true,未命中返回 false 交给下一组。
|
||||
*
|
||||
* 角色与呈现类帧(capacity_awaken / mode_switch / mood)在 `handlers/persona.ts`
|
||||
* —— 那三帧的改动理由与本组不同(AGENTS §10 函数 ≤80 行)。
|
||||
*
|
||||
* 忙时并行(design/15 §2.5,契约 v0.65 §3.4.1):这些帧现在还带 `activeTurns` 等计数,
|
||||
* 判定必须据实读,尤其 `session_interrupted` **不能无条件当空闲**(`scope:"current"`
|
||||
* 只取消了当时忙碌的那条通道,并行的其它路仍在跑)。 */
|
||||
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}。
|
||||
// design/15 §5 — 她忙/有挂起消息时的轻状态(不阻塞输入框)。
|
||||
// 忙态是**单槽全局**状态,只认屏幕上这个会话(见下方 session_idle 的说明)。
|
||||
// v0.65 §3.4.1 起帧里还带 `activeTurns`(在途路数):忙 = activeTurns > 0,
|
||||
// 比 `busy` 更能表达「并行 N 路」;老后端不发时回落 `busy`(缺省按忙处理)。
|
||||
if (!frameIsActive(frameSessionId(ev, r), s.activeSessionId)) return true;
|
||||
const busy = typeof r?.busy === "boolean" ? r.busy : true;
|
||||
const turns = frameNumber(r?.activeTurns);
|
||||
const busy = turns !== undefined ? turns > 0 : typeof r?.busy === "boolean" ? r.busy : true;
|
||||
const pendingCount =
|
||||
typeof r?.pendingCount === "number" ? r.pendingCount : s.pendingCount;
|
||||
s.setSessionBusy(busy, pendingCount);
|
||||
typeof r?.pendingCount === "number" ? r.pendingCount : undefined;
|
||||
s.setSessionBusy(busy, pendingCount, turns);
|
||||
return true;
|
||||
}
|
||||
case "session_idle":
|
||||
case "session_interrupted": {
|
||||
// design/15 §3/§4/§5 — 会话回到空闲:`session_idle`(队列清空)与
|
||||
// `session_interrupted`(用户 interrupt:当前任务取消、pending 丢弃)都自带
|
||||
// `{sessionId, mode}`、**没有 busy 字段**,语义就是「这个会话不再忙」。
|
||||
// 缺这两个 case 时忙态只能靠 `stream_end` 回落:回合卡住/被中断时
|
||||
// `useAppStore.sessionBusy` 一直为 true,裁决 16.4.1 的「预约切换」永不落地。
|
||||
case "session_idle": {
|
||||
// design/15 §3/§5 — 会话回到空闲:`session_idle` 只在**全部在途路收尾且 pending
|
||||
// 空**时才发(v0.65 时点收紧),所以它是可以放心置闲的权威信号。
|
||||
const sid = frameSessionId(ev, r);
|
||||
// 单槽忙态跨会话会串(design/16 §4.2 已登记的债):置忙与置闲必须用同一把尺子,
|
||||
// 否则后台会话的 bus 帧把徽标点亮、它自己的 idle 帧又被过滤掉 ⇒ 永久卡忙。
|
||||
if (frameIsActive(sid, s.activeSessionId)) s.setSessionBusy(false, 0);
|
||||
if (!frameIsActive(sid, s.activeSessionId)) return true;
|
||||
s.setSessionBusy(false, 0, 0);
|
||||
s.setQueueNotice(null);
|
||||
// 空闲 ⇒ 该会话不可能还有在途路:把(可能漏收 turn.end 的)绑定清掉,
|
||||
// 否则下一轮的「还有别的路在跑」判定会拿旧绑定当真。
|
||||
if (sid) s.dropSessionTurns(sid);
|
||||
return true;
|
||||
}
|
||||
case "session_interrupted": {
|
||||
// 打断结果(v0.65 §3.4.1):**不能无条件当空闲**。
|
||||
// scope:"all" ⇒ mode:"idle"、dropped=N、activeTurns:0 —— 这个会话不再忙;
|
||||
// scope:"current" ⇒ mode 是当时忙碌的通道(**非 idle**)、dropped:0、
|
||||
// activeTurns 可能 > 0(并行的其它路仍在跑)——置闲就会
|
||||
// 显示「她闲了」而实际还在答。
|
||||
const sid = frameSessionId(ev, r);
|
||||
if (!frameIsActive(sid, s.activeSessionId)) return true;
|
||||
const mode = frameText(r?.mode);
|
||||
const turns = frameNumber(r?.activeTurns);
|
||||
if (mode !== undefined && mode !== "idle") {
|
||||
s.setSessionBusy(turns === undefined ? true : turns > 0, undefined, turns);
|
||||
return true;
|
||||
}
|
||||
s.setSessionBusy(false, 0, 0);
|
||||
s.setQueueNotice(null);
|
||||
// 取消的路不会再发 `turn.end`,绑定必须清掉(否则永远认为「还有路在跑」)。
|
||||
if (sid) s.dropSessionTurns(sid);
|
||||
return true;
|
||||
}
|
||||
case "session_read": {
|
||||
@@ -54,27 +77,6 @@ export function handleSessionEvent(ev: FrameEvent, r: FramePayload, s: StoreStat
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@ import { useAppStore } from "../../../store/useAppStore";
|
||||
export interface FrameEvent {
|
||||
type: string;
|
||||
sessionId?: string;
|
||||
/** 帧壳的回合标识(契约 §3.4 帧壳 `{type,turnId?,seq?,sessionId?,data}`)。
|
||||
* 忙时并行下它是**分桶键**:同一会话两条回复靠它区分,缺了它两条会糊成一个气泡。 */
|
||||
turnId?: string;
|
||||
/** 帧壳的帧序号(同回合内的到达序;当前只透传,未参与判定)。 */
|
||||
seq?: number;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
@@ -34,3 +39,22 @@ export function frameIsActive(
|
||||
if (!sessionId || !activeSessionId) return true;
|
||||
return sessionId === activeSessionId;
|
||||
}
|
||||
|
||||
/** 一帧所属的回合 id。帧壳 `turnId` 是契约里的权威位置(结构化流帧、`turn_begin`、
|
||||
* `steer_ack` 都带);payload 里若也给了同名键(历史 DTO / graph 等)以 payload 为准
|
||||
* —— 它离内容更近。都没有时返回 undefined,调用方回落既有的「列表尾气泡」行为,
|
||||
* 因此老后端的帧逐字不变。 */
|
||||
export function frameTurnId(ev: FrameEvent, r: FramePayload): string | undefined {
|
||||
if (r && typeof r.turnId === "string" && r.turnId) return r.turnId;
|
||||
return ev.turnId || undefined;
|
||||
}
|
||||
|
||||
/** 取一个数字字段(帧字段是 `unknown`,各 handler 里重复收窄三次以上 ⇒ 抽出来)。 */
|
||||
export function frameNumber(v: unknown): number | undefined {
|
||||
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
||||
}
|
||||
|
||||
/** 取一个非空字符串字段(同上;空串按「没给」处理,避免用空值覆盖真实状态)。 */
|
||||
export function frameText(v: unknown): string | undefined {
|
||||
return typeof v === "string" && v ? v : undefined;
|
||||
}
|
||||
@@ -1,23 +1,38 @@
|
||||
import { frameIsActive, frameSessionId, type FrameEvent, type FramePayload, type StoreState } from "./shared";
|
||||
import { turnsInSession } from "../../../store/turnCache";
|
||||
import {
|
||||
frameIsActive,
|
||||
frameSessionId,
|
||||
frameTurnId,
|
||||
type FrameEvent,
|
||||
type FramePayload,
|
||||
type StoreState,
|
||||
} from "./shared";
|
||||
|
||||
/** 流式正文帧处理组:stream_chunk / stream_end / message.start / message.delta /
|
||||
* message.end。命中(含组内主动丢弃)返回 true,未命中返回 false 交给下一组。
|
||||
*
|
||||
* 裁决 16.4.5:帧按自己的 `sessionId` 落进对应会话的消息桶,**不再**因为不属于
|
||||
* 激活会话而被丢掉 —— 后台会话的正文继续累积,切过去就能看到完整的这一轮。 */
|
||||
* 激活会话而被丢掉 —— 后台会话的正文继续累积,切过去就能看到完整的这一轮。
|
||||
*
|
||||
* 忙时并行(design/15 §2.5):帧壳 `turnId` 决定它属于**哪一路**,增量按
|
||||
* `(sessionId, turnId)` 二级分桶写进那一路的助手气泡;两条回复交错到达时不会糊成
|
||||
* 一条(此前只按「列表尾气泡」写,正是把两条回复糊在一起的成因)。 */
|
||||
export function handleStreamEvent(ev: FrameEvent, r: FramePayload, s: StoreState): boolean {
|
||||
switch (ev.type) {
|
||||
case "stream_chunk": {
|
||||
if (r && typeof r.delta === "string") s.appendChunk(r.delta, frameSessionId(ev, r));
|
||||
if (r && typeof r.delta === "string") {
|
||||
s.appendChunk(r.delta, frameSessionId(ev, r), frameTurnId(ev, r));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case "stream_end": {
|
||||
const sid = frameSessionId(ev, r);
|
||||
s.endStream(sid);
|
||||
const turnId = frameTurnId(ev, r);
|
||||
s.endStream(sid, turnId);
|
||||
// 忙态 / 上下文是**单槽全局**状态(design/15 §5),所以只有屏幕上这个会话的
|
||||
// 回合结束才收口;别的会话结束只能收它自己气泡的流,不能清掉这里的徽标。
|
||||
if (frameIsActive(sid, s.activeSessionId)) {
|
||||
s.setSessionBusy(false, 0);
|
||||
if (frameIsActive(sid, s.activeSessionId) && !otherLegsRunning(s, sid, turnId)) {
|
||||
s.setSessionBusy(false, 0, turnId ? 0 : undefined);
|
||||
if (r && typeof r.stopReason === "string") {
|
||||
s.setContext({ lastAgentMode: s.context.lastAgentMode });
|
||||
}
|
||||
@@ -31,7 +46,9 @@ export function handleStreamEvent(ev: FrameEvent, r: FramePayload, s: StoreState
|
||||
return true;
|
||||
}
|
||||
case "message.delta": {
|
||||
if (r && typeof r.delta === "string") s.appendChunk(r.delta, frameSessionId(ev, r));
|
||||
if (r && typeof r.delta === "string") {
|
||||
s.appendChunk(r.delta, frameSessionId(ev, r), frameTurnId(ev, r));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case "message.end": {
|
||||
@@ -43,3 +60,12 @@ export function handleStreamEvent(ev: FrameEvent, r: FramePayload, s: StoreState
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 除了刚收尾的这一路,该会话还有别的路在跑吗?有就**不能**把忙态置闲 —— 并行时
|
||||
* 先答完的那一路会把「她正在忙」徽标误关掉(v0.65 §4.6 的 `session_idle` 时点收紧
|
||||
* 就是为这个)。没有 `turnId` 的旧帧(单路老后端)逐字沿用旧行为:照样清忙。 */
|
||||
function otherLegsRunning(s: StoreState, sessionId: string | undefined, turnId: string | undefined): boolean {
|
||||
if (!turnId) return false;
|
||||
const sid = sessionId ?? s.activeSessionId;
|
||||
return turnsInSession(s.turnBindings, sid).some((b) => b.turnId !== turnId);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { frameSessionId, type FrameEvent, type FramePayload, type StoreState } from "./shared";
|
||||
import { frameSessionId, frameTurnId, type FrameEvent, type FramePayload, type StoreState } from "./shared";
|
||||
|
||||
/** Accumulator for a streamed thinking segment (thinking.* events), **per session**:
|
||||
* a background turn's reasoning must not be spliced into the segment being built
|
||||
@@ -6,9 +6,12 @@ import { frameSessionId, type FrameEvent, type FramePayload, type StoreState } f
|
||||
* mid-turn reconnect can rebuild the collapsed block without relying on prior state. */
|
||||
const pendingThinking: Record<string, string> = {};
|
||||
|
||||
/** 累积器与写入用的会话键:帧带的 sessionId 优先,缺席时归当前激活会话。 */
|
||||
/** 累积器与写入用的键:`sessionId|turnId` 两级 —— 忙时并行下同一会话两条回复的思维链
|
||||
* 会交错到达,只按会话累积会把两路的思考文本拼成一段(`turnId` 缺席时回落会话键,
|
||||
* 与旧行为一致)。 */
|
||||
function thinkKey(ev: FrameEvent, r: FramePayload, s: StoreState): string {
|
||||
return frameSessionId(ev, r) ?? s.activeSessionId;
|
||||
const sid = frameSessionId(ev, r) ?? s.activeSessionId;
|
||||
return `${sid}|${frameTurnId(ev, r) ?? ""}`;
|
||||
}
|
||||
|
||||
/** 思考流帧处理组:thinking.start / thinking.delta / thinking.end + 遗留单帧 thinking。
|
||||
@@ -24,22 +27,22 @@ export function handleThinkingEvent(ev: FrameEvent, r: FramePayload, s: StoreSta
|
||||
const key = thinkKey(ev, r, s);
|
||||
const text = (pendingThinking[key] ?? "") + r.delta;
|
||||
pendingThinking[key] = text;
|
||||
s.setThinking(text, frameSessionId(ev, r));
|
||||
s.setThinking(text, frameSessionId(ev, r), frameTurnId(ev, r));
|
||||
return true;
|
||||
}
|
||||
case "thinking.end": {
|
||||
const key = thinkKey(ev, r, s);
|
||||
const text = pendingThinking[key] ?? "";
|
||||
if (text) s.setThinking(text, frameSessionId(ev, r));
|
||||
if (text) s.setThinking(text, frameSessionId(ev, r), frameTurnId(ev, r));
|
||||
delete pendingThinking[key];
|
||||
return true;
|
||||
}
|
||||
case "thinking": {
|
||||
// Legacy single-shot CoT frame — renders into the same bubble.
|
||||
if (typeof ev.data === "string") {
|
||||
s.setThinking(ev.data, frameSessionId(ev, r));
|
||||
s.setThinking(ev.data, frameSessionId(ev, r), frameTurnId(ev, r));
|
||||
} else if (r && typeof r.text === "string") {
|
||||
s.setThinking(r.text, frameSessionId(ev, r));
|
||||
s.setThinking(r.text, frameSessionId(ev, r), frameTurnId(ev, r));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { frameSessionId, type FrameEvent, type FramePayload, type StoreState } from "./shared";
|
||||
import { parseTool, parseToolCall } from "../../wsParse";
|
||||
import { frameSessionId, frameTurnId, type FrameEvent, type FramePayload, type StoreState } from "./shared";
|
||||
import { parseTool, parseToolCall, parseToolExecution } from "../../wsParse";
|
||||
|
||||
/** 工具帧处理组:tool_call.start / tool_call.end / tool_call.delta /
|
||||
* tool_execution.update + 遗留 tool_used。命中(含组内主动丢弃)返回 true,
|
||||
* 未命中返回 false 交给下一组。
|
||||
* tool_execution.start / tool_execution.end / tool_execution.update + 遗留 tool_used。
|
||||
* 命中(含组内主动丢弃)返回 true,未命中返回 false 交给下一组。
|
||||
*
|
||||
* 裁决 16.4.5:工具卡按帧的 `sessionId` 落在该会话的助手气泡上(其它会话的调用
|
||||
* 卡不会跑到屏幕上的这一轮里)。 */
|
||||
* 卡不会跑到屏幕上的这一轮里)。忙时并行(design/15 §2.5):帧壳 `turnId` 决定它
|
||||
* 落在**哪一路**的气泡上 —— 两条回复的工具卡不会串到对方那里。 */
|
||||
export function handleToolEvent(ev: FrameEvent, r: FramePayload, s: StoreState): boolean {
|
||||
switch (ev.type) {
|
||||
case "tool_call.start":
|
||||
case "tool_call.end": {
|
||||
const tool = parseToolCall(ev.data);
|
||||
if (tool) s.addTool(tool, frameSessionId(ev, r));
|
||||
if (tool) s.addTool(tool, frameSessionId(ev, r), frameTurnId(ev, r));
|
||||
return true;
|
||||
}
|
||||
case "tool_call.delta": {
|
||||
@@ -20,16 +21,20 @@ export function handleToolEvent(ev: FrameEvent, r: FramePayload, s: StoreState):
|
||||
// boundary so mid-stream reconnects do not drop it.
|
||||
return true;
|
||||
}
|
||||
case "tool_execution.start":
|
||||
case "tool_execution.end":
|
||||
case "tool_execution.update": {
|
||||
// Same card as tool_call.* — same routing.
|
||||
const tool = parseToolCall(ev.data);
|
||||
if (tool) s.addTool(tool, frameSessionId(ev, r));
|
||||
// 执行阶段帧(契约 §3.4.2):`{tool, phase, id?, delta?}` —— 键名与 tool_call.*
|
||||
// 不同,必须用 parseToolExecution 读(用 parseToolCall 会一律 null ⇒ 卡片永不出现)。
|
||||
// `update` 目前后端暂无生产者(契约已注明),保留分支:一旦开报即同路。
|
||||
const tool = parseToolExecution(ev.data);
|
||||
if (tool) s.addTool(tool, frameSessionId(ev, r), frameTurnId(ev, r));
|
||||
return true;
|
||||
}
|
||||
case "tool_used": {
|
||||
// Legacy tool echo — same card as tool_call.*.
|
||||
const tool = parseTool(ev.data);
|
||||
if (tool) s.addTool(tool, frameSessionId(ev, r));
|
||||
if (tool) s.addTool(tool, frameSessionId(ev, r), frameTurnId(ev, r));
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MODES, type AdminChannelMessage, type AgentInfo, type BackthinkItem, type ChatMessage, type FlowGraph, type Mode, type ToolUsed } from "./types";
|
||||
import { MODES, type AdminChannelMessage, type AgentInfo, type BackthinkItem, type ChatMessage, type FlowGraph, type MessageDocument, type Mode, type ToolUsed } from "./types";
|
||||
import { now, uid } from "./util";
|
||||
|
||||
/**
|
||||
@@ -83,6 +83,24 @@ export function parseToolCall(v: unknown): ToolUsed | null {
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a tool card from a `tool_execution.*` payload (契约 §3.4.2):
|
||||
* `{tool, phase, id?, delta?}`,`phase` ∈ `running/done/error`(**phase 绝不塞进
|
||||
* delta**)。注意这里的键名与 `tool_call.*` 不同:工具名在 `tool` 上(不是 `name`),
|
||||
* 成败由 `phase` 决定 —— 用 `parseToolCall` 读它只会得到 null,卡片就永远不会出现。 */
|
||||
export function parseToolExecution(v: unknown): ToolUsed | null {
|
||||
const r = toRecord(v);
|
||||
if (!r) return null;
|
||||
const name = typeof r.tool === "string" ? r.tool : "";
|
||||
if (!name) return null;
|
||||
const phase = typeof r.phase === "string" ? r.phase : "";
|
||||
return {
|
||||
name,
|
||||
summary: typeof r.detail === "string" ? r.detail : undefined,
|
||||
ok: phase === "done" ? true : phase === "error" ? false : undefined,
|
||||
at: now(),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseAgent(payload: unknown): AgentInfo | null {
|
||||
const r = toRecord(payload);
|
||||
if (!r) return null;
|
||||
@@ -159,6 +177,28 @@ export function parseGraph(payload: unknown): FlowGraph | null {
|
||||
};
|
||||
}
|
||||
|
||||
/** 解析消息 DTO 的 `documents` 数组(契约 §4.4)。**不含正文**,只在有附件时出现
|
||||
* (0 个附件时键缺席)。`status` 允许后端新增取值(display-raw:未知值原样带下去,
|
||||
* 渲染层按未知处理,绝不丢掉附件、也绝不把未知当错误)。`reason` 只在
|
||||
* `status:"unreadable"` 时下发,是服务端产出的逐字中文原因,客户端原样展示。 */
|
||||
export function parseDocuments(v: unknown): MessageDocument[] | undefined {
|
||||
if (!Array.isArray(v)) return undefined;
|
||||
const docs = v
|
||||
.map((d) => toRecord(d))
|
||||
.filter((d): d is Record<string, unknown> => d !== null)
|
||||
.map((d) => ({
|
||||
name: typeof d.name === "string" ? d.name : "",
|
||||
mime: typeof d.mime === "string" && d.mime ? d.mime : undefined,
|
||||
size: typeof d.size === "number" ? d.size : undefined,
|
||||
chars: typeof d.chars === "number" ? d.chars : undefined,
|
||||
truncated: typeof d.truncated === "boolean" ? d.truncated : undefined,
|
||||
status: typeof d.status === "string" && d.status ? d.status : undefined,
|
||||
reason: typeof d.reason === "string" && d.reason ? d.reason : undefined,
|
||||
}))
|
||||
.filter((d) => d.name !== "");
|
||||
return docs.length ? docs : undefined;
|
||||
}
|
||||
|
||||
/** Build a ChatMessage from a `message_persisted` payload (design/16 §3.3/§4.2):
|
||||
* the backend broadcast a fully-persisted message carrying the backend-generated
|
||||
* id. mode is validated against the known modes so a bad value never reaches the
|
||||
@@ -177,6 +217,11 @@ export function parsePersistedMessage(v: unknown): ChatMessage | null {
|
||||
mode: typeof r.mode === "string" && MODES.includes(r.mode as Mode) ? (r.mode as Mode) : undefined,
|
||||
mood: typeof r.mood === "string" ? r.mood : undefined,
|
||||
image: typeof r.image === "string" ? r.image : undefined,
|
||||
documents: parseDocuments(r.documents),
|
||||
// `chat_messages.turn_id`(契约 §4.4):忙时并行下靠它把回复与提问配对、把两条
|
||||
// 交错到达的回复分进两个气泡;旧行该键省略 ⇒ 回落按顺序渲染。
|
||||
turnId: typeof r.turnId === "string" && r.turnId ? r.turnId : undefined,
|
||||
clientMsgId: typeof r.clientMsgId === "string" && r.clientMsgId ? r.clientMsgId : undefined,
|
||||
platform: typeof r.platform === "string" && r.platform ? r.platform : undefined,
|
||||
deviceId: typeof r.deviceId === "string" && r.deviceId ? r.deviceId : undefined,
|
||||
senderName: typeof r.senderName === "string" && r.senderName ? r.senderName : undefined,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { AppStore } from "./useAppStore";
|
||||
|
||||
/**
|
||||
* zustand `persist` 中间件的配置(从 `useAppStore.ts` 拆出,AGENTS §10 文件 ≤500 行)。
|
||||
*
|
||||
* 只持久化「小且可重建」的东西:会话元数据 + 聚焦的会话 id + 角色目录 + 情绪 + 上下文 +
|
||||
* 后台思考条目。消息**不持久化**(后端是消息真源,design/16 §1)。v1 的形状(整份聊天 +
|
||||
* 单个会话 id)与 v2(一个全局 `currentPersona`)都无法描述「角色属于会话」的现状,
|
||||
* 统一丢弃并由接口重新取回。
|
||||
*/
|
||||
export const persistOptions = {
|
||||
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: unknown, version: number) => {
|
||||
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: AppStore) => ({
|
||||
// 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,
|
||||
}),
|
||||
};
|
||||
@@ -30,7 +30,13 @@ export type SessionMessages = Record<string, ChatMessage[]>;
|
||||
|
||||
/** 把一条后端消息并入消息列表:按 id upsert;没有同 id 时采纳本端同 role 的本地
|
||||
* 乐观气泡(本地 `uid` → 后端 `id`),使同一句话不会渲染两遍(design/16 §4.3 去重)。
|
||||
* 后端消息是内容的真源,本地气泡保留流式/工具/思考等本端状态。 */
|
||||
* 后端消息是内容的真源,本地气泡保留流式/工具/思考等本端状态。
|
||||
*
|
||||
* 采纳顺序(忙时并行后很重要,规范草案 §1.7):
|
||||
* ① 同 `turnId` 且尚未落库的气泡 —— `turn_begin` 已把本端气泡绑到这一路,turnId
|
||||
* 才是权威配对(并行时两条回复内容可能相似,靠内容判等会**配错路**);
|
||||
* ② 内容完全相同的那条(旧启发式,保持老后端行为);
|
||||
* ③ createdAt 最近的那条。 */
|
||||
export function mergeMessage(list: ChatMessage[], msg: ChatMessage): ChatMessage[] {
|
||||
const idx = list.findIndex((m) => m.id === msg.id);
|
||||
if (idx !== -1) {
|
||||
@@ -38,6 +44,14 @@ export function mergeMessage(list: ChatMessage[], msg: ChatMessage): ChatMessage
|
||||
next[idx] = { ...next[idx], ...msg };
|
||||
return next;
|
||||
}
|
||||
if (msg.turnId) {
|
||||
for (let i = list.length - 1; i >= 0; i -= 1) {
|
||||
const m = list[i];
|
||||
if (!m.sessionId && m.role === msg.role && m.turnId === msg.turnId) {
|
||||
return adopt(list, i, msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 没有同 id:找一个本端尚未落库的同 role 气泡(无 sessionId)来归一化。
|
||||
// 优先内容完全相同的那条,否则取 createdAt 最近的;从新到旧扫描。
|
||||
let best = -1;
|
||||
@@ -52,9 +66,14 @@ export function mergeMessage(list: ChatMessage[], msg: ChatMessage): ChatMessage
|
||||
}
|
||||
}
|
||||
if (best === -1) return [...list, msg];
|
||||
const m = list[best];
|
||||
return adopt(list, best, msg);
|
||||
}
|
||||
|
||||
/** 把本端乐观气泡归一为后端消息(id 换成服务端的,其余保留本端状态)。 */
|
||||
function adopt(list: ChatMessage[], at: number, msg: ChatMessage): ChatMessage[] {
|
||||
const m = list[at];
|
||||
const next = list.slice();
|
||||
next[best] = {
|
||||
next[at] = {
|
||||
...m,
|
||||
id: msg.id,
|
||||
sessionId: msg.sessionId ?? m.sessionId,
|
||||
@@ -62,6 +81,9 @@ export function mergeMessage(list: ChatMessage[], msg: ChatMessage): ChatMessage
|
||||
mode: msg.mode ?? m.mode,
|
||||
mood: msg.mood ?? m.mood,
|
||||
image: msg.image ?? m.image,
|
||||
documents: msg.documents ?? m.documents,
|
||||
turnId: msg.turnId ?? m.turnId,
|
||||
clientMsgId: msg.clientMsgId ?? m.clientMsgId,
|
||||
platform: msg.platform ?? m.platform,
|
||||
deviceId: msg.deviceId ?? m.deviceId,
|
||||
personaId: msg.personaId ?? m.personaId,
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import type { ChatMessage, MessageDelivery, TurnBinding } from "../lib/types";
|
||||
import { now, uid } from "../lib/util";
|
||||
|
||||
/**
|
||||
* 回合级(按 `(sessionId, turnId)` 二级分桶)的纯函数(忙时并行规范草案
|
||||
* design/15 §2.5)。与 `sessionCache.ts` 的分工:
|
||||
*
|
||||
* sessionCache 管「哪一份会话列表」(按 `sessionId` 一级分桶);
|
||||
* 本模块管「列表里哪一条气泡」(按 `turnId` 二级分桶 + 六态投递态)。
|
||||
*
|
||||
* 为什么必须分开:忙时并行下同一会话的两条回复会**交错**到达(同 sessionId、不同
|
||||
* turnId),任何「改列表尾那条助手气泡」的写法都会把两条回复糊成一条。所有按路写入
|
||||
* 都从这里走,规则只有一处,才不会在几条帧路径上走样。
|
||||
*
|
||||
* 全部是纯函数(只读 list,返回新 list 或 null 表示「帧与我们持有的气泡对不上,不改」),
|
||||
* 因此可以在 node 下逐条跑逻辑测试,不需要 jsdom(项目无浏览器测试环境)。
|
||||
*/
|
||||
|
||||
/** 找这条路(`turnId`)的助手气泡并就地变换。顺序:
|
||||
* ① 已绑定同 turnId 的气泡 —— 直接命中(唯一正确的分桶);
|
||||
* ② 还没有绑定:采纳**列表尾**那条本端刚开、既无 turnId 又是流式的手气泡
|
||||
* (`turn_begin` 可能比首个 `message.delta` 晚到一帧);
|
||||
* ③ 都不匹配返回 `null`:已绑到别的 turnId 的气泡**绝不复用** —— 那正是「两条回复
|
||||
* 糊成一条」的成因。丢一帧增量,好过把另一路的正文写进这一路。 */
|
||||
export function patchAssistantForTurn(
|
||||
list: ChatMessage[],
|
||||
turnId: string,
|
||||
fn: (m: ChatMessage) => ChatMessage
|
||||
): ChatMessage[] | null {
|
||||
for (let i = list.length - 1; i >= 0; i -= 1) {
|
||||
const m = list[i];
|
||||
if (m.role === "assistant" && m.turnId === turnId) {
|
||||
const next = list.slice();
|
||||
next[i] = fn(m);
|
||||
return next;
|
||||
}
|
||||
}
|
||||
const tail = list[list.length - 1];
|
||||
if (tail && tail.role === "assistant" && !tail.turnId && tail.streaming) {
|
||||
const next = list.slice();
|
||||
next[next.length - 1] = fn({ ...tail, turnId });
|
||||
return next;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 把一条 `turn_begin` 绑定落到该会话的消息列表上(规范草案 §2.4):
|
||||
* ① 归属本路的用户消息 → 灰态 `answering` + 绑定 turnId;
|
||||
* ② 本路需要一个**生长中的助手气泡**:优先采纳该用户消息之后最近的「未绑定且流式」
|
||||
* 助手气泡(本端 `beginAssistant` 开的那条),否则在列表尾新开一条 —— 远端发起的
|
||||
* 并行路在本端没有开过气泡,不新开就没有落点。
|
||||
* 返回 `null` 表示这条绑定在这个列表里还没有落点(用户消息尚未落库/尚未并入),
|
||||
* 调用方保留绑定,等 `message_persisted` 到达时再落(规范草案 §1.7)。 */
|
||||
export function applyTurnBinding(list: ChatMessage[], b: TurnBinding): ChatMessage[] | null {
|
||||
let next = list;
|
||||
let hit = false;
|
||||
for (const id of b.messageIds) {
|
||||
const i = next.findIndex((m) => m.id === id);
|
||||
if (i === -1) continue;
|
||||
const m = next[i];
|
||||
if (m.role !== "user") continue;
|
||||
hit = true;
|
||||
if (m.turnId !== b.turnId || m.delivery !== "answering") {
|
||||
if (next === list) next = list.slice();
|
||||
next[i] = {
|
||||
...m,
|
||||
turnId: b.turnId,
|
||||
delivery: "answering",
|
||||
rejectReason: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (!hit) return null;
|
||||
if (next.some((m) => m.role === "assistant" && m.turnId === b.turnId)) return next;
|
||||
const anchor = next.findIndex((m) => b.messageIds.includes(m.id));
|
||||
for (let i = anchor + 1; i < next.length; i += 1) {
|
||||
const m = next[i];
|
||||
if (m.role === "assistant" && !m.turnId && m.streaming) {
|
||||
if (next === list) next = list.slice();
|
||||
next[i] = { ...m, turnId: b.turnId };
|
||||
return next;
|
||||
}
|
||||
}
|
||||
return [
|
||||
...next,
|
||||
{
|
||||
id: uid(),
|
||||
role: "assistant",
|
||||
content: "",
|
||||
streaming: true,
|
||||
turnId: b.turnId,
|
||||
sessionId: b.sessionId || undefined,
|
||||
createdAt: now(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 灰态收口(规范草案 §1.2 的**唯一**口径):把该 `turnId` 的用户消息从
|
||||
* 「已受理·回答中」改为「已答」。由该路的 `turn.end` 或该路的助手
|
||||
* `message_persisted`(兜底)触发;两个事件顺序不定,任一到即收口。
|
||||
* 返回 `null` 表示没有需要收口的气泡(不改状态,避免无谓重渲染)。 */
|
||||
export function clearGreyForTurn(list: ChatMessage[], turnId: string): ChatMessage[] | null {
|
||||
let next: ChatMessage[] | null = null;
|
||||
for (let i = 0; i < list.length; i += 1) {
|
||||
const m = list[i];
|
||||
if (m.role !== "user" || m.turnId !== turnId || m.delivery !== "answering") continue;
|
||||
if (next === null) next = list.slice();
|
||||
next[i] = { ...m, delivery: "answered" };
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 整路收口(`turn.end` / 该路的 `stream_end`):灰态收口 + 该路助手气泡停止流式。
|
||||
* 只动本 `turnId` 的气泡,另一路继续生长(这正是多路下必须按路收口的原因)。 */
|
||||
export function closeTurn(list: ChatMessage[], turnId: string): ChatMessage[] | null {
|
||||
const grey = clearGreyForTurn(list, turnId);
|
||||
let next = grey ?? list;
|
||||
let changed = grey !== null;
|
||||
for (let i = 0; i < next.length; i += 1) {
|
||||
const m = next[i];
|
||||
if (m.role !== "assistant" || m.turnId !== turnId || m.streaming !== true) continue;
|
||||
if (next === list) next = list.slice();
|
||||
next[i] = { ...m, streaming: false };
|
||||
changed = true;
|
||||
}
|
||||
return changed ? next : null;
|
||||
}
|
||||
|
||||
/** 把一条消息改成某个投递态(本地待落库/排队/被拒的乐观气泡用)。 */
|
||||
export function setDelivery(
|
||||
list: ChatMessage[],
|
||||
index: number,
|
||||
delivery: MessageDelivery,
|
||||
patch: Partial<ChatMessage> = {}
|
||||
): ChatMessage[] | null {
|
||||
if (index < 0 || index >= list.length) return null;
|
||||
const next = list.slice();
|
||||
next[index] = { ...next[index], delivery, ...patch };
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 找到「本端刚发、还没被任何一路接手」的那条用户消息的下标:`pending_ack` /
|
||||
* `pending_full` 只带会话与计数、不带消息 id,本端能确定的只有这个 —— 队列深度是
|
||||
* 权威的,气泡标注是尽力而为。已绑定 turnId 的气泡绝不会被改(它已被接手)。 */
|
||||
export function localPendingIndex(list: ChatMessage[]): number {
|
||||
for (let i = list.length - 1; i >= 0; i -= 1) {
|
||||
const m = list[i];
|
||||
if (m.role !== "user" || m.turnId) continue;
|
||||
if (m.delivery === "queued" || m.delivery === "sending" || m.delivery === undefined || m.delivery === "sent") {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** 按幂等键找一个气泡(`chat_duplicate` 用来把本地乐观气泡对齐到既有 id)。 */
|
||||
export function indexByClientMsgId(list: ChatMessage[], clientMsgId: string): number {
|
||||
if (!clientMsgId) return -1;
|
||||
return list.findIndex((m) => m.clientMsgId === clientMsgId);
|
||||
}
|
||||
|
||||
/** `chat_duplicate` 命中:没有新回合、没有新内容帧,只把本地气泡的 id 换成服务端
|
||||
* 既有 id(design/16 §4.3 去重),并把投递态落回「已送达」。 */
|
||||
export function alignDuplicate(
|
||||
list: ChatMessage[],
|
||||
clientMsgId: string,
|
||||
messageId: string,
|
||||
sessionId?: string
|
||||
): ChatMessage[] | null {
|
||||
const i = indexByClientMsgId(list, clientMsgId);
|
||||
if (i === -1) return null;
|
||||
const next = list.slice();
|
||||
next[i] = {
|
||||
...next[i],
|
||||
id: messageId,
|
||||
sessionId: sessionId ?? next[i].sessionId,
|
||||
delivery: "sent",
|
||||
};
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 某会话在途路数(绑定表里属于该会话的条数)。`session_busy`/`session_idle` 帧自带的
|
||||
* `activeTurns` 是权威;这里用于帧没带计数时的多路判定(如 `stream_end` 收尾时
|
||||
* 「是不是还有别的路在跑」)。 */
|
||||
export function turnsInSession(
|
||||
bindings: Record<string, TurnBinding>,
|
||||
sessionId: string
|
||||
): TurnBinding[] {
|
||||
return Object.values(bindings).filter((b) => b.sessionId === sessionId);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { AppStore } from "./useAppStore";
|
||||
import type { ChatMessage, MessageDelivery, TurnBinding } from "../lib/types";
|
||||
import { writeSession, writeSessionById, patchMessage } from "./sessionCache";
|
||||
import {
|
||||
alignDuplicate,
|
||||
applyTurnBinding,
|
||||
clearGreyForTurn,
|
||||
closeTurn,
|
||||
localPendingIndex,
|
||||
setDelivery,
|
||||
turnsInSession,
|
||||
} from "./turnCache";
|
||||
|
||||
/**
|
||||
* 忙时并行的**回合切片**(design/15 §2.5 / 规范草案 §1.1 §2):忙态与在途路数、
|
||||
* `turn_begin` 绑定表、六态投递态的写入动作。
|
||||
*
|
||||
* 从 `useAppStore.ts` 拆出来(AGENTS §10 文件 ≤500 行):这些都是「按路」的状态,
|
||||
* 与消息桶(`sessionCache`)、与其余界面状态不是一回事,放一处才好对照规范逐条核对。
|
||||
* 由于每个动作都要「写进对的那个会话桶」,这里统一走 `sessionCache` 的写入路径,
|
||||
* 规则不复制第二份。
|
||||
*/
|
||||
|
||||
/** 本切片贡献给 AppStore 的状态与动作。 */
|
||||
export interface TurnSlice extends TurnState, TurnActions, DeliveryActions {}
|
||||
|
||||
/** 忙时并行的**回合状态**(design/15 §2.5)。 */
|
||||
export interface TurnState {
|
||||
/** 会话在途路数(`session_busy`/`session_idle`/`pending_drain`/`session_interrupted`
|
||||
* 帧的 `activeTurns`,权威)。忙 = `activeTurns > 0`;收到前恒 0(旧后端行为不变)。 */
|
||||
activeTurns: number;
|
||||
/** 在途回合的绑定表,键 = 帧壳 `turnId`(`turn_begin` 建立、`turn.end` 丢弃)。
|
||||
* 不持久化:它的唯一来源是这次连接收到的帧,刷新后由 REST 历史里的 `turnId` 接管。 */
|
||||
turnBindings: Record<string, TurnBinding>;
|
||||
/** 队列/并行额度的一句话提示(`pending_ack.reason` / `pending_full` 时后端给的
|
||||
* 计数与原因)。null = 无提示;文案由前端按计数拼,但**不编造后端没给的事实**。 */
|
||||
queueNotice: string | null;
|
||||
}
|
||||
|
||||
/** 在途回合的生命周期动作(起路 / 收口 / 绑定表维护)。 */
|
||||
export interface TurnActions {
|
||||
/** 会话忙闲 + 挂起数 + 在途路数。三个参数都可选:缺省沿用旧值,因此老后端的
|
||||
* `session_busy{busy}` / `session_idle` 逐字不变。 */
|
||||
setSessionBusy: (busy: boolean, pendingCount?: number, activeTurns?: number) => void;
|
||||
/** 记录一条 `turn_begin` 绑定并立刻尝试落到消息列表(消息未落库则留在绑定表里,
|
||||
* 等 `message_persisted` 到达再落)。 */
|
||||
beginTurn: (binding: TurnBinding) => void;
|
||||
/** 该路收口(`turn.end`):灰态转「已答」、该路助手气泡停止流式、丢弃绑定;
|
||||
* 若该会话已无在途路且队列空,顺手把忙态置闲(`session_idle` 是权威,这里是
|
||||
* 兜底,条件与它逐字一致,不会提前置闲)。 */
|
||||
endTurn: (turnId: string, sessionId?: string) => void;
|
||||
/** 只收灰态、不动流式气泡(助手 `message_persisted` 的兜底收口,规范草案 §1.2)。 */
|
||||
clearGrey: (turnId: string, sessionId?: string) => void;
|
||||
/** 把该会话的**全部**绑定重新落到消息列表:`turn_begin` 早于用户行落库到达时,
|
||||
* 绑定只记在表里;用户行一到就用它把气泡补进灰态。无绑定/无落点时不改状态。 */
|
||||
rebindSessionTurns: (sessionId: string) => void;
|
||||
/** 该会话的全部在途路作废(`session_interrupted` scope:"all"):取消的路不会再发
|
||||
* `turn.end`,绑定必须清掉,否则忙态判定会永远认为「还有路在跑」。 */
|
||||
dropSessionTurns: (sessionId: string) => void;
|
||||
setQueueNotice: (text: string | null) => void;
|
||||
}
|
||||
|
||||
/** 六态投递态的写入动作(规范草案 §1.1)。 */
|
||||
export interface DeliveryActions {
|
||||
/** 按 id 打投递态补丁(`pending_full.messageId` / `chat_busy.messageId` 等)。 */
|
||||
markDelivery: (id: string, patch: Partial<ChatMessage>, sessionId?: string) => void;
|
||||
/** 把「本端刚发、尚未被任何一路接手」的那条气泡改为某个投递态(队列深度是权威的,
|
||||
* 气泡标注是尽力而为:`pending_ack`/`pending_full` 不带消息 id)。 */
|
||||
markLocalDelivery: (delivery: MessageDelivery, patch?: Partial<ChatMessage>) => void;
|
||||
/** `chat_duplicate` 命中:把本地乐观气泡对齐到服务端既有 id。 */
|
||||
alignDuplicateMessage: (clientMsgId: string, messageId: string, sessionId?: string) => void;
|
||||
}
|
||||
|
||||
/** store 的 set/get 结构类型(只取本切片用到的形态,避免与 zustand 的完整
|
||||
* 重载签名纠缠)。`AppStore` 只作类型引入,编译后被擦除。 */
|
||||
type SetState = (
|
||||
partial: Partial<AppStore> | ((s: AppStore) => Partial<AppStore> | AppStore)
|
||||
) => void;
|
||||
type GetState = () => AppStore;
|
||||
|
||||
/** 切片的初始状态(与动作分开,便于 useAppStore 各自 spread 且每个函数守住 ≤80 行)。 */
|
||||
export function turnState(): TurnState {
|
||||
return { activeTurns: 0, turnBindings: {}, queueNotice: null };
|
||||
}
|
||||
|
||||
/** 在途回合的生命周期动作。 */
|
||||
export function turnActions(set: SetState, get: GetState): TurnActions {
|
||||
return {
|
||||
setSessionBusy: (busy, pendingCount, activeTurns) =>
|
||||
set((s) => ({
|
||||
sessionBusy: busy,
|
||||
pendingCount: pendingCount ?? s.pendingCount,
|
||||
activeTurns: activeTurns ?? s.activeTurns,
|
||||
})),
|
||||
|
||||
beginTurn: (b) =>
|
||||
set((s) => {
|
||||
const sessionId = b.sessionId || s.activeSessionId;
|
||||
const turnBindings = { ...s.turnBindings, [b.turnId]: { ...b, sessionId } };
|
||||
const patch = writeSession(s, sessionId, (list) => applyTurnBinding(list, b));
|
||||
// `writeSession` 在「这一帧与持有的消息对不上」时原样返回 state(表示不改),
|
||||
// 那种情况下只更新绑定表,别把整份 state 当成补丁重放。
|
||||
return {
|
||||
...(patch === s ? {} : patch),
|
||||
turnBindings,
|
||||
activeTurns: b.activeTurns || s.activeTurns,
|
||||
};
|
||||
}),
|
||||
|
||||
endTurn: (turnId, sessionId) =>
|
||||
set((s) => {
|
||||
const binding = s.turnBindings[turnId];
|
||||
const sid = binding?.sessionId ?? sessionId ?? s.activeSessionId;
|
||||
const turnBindings = { ...s.turnBindings };
|
||||
delete turnBindings[turnId];
|
||||
const patch = writeSession(s, sid, (list) => closeTurn(list, turnId));
|
||||
const remaining = turnsInSession(turnBindings, sid).length;
|
||||
const quiescent = remaining === 0 && s.pendingCount === 0;
|
||||
return {
|
||||
...(patch === s ? {} : patch),
|
||||
turnBindings,
|
||||
...(quiescent ? { sessionBusy: false, activeTurns: 0 } : {}),
|
||||
};
|
||||
}),
|
||||
|
||||
clearGrey: (turnId, sessionId) =>
|
||||
set((s) => {
|
||||
const sid = s.turnBindings[turnId]?.sessionId ?? sessionId ?? s.activeSessionId;
|
||||
const patch = writeSession(s, sid, (list) => clearGreyForTurn(list, turnId));
|
||||
return patch === s ? {} : patch;
|
||||
}),
|
||||
|
||||
rebindSessionTurns: (sessionId) =>
|
||||
set((s) => {
|
||||
const bindings = turnsInSession(s.turnBindings, sessionId);
|
||||
if (bindings.length === 0) return s;
|
||||
return writeSession(s, sessionId, (list) => {
|
||||
let next = list;
|
||||
let changed = false;
|
||||
for (const b of bindings) {
|
||||
const out = applyTurnBinding(next, b);
|
||||
if (out) {
|
||||
next = out;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : null;
|
||||
});
|
||||
}),
|
||||
|
||||
dropSessionTurns: (sessionId) =>
|
||||
set((s) => {
|
||||
const turnBindings: Record<string, TurnBinding> = {};
|
||||
for (const [k, v] of Object.entries(s.turnBindings)) {
|
||||
if (v.sessionId !== sessionId) turnBindings[k] = v;
|
||||
}
|
||||
return { turnBindings };
|
||||
}),
|
||||
|
||||
setQueueNotice: (text) => set({ queueNotice: text }),
|
||||
};
|
||||
}
|
||||
|
||||
/** 六态投递态的写入动作。 */
|
||||
export function deliveryActions(set: SetState, get: GetState): DeliveryActions {
|
||||
return {
|
||||
markDelivery: (id, patch, sessionId) =>
|
||||
set((s) =>
|
||||
writeSessionById(s, sessionId, (list) => patchMessage(list, id, patch))
|
||||
),
|
||||
|
||||
markLocalDelivery: (delivery, patch = {}) =>
|
||||
set((s) => {
|
||||
const idx = localPendingIndex(s.messages);
|
||||
return writeSession(s, s.activeSessionId, (list) =>
|
||||
setDelivery(list, idx, delivery, patch)
|
||||
);
|
||||
}),
|
||||
|
||||
alignDuplicateMessage: (clientMsgId, messageId, sessionId) =>
|
||||
set((s) =>
|
||||
writeSessionById(s, sessionId, (list) =>
|
||||
alignDuplicate(list, clientMsgId, messageId, sessionId)
|
||||
)
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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