- 1.1(核对):registry 默认两布尔 false 已一致,零代码改动(设计文档已按裁决改写) - 1.3 + 10.4.2:新增迁移 0012_tool_policy(tool_policies 表,表名复数 + idx_tool_policies_name); 新增 registry.PolicyStore/SQLitePolicyStore,启动 Load 覆盖注册初值、SetPolicy 先落库后改内存 (落盘失败保持旧值并报错);面板保存路径不变但从此重启仍在 - 13.4.3:代审真接线 —— restrict.SensitiveCall 结构化判定(terminal / file write / http POST)、 restrict.Gate/Reviewer/Resolver(无代审者或代审报错一律 fail-closed 拦截), 组合根装进 loop.Options.Hooks 的 CallAdmit;gateway.RecordApproval 落 audit_logs (who=角色名 / what=approval.<工具> / result=granted|denied)并广播 approval_notice 帧 - 前端:代审小字「xxx 想要干什么,我审查后批准了」进聊天流顶部(帧→store→组件,文案单一来源 复用 audit.Approval.Explain()),附纯 node 逻辑测试 - 复核:迁移在 data/cyrene.db **副本**上 up/down/up 各一次通过;TestRegistry_PolicyStoreSurvivesRestart 与 TestAdminSetPolicySurvivesRestart 证明「重启后策略仍在」;terminal 触发代审 + 审计有行有端到端断言
107 lines
4.5 KiB
TypeScript
107 lines
4.5 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { useAppStore } from "../store/useAppStore";
|
|
import { useCurrentPersona } from "../store/personaSelectors";
|
|
import { useModeStore } from "../store/useModeStore";
|
|
import { useSendChat } from "../lib/useChat";
|
|
import { sendUp } from "../lib/ws";
|
|
import { HistoryList } from "../components/messages/HistoryList";
|
|
import { ChatInput } from "../components/ChatInput";
|
|
import { EmptyState } from "../components/primitives";
|
|
import { Icon } from "../components/Icon";
|
|
import { SessionStatusHint, ApprovalNoticeHint } from "../components/status";
|
|
import { personaChatBackground, type Orientation } from "../lib/persona";
|
|
|
|
/**
|
|
* The single shared page (design/07 §1). All behavior modes live in ONE message
|
|
* stream; each message carries its own `mode` (assigned by the agent at runtime,
|
|
* design/01 §3 hot-switch), so its bubble renders per that mode. There is no
|
|
* manual mode selector — the agent / backend drives the mode.
|
|
*/
|
|
export function ChatShell(): React.ReactNode {
|
|
const messages = useAppStore((s) => s.messages);
|
|
// The chat wallpaper follows the *active session's* role (design/22 §4.6).
|
|
const currentPersona = useCurrentPersona();
|
|
const { send, sending } = useSendChat();
|
|
const bottomRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Chat wallpaper follows the current orientation (landscape when wide, else
|
|
// portrait) and re-evaluates on window resize.
|
|
const [orientation, setOrientation] = useState<Orientation>(() =>
|
|
window.innerWidth > window.innerHeight ? "landscape" : "portrait"
|
|
);
|
|
|
|
useEffect(() => {
|
|
const onResize = () =>
|
|
setOrientation(window.innerWidth > window.innerHeight ? "landscape" : "portrait");
|
|
window.addEventListener("resize", onResize);
|
|
return () => window.removeEventListener("resize", onResize);
|
|
}, []);
|
|
|
|
// The persona's own wallpaper for the current orientation; undefined keeps the
|
|
// plain Miuix surface (never another role's image).
|
|
const chatBackground = personaChatBackground(currentPersona, orientation);
|
|
|
|
// The latest assistant message is the one being streamed right now; only it
|
|
// carries the live 唤醒路径 node sequence (WakePath), so the capacity state
|
|
// reflects the current turn rather than a collapsed historical message.
|
|
const lastAssistantId = useMemo(() => {
|
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
if (messages[i].role === "assistant") return messages[i].id;
|
|
}
|
|
return undefined;
|
|
}, [messages]);
|
|
|
|
// Scroll to the newest message only when a *new* message reaches the tail
|
|
// (append/session-switch), not when the user hot-pages older history into the
|
|
// list — otherwise "load earlier" would yank the viewport to the bottom.
|
|
const lastMessageId = messages[messages.length - 1]?.id;
|
|
useEffect(() => {
|
|
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
|
}, [lastMessageId]);
|
|
|
|
return (
|
|
<div className="flex h-full flex-col">
|
|
<div className="relative min-h-0 flex-1">
|
|
{chatBackground ? (
|
|
<div className="absolute inset-0 overflow-hidden" aria-hidden>
|
|
<img
|
|
src={chatBackground}
|
|
alt=""
|
|
draggable={false}
|
|
className="h-full w-full object-cover"
|
|
/>
|
|
<div className="absolute inset-0 bg-[var(--mu-surface-2)]/40" />
|
|
</div>
|
|
) : null}
|
|
<div className="relative h-full overflow-y-auto">
|
|
<div className="mx-auto flex max-w-2xl flex-col gap-4 p-4">
|
|
<SessionStatusHint />
|
|
<ApprovalNoticeHint />
|
|
{messages.length === 0 ? (
|
|
<EmptyState
|
|
icon={<Icon name="chat" size={24} />}
|
|
title="开始与昔涟对话"
|
|
hint="模式只改变消息框的呈现方式,会话上下文与记忆保持不变"
|
|
/>
|
|
) : (
|
|
<HistoryList lastAssistantId={lastAssistantId} />
|
|
)}
|
|
<div ref={bottomRef} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<ChatInput
|
|
onSend={(t, image) => void send(t, image)}
|
|
sending={sending}
|
|
onVoice={() => {
|
|
// Voice is not wired to a live pipeline yet (real call deferred). Activate
|
|
// the voice_input uplink and switch the shell to voice mode so the agent
|
|
// (or the demo) reflects a voice session.
|
|
useModeStore.getState().setMode("voice");
|
|
sendUp({ type: "voice_input", data: { mode: "voice" } });
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|