feat: expand intelligent investigation workspace
Generic Container CI/CD / test-build-publish (push) Successful in 1m52s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m52s

This commit is contained in:
2026-10-30 17:30:19 +08:00
parent ddd03dbff6
commit c0e4231b83
223 changed files with 13155 additions and 5304 deletions
@@ -10,7 +10,6 @@ import { DateTimeField } from "@/shared/ui/date-time-field";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { AnswerInteractionContext as InteractionContext, type AnswerUiMapTarget } from "./answer-ui-interaction-context";
import { SelectForAnalysis } from "./answer-ui-interactions";
import { useAnswerNodeState } from "./answer-view-state-provider";
import { answerStatePointer, type AnswerFormState } from "./answer-view-state";
@@ -75,7 +74,6 @@ export function LocateButton({
trigger={<Button type="button" variant="ghost" size="sm" className="h-9 gap-1 px-0 text-[11px] font-normal">查看关联要素<AnswerDisclosureChevron open={showIds} /></Button>}>
<p className="mt-1 break-words font-mono">{target.feature_ids.join("、")}</p>
</AnswerDisclosure>
<SelectForAnalysis title={label} text={`关联图层:${target.layer}\n要素编号:${target.feature_ids.join("、")}\n依据:${source}`} />
{feedback || running || !onLocate ? <ActionFeedback state={state}>{running ? "正在查找关联要素…" : feedback ?? "当前视图不支持地图定位,可查看关联要素编号。"}</ActionFeedback> : null}
</div>
</Card>
@@ -4,6 +4,7 @@ import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/shared/ui/dropdown-menu";
import type { AnswerUiBlock } from "./contract";
import { canExpandAnswer } from "./answer-presentation";
const contentKinds = [
{ type: "Result", label: "结果", icon: CircleCheck, selector: "[data-answer-result]" },
@@ -25,7 +26,7 @@ export function AnswerContentNav({ block, contentRef }: {
const items = contentKinds.map((kind) => ({
...kind, count: elements.filter((element) => element.type === kind.type).length
})).filter((item) => item.count > 0);
if (items.length < 2) return null;
if (items.length < 2 || !canExpandAnswer([{ type: "answer-ui", block }])) return null;
return (
<nav aria-label="回答内容导航" className="answer-content-nav">
@@ -0,0 +1,67 @@
import { act, fireEvent, render, screen, cleanup } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import type { AnswerViewStateRecord, AnswerViewStateWrite } from "../api/answer-view-state";
import { AnswerViewStateScope } from "../session-state/answer-view-state-scope";
import { AnswerViewPersistenceContext } from "../session-state/answer-view-persistence-context";
import type { AnswerUiBlock } from "./contract";
import { AnswerPartsView } from "./answer-parts-view";
vi.mock("../components/streaming-token-response", () => ({ StreamingTokenResponse: () => null }));
afterEach(() => { cleanup(); vi.useRealTimers(); });
const block: AnswerUiBlock = {
version: "answer-ui@1", block_id: "block", phase: "final", fallback_text: "资料",
spec: { root: "form", elements: { form: { type: "FollowUpForm", children: [], props: {
title: "资料", description: "补充", fields: [{ name: "note", label: "现场记录", kind: "text", required: false }]
} } } }
};
const remote: AnswerViewStateRecord = {
version: "answer-view-state@1", message_id: "message", block_id: "block", revision: 4,
schema_digest: "digest", updated_at: null, nodes: { form: { type: "FollowUpForm", values: { note: "远端" } } }, block: { evidenceFilter: "all" }
};
function fixture() {
let restore!: (value: AnswerViewStateRecord) => void;
const client = { getAnswerViewState: vi.fn(() => new Promise<AnswerViewStateRecord>((resolve) => { restore = resolve; })),
putAnswerViewState: vi.fn(async (_session: string, write: AnswerViewStateWrite) => ({ ...remote, revision: 5, nodes: write.nodes, block: write.block })) };
const scope = new AnswerViewStateScope("actor:project", client);
const view = (persist: boolean, value = block) => <AnswerViewPersistenceContext.Provider value={{ scope, sessionId: "session" }}>
<AnswerPartsView parts={[{ type: "answer-ui", block: value }]} messageId="message" disabled={false} streaming={!persist} streamDone={persist} persist={persist} />
</AnswerViewPersistenceContext.Provider>;
return { scope, client, view, restore: (record: AnswerViewStateRecord) => restore(record) };
}
it("keeps the same form DOM and selection when persistence starts, preserving edits against late restore", async () => {
vi.useFakeTimers();
const f = fixture();
const rendered = render(f.view(false));
const input = screen.getByLabelText("现场记录") as HTMLInputElement;
fireEvent.change(input, { target: { value: "本地草稿" } });
input.focus(); input.setSelectionRange(1, 3);
rendered.rerender(f.view(false, structuredClone(block)));
expect(screen.getByLabelText("现场记录")).toBe(input);
expect(f.client.getAnswerViewState).not.toHaveBeenCalled();
rendered.rerender(f.view(true, structuredClone(block)));
expect(screen.getByLabelText("现场记录")).toBe(input);
expect(input.value).toBe("本地草稿");
expect(input.selectionStart).toBe(1);
expect(input.selectionEnd).toBe(3);
expect(f.client.getAnswerViewState).toHaveBeenCalledTimes(1);
await act(async () => f.restore(remote));
expect(input.value).toBe("本地草稿");
await act(async () => vi.advanceTimersByTimeAsync(500));
expect(f.client.putAnswerViewState.mock.calls[0][1]).toMatchObject({ expected_revision: 4, nodes: { form: { values: { note: "本地草稿" } } } });
rendered.unmount(); f.scope.dispose();
});
it("never reads or writes process blocks even when persistence is requested", async () => {
vi.useFakeTimers();
const f = fixture();
const rendered = render(f.view(true, { ...block, phase: "process" }));
fireEvent.change(screen.getByLabelText("现场记录"), { target: { value: "过程草稿" } });
expect(screen.queryByText("正在恢复交互状态")).toBeNull();
await act(async () => vi.advanceTimersByTimeAsync(2000));
rendered.unmount();
await act(async () => vi.advanceTimersByTimeAsync(1000));
expect(f.client.getAnswerViewState).not.toHaveBeenCalled();
expect(f.client.putAnswerViewState).not.toHaveBeenCalled();
f.scope.dispose();
});
@@ -1,7 +1,11 @@
import { useState, type ComponentProps } from "react";
import { X } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { useAnswerViewPersistence, useAnswerViewState } from "../session-state/use-answer-view-state";
import type { AnswerViewStateScope } from "../session-state/answer-view-state-scope";
import { StreamingTokenResponse } from "../components/streaming-token-response";
import { AnswerUiView, type AnswerUiMapTarget } from "./answer-ui-view";
import type { AnswerUiPart } from "./contract";
import type { AnswerUiSelection } from "./answer-ui-interaction-context";
export function AnswerPartsView({
parts,
@@ -9,30 +13,33 @@ export function AnswerPartsView({
disabled,
streaming,
streamDone,
persist = false,
onLocate,
onSubmit,
onAsk
}: {
parts: AnswerUiPart[];
messageId: string;
disabled: boolean;
streaming: boolean;
streamDone: boolean;
persist?: boolean;
onLocate?: (target: AnswerUiMapTarget) => Promise<unknown> | void;
onSubmit?: (text: string) => Promise<void> | void;
onAsk?: (selection: AnswerUiSelection) => void;
}) {
const persistence = useAnswerViewPersistence();
return (
<div className="space-y-4" data-answer-parts>
<div className="flex min-w-0 flex-col gap-5" data-answer-parts>
{parts.map((part, index) =>
part.type === "answer-ui" ? (
<AnswerUiView
<PersistableAnswerUiView
key={`ui-${part.block.block_id}`}
block={part.block}
persistence={persistence}
active={persist && part.block.phase !== "process"}
messageId={messageId}
disabled={disabled}
onLocate={onLocate}
onSubmit={onSubmit}
onAsk={onAsk}
/>
) : (
<StreamingTokenResponse
@@ -48,3 +55,37 @@ export function AnswerPartsView({
</div>
);
}
type PersistableProps = ComponentProps<typeof AnswerUiView> & {
messageId: string;
active: boolean;
persistence: { scope: AnswerViewStateScope; sessionId: string } | null;
};
function PersistableAnswerUiView({ persistence, messageId, active, ...props }: PersistableProps) {
if (!persistence) return <AnswerUiView {...props} />;
return <SavedAnswerUiView key={`${persistence.scope.scope}:${persistence.sessionId}:${messageId}:${props.block.block_id}`}
{...props} messageId={messageId} active={active} persistence={persistence} />;
}
function SavedAnswerUiView({ persistence, messageId, active, ...props }: PersistableProps & { persistence: NonNullable<PersistableProps["persistence"]> }) {
const saved = useAnswerViewState(persistence.scope, persistence.sessionId, messageId, props.block.block_id, props.block.spec, active);
const [confirmReload, setConfirmReload] = useState(false);
const label = { loading: "正在恢复交互状态", saving: "正在保存", saved: "已保存", unsaved: "未保存", conflict: "保存冲突" }[saved.status];
return <div className="space-y-2" data-answer-state={active ? saved.status : undefined}>
<AnswerUiView {...props} viewState={saved.state} onViewStateChange={saved.update} />
{active ? <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span role="status">{label}</span>
{saved.error ? <span>{saved.error}</span> : null}
{saved.status === "unsaved" ? <Button size="sm" variant="ghost" onClick={saved.retry}>重试保存</Button> : null}
{saved.status === "conflict" && !confirmReload ? <>
<Button size="sm" variant="ghost" onClick={() => setConfirmReload(true)}>重新加载远端</Button>
<Button size="sm" variant="ghost" onClick={saved.resave}>保留本地并重新保存</Button>
</> : null}
{confirmReload ? <div role="alert" className="flex flex-wrap items-center gap-2">
<span>重新加载将替换本块的本地筛选与表单草稿。</span>
<Button size="sm" variant="outline" onClick={() => { setConfirmReload(false); saved.reload(); }}>确认替换本地草稿</Button>
<Button size="icon-sm" variant="ghost" aria-label="取消重新加载" onClick={() => setConfirmReload(false)}><X aria-hidden /></Button>
</div> : null}
</div> : null}
</div>;
}
@@ -1,59 +0,0 @@
import { useId, useState } from "react";
import { ArrowUpRight, Layers2, X } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Textarea } from "@/shared/ui/textarea";
import type { AnswerUiSelection } from "./answer-ui-interaction-context";
import { AnswerDisclosure, AnswerDisclosureChevron, AnswerReveal } from "./answer-motion";
import { getSelectionText } from "./answer-selection";
export function AnswerSelectionTray({ selections, disabled, onRemove, onClear, onAsk }: {
selections: AnswerUiSelection[];
disabled: boolean;
onRemove: (selection: AnswerUiSelection) => void;
onClear: () => void;
onAsk: (selection: AnswerUiSelection) => Promise<void> | void;
}) {
const id = useId();
const [intent, setIntent] = useState("");
const [running, setRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
return <AnswerReveal open={selections.length > 0}><section aria-label="分析内容篮" className="answer-selection-tray rounded-xl border border-border bg-card p-3">
<div className="flex items-center justify-between gap-2">
<h4 className="flex items-center gap-2 text-xs font-medium"><Layers2 size={15} aria-hidden />已选 {selections.length} 项分析内容</h4>
<Button type="button" variant="ghost" size="sm" className="h-8 text-xs" onClick={onClear} disabled={running}>清空选择</Button>
</div>
<ul className="my-2 max-h-48 space-y-1.5 overflow-y-auto">
{selections.map((selection) => <li key={`${selection.title}\n${selection.text}`} className="flex items-start gap-2 rounded-md bg-muted/40 px-2.5 py-2">
<SelectionDetail selection={selection} />
<Button type="button" size="icon-sm" variant="ghost" className="shrink-0" aria-label={`移除${selection.title}`} disabled={running} onClick={() => onRemove(selection)}><X size={13} aria-hidden /></Button>
</li>)}
</ul>
<label htmlFor={id} className="text-xs font-medium">想继续分析什么?</label>
<Textarea id={id} className="mt-1.5 min-h-16 text-xs" value={intent} maxLength={800}
placeholder="例如:比较差异,并核查可能的原因" onChange={(event) => setIntent(event.currentTarget.value)} />
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
<p className="text-[11px] leading-5 text-muted-foreground">保留原始数值与来源,确认后再发送。</p>
<Button type="button" size="sm" disabled={disabled || running} onClick={async () => {
setRunning(true); setError(null);
try {
await onAsk({
title: `${selections.length} 项内容的联合分析`,
text: `${intent.trim() ? `分析目标:${intent.trim()}\n\n` : ""}${getSelectionText(selections)}`
});
} catch (cause) { setError(cause instanceof Error ? cause.message : "内容暂未带入对话,请重试。"); }
finally { setRunning(false); }
}}><ArrowUpRight size={14} aria-hidden />{running ? "正在处理…" : "带入对话"}</Button>
</div>
{error ? <p role="status" className="mt-2 text-xs text-destructive">{error}</p> : null}
</section></AnswerReveal>;
}
function SelectionDetail({ selection }: { selection: AnswerUiSelection }) {
const [open, setOpen] = useState(false);
return <AnswerDisclosure open={open} onOpenChange={setOpen} className="min-w-0 flex-1 text-xs leading-5"
trigger={<Button type="button" variant="ghost" className="h-auto min-h-9 w-full justify-between gap-2 whitespace-normal px-0 py-1 text-left text-xs">
<span className="min-w-0 break-words">{selection.title}</span><AnswerDisclosureChevron open={open} />
</Button>}>
<p className="mt-1 whitespace-pre-wrap break-words text-muted-foreground">{selection.text}</p>
</AnswerDisclosure>;
}
@@ -1,6 +0,0 @@
import type { AnswerUiSelection } from "./answer-ui-interaction-context";
export const ANSWER_SELECTION_LIMIT = 8000;
export function getSelectionText(selections: AnswerUiSelection[]) {
return selections.map((selection, index) => `${index + 1}. ${selection.title}\n${selection.text}`).join("\n\n");
}
@@ -9,9 +9,6 @@ export type AnswerUiInteractions = {
disabled: boolean;
onLocate?: (target: AnswerUiMapTarget) => Promise<unknown> | void;
onSubmit?: (text: string) => Promise<void> | void;
onAsk?: (selection: AnswerUiSelection) => Promise<void> | void;
onToggleSelection?: (selection: AnswerUiSelection) => void;
selections?: AnswerUiSelection[];
evidenceFilter?: string;
submitMode?: "send" | "draft";
};
@@ -1,45 +0,0 @@
import { AnswerInteractionContext, type AnswerUiSelection } from "./answer-ui-interaction-context";
import { useContext, useState } from "react";
import { Check, MessageSquarePlus, Plus } from "lucide-react";
import { motion } from "motion/react";
import { useAnswerMotion } from "./answer-motion-context";
import { agentEase } from "../components/agent-motion";
import { Button } from "@/shared/ui/button";
export function AskAboutContent({ title, text, compact = false }: AnswerUiSelection & { compact?: boolean }) {
const { onAsk, disabled } = useContext(AnswerInteractionContext);
const [running, setRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!onAsk) return null;
return <div className={compact ? "answer-ui-metric-ask" : undefined}><Button type="button"
size={compact ? "icon-sm" : "sm"} variant="ghost"
className={compact ? "text-muted-foreground" : "h-9 gap-1.5 text-xs text-muted-foreground"}
aria-label={compact ? `针对${title}提问` : undefined}
title={compact ? `针对${title}提问` : undefined}
disabled={disabled || running} onClick={async () => {
setRunning(true); setError(null);
try { await onAsk({ title, text }); }
catch (error) { setError(error instanceof Error ? error.message : "引用暂未带入对话,请重试。"); }
finally { setRunning(false); }
}}>
<MessageSquarePlus size={14} aria-hidden />{compact ? null : "针对这部分提问"}
</Button>{error ? <p role="status" className="text-xs text-destructive">{error}</p> : null}</div>;
}
export function SelectForAnalysis({ title, text, compact = false, label }: AnswerUiSelection & { compact?: boolean; label?: string }) {
const shouldAnimate = useAnswerMotion();
const { disabled, onToggleSelection, selections = [] } = useContext(AnswerInteractionContext);
if (!onToggleSelection) return null;
const selected = selections.some((item) => item.title === title && item.text === text);
return <Button type="button" variant={selected ? "secondary" : "ghost"} size={compact ? "icon-sm" : "sm"}
className={compact ? "shrink-0 text-muted-foreground" : "h-9 gap-1.5 text-xs"}
disabled={disabled} aria-pressed={selected} aria-label={label ?? `将${title}加入分析`}
title={label ?? `将${title}加入分析`} onClick={() => onToggleSelection({ title, text })}>
<motion.span key={String(selected)} className="inline-flex" aria-hidden
initial={shouldAnimate() ? { opacity: 0, scale: 0.8, rotate: -30 } : false}
animate={{ opacity: 1, scale: 1, rotate: 0 }} transition={{ duration: 0.18, ease: agentEase }}>
{selected ? <Check size={14} /> : <Plus size={14} />}
</motion.span>
{compact ? null : selected ? "已加入分析" : "加入分析"}
</Button>;
}
@@ -43,24 +43,22 @@ describe("controlled native answer state", () => {
expect(screen.getByLabelText("其他资料")).toHaveValue("");
});
it("keeps chart data-tab search across data refresh and clears row selection without remounting", () => {
it("keeps chart data-tab search across data refresh without remounting", () => {
const current = chart();
const state = normalizeAnswerViewState(current.spec);
if (state.nodes.node.type === "Chart") state.nodes.node.view = "table";
const { rerender } = render(<AnswerUiView block={current} viewState={state} />);
const search = screen.getByRole("textbox", { name: "搜索压力" });
fireEvent.change(search, { target: { value: "一" } });
fireEvent.click(screen.getByRole("checkbox", { name: "选择第 1 行" }));
expect(screen.getByRole("checkbox", { name: "选择第 1 行" })).toBeChecked();
// A local owner can omit the external snapshot while keeping the same block instance.
rerender(<AnswerUiView block={chart(["一", "三"])} />);
expect(screen.getByRole("textbox", { name: "搜索压力" })).toBe(search);
expect(search).toHaveValue("一");
expect(screen.getByRole("checkbox", { name: "选择第 1 行" })).not.toBeChecked();
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
expect(screen.getByRole("tab", { name: "数据" })).toHaveAttribute("aria-selected", "true");
});
it("restores chart data selections atomically when a delayed snapshot changes hidden series and range", () => {
it("restores chart search and sort while discarding legacy row selection", () => {
const current = block("Chart", { title: "压力", chart_type: "line", x_data: ["08:00", "09:00", "10:00"], series: [{ name: "入口", data: [1, 2, 3] }, { name: "出口", data: [3, 4, 5] }], source: "测试" });
const changed = vi.fn();
const { rerender } = render(<AnswerUiView block={current} viewState={normalizeAnswerViewState(current.spec)} onViewStateChange={changed} />);
@@ -68,15 +66,15 @@ describe("controlled native answer state", () => {
table: { query: "09:00", sort: { column: 1, descending: true }, selection: [1] } } } });
rerender(<AnswerUiView block={current} viewState={restored} onViewStateChange={changed} />);
expect(screen.getByRole("textbox", { name: "搜索压力" })).toHaveValue("09:00");
expect(screen.getByRole("checkbox", { name: "选择第 2 行" })).toBeChecked();
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "入口" })).toHaveAttribute("aria-sort", "descending");
expect(screen.queryByRole("columnheader", { name: "出口" })).not.toBeInTheDocument();
expect(changed).not.toHaveBeenCalled();
// A subsequent deliberate series change clears invalid derived-table indices.
// A subsequent deliberate series change clears the derived table sort.
fireEvent.click(screen.getByRole("button", { name: "出口" }));
expect(screen.getByRole("checkbox", { name: "选择第 2 行" })).not.toBeChecked();
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
expect(screen.getByRole("textbox", { name: "搜索压力" })).toHaveValue("09:00");
expect(changed).toHaveBeenLastCalledWith(expect.anything(), ["/nodes/node/hidden", "/nodes/node/table/sort", "/nodes/node/table/selection"]);
expect(changed).toHaveBeenLastCalledWith(expect.anything(), ["/nodes/node/hidden", "/nodes/node/table/sort"]);
});
it("preserves form drafts during streaming repeats and drops a draft when the field kind changes", () => {
@@ -1,4 +1,4 @@
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { AnswerUiView } from "./answer-ui-view";
@@ -33,6 +33,29 @@ function taskBlock(includeData = true) {
return parsed;
}
describe("local interactive answer renderer", () => {
it("shows navigation for long answers while keeping short metric and evidence replies quiet", () => {
const fixture = taskBlock(false);
fixture.spec.elements.root.children = ["metric", "evidence"];
delete fixture.spec.elements.form;
const { rerender } = render(<AnswerUiView block={fixture} />);
expect(screen.getByRole("group", { name: "当前压力" })).toBeVisible();
expect(screen.queryByRole("navigation", { name: "回答内容导航" })).not.toBeInTheDocument();
const longer = structuredClone(fixture);
longer.spec.elements.evidence.props.summary = "待核对的监测资料。".repeat(150);
rerender(<AnswerUiView block={longer} />);
expect(screen.getByRole("navigation", { name: "回答内容导航" })).toBeVisible();
});
it("uses a single form and evidence card's own headings without fixed wrapper instructions", () => {
render(<AnswerUiView block={taskBlock()} onSubmit={vi.fn()} />);
expect(screen.getByLabelText("现场记录(必填)")).toBeVisible();
expect(screen.getByRole("button", { name: /读数依据/ })).toBeVisible();
expect(screen.queryByRole("button", { name: "收起补充资料" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "收起核查依据" })).not.toBeInTheDocument();
expect(screen.queryByText("直接探索已加载数据")).not.toBeInTheDocument();
expect(screen.queryByText("区分记录、推断与用户资料")).not.toBeInTheDocument();
});
it("adapts response and report composition without replacing a reader's form draft", async () => {
const user = userEvent.setup();
const fixture = taskBlock();
@@ -68,38 +91,33 @@ describe("local interactive answer renderer", () => {
expect(screen.getByRole("note")).toHaveTextContent("能力限制");
expect(screen.getByRole("note")).not.toHaveTextContent("注意事项");
});
it("combines metrics, selected rows and evidence into one deliberate quote without sending", async () => {
it("shows data and evidence without row selection, quoting or analysis controls", async () => {
const user = userEvent.setup();
const ask = vi.fn();
const submit = vi.fn();
render(<AnswerUiView block={taskBlock()} onAsk={ask} onSubmit={submit} />);
await user.click(screen.getByRole("button", { name: "将当前压力加入分析" }));
await user.click(screen.getByRole("checkbox", { name: "选择第 1 行" }));
await user.click(screen.getByRole("button", { name: "将所选行加入分析" }));
render(<AnswerUiView block={taskBlock()} onSubmit={submit} />);
expect(screen.getByRole("group", { name: "当前压力" })).toHaveTextContent("0.2130MPa");
expect(screen.getByRole("table")).toHaveTextContent("北区入口");
expect(screen.getAllByRole("columnheader")).toHaveLength(2);
await user.click(screen.getByRole("button", { name: /读数依据.*测试查询/ }));
await user.click(screen.getByRole("button", { name: "将读数依据加入分析" }));
const tray = screen.getByRole("region", { name: "分析内容篮" });
expect(tray).toHaveTextContent("已选 3 项分析内容");
expect(ask).not.toHaveBeenCalled();
await user.type(within(tray).getByRole("textbox"), "比较差异并核查原因");
await user.click(within(tray).getByRole("button", { name: "带入对话" }));
expect(ask).toHaveBeenCalledWith({ title: "3 项内容的联合分析", text: expect.stringContaining("分析目标:比较差异并核查原因") });
const quote = ask.mock.calls[0][0].text;
for (const exact of ["0.2130MPa", "来源:监测快照", "对象:北区入口;压力(MPa):0.2130", "资料编号:DEMO-26", "引用内容块:task"]) expect(quote).toContain(exact);
await waitFor(() => expect(screen.getByText("仅用于演练")).toBeVisible());
expect(screen.getByText("DEMO-26")).toBeVisible();
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /加入分析|针对.*提问|取消选行/ })).not.toBeInTheDocument();
expect(screen.queryByRole("region", { name: "分析内容篮" })).not.toBeInTheDocument();
expect(submit).not.toHaveBeenCalled();
await user.click(within(tray).getByRole("button", { name: "移除当前压力" }));
expect(tray).toHaveTextContent("已选 2 项分析内容");
expect(screen.getByRole("button", { name: "将当前压力加入分析" })).toHaveAttribute("aria-pressed", "false");
});
it("keeps the same form and selected content when more data arrives and support sections collapse", async () => {
it("keeps the same form when more data arrives and support sections collapse", async () => {
const user = userEvent.setup();
const ask = vi.fn();
const { rerender } = render(<AnswerUiView block={taskBlock(false)} onAsk={ask} onSubmit={vi.fn()} />);
const { rerender } = render(<AnswerUiView block={taskBlock(false)} onSubmit={vi.fn()} />);
const editor = screen.getByLabelText("现场记录(必填)");
await user.type(editor, "未发送的现场草稿");
await user.click(screen.getByRole("button", { name: "将当前压力加入分析" }));
rerender(<AnswerUiView block={taskBlock()} onAsk={ask} onSubmit={vi.fn()} />);
const grouped = taskBlock();
grouped.spec.elements.root.children = [...(grouped.spec.elements.root.children ?? []), "extra-form"];
grouped.spec.elements["extra-form"] = { ...grouped.spec.elements.form, props: {
title: "其他资料", description: "可选补充", fields: [{ name: "extra", label: "其他记录", kind: "text", required: false }]
} };
rerender(<AnswerUiView block={grouped} onSubmit={vi.fn()} />);
expect(screen.getByLabelText("现场记录(必填)")).toBe(editor);
expect(editor).toHaveValue("未发送的现场草稿");
await user.click(screen.getByRole("button", { name: "收起补充资料" }));
@@ -108,25 +126,22 @@ describe("local interactive answer renderer", () => {
await user.click(screen.getByRole("button", { name: "展开补充资料" }));
await waitFor(() => expect(editor).toBeVisible());
expect(editor).toHaveValue("未发送的现场草稿");
expect(screen.getByRole("region", { name: "分析内容篮" })).toHaveTextContent("已选 1 项分析内容");
await user.click(screen.getByRole("button", { name: "收起补充资料" }));
const updated = taskBlock();
updated.spec.elements.table.props.rows = [["新增入口", "0.24"]];
rerender(<AnswerUiView block={updated} onAsk={ask} onSubmit={vi.fn()} />);
rerender(<AnswerUiView block={updated} onSubmit={vi.fn()} />);
expect(screen.getByLabelText("现场记录(必填)")).toBe(editor);
await waitFor(() => expect(editor).toBeVisible());
expect(editor).toHaveValue("未发送的现场草稿");
});
it("rejects oversized row quotes without truncating values or removing existing selections", async () => {
const user = userEvent.setup();
it("preserves long original cell values without requiring a selection or quote action", () => {
const fixture = taskBlock();
fixture.spec.elements.table.props.rows = Array.from({ length: 12 }, (_, index) => [String(index), "长".repeat(800)]);
render(<AnswerUiView block={fixture} onAsk={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "将当前压力加入分析" }));
await user.click(screen.getByRole("checkbox", { name: "选择入口记录的全部筛选结果" }));
await user.click(screen.getByRole("button", { name: "将所选行加入分析" }));
expect(screen.getByRole("status")).toHaveTextContent("8000 字");
expect(screen.getByRole("region", { name: "分析内容篮" })).toHaveTextContent("已选 1 项分析内容");
expect(screen.getByRole("checkbox", { name: "选择第 12 行" })).toBeChecked();
render(<AnswerUiView block={fixture} />);
expect(screen.getAllByText("长".repeat(800))).toHaveLength(12);
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /加入分析|针对.*提问/ })).not.toBeInTheDocument();
});
it("expresses supplied context and cautions as readable notes without announcing historical alerts", () => {
@@ -143,16 +158,14 @@ describe("local interactive answer renderer", () => {
expect(screen.queryByRole("note")).not.toBeInTheDocument();
expect(screen.getByText("普通回答")).toBeInTheDocument();
});
it("offers a named metric quote action and retains the exact value, unit and source", async () => {
const user = userEvent.setup();
const ask = vi.fn();
it("retains a metric's exact value, unit and source without action buttons", () => {
render(<AnswerUiView block={block("Metric", {
label: "当前压力", value: "0.3000", unit: "MPa", source: "已加载记录",
})} onAsk={ask} />);
await user.click(screen.getByRole("button", { name: "针对当前压力提问" }));
expect(ask).toHaveBeenCalledWith({
title: "当前压力", text: "当前压力:0.3000MPa\n来源:已加载记录\n引用内容块:test",
});
})} />);
const metric = screen.getByRole("group", { name: "当前压力" });
expect(metric).toHaveTextContent("0.3000MPa");
expect(metric).toHaveTextContent("来源:已加载记录");
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("renders through json-render and lets readers inspect exact chart data with keyboard tabs", async () => {
const user = userEvent.setup();
@@ -199,22 +212,21 @@ describe("local interactive answer renderer", () => {
feature_ids: ["DEMO-P001", "DEMO-P002"]
});
});
it("filters and sorts loaded rows, then quotes the selected original values without sending", async () => {
it("filters and sorts loaded rows without selecting or sending content", async () => {
const user = userEvent.setup();
const ask = vi.fn();
const submit = vi.fn();
render(<AnswerUiView block={block("Table", {
title: "监测记录", columns: ["监测点", "压力"],
rows: [["南站", "0.32"], ["北站", "0.21"], ["南站二号", "0.26"]]
})} onAsk={ask} onSubmit={submit} />);
})} onSubmit={submit} />);
await user.type(screen.getByRole("textbox", { name: "搜索监测记录" }), "南站");
expect(screen.getByText("2 / 3 行")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "按压力排序" }));
const rows = screen.getAllByRole("row");
expect(rows[1]).toHaveTextContent("南站二号");
await user.click(screen.getByRole("checkbox", { name: "选择第 3 行" }));
await user.click(screen.getByRole("button", { name: "针对这部分提问" }));
expect(ask).toHaveBeenCalledWith({ title: "监测记录", text: expect.stringContaining("监测点:南站二号;压力:0.26") });
expect(rows[1]).toHaveTextContent("0.26");
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /加入分析|针对.*提问/ })).not.toBeInTheDocument();
expect(submit).not.toHaveBeenCalled();
});
it("keeps form values after a failed submission", async () => {
+11 -45
View File
@@ -1,5 +1,4 @@
import { LocateButton, FollowUpForm } from "./answer-action-components";
import { AskAboutContent, SelectForAnalysis } from "./answer-ui-interactions";
import { Renderer, defineRegistry } from "@json-render/react";
import { useContext, useRef, useState, type ReactNode } from "react";
import {
@@ -15,22 +14,20 @@ import {
import { Button } from "@/shared/ui/button";
import { Badge } from "@/shared/ui/badge";
import { Alert, AlertDescription, AlertTitle } from "@/shared/ui/alert";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/ui/card";
import { Card, CardContent } from "@/shared/ui/card";
import { AnswerDisclosure, AnswerDisclosureChevron, AnswerMotionScope } from "./answer-motion";
import { cn } from "@/shared/ui/cn";
import { InteractiveChart } from "./interactive-chart";
import { InteractiveDataTable } from "./interactive-data-table";
import { AnswerResult } from "./answer-result";
import { AnswerInteractionContext as InteractionContext, type AnswerUiInteractions, type AnswerUiMapTarget, type AnswerUiSelection } from "./answer-ui-interaction-context";
import { AnswerInteractionContext as InteractionContext, type AnswerUiInteractions } from "./answer-ui-interaction-context";
export type { AnswerUiMapTarget, AnswerUiSelection } from "./answer-ui-interaction-context";
import { answerUiCatalog } from "./catalog";
import type { AnswerUiBlock } from "./contract";
import { AnswerContentNav } from "./answer-content-nav";
import { getAnswerTaskLayout } from "./answer-task-layout";
import { AnswerSelectionTray } from "./answer-selection-tray";
import { AnswerViewStateProvider, useAnswerBlockState, useAnswerNodeState, withAnswerNodeIdentity, withAnswerNodeIds } from "./answer-view-state-provider";
import { type AnswerEvidenceState, type AnswerViewState, type AnswerViewStateChange } from "./answer-view-state";
import { ANSWER_SELECTION_LIMIT, getSelectionText } from "./answer-selection";
const { registry: baseRegistry } = defineRegistry(answerUiCatalog, {
actions: {},
@@ -86,10 +83,6 @@ const { registry: baseRegistry } = defineRegistry(answerUiCatalog, {
{props.source ? (
<p className="answer-ui-metric-source text-[11px] leading-5 text-muted-foreground">来源:{props.source}</p>
) : null}
<div className="answer-ui-metric-actions flex items-center">
<SelectForAnalysis compact title={props.label} text={`${props.label}:${props.value}${props.unit ?? ""}${props.source ? `\n来源:${props.source}` : ""}`} />
<AskAboutContent compact title={props.label} text={`${props.label}:${props.value}${props.unit ?? ""}${props.source ? `\n来源:${props.source}` : ""}`} />
</div>
</CardContent>
</Card>
),
@@ -119,43 +112,25 @@ function AnswerUiBody({
disabled = false,
onLocate,
onSubmit,
onAsk,
submitMode = "send"
}: { block: AnswerUiBlock } & Partial<AnswerUiInteractions>) {
const [{ evidenceFilter: filter }, setFilter] = useAnswerBlockState();
const [selections, setSelections] = useState<AnswerUiSelection[]>([]);
const [selectionError, setSelectionError] = useState<string | null>(null);
const contentRef = useRef<HTMLDivElement>(null);
const taskLayout = getAnswerTaskLayout(block.spec);
const root = block.spec.elements[block.spec.root];
const density = root.type === "Stack" ? root.props.density : undefined;
const intent = root.type === "Stack" ? root.props.intent : undefined;
const hasTaskLayout = intent !== "response" && Boolean(taskLayout?.analysis.length && (taskLayout.evidence.length || taskLayout.followUp.length));
const ask = onAsk ? (selection: AnswerUiSelection) => onAsk({ ...selection, text: `${selection.text}\n引用内容块:${block.block_id}` }) : undefined;
const kinds = [...new Set(Object.values(block.spec.elements).filter((node) => node.type === "Evidence").map((node) => String(node.props.kind)))];
const evidenceFilters = kinds.length > 1 ? <div className="mb-3 flex flex-wrap gap-1.5" aria-label="筛选证据类别">
{["all", ...kinds].map((kind) => <Button key={kind} variant={filter === kind ? "secondary" : "outline"} size="sm" className="h-8 text-xs" aria-pressed={filter === kind} onClick={() => setFilter(kind)}>
{kind === "all" ? "全部依据" : evidenceKinds[kind as keyof typeof evidenceKinds].label}
</Button>)}
</div> : null;
const isGroup = (ids: string[]) => hasTaskLayout && ids.length > 1 && !ids.every((id) => block.spec.elements[id].type === "Section");
const renderNodes = (ids: string[]) => ids.map((id) => <Renderer key={id} spec={{ ...block.spec, root: id }} registry={registry} />);
return (
<InteractionContext.Provider value={{ disabled, onLocate, onSubmit, submitMode, evidenceFilter: filter,
onAsk: ask, selections,
onToggleSelection: onAsk ? (selection) => {
setSelectionError(null);
const alreadySelected = selections.some((item) => item.title === selection.title && item.text === selection.text);
if (alreadySelected) {
setSelections((current) => current.filter((item) => item.title !== selection.title || item.text !== selection.text));
return;
}
const next = [...selections, selection];
if (next.length > 8 || getSelectionText(next).length > ANSWER_SELECTION_LIMIT) {
setSelectionError("一次最多选择 8 项、8000 字内容。请减少所选数据行,或移除已有内容后重试。");
return;
}
setSelections(next);
} : undefined }}>
<InteractionContext.Provider value={{ disabled, onLocate, onSubmit, submitMode, evidenceFilter: filter }}>
<AnswerMotionScope className="answer-ui-root" data-answer-ui={block.block_id} data-answer-phase={block.phase} data-answer-intent={intent} data-answer-density={density}>
<AnswerContentNav block={block} contentRef={contentRef} />
<div ref={contentRef}>
@@ -163,35 +138,30 @@ function AnswerUiBody({
{taskLayout ? <div className={hasTaskLayout ? "answer-task-layout" : "answer-task-layout-simple"} data-answer-task-layout={hasTaskLayout || undefined}>
{taskLayout.overview.length ? <div className="answer-task-overview grid gap-3" aria-label="回答概览">{renderNodes(taskLayout.overview)}</div> : null}
{taskLayout.analysis.length ? <section className="answer-task-analysis grid min-w-0 gap-3" aria-label="分析内容">
{hasTaskLayout ? <div className="answer-task-heading"><span>分析内容</span><span>直接探索已加载数据</span></div> : null}
{renderNodes(taskLayout.analysis)}
</section> : null}
{taskLayout.evidence.length || taskLayout.followUp.length ? <div className="answer-task-support grid min-w-0 gap-4">
{taskLayout.evidence.length ? <TaskSupport enhanced={hasTaskLayout} title="核查依据" description="区分记录、推断与用户资料" icon={<Database size={15} aria-hidden />}>
{taskLayout.evidence.length ? <TaskSupport grouped={isGroup(taskLayout.evidence)} title="核查依据" icon={<Database size={15} aria-hidden />}>
{evidenceFilters}{renderNodes(taskLayout.evidence)}
</TaskSupport> : null}
{taskLayout.followUp.length ? <TaskSupport enhanced={hasTaskLayout} title="补充资料" description="由你填写,主动提交" icon={<FileCheck2 size={15} aria-hidden />}>
{taskLayout.followUp.length ? <TaskSupport grouped={isGroup(taskLayout.followUp)} title="补充资料" icon={<FileCheck2 size={15} aria-hidden />}>
{renderNodes(taskLayout.followUp)}
</TaskSupport> : null}
</div> : null}
</div> : <>{evidenceFilters}<Renderer spec={block.spec} registry={registry} /></>}
</>
</div>
{selectionError ? <p role="status" className="mt-3 text-xs leading-5 text-destructive">{selectionError}</p> : null}
{ask ? <AnswerSelectionTray selections={selections} disabled={disabled}
onRemove={(selection) => { setSelectionError(null); setSelections((current) => current.filter((item) => item !== selection)); }}
onClear={() => { setSelections([]); setSelectionError(null); }} onAsk={ask} /> : null}
</AnswerMotionScope>
</InteractionContext.Provider>
);
}
function TaskSupport({ title, description, icon, children, enhanced }: { title: string; description: string; icon: ReactNode; children: ReactNode; enhanced: boolean }) {
function TaskSupport({ title, icon, children, grouped }: { title: string; icon: ReactNode; children: ReactNode; grouped: boolean }) {
const [open, setOpen] = useState(true);
return <AnswerDisclosure open={open} onOpenChange={setOpen} className="answer-task-support-section"
contentClassName="answer-task-support-content space-y-2.5 pt-2"
trigger={<Button type="button" variant="ghost" className={cn("h-auto min-h-11 w-full justify-start gap-2 px-0 text-left", !enhanced && "hidden")} aria-label={`${open ? "收起" : "展开"}${title}`}>
{icon}<span className="min-w-0 flex-1 text-xs font-medium">{title}<span className="mt-0.5 block text-[11px] font-normal text-muted-foreground">{description}</span></span>
return <AnswerDisclosure open={grouped ? open : true} onOpenChange={setOpen} className="answer-task-support-section" data-grouped={grouped || undefined}
contentClassName={cn("answer-task-support-content space-y-2.5", grouped && "pt-2")}
trigger={<Button type="button" variant="ghost" hidden={!grouped} className={cn("h-auto min-h-11 w-full justify-start gap-2 px-0 text-left", !grouped && "hidden")} aria-label={`${open ? "收起" : "展开"}${title}`}>
{icon}<span className="min-w-0 flex-1 text-xs font-medium">{title}</span>
<AnswerDisclosureChevron open={open} />
</Button>}>{children}</AnswerDisclosure>;
}
@@ -256,10 +226,6 @@ function EvidenceCard(props: {
) : null}
</dl>
) : null}
<div className="mt-2 flex flex-wrap items-center gap-1">
<SelectForAnalysis title={props.title} text={`${props.summary}\n来源:${props.source}\n类别:${meta.label}${props.evidence_id ? `\n资料编号:${props.evidence_id}` : ""}${props.observed_at ? `\n记录时间:${props.observed_at}` : ""}`} />
<AskAboutContent title={props.title} text={`${props.summary}\n来源:${props.source}\n类别:${meta.label}${props.evidence_id ? `\n资料编号:${props.evidence_id}` : ""}${props.observed_at ? `\n记录时间:${props.observed_at}` : ""}`} />
</div>
</AnswerDisclosure>
);
}
@@ -10,7 +10,7 @@ const table = (rows = [["a", "1"], ["b", "2"]], columns = ["对象", "读数"])
const form = (fields = [{ name: "record", kind: "text" }]) => spec("FollowUpForm", { fields });
describe("answer view state compatibility", () => {
it("clears row indices on reordered data, retains search, and clears sort when columns change", () => {
it("ignores legacy row selection, retains search, and clears sort when columns change", () => {
const before = table();
const state = normalizeAnswerViewState(before, { nodes: { node: { type: "Table", query: "a", sort: { column: 1, descending: true }, selection: [0] } } });
expect(migrateAnswerViewState(before, table([["b", "2"], ["a", "1"]]), state).nodes.node).toEqual({ type: "Table", query: "a", sort: { column: 1, descending: true }, selection: [] });
@@ -37,7 +37,7 @@ describe("answer view state compatibility", () => {
it("only accepts supported state, bounds indices, preserves draft text, and resets missing evidence filters", () => {
const current = table();
const result = normalizeAnswerViewState(current, { nodes: { node: { type: "Table", query: "查".repeat(2000), sort: { column: 99, descending: true }, selection: [-1, 0, 0, 2] }, unknown: { type: "Evidence", open: true } }, block: { evidenceFilter: "gone" }, action: "ignored" });
expect(result).toEqual({ nodes: { node: { type: "Table", query: "查".repeat(2000), sort: null, selection: [0] } }, block: { evidenceFilter: "all" } });
expect(result).toEqual({ nodes: { node: { type: "Table", query: "查".repeat(2000), sort: null, selection: [] } }, block: { evidenceFilter: "all" } });
});
it("tracks form and nested chart-table edits at field granularity", () => {
@@ -1,5 +1,6 @@
import type { Spec } from "@json-render/core";
// `selection` is an empty legacy v1 wire field, never an interactive row selection.
export type AnswerTableState = { type: "Table"; query: string; sort: { column: number; descending: boolean } | null; selection: number[] };
export type AnswerChartState = { type: "Chart"; view: "line" | "bar" | "table"; hidden: number[]; range: [number, number]; point: number; inspecting: boolean; table?: Omit<AnswerTableState, "type"> };
export type AnswerFormState = { type: "FollowUpForm"; values: Record<string, string> };
@@ -39,11 +40,10 @@ export function normalizeAnswerNodeState(node: Node, input?: unknown): AnswerUiN
}
if (node.type === "Table") {
const columns = node.props.columns as unknown[];
const rows = node.props.rows as unknown[];
const sort = record(state.sort);
return { type: "Table", query: typeof state.query === "string" ? state.query : "",
sort: Number.isInteger(sort.column) && Number(sort.column) >= 0 && Number(sort.column) < columns.length && typeof sort.descending === "boolean" ? { column: Number(sort.column), descending: sort.descending } : null,
selection: indices(state.selection, rows.length) };
selection: [] };
}
if (node.type === "FollowUpForm") {
const values = record(state.values);
@@ -74,8 +74,7 @@ export function migrateAnswerViewState(previous: Spec, next: Spec, input: Answer
const before = previous.elements[id], after = next.elements[id], old = input.nodes[id];
if (!before || before.type !== after.type || !old || old.type !== value.type) continue;
if (value.type === "Table") {
if (!same(before.props.rows, after.props.rows)) value.selection = [];
if (!same(before.props.columns, after.props.columns)) { value.sort = null; value.selection = []; }
if (!same(before.props.columns, after.props.columns)) value.sort = null;
}
if (value.type === "FollowUpForm") {
const fields = before.props.fields as Array<{ name: string; kind: string }>;
@@ -98,7 +97,6 @@ export function migrateAnswerViewState(previous: Spec, next: Spec, input: Answer
}
if (value.table) {
const oldData = answerChartTableData(before, old), newData = answerChartTableData(after, value);
if (!same(oldData.rows, newData.rows)) value.table.selection = [];
if (!same(oldData.columns, newData.columns)) value.table.sort = null;
}
}
@@ -11,6 +11,30 @@ const block = {
}
};
describe("untrusted answer components", () => {
it("keeps explicit UI phases and rejects invalid phase values", () => {
expect(parseAnswerUiBlock(block)?.phase).toBeUndefined();
expect(parseAnswerUiBlock({ ...block, phase: "process" })?.phase).toBe("process");
expect(parseAnswerUiBlock({ ...block, phase: "final" })?.phase).toBe("final");
expect(parseAnswerUiBlock({ ...block, phase: "other" })).toBeNull();
expect(parseAnswerUiBlock({ ...block, phase: null })).toBeNull();
});
it("accepts controlled task semantics and result states without accepting arbitrary presentation code", () => {
const result = { type: "Result", props: { title: "定位结果", summary: "3 根管道已定位,高亮未确认。", status: "partial", source: "地图工具回执" }, children: [] };
const spec = {
root: "root", elements: {
root: { type: "Stack", props: { columns: "one", gap: "normal", intent: "response", density: "compact" }, children: ["section"] },
section: { type: "Section", props: { title: "本次结果", description: null, role: "result" }, children: ["result"] },
result
}
};
expect(parseAnswerUiBlock({ ...block, spec })).not.toBeNull();
for (const props of [
{ ...result.props, status: "running" },
{ ...result.props, css: "display:none" },
{ ...result.props, onClick: "fetch('/write')" }
]) expect(parseAnswerUiBlock({ ...block, spec: { ...spec, elements: { ...spec.elements, result: { ...result, props } } } })).toBeNull();
expect(parseAnswerUiBlock({ ...block, spec: { root: "note", elements: { note: { ...block.spec.elements.note, props: { text: "高亮操作未提供。", tone: "warning", title: "能力限制" } } } } })).not.toBeNull();
});
it("accepts registered text and rejects arbitrary code, URLs and unknown components", () => {
expect(parseAnswerUiBlock(block)).not.toBeNull();
for (const node of [
+1 -1
View File
@@ -28,7 +28,7 @@ export const storedAnswerUiBlockSchema = z.object({
export const reportUiBlocksSchema = z.array(z.object({
block: storedAnswerUiBlockSchema,
evidence_refs: z.array(z.string().min(1).max(128)).min(1).max(100)
}).strict()).max(8);
}).strict().refine((item) => item.block.phase !== "process", "过程界面不能发布为正式报告")).max(8);
export type StoredReportUiBlock = z.infer<typeof reportUiBlocksSchema>[number];
export type AnswerUiBlock = {
@@ -9,7 +9,6 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/ui/collapsible";
import { InteractiveDataTable } from "./interactive-data-table";
import { agentEase } from "../components/agent-motion";
import { AskAboutContent, SelectForAnalysis } from "./answer-ui-interactions";
export type AnswerChartProps = {
title: string;
@@ -55,7 +54,7 @@ export function InteractiveChart(props: AnswerChartProps) {
pendingTransition.current = animate;
patch({ view: next as AnswerChartState["view"] });
};
const setRange = (range: [number, number]) => patch({ range, table: { query: "", sort: null, ...table, selection: [] } }, ["range", "table/selection"]);
const setRange = (range: [number, number]) => patch({ range, table: { query: "", sort: null, ...table, selection: [] } }, ["range"]);
const setPoint = (point: number) => patch({ point });
const setInspecting = (inspecting: boolean) => patch({ inspecting });
const visible = props.series.filter((_, index) => !hidden.includes(index));
@@ -66,8 +65,6 @@ export function InteractiveChart(props: AnswerChartProps) {
const values = series.data.slice(range[0], range[1] + 1);
return { name: series.name, first: values[0], last: values.at(-1)!, min: Math.min(...values), max: Math.max(...values) };
});
const rangeTitle = `${props.title} · ${props.x_data[range[0]]} 至 ${props.x_data[range[1]]}`;
const rangeText = `${labels.length} 个已加载数据点的本地统计\n${summaries.map((item) => `${item.name}:起点 ${item.first};终点 ${item.last};最小 ${item.min};最大 ${item.max}`).join("\n")}\n来源:${props.source}`;
const option = (type: "line" | "bar") => ({
animation: animateInteraction && !reducedMotion,
animationDuration: 180,
@@ -115,7 +112,7 @@ export function InteractiveChart(props: AnswerChartProps) {
className="h-8 gap-1.5 text-[11px]" aria-pressed={!hidden.includes(index)}
disabled={visible.length === 1 && !hidden.includes(index)}
onClick={() => { setAnimateInteraction(pointer.current && !reducedMotion); patch({ hidden: hidden.includes(index) ? hidden.filter((item) => item !== index) : [...hidden, index],
table: { query: "", ...table, sort: null, selection: [] } }, ["hidden", "table/sort", "table/selection"]); }}>
table: { query: "", ...table, sort: null, selection: [] } }, ["hidden", "table/sort"]); }}>
<span className="size-2 rounded-full" style={{ background: hidden.includes(index) ? "#cbd5e1" : colors[index] }} />{series.name}
</Button>)}
</div>
@@ -145,7 +142,7 @@ export function InteractiveChart(props: AnswerChartProps) {
}
if (event.key === "Escape") setInspecting(false);
}}>
<span className="sr-only">点击图中数据点查看详情并追问,也可使用回车和左右方向键。</span>
<span className="sr-only">点击图中数据点查看详情,也可使用回车和左右方向键。</span>
<ReactECharts option={option(type)} opts={{ renderer: "svg" }} notMerge style={{ height: 220 }}
onEvents={{ click: (event: { componentType?: string; dataIndex?: number }) => {
if (event.componentType === "series" && typeof event.dataIndex === "number") {
@@ -157,15 +154,13 @@ export function InteractiveChart(props: AnswerChartProps) {
<TabsContent value="table" forceMount style={view === "table" ? undefined : { display: "none" }}>
<InteractiveDataTable title={props.title} columns={["时间或类别", ...visible.map((series) => series.name)]}
rows={labels.map((label, index) => [label, ...visible.map((series) => String(series.data[range[0] + index]))])}
state={table} onStateChange={(updates) => patch({ table: { query: "", sort: null, selection: [], ...table, ...updates } }, Object.keys(updates).map((key) => `table/${key}`))}
source={props.source} />
state={table} onStateChange={(updates) => patch({ table: { query: "", sort: null, selection: [], ...table, ...updates } }, Object.keys(updates).map((key) => `table/${key}`))} />
</TabsContent>
</motion.div>
</Tabs>
{view !== "table" ? <div className="rounded-lg bg-muted/40 p-2.5" role="group" aria-label="当前区间摘要">
<div className="mb-2 flex flex-wrap items-center justify-between gap-1">
<span className="text-[11px] font-medium">区间摘要 <span className="font-normal text-muted-foreground">· 本地统计</span></span>
<SelectForAnalysis compact label="将当前区间加入分析" title={rangeTitle} text={rangeText} />
</div>
<div className="space-y-2">
{summaries.map((item) => <div key={item.name} className="answer-chart-range-summary text-[11px] leading-5">
@@ -191,12 +186,6 @@ export function InteractiveChart(props: AnswerChartProps) {
<dd className="shrink-0 font-medium tabular-nums">{series.data[pointIndex]}</dd>
</div>)}
</dl>
<div className="flex flex-wrap items-center gap-1">
<SelectForAnalysis label="将当前数据点加入分析" title={`${props.title} · ${props.x_data[pointIndex]}`}
text={`${props.x_data[pointIndex]}\n${visible.map((series) => `${series.name}:${series.data[pointIndex]}`).join("\n")}\n来源:${props.source}`} />
<AskAboutContent title={`${props.title} · ${props.x_data[pointIndex]}`}
text={`${props.x_data[pointIndex]}\n${visible.map((series) => `${series.name}:${series.data[pointIndex]}`).join("\n")}\n来源:${props.source}`} />
</div>
</motion.section> : null}
<p className="flex items-start gap-1.5 border-t pt-2.5 text-[11px] leading-5 text-muted-foreground">
<Database size={12} className="mt-1 shrink-0" /><span>{fullRange ? labels.length : `${labels.length} / ${props.x_data.length}`} 个数据点 · {visible.length} 组序列<br />来源:{props.source}</span>
@@ -2,27 +2,21 @@ import { useMemo } from "react";
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
import { Input } from "@/shared/ui/input";
import { Button } from "@/shared/ui/button";
import { Checkbox } from "@/shared/ui/checkbox";
import { cn } from "@/shared/ui/cn";
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/shared/ui/table";
import { AnswerReveal } from "./answer-motion";
import { AskAboutContent, SelectForAnalysis } from "./answer-ui-interactions";
import { useAnswerNodeState } from "./answer-view-state-provider";
import type { AnswerTableState } from "./answer-view-state";
export function InteractiveDataTable({ title, columns, rows, source, state: controlledState, onStateChange }: {
title: string; columns: string[]; rows: string[][]; source?: string;
export function InteractiveDataTable({ title, columns, rows, state: controlledState, onStateChange }: {
title: string; columns: string[]; rows: string[][];
state?: Omit<AnswerTableState, "type">;
onStateChange?: (patch: Partial<Omit<AnswerTableState, "type">>) => void;
}) {
const [localState, patchLocal] = useAnswerNodeState<AnswerTableState>("Table", { title, columns, rows });
const state = controlledState ?? localState;
const patch = onStateChange ?? patchLocal;
// The owning schema node migrates data changes. A complete restored snapshot
// can change chart columns/range and restore a valid selection atomically.
const { query, selection, sort } = state;
const setSelection = (selection: number[]) => patch({ selection });
const { query, sort } = state;
const filtered = useMemo(() => {
const items = rows.map((cells, id) => ({ cells, id }))
.filter(({ cells }) => cells.join(" ").toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()));
@@ -35,12 +29,10 @@ export function InteractiveDataTable({ title, columns, rows, source, state: cont
});
return items;
}, [rows, sort, query]);
const selected = filtered.filter(({ id }) => selection.includes(id));
const quote = selected.map(({ cells }) => cells.map((cell, index) => `${columns[index]}:${cell}`).join(";")).join("\n");
return <div className="min-w-0 space-y-2" data-interactive-table>
<div className="flex flex-wrap items-center gap-2">
<Input aria-label={`搜索${title}`} placeholder="搜索当前数据" value={query} className="h-9 min-w-0 flex-1 text-xs"
onChange={(event) => { patch({ query: event.currentTarget.value, selection: [] }); }} />
onChange={(event) => { patch({ query: event.currentTarget.value }); }} />
<span className="text-[11px] tabular-nums text-muted-foreground">{filtered.length} / {rows.length} 行</span>
</div>
<div role="region" aria-label={title} tabIndex={0}
@@ -48,9 +40,6 @@ export function InteractiveDataTable({ title, columns, rows, source, state: cont
<Table>
<TableCaption className="caption-top font-medium">{title}</TableCaption>
<TableHeader className="bg-muted/50"><TableRow>
<TableHead scope="col" className="w-10"><Checkbox aria-label={`选择${title}的全部筛选结果`}
checked={filtered.length > 0 && selected.length === Math.min(filtered.length, 100)} disabled={!filtered.length}
onChange={(event) => setSelection(event.target.checked ? filtered.slice(0, 100).map(({ id }) => id) : [])} /></TableHead>
{columns.map((column, index) => <TableHead key={index} scope="col" className="whitespace-nowrap"
aria-sort={sort?.column === index ? sort.descending ? "descending" : "ascending" : "none"}>
<Button size="sm" variant="ghost" className="h-9 gap-1 px-1 text-xs" aria-label={`按${column}排序`}
@@ -62,26 +51,15 @@ export function InteractiveDataTable({ title, columns, rows, source, state: cont
</TableHead>)}
</TableRow></TableHeader>
<TableBody>
{filtered.map(({ cells, id }) => <TableRow key={id} data-state={selection.includes(id) ? "selected" : undefined}>
<TableCell className="w-10"><Checkbox aria-label={`选择第 ${id + 1} 行`} checked={selection.includes(id)} disabled={!selection.includes(id) && selection.length >= 100}
onChange={(event) => setSelection(event.target.checked ? [...selection, id] : selection.filter((value) => value !== id))} /></TableCell>
{filtered.map(({ cells, id }) => <TableRow key={id}>
{cells.map((cell, index) => <TableCell key={index}
className={cn("min-w-24", /^[-+]?\d+(\.\d+)?%?$/.test(cell) && "text-right tabular-nums")}>{cell}</TableCell>)}
</TableRow>)}
{!filtered.length ? <TableRow><TableCell colSpan={columns.length + 1} className="py-6 text-center text-xs text-muted-foreground">
{!filtered.length ? <TableRow><TableCell colSpan={columns.length} className="py-6 text-center text-xs text-muted-foreground">
{rows.length ? "当前筛选没有匹配的数据。" : "没有已加载的数据。"}
</TableCell></TableRow> : null}
</TableBody>
</Table>
</div>
<AnswerReveal open={selected.length > 0} className="flex flex-wrap items-center justify-between gap-1">
<span className="text-xs text-muted-foreground">已选 {selected.length} 行{filtered.length > 100 ? " · 每次最多选择前 100 行" : ""}</span>
<div className="flex flex-wrap gap-1">
<Button type="button" variant="ghost" size="sm" className="h-9 text-xs" onClick={() => setSelection([])}>取消选行</Button>
<SelectForAnalysis label="将所选行加入分析" title={title} text={`${quote}${source ? `\n来源:${source}` : ""}`} />
<AskAboutContent title={title} text={`${quote}${source ? `\n来源:${source}` : ""}`} />
</div>
</AnswerReveal>
{!selected.length && filtered.length ? <p className="text-[11px] text-muted-foreground">勾选数据行可加入分析或继续提问。</p> : null}
</div>;
}
@@ -0,0 +1,51 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createAgentApiClient } from "./client";
import type { AnswerViewStateWrite } from "./answer-view-state";
const remote = { version: "answer-view-state@1", message_id: "message", block_id: "block", revision: 2,
schema_digest: "digest", nodes: {}, block: { evidenceFilter: "all" }, updated_at: null };
const write: AnswerViewStateWrite = { message_id: "message", block_id: "block", expected_revision: 2, schema_digest: "digest", request_id: "intent", nodes: {}, block: { evidenceFilter: "all" } };
const token = (subject: string) => `header.${btoa(JSON.stringify({ iss: "https://identity.local", sub: subject }))}.signature`;
afterEach(() => vi.unstubAllGlobals());
describe("authorized answer view state API", () => {
it("keeps the message and block identity and uses the configured auth/project context", async () => {
const fetcher = vi.fn().mockImplementation(async () => Response.json(remote)); vi.stubGlobal("fetch", fetcher);
const api = createAgentApiClient("https://agent.local", { getAccessToken: async () => token("alice"), getProjectId: async () => "project" });
await expect(api.getAnswerViewState("session/one", "message", "block")).resolves.toEqual(remote);
await api.putAnswerViewState("session/one", write);
expect(fetcher.mock.calls[0][0]).toBe("https://agent.local/api/v1/session/session%2Fone/ui-state?message_id=message&block_id=block");
expect(fetcher.mock.calls[1][0]).toBe("https://agent.local/api/v1/session/session%2Fone/ui-state");
expect(fetcher.mock.calls[1][1].body).toBe(JSON.stringify(write));
for (const [, init] of fetcher.mock.calls) {
expect(new Headers(init.headers).get("Authorization")).toBe(`Bearer ${token("alice")}`);
expect(new Headers(init.headers).get("X-Project-Id")).toBe("project");
}
});
it("retains structured conflict details for the explicit user choices", async () => {
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ code: "ANSWER_VIEW_STATE_CONFLICT", detail: "new revision", current: remote }, { status: 409 })));
await expect(createAgentApiClient("https://agent.local").putAnswerViewState("session", write)).rejects.toMatchObject({
status: 409, code: "ANSWER_VIEW_STATE_CONFLICT", current: remote,
});
});
it("rejects writes if a mutable token provider changes subject in the same project", async () => {
const fetcher = vi.fn(async () => Response.json(remote)); vi.stubGlobal("fetch", fetcher);
let subject = "alice";
const api = createAgentApiClient("https://agent.local", { getAccessToken: async () => token(subject), getProjectId: async () => "same-project" });
await api.getAnswerViewState("session", "message", "block"); subject = "bob";
await expect(api.putAnswerViewState("session", write)).rejects.toMatchObject({ code: "ANSWER_VIEW_IDENTITY_CHANGED" });
expect(fetcher).toHaveBeenCalledTimes(1);
});
it("rejects a late read if identity changes while the request is in flight", async () => {
let finish!: (response: Response) => void;
const fetcher = vi.fn(() => new Promise<Response>((resolve) => { finish = resolve; })); vi.stubGlobal("fetch", fetcher);
let subject = "alice";
const api = createAgentApiClient("https://agent.local", { getAccessToken: async () => token(subject) });
const read = api.getAnswerViewState("session", "message", "block");
await vi.waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1)); subject = "bob"; finish(Response.json(remote));
await expect(read).rejects.toMatchObject({ code: "ANSWER_VIEW_IDENTITY_CHANGED" });
});
it("rejects remote state containing unknown executable fields", async () => {
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ ...remote, nodes: { x: { type: "Evidence", open: true, script: "bad" } } })));
await expect(createAgentApiClient("https://agent.local").getAnswerViewState("session", "message", "block")).rejects.toThrow();
});
});
@@ -0,0 +1,44 @@
import { z } from "zod";
import type { AnswerViewState } from "../answer-ui/answer-view-state";
const index = z.number().int().nonnegative();
const chartTable = z.object({ query: z.string(), sort: z.object({ column: index, descending: z.boolean() }).strict().nullable(), selection: z.array(index) }).strict();
const node = z.discriminatedUnion("type", [
z.object({ type: z.literal("Chart"), view: z.enum(["line", "bar", "table"]), hidden: z.array(index), range: z.tuple([index, index]), point: index, inspecting: z.boolean(), table: chartTable.optional() }).strict(),
z.object({ type: z.literal("Table"), query: z.string(), sort: z.object({ column: index, descending: z.boolean() }).strict().nullable(), selection: z.array(index) }).strict(),
z.object({ type: z.literal("FollowUpForm"), values: z.record(z.string()) }).strict(),
z.object({ type: z.literal("Evidence"), open: z.boolean() }).strict(),
]);
const record = z.object({
version: z.literal("answer-view-state@1"), message_id: z.string(), block_id: z.string(),
revision: index, schema_digest: z.string().min(1), nodes: z.record(node),
block: z.object({ evidenceFilter: z.string() }).strict(), updated_at: z.string().nullable(),
}).strict();
export type AnswerViewStateRecord = AnswerViewState & {
version: "answer-view-state@1"; message_id: string; block_id: string;
revision: number; schema_digest: string; updated_at: string | null;
};
export type AnswerViewStateWrite = AnswerViewState & {
message_id: string; block_id: string; schema_digest: string;
expected_revision: number; request_id: string;
};
export class AnswerViewStateApiError extends Error {
constructor(message: string, readonly status: number, readonly code?: string, readonly current?: AnswerViewStateRecord) {
super(message);
this.name = "AnswerViewStateApiError";
}
}
export function parseAnswerViewStateRecord(value: unknown): AnswerViewStateRecord {
return record.parse(value) as AnswerViewStateRecord;
}
export async function readAnswerViewStateResponse(response: Response): Promise<AnswerViewStateRecord> {
const data: unknown = await response.json();
if (!response.ok) {
const error = z.object({ code: z.string().optional(), detail: z.string().optional(), current: z.unknown().optional() }).passthrough().safeParse(data);
const current = error.success && error.data.current ? record.safeParse(error.data.current) : null;
throw new AnswerViewStateApiError(error.success ? error.data.detail ?? "交互状态未保存,请重试" : "交互状态暂不可用,请重试", response.status,
error.success ? error.data.code : undefined, current?.success ? current.data as AnswerViewStateRecord : undefined);
}
return parseAnswerViewStateRecord(data);
}
+26 -1
View File
@@ -1,7 +1,32 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createAgentApiClient } from "./client";
import { AgentResponseFinishedError, createAgentApiClient } from "./client";
describe("Agent prompt suggestions", () => {
it("probes public readiness without loading identity or project credentials", async () => {
const getAccessToken = vi.fn(async () => "private-token");
const getProjectId = vi.fn(async () => "project");
const fetcher = vi.fn().mockResolvedValueOnce(Response.json({ ok: true, ready: true }))
.mockResolvedValueOnce(Response.json({ ok: true, ready: false }));
vi.stubGlobal("fetch", fetcher);
const client = createAgentApiClient("https://agent.local/prefix", { getAccessToken, getProjectId });
const signal = new AbortController().signal;
await expect(client.getHealth(signal)).resolves.toBeUndefined();
expect(fetcher).toHaveBeenCalledWith("https://agent.local/prefix/health", { signal, cache: "no-store" });
expect(getAccessToken).not.toHaveBeenCalled();
expect(getProjectId).not.toHaveBeenCalled();
await expect(client.getHealth(signal)).rejects.toThrow("尚未就绪");
});
it("distinguishes a proven pre-admission idle response from ambiguous input conflicts", async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(Response.json({ code: "RESPONSE_FINISHED", message: "finished" }, { status: 409 }))
.mockResolvedValueOnce(Response.json({ message: "unknown conflict" }, { status: 409 }));
vi.stubGlobal("fetch", fetchMock);
const client = createAgentApiClient("http://agent.local");
await expect(client.submitResponseInput("s", "next", "queue", `msg_${"1".repeat(32)}`)).rejects.toBeInstanceOf(AgentResponseFinishedError);
const uncertain = client.submitResponseInput("s", "next", "queue", `msg_${"2".repeat(32)}`);
await expect(uncertain).rejects.not.toBeInstanceOf(AgentResponseFinishedError);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("uses owned topic endpoints and retains full-text hits from another topic", async () => {
const topic = { id: "topic-a", name: "城南压力问题", createdAt: "2026-10-22T01:00:00Z", updatedAt: "2026-10-22T01:00:00Z" };
const fetchMock = vi.fn()
+42 -3
View File
@@ -2,6 +2,7 @@ import { createInteractionRequestApi, type InteractionRequestApi } from "./inter
import { env } from "@/shared/config/env";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { agentMessagePageSchema, type AgentMessagePage } from "./agent-messages";
import { AnswerViewStateApiError, readAnswerViewStateResponse, type AnswerViewStateRecord, type AnswerViewStateWrite } from "./answer-view-state";
export type AgentRunStatus = "running" | "completed" | "error" | "aborted" | "waiting_input";
export type AgentSessionPurpose = "ordinary_chat" | "task_execution" | "task_collaboration";
@@ -20,8 +21,8 @@ export type AgentEventTriggerContext = {
summary: string;
location?: string | null;
priority: "low" | "normal" | "high" | "urgent";
scenario_code: "WQ-SECONDARY-SUPPLY-001";
origin: "synthetic";
scenario_code?: string | null;
origin: "system_data";
};
};
@@ -156,6 +157,8 @@ export type AgentSessionStreamEvent =
export type AgentApiClient = InteractionRequestApi & {
getHealth: (signal: AbortSignal) => Promise<void>;
getAnswerViewState: (sessionId: string, messageId: string, blockId: string) => Promise<AnswerViewStateRecord>;
putAnswerViewState: (sessionId: string, state: AnswerViewStateWrite) => Promise<AnswerViewStateRecord>;
downloadFile: (path: string) => Promise<void>;
submitResponseInput: (sessionId: string, text: string, delivery: AgentInputDelivery, id: string) => Promise<AgentResponseInput>;
listResponseInputs: (sessionId: string) => Promise<AgentResponseInput[]>;
@@ -249,14 +252,41 @@ export function createAgentApiClient(
return data as T;
};
let answerStateActor: string | undefined;
const checkedAnswerStateToken = options.getAccessToken ? async () => {
const token = await options.getAccessToken!();
const actor = answerStateTokenActor(token);
if (answerStateActor !== undefined && actor !== answerStateActor) {
throw new AnswerViewStateApiError("登录身份已变化,请刷新后继续。", 403, "ANSWER_VIEW_IDENTITY_CHANGED");
}
answerStateActor = actor;
return token;
} : undefined;
const answerStateRequest = async (path: string, init?: RequestInit) => {
const response = await fetchWithFallback(candidates, activeBaseUrl, setActiveBaseUrl, path, init,
checkedAnswerStateToken, options.getProjectId);
// An identity change while awaiting the network also invalidates the result.
await checkedAnswerStateToken?.();
return response;
};
return {
...createInteractionRequestApi(request),
...createInteractionRequestApi(answerStateRequest),
async getHealth(signal) {
const response = await fetchWithFallback(candidates, activeBaseUrl, setActiveBaseUrl, "/health", { signal, cache: "no-store" }, undefined, undefined, "");
if (!response.ok) throw new Error("Agent 服务暂不可用");
const data: unknown = await response.json();
if (!isRecord(data) || data.ok !== true || data.ready !== true) throw new Error("Agent 服务尚未就绪");
},
async getAnswerViewState(sessionId, messageId, blockId) {
const query = new URLSearchParams({ message_id: messageId, block_id: blockId });
return readAnswerViewStateResponse(await answerStateRequest(`/session/${encodeURIComponent(sessionId)}/ui-state?${query}`));
},
async putAnswerViewState(sessionId, state) {
return readAnswerViewStateResponse(await answerStateRequest(`/session/${encodeURIComponent(sessionId)}/ui-state`, {
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(state)
}));
},
async downloadFile(path) {
const match = /^\/api\/v1(\/session\/[^/?#]+\/files\/mcp-[a-f0-9-]{36}\.(?:zip|xlsx))$/.exec(path);
if (!match) throw new Error("无效的会话文件地址");
@@ -845,3 +875,12 @@ function readMessagePagination(value: unknown): AgentMessagePagination | undefin
if (!isRecord(value)) return undefined;
return { hasMore: value.has_more === true, beforeCursor: typeof value.before_cursor === "string" ? value.before_cursor : null };
}
function answerStateTokenActor(token: string | null): string {
if (!token) return "development-context";
try {
const payload: unknown = JSON.parse(atob(token.split(".")[1].replaceAll("-", "+").replaceAll("_", "/")));
if (isRecord(payload) && typeof payload.sub === "string") return JSON.stringify([payload.iss, payload.sub]);
} catch { /* Non-JWT development credentials use the enclosing workbench owner. */ }
return "development-context";
}
@@ -55,3 +55,11 @@ describe("Responses transport", () => {
expect((await collect(responsesToUiStream(bytes(failed)))).at(-2)).toEqual({ type: "error", errorText: "数据源不可用" });
});
});
it("binds a saved interaction continuation to the owning conversation", () => {
const request = createResponseRequest([{ id: "continuation", role: "user", parts: [{ type: "text", text: "继续处理已保存回答" }] }], {
conversation: "original-session", interactionId: "saved-interaction", capabilities: ["host-question@1"],
});
expect(request.conversation).toBe("original-session");
expect(request.tjwater).toMatchObject({ interaction_id: "saved-interaction", capabilities: ["host-question@1"] });
});
@@ -1,9 +1,17 @@
import type { ComponentProps, Dispatch, Ref, SetStateAction } from "react";
import type { PersonaState } from "@/shared/ai-elements/persona";
import type { StatusTone } from "@/shared/ui/status";
import type { AgentChatSessionSummary, AgentInputDelivery, AgentResponseInput } from "../api/client";
import type { AgentApprovalMode, AgentChatMessage, AgentModelOption, AgentPermissionReply, AgentPermissionRequest, AgentQuestionRequest, AgentStreamRenderState, AgentUiResult } from "../types";
import type { AgentSpeakOptions, AgentSpeechState } from "../speech";
import type { AnswerUiMapTarget } from "../answer-ui/answer-ui-view";
import type { AnswerUiSelection } from "../answer-ui/answer-ui-interaction-context";
import type { AnswerViewStateScope } from "../session-state/answer-view-state-scope";
import { AgentInteractionReply, AgentInteractionRequests } from "./agent-interaction-request-card";
import { canReplaceInteractionRequest } from "../api/interaction-requests";
import type { InteractionRequestsController } from "../hooks/use-interaction-requests";
import type { AnswerUiMapTarget } from "../answer-ui/answer-ui-view";
import { AnswerViewStateProvider } from "../session-state/answer-view-state-context";
import { AnswerPartsView } from "../answer-ui/answer-parts-view";
import type { AnswerUiSelection } from "../answer-ui/answer-ui-interaction-context";
import { ExpandableReading } from "@/shared/ui/reading-workspace";
import { canExpandAnswer, getAnswerPartsTranscript, getAnswerPresentation } from "../answer-ui/answer-presentation";
import { AgentProcessDisclosure } from "./agent-process-disclosure";
@@ -12,109 +20,35 @@ import { groupMessageProcesses, hasPendingMessageInput } from "../presentation/m
import { AgentUserMessageText } from "./agent-user-message-text";
import { useAgentAnswerTransition } from "../hooks/use-agent-answer-transition";
import { copySelectedText } from "@/shared/ai-elements/paragraph-copy";
import {
ArrowUp,
AudioLines,
CheckCircle2,
ChevronDown,
ChevronLeft,
Eye,
History,
LoaderCircle,
Pause,
Play,
Plus,
Quote,
Shield,
ShieldCheck,
Square,
Sparkles,
Zap,
Volume2,
X
} from "lucide-react";
import { ArrowUp, ChevronLeft, Eye, History, Plus, Quote, Square, X } from "lucide-react";
import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "motion/react";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type Dispatch,
type Ref,
type ReactNode,
type SetStateAction
} from "react";
import { createPortal } from "react-dom";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useStickToBottomContext } from "use-stick-to-bottom";
import { showMapNotice } from "@/features/map/core";
import { Agent, AgentContent } from "@/shared/ai-elements/agent";
import {
Conversation,
ConversationContent,
ConversationEmptyState,
ConversationScrollButton
} from "@/shared/ai-elements/conversation";
import type { PersonaState } from "@/shared/ai-elements/persona";
import { Conversation, ConversationContent, ConversationEmptyState, ConversationScrollButton } from "@/shared/ai-elements/conversation";
import { Message, MessageContent } from "@/shared/ai-elements/message";
import {
PromptInput,
PromptInputBody,
PromptInputFooter,
PromptInputSubmit,
PromptInputTextarea
} from "@/shared/ai-elements/prompt-input";
import { PromptInput, PromptInputBody, PromptInputFooter, PromptInputSubmit, PromptInputTextarea } from "@/shared/ai-elements/prompt-input";
import { Suggestion, Suggestions } from "@/shared/ai-elements/suggestion";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/shared/ui/dropdown-menu";
import { Button } from "@/shared/ui/button";
import { Separator } from "@/shared/ui/separator";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/ui/tooltip";
import { cn } from "@/shared/ui/cn";
import { StatusDot, type StatusTone } from "@/shared/ui/status";
import type { AgentChatSessionSummary, AgentInputDelivery, AgentResponseInput } from "../api/client";
import { AGENT_MESSAGE_ACTION_CLASS_NAME, AGENT_MESSAGE_ACTION_LABEL_CLASS_NAME, AgentChatMessageActions, AgentChatMessageHeader, AgentChatMessageToolbar } from "./agent-chat-message-chrome";
import {
agentLayoutTransition,
agentPanelItemVariants,
agentPanelListVariants,
agentPanelSectionVariants
} from "./agent-motion";
import { StatusDot } from "@/shared/ui/status";
import { AgentChatMessageActions, AgentChatMessageHeader, AgentChatMessageToolbar } from "./agent-chat-message-chrome";
import { agentLayoutTransition, agentPanelItemVariants, agentPanelListVariants } from "./agent-motion";
import { AgentPersona } from "./agent-persona";
import { AgentTopicControls } from "./agent-topic-controls";
import { AgentHistoryPanel } from "./agent-history-panel";
import { AgentMessageInbox } from "./agent-message-inbox";
import { AgentPendingInputs } from "./agent-pending-inputs";
import type { ComponentProps } from "react";
import { AgentMessageDetails } from "./agent-message-details";
import { AgentUiEnvelopeRenderer } from "./agent-ui-envelope-renderer";
import { useAgentSpeechRecognition, useAgentSpeechSynthesis } from "../hooks/use-agent-voice";
import {
findSpeechSelectionStartOffset,
stripSpeechMarkdown,
type AgentSpeakOptions,
type AgentSpeechState
} from "../speech";
import type {
AgentApprovalMode,
AgentChatMessage,
AgentConnectionStatus,
AgentModelOption,
AgentPermissionReply,
AgentPermissionRequest,
AgentQuestionRequest,
AgentStreamRenderState,
AgentUiResult
} from "../types";
import { stripSpeechMarkdown } from "../speech";
import type { AgentConnectionStatus } from "../types";
import { VoiceInputButton, InputDeliverySelect, ApprovalModeSelect, AgentModelSelect } from "./agent-panel-controls";
import { AgentSpeechMessage, AgentMessageSpeechControls } from "./agent-message-speech";
export type AgentCommandPanelPresentation = "desktop-dock" | "desktop-floating" | "mobile-sheet";
@@ -125,7 +59,6 @@ export type AgentWorkspaceContextItem = {
tone?: "neutral" | "info" | "warning";
};
type AgentCommandPanelProps = {
presentation?: AgentCommandPanelPresentation;
collapsing?: boolean;
@@ -153,6 +86,7 @@ type AgentCommandPanelProps = {
promptSuggestionsUnavailable?: boolean;
onRetryPromptSuggestions?: () => void;
activeSessionId?: string | null;
answerStateScope?: AnswerViewStateScope;
qaOnly?: boolean;
executionHistory?: boolean;
statusLabel?: string;
@@ -177,7 +111,6 @@ type AgentCommandPanelProps = {
approvalMode?: AgentApprovalMode;
uiResults?: AgentUiResult[];
interactionRequests?: InteractionRequestsController;
reviewPanel?: ReactNode;
messageInbox?: ComponentProps<typeof AgentMessageInbox>;
workspaceContext?: AgentWorkspaceContextItem[];
prompt: string;
@@ -221,6 +154,7 @@ export function AgentCommandPanel({
promptSuggestionsUnavailable = false,
onRetryPromptSuggestions,
activeSessionId,
answerStateScope,
qaOnly = false,
executionHistory = false,
connectionStatus = "connecting",
@@ -241,7 +175,6 @@ export function AgentCommandPanel({
approvalMode = "request",
uiResults = [],
interactionRequests,
reviewPanel,
messageInbox,
prompt,
promptFocusRequest = 0,
@@ -278,7 +211,7 @@ export function AgentCommandPanel({
const floatingPresentation = desktopFloating;
const hasConversation = messages.length > 0 || streaming || Boolean(messageInbox?.messages.length);
// Task discussions keep their reference and composer layout before the first message.
const centeredComposer = !hasConversation && !loadingSessionId && !reviewPanel && !promptReference
const centeredComposer = !hasConversation && !loadingSessionId && !promptReference
&& uiResults.length === 0 && !messageInbox?.loading && !messageInbox?.error;
const showStartSuggestions = centeredComposer && !qaOnly && !promptReference;
const trimmedPrompt = prompt.trim();
@@ -302,7 +235,15 @@ export function AgentCommandPanel({
resume: resumeSpeech,
stop: stopSpeech,
isSupported: isSpeechSupported
} = useAgentSpeechSynthesis();
} = useAgentSpeechSynthesis((message) => {
showMapNotice({
id: "agent-speech-playback-error",
tone: "error",
title: "语音播放失败",
message,
duration: 5000
});
});
const appendRecognitionResult = useCallback((text: string) => {
promptRestoreGenerationRef.current += 1;
onPromptChange((current) => `${current}${current.trim() ? " " : ""}${text}`);
@@ -396,6 +337,7 @@ export function AgentCommandPanel({
/>
);
return (
<AnswerViewStateProvider scope={answerStateScope} sessionId={activeSessionId}>
<MotionConfig reducedMotion="user">
{historyPanel}
<motion.aside
@@ -567,16 +509,12 @@ export function AgentCommandPanel({
showProcess={!qaOnly || executionHistory}
onLocateAnswerFeatures={onLocateAnswerFeatures}
onSubmitAnswerForm={onSubmitPrompt}
onAsk={(selection) => {
onContentReferenceChange?.(selection);
requestAnimationFrame(() => promptTextareaRef.current?.focus());
}}
/>
) : null}
</AnimatePresence>
{!qaOnly ? <AgentUiEnvelopeRenderer results={uiResults} /> : null}
{reviewPanel}
{interactionRequests ? <AgentInteractionRequests controller={interactionRequests}
representedMessageIds={new Set(messages.filter((message) => message.role === "user").map((message) => message.id))} /> : null}
{messageInbox ? <AgentMessageInbox {...messageInbox} /> : null}
@@ -762,255 +700,7 @@ export function AgentCommandPanel({
</Agent>
</motion.aside>
</MotionConfig>
);
}
const VOICE_WAVE_BARS = [
{ height: 5, duration: 0.72, delay: 0.08 },
{ height: 10, duration: 0.58, delay: 0 },
{ height: 15, duration: 0.66, delay: 0.12 },
{ height: 9, duration: 0.62, delay: 0.04 },
{ height: 4, duration: 0.7, delay: 0.16 }
] as const;
function VoiceInputButton({
isListening,
disabled,
onClick
}: {
isListening: boolean;
disabled: boolean;
onClick: () => void;
}) {
const reduceMotion = useReducedMotion();
const label = isListening ? "停止语音输入" : "语音输入";
return (
<motion.button
type="button"
aria-label={label}
aria-pressed={isListening}
title={label}
className={cn(
"agent-panel-voice-input group relative grid size-10 shrink-0 place-items-center overflow-visible rounded-full border-0",
"bg-transparent shadow-none",
"transition-[color,background-color,border-color,box-shadow,transform] duration-200",
"focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-0",
"disabled:cursor-not-allowed disabled:opacity-45",
isListening
? "status-tone-danger bg-[var(--status-soft)] text-[var(--status-foreground)] [@media(hover:hover)]:hover:border-[var(--status-border)] [@media(hover:hover)]:hover:brightness-95"
: "text-slate-600 [@media(hover:hover)]:hover:bg-[var(--action-soft)] [@media(hover:hover)]:hover:text-[var(--action-blue-hover)] active:bg-[var(--action-soft-pressed)]"
)}
disabled={disabled}
onClick={onClick}
>
{isListening ? (
<motion.span
data-slot="voice-input-pulse"
className="pointer-events-none absolute inset-0 rounded-full bg-[var(--status-mark)] opacity-20"
aria-hidden="true"
initial={{ opacity: 0, scale: 0.9 }}
animate={
reduceMotion
? { opacity: 0.38, scale: 1 }
: { opacity: [0, 0.4, 0], scale: [0.9, 1, 1.28] }
}
transition={
reduceMotion
? { duration: 0.16 }
: {
duration: 1.7,
ease: "easeOut",
repeat: Infinity,
times: [0, 0.12, 1]
}
}
/>
) : null}
<span className="relative grid h-5 w-5 place-items-center overflow-hidden">
<AnimatePresence initial={false} mode="wait">
{isListening ? (
<motion.span
key="voice-wave"
className="flex h-4 items-center gap-0.5"
aria-hidden="true"
initial={{ opacity: 0, scale: 0.72 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.72 }}
transition={{ duration: 0.16, ease: "easeOut" }}
>
{VOICE_WAVE_BARS.map((bar) => (
<motion.span
key={bar.height}
className="w-px rounded-full bg-[var(--status-mark)]"
style={{ height: bar.height }}
animate={
reduceMotion ? { scaleY: 0.72 } : { scaleY: [0.38, 1, 0.52, 0.82, 0.38] }
}
transition={
reduceMotion
? { duration: 0.16 }
: {
duration: bar.duration,
delay: bar.delay,
ease: "easeInOut",
repeat: Infinity
}
}
aria-hidden="true"
/>
))}
</motion.span>
) : (
<motion.span
key="voice-idle"
className="grid place-items-center"
aria-hidden="true"
initial={{ opacity: 0, scale: 0.72, y: 2 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.72, y: -2 }}
transition={{ duration: 0.16, ease: "easeOut" }}
>
<AudioLines
strokeWidth={2}
className="size-4 transition-transform duration-200 group-hover:scale-105"
/>
</motion.span>
)}
</AnimatePresence>
</span>
</motion.button>
);
}
const AGENT_SELECT_ITEM_CLASS_NAME =
"rounded-lg border border-transparent px-2.5 py-2 focus:border-[var(--action-border-hover)] focus:bg-[var(--action-selection-soft-hover)] data-[highlighted]:border-[var(--action-border-hover)] data-[highlighted]:bg-[var(--action-selection-soft-hover)] motion-reduce:transition-none";
function AgentToolbarCaret() {
return <ChevronDown className="agent-panel-toolbar-caret size-3 shrink-0 opacity-50" aria-hidden="true" />;
}
function InputDeliverySelect({ presentation, label, value, onValueChange }: {
presentation: AgentCommandPanelPresentation;
label: string;
value: AgentInputDelivery;
onValueChange?: (delivery: AgentInputDelivery) => void;
}) {
return <DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="ghost" size="sm" aria-label={label}
title={value === "queue" ? "当前任务结束后处理" : "在下一步补充当前任务"}
className="agent-panel-toolbar-trigger h-10 shrink-0 gap-1.5 px-2 text-sm font-normal active:scale-95 motion-reduce:transform-none"
disabled={!onValueChange}>
{value === "queue" ? "排队" : "引导"}
<AgentToolbarCaret />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent data-agent-presentation={presentation} side="top" align="end" sideOffset={6}
collisionPadding={12} className="agent-panel-overlay agent-panel-control w-44 rounded-xl p-1.5 shadow-lg">
<DropdownMenuRadioGroup aria-label={label} value={value} onValueChange={(delivery) => {
if (delivery === "steer" || delivery === "queue") onValueChange?.(delivery);
}}>
{([ ["steer", "引导当前任务"], ["queue", "排队处理"] ] as const).map(([delivery, text]) => (
<DropdownMenuRadioItem key={delivery} value={delivery}
className={cn(AGENT_SELECT_ITEM_CLASS_NAME, "min-h-10 pl-8 pr-2.5 text-sm",
value === delivery && "border-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-hover)]")}>
{text}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>;
}
function ApprovalModeSelect({
presentation,
value,
disabled,
onValueChange
}: {
presentation: AgentCommandPanelPresentation;
value: AgentApprovalMode;
disabled: boolean;
onValueChange?: (mode: AgentApprovalMode) => void;
}) {
const requestApproval = value === "request";
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="agent-panel-toolbar-trigger h-10 shrink-0 gap-1.5 border-0 bg-transparent px-1.5 text-sm text-slate-600 shadow-none data-[state=open]:bg-[var(--action-soft)] data-[state=open]:text-[var(--action-blue-hover)] focus-visible:ring-1 focus-visible:ring-offset-0"
disabled={disabled || !onValueChange}
aria-label="权限批准模式"
title={requestApproval ? "请求批准" : "始终允许"}
>
{requestApproval ? (
<Shield className="size-4" aria-hidden="true" />
) : (
<ShieldCheck className="size-4" aria-hidden="true" />
)}
<span>{requestApproval ? "请求批准" : "始终允许"}</span>
<AgentToolbarCaret />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
data-agent-presentation={presentation}
side="top"
align="start"
sideOffset={8}
className="agent-panel-overlay agent-panel-control w-[220px] rounded-xl p-2 shadow-lg"
>
<DropdownMenuLabel className="px-2 pb-2 pt-1 text-xs text-[var(--ink-muted)]">
权限批准模式
</DropdownMenuLabel>
<DropdownMenuItem
className={cn(
AGENT_SELECT_ITEM_CLASS_NAME,
"items-start",
requestApproval && "border-[var(--surface-border)] bg-[var(--action-soft)]"
)}
onSelect={() => onValueChange?.("request")}
>
<Shield className="mt-0.5 text-[var(--action-blue-hover)]" aria-hidden="true" />
<span>
<span className="block text-sm font-semibold text-slate-800">请求批准</span>
<span className="block text-xs leading-5 text-[var(--ink-muted)]">需要审批的请求逐次确认;只读白名单直接执行</span>
</span>
{requestApproval ? (
<CheckCircle2 className="ml-auto mt-0.5 text-[var(--action-blue-hover)]" aria-hidden="true" />
) : null}
</DropdownMenuItem>
<DropdownMenuItem
className={cn(
AGENT_SELECT_ITEM_CLASS_NAME,
"mt-1 items-start",
!requestApproval &&
"status-tone-success border-[var(--status-border)] bg-[var(--status-soft)]"
)}
onSelect={() => onValueChange?.("always")}
>
<ShieldCheck
className="status-tone-success mt-0.5 text-[var(--status-foreground)]"
aria-hidden="true"
/>
<span>
<span className="block text-sm font-semibold text-slate-800">始终允许</span>
<span className="block text-xs leading-5 text-[var(--ink-muted)]">自动批准本轮工具权限请求,不改变业务权限或安全限制</span>
</span>
{!requestApproval ? (
<CheckCircle2
className="status-tone-success ml-auto mt-0.5 text-[var(--status-foreground)]"
aria-hidden="true"
/>
) : null}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</AnswerViewStateProvider>
);
}
@@ -1167,101 +857,12 @@ function getLastUserMessageId(messages: AgentChatMessage[]) {
return null;
}
function AgentModelSelect({
presentation,
models,
value,
onValueChange
}: {
presentation: AgentCommandPanelPresentation;
models: AgentModelOption[];
value: string;
onValueChange?: (model: string) => void;
}) {
const selectedModel = models.find((model) => model.id === value);
if (!models.length) {
return (
<span className="shrink-0 rounded-md px-2 py-1 text-sm text-[var(--ink-muted)]">模型</span>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="agent-panel-toolbar-trigger h-10 w-11 shrink-0 justify-between gap-1 border-0 bg-transparent px-1 text-sm text-slate-600 shadow-none data-[state=open]:bg-[var(--action-soft)] data-[state=open]:text-[var(--action-blue-hover)] focus-visible:ring-1 focus-visible:ring-offset-0 @[400px]/agent-composer:w-auto @[400px]/agent-composer:max-w-40 @[400px]/agent-composer:gap-1.5 @[400px]/agent-composer:px-1.5"
disabled={!onValueChange}
aria-label="选择 Agent 模型"
aria-description={`当前模型:${selectedModel?.label ?? "模型"}`}
title={selectedModel?.label ?? "模型"}
>
<span className="flex min-w-0 flex-1 items-center gap-1">
<ModelIcon model={selectedModel} />
<span className="hidden truncate @[400px]/agent-composer:inline">{selectedModel?.label ?? "模型"}</span>
</span>
<AgentToolbarCaret />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
data-agent-presentation={presentation}
side="top"
align="end"
sideOffset={8}
className="agent-panel-overlay agent-panel-control w-[230px] rounded-xl p-2 shadow-lg"
>
<DropdownMenuLabel className="px-2 pb-2 pt-1 text-xs text-[var(--ink-muted)]">
模型选择
</DropdownMenuLabel>
<div className="space-y-1">
{models.map((model) => {
const selected = model.id === selectedModel?.id;
return (
<DropdownMenuItem
key={model.id}
className={cn(
AGENT_SELECT_ITEM_CLASS_NAME,
selected && "border-[var(--surface-border)] bg-[var(--action-soft)]"
)}
onSelect={() => onValueChange?.(model.id)}
>
<span className="ml-0.5 shrink-0 pt-0.5">
<ModelIcon model={model} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-slate-800">
{model.label}
</span>
<span className="block truncate text-xs leading-4 text-[var(--ink-muted)]">
{model.description}
</span>
</span>
{selected ? (
<CheckCircle2 size={15} className={presentation === "desktop-dock" ? "text-[var(--action-blue-hover)]" : "text-[var(--action-blue-hover)]"} aria-hidden="true" />
) : null}
</DropdownMenuItem>
);
})}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
const agentConnectionMetadata = {
connecting: { label: "连接中", tone: "info" },
online: { label: "在线", tone: "success" },
offline: { label: "离线", tone: "danger" }
} satisfies Record<AgentConnectionStatus, { label: string; tone: StatusTone }>;
function ModelIcon({ model }: { model?: AgentModelOption }) {
const Icon = model?.icon === "bolt" ? Zap : Sparkles;
return <Icon className="size-4" aria-hidden="true" />;
}
type BackendMessageListProps = {
presentation: AgentCommandPanelPresentation;
messages: AgentChatMessage[];
@@ -1281,7 +882,6 @@ type BackendMessageListProps = {
showProcess: boolean;
onLocateAnswerFeatures?: (target: AnswerUiMapTarget) => Promise<unknown> | void;
onSubmitAnswerForm: (text: string) => Promise<void> | void;
onAsk?: (selection: AnswerUiSelection) => void;
interactionRequests?: InteractionRequestsController;
};
@@ -1346,7 +946,6 @@ function BackendMessage({
showProcess,
onLocateAnswerFeatures,
onSubmitAnswerForm,
onAsk,
interactionRequests,
}: BackendMessageListProps & {
ref?: Ref<HTMLDivElement>;
@@ -1448,13 +1047,13 @@ function BackendMessage({
>
<AnswerPartsView
parts={answer.report}
persist={answer.final && completed}
messageId={message.id}
disabled={!completed}
streaming={!answer.final && !completed}
streamDone={answer.final || (streamRenderState[message.id]?.done ?? completed)}
onLocate={onLocateAnswerFeatures}
onSubmit={onSubmitAnswerForm}
onAsk={onAsk}
/>
</ExpandableReading>
</AgentSpeechMessage>
@@ -1513,251 +1112,3 @@ function BackendMessage({
</motion.div>
);
}
type SpeechSelection = {
startOffset: number;
left: number;
top: number;
};
function AgentSpeechMessage({
presentation,
messageId,
content,
disabled,
isSupported,
onSpeak,
children
}: {
presentation: AgentCommandPanelPresentation;
messageId: string;
content: string;
disabled: boolean;
isSupported: boolean;
onSpeak: (messageId: string, text: string, options?: AgentSpeakOptions) => Promise<void> | void;
children: ReactNode;
}) {
const contentRef = useRef<HTMLDivElement | null>(null);
const lastSelectionRef = useRef<SpeechSelection | null>(null);
const [selection, setSelection] = useState<SpeechSelection | null>(null);
const speechText = useMemo(() => stripSpeechMarkdown(content), [content]);
const selectionAnchor = selection ?? lastSelectionRef.current;
if (selection) {
lastSelectionRef.current = selection;
}
const captureSelection = useCallback(() => {
if (!isSupported || disabled) {
setSelection(null);
return;
}
const browserSelection = window.getSelection();
const container = contentRef.current;
if (
!browserSelection ||
browserSelection.rangeCount === 0 ||
browserSelection.isCollapsed ||
!container
) {
setSelection(null);
return;
}
const range = browserSelection.getRangeAt(0);
if (!container.contains(range.commonAncestorContainer)) {
setSelection(null);
return;
}
const startOffset = findSpeechSelectionStartOffset(speechText, browserSelection.toString());
const rect = range.getBoundingClientRect();
if (startOffset === null || (!rect.width && !rect.height)) {
setSelection(null);
return;
}
setSelection({
startOffset,
left: Math.min(Math.max(rect.left + rect.width / 2, 88), window.innerWidth - 88),
top: Math.max(rect.top - 10, 48)
});
}, [disabled, isSupported, speechText]);
useEffect(() => {
setSelection(null);
}, [messageId, speechText]);
useEffect(() => {
if (!selection) return;
const close = () => setSelection(null);
const closeCollapsedSelection = () => {
if (window.getSelection()?.isCollapsed) close();
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") close();
};
document.addEventListener("selectionchange", closeCollapsedSelection);
document.addEventListener("keydown", closeOnEscape);
window.addEventListener("resize", close);
window.addEventListener("scroll", close, true);
return () => {
document.removeEventListener("selectionchange", closeCollapsedSelection);
document.removeEventListener("keydown", closeOnEscape);
window.removeEventListener("resize", close);
window.removeEventListener("scroll", close, true);
};
}, [selection]);
const speakFromSelection = () => {
if (!selection) return;
void Promise.resolve(onSpeak(messageId, speechText, { startOffset: selection.startOffset }));
window.getSelection()?.removeAllRanges();
setSelection(null);
};
return (
<div>
<div
ref={contentRef}
onPointerUp={() => window.requestAnimationFrame(captureSelection)}
onKeyUp={() => window.requestAnimationFrame(captureSelection)}
>
{children}
</div>
{selectionAnchor && typeof document !== "undefined"
? createPortal(
<div
className="pointer-events-none fixed z-[80] -translate-x-1/2 -translate-y-full"
style={{ left: selectionAnchor.left, top: selectionAnchor.top }}
>
<AnimatePresence>
{selection ? (
<motion.button
key="speech-selection-action"
type="button"
data-agent-presentation={presentation}
className="agent-panel-overlay agent-panel-floating-acrylic agent-speech-selection-action pointer-events-auto inline-flex h-10 items-center gap-2 rounded-xl px-2.5 pr-3 text-xs font-semibold text-slate-800 transition-colors hover:text-[var(--action-blue-hover)] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]"
initial={{ opacity: 0 }}
animate={{
opacity: 1,
transition: { duration: 0.12, ease: [0.16, 1, 0.3, 1] }
}}
exit={{
opacity: 0,
transition: { duration: 0.08, ease: [0.4, 0, 1, 1] }
}}
onMouseDown={(event) => event.preventDefault()}
onClick={speakFromSelection}
>
<span
className="grid h-6 w-6 shrink-0 place-items-center rounded-lg bg-[var(--action-soft)]/75 text-[var(--action-blue-hover)]"
aria-hidden="true"
>
<Volume2 size={15} />
</span>
从这里开始朗读
</motion.button>
) : null}
</AnimatePresence>
</div>,
document.body
)
: null}
</div>
);
}
function AgentMessageSpeechControls({
speechState,
onSpeak,
onPause,
onResume,
onStop
}: {
speechState: AgentSpeechState;
onSpeak: () => void;
onPause: () => void;
onResume: () => void;
onStop: () => void;
}) {
return (
<div className="flex shrink-0 items-center gap-0.5">
{speechState === "idle" ? (
<SpeechIconButton label="语音播放" onClick={onSpeak}>
<Volume2 size={16} aria-hidden="true" />
<span className={AGENT_MESSAGE_ACTION_LABEL_CLASS_NAME}>语音播放</span>
</SpeechIconButton>
) : null}
{speechState === "loading" ? (
<>
<SpeechIconButton label="正在生成语音" disabled>
<LoaderCircle size={16} className="motion-safe:animate-spin" aria-hidden="true" />
</SpeechIconButton>
<SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}>
<Square size={16} aria-hidden="true" />
</SpeechIconButton>
</>
) : null}
{speechState === "playing" ? (
<>
<SpeechIconButton label="暂停语音播放" tone="primary" onClick={onPause}>
<Pause size={16} aria-hidden="true" />
<span className={AGENT_MESSAGE_ACTION_LABEL_CLASS_NAME}>暂停</span>
</SpeechIconButton>
<SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}>
<Square size={16} aria-hidden="true" />
</SpeechIconButton>
</>
) : null}
{speechState === "paused" ? (
<>
<SpeechIconButton label="继续语音播放" tone="primary" onClick={onResume}>
<Play size={16} aria-hidden="true" />
<span className={AGENT_MESSAGE_ACTION_LABEL_CLASS_NAME}>继续</span>
</SpeechIconButton>
<SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}>
<Square size={16} aria-hidden="true" />
</SpeechIconButton>
</>
) : null}
</div>
);
}
function SpeechIconButton({
label,
tone = "default",
disabled = false,
onClick,
children
}: {
label: string;
tone?: "default" | "primary" | "danger";
disabled?: boolean;
onClick?: () => void;
children: ReactNode;
}) {
return (
<Button
type="button"
aria-label={label}
title={label}
disabled={disabled}
onClick={onClick}
size="sm"
variant="ghost"
className={cn(
AGENT_MESSAGE_ACTION_CLASS_NAME,
"shrink-0 disabled:cursor-wait",
tone === "primary" && "text-[var(--action-blue-hover)]",
tone === "danger" && "status-tone-danger text-[var(--status-foreground)]"
)}
>
{children}
</Button>
);
}
@@ -7,6 +7,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuRadioG
import { Separator } from "@/shared/ui/separator";
import { cn } from "@/shared/ui/cn";
import { StatusBadge } from "@/shared/ui/status";
import { copySelectedText } from "@/shared/ai-elements/paragraph-copy";
import type { AgentChatSessionSummary, AgentTopic } from "../api/client";
type Props = {
@@ -77,7 +78,7 @@ export function AgentHistoryPanel({ open, onOpenChange, triggerRef, scopeLabel,
};
let previousGroup = "";
return <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent showCloseButton={false} data-agent-theme="chat"
<DialogContent onCopy={copySelectedText} showCloseButton={false} data-agent-theme="chat"
overlayClassName="agent-history-overlay motion-reduce:animate-none"
className="agent-history-dialog flex flex-col gap-0 overflow-hidden bg-card p-0 text-foreground motion-reduce:animate-none"
style={viewport ? { "--history-viewport-height": `${viewport.height}px`, "--history-viewport-top": `${viewport.top}px` } as CSSProperties : undefined}
@@ -0,0 +1,261 @@
import type { ReactNode } from "react";
import type { AgentSpeakOptions, AgentSpeechState } from "../speech";
import { LoaderCircle, Pause, Play, Square, Volume2 } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/ui/cn";
import { AGENT_MESSAGE_ACTION_CLASS_NAME, AGENT_MESSAGE_ACTION_LABEL_CLASS_NAME } from "./agent-chat-message-chrome";
import { AgentSpeechVoicePicker } from "./agent-speech-voice-picker";
import { findSpeechSelectionStartOffset, stripSpeechMarkdown } from "../speech";
import type { AgentCommandPanelPresentation } from "./agent-command-panel";
type SpeechSelection = {
startOffset: number;
left: number;
top: number;
};
export function AgentSpeechMessage({
presentation,
messageId,
content,
disabled,
isSupported,
onSpeak,
children
}: {
presentation: AgentCommandPanelPresentation;
messageId: string;
content: string;
disabled: boolean;
isSupported: boolean;
onSpeak: (messageId: string, text: string, options?: AgentSpeakOptions) => Promise<void> | void;
children: ReactNode;
}) {
const contentRef = useRef<HTMLDivElement | null>(null);
const lastSelectionRef = useRef<SpeechSelection | null>(null);
const [selection, setSelection] = useState<SpeechSelection | null>(null);
const speechText = useMemo(() => stripSpeechMarkdown(content), [content]);
const selectionAnchor = selection ?? lastSelectionRef.current;
if (selection) {
lastSelectionRef.current = selection;
}
const captureSelection = useCallback(() => {
if (!isSupported || disabled) {
setSelection(null);
return;
}
const browserSelection = window.getSelection();
const container = contentRef.current;
if (
!browserSelection ||
browserSelection.rangeCount === 0 ||
browserSelection.isCollapsed ||
!container
) {
setSelection(null);
return;
}
const range = browserSelection.getRangeAt(0);
if (!container.contains(range.commonAncestorContainer)) {
setSelection(null);
return;
}
const startOffset = findSpeechSelectionStartOffset(speechText, browserSelection.toString());
const rect = range.getBoundingClientRect();
if (startOffset === null || (!rect.width && !rect.height)) {
setSelection(null);
return;
}
setSelection({
startOffset,
left: Math.min(Math.max(rect.left + rect.width / 2, 88), window.innerWidth - 88),
top: Math.max(rect.top - 10, 48)
});
}, [disabled, isSupported, speechText]);
useEffect(() => {
setSelection(null);
}, [messageId, speechText]);
useEffect(() => {
if (!selection) return;
const close = () => setSelection(null);
const closeCollapsedSelection = () => {
if (window.getSelection()?.isCollapsed) close();
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") close();
};
document.addEventListener("selectionchange", closeCollapsedSelection);
document.addEventListener("keydown", closeOnEscape);
window.addEventListener("resize", close);
window.addEventListener("scroll", close, true);
return () => {
document.removeEventListener("selectionchange", closeCollapsedSelection);
document.removeEventListener("keydown", closeOnEscape);
window.removeEventListener("resize", close);
window.removeEventListener("scroll", close, true);
};
}, [selection]);
const speakFromSelection = () => {
if (!selection) return;
void Promise.resolve(onSpeak(messageId, speechText, { startOffset: selection.startOffset }));
window.getSelection()?.removeAllRanges();
setSelection(null);
};
return (
<div>
<div
ref={contentRef}
onPointerUp={() => window.requestAnimationFrame(captureSelection)}
onKeyUp={() => window.requestAnimationFrame(captureSelection)}
>
{children}
</div>
{selectionAnchor && typeof document !== "undefined"
? createPortal(
<div
className="pointer-events-none fixed z-[80] -translate-x-1/2 -translate-y-full"
style={{ left: selectionAnchor.left, top: selectionAnchor.top }}
>
<AnimatePresence>
{selection ? (
<motion.button
key="speech-selection-action"
type="button"
data-agent-presentation={presentation}
className="agent-panel-overlay agent-panel-floating-acrylic agent-speech-selection-action pointer-events-auto inline-flex h-10 items-center gap-2 rounded-xl px-2.5 pr-3 text-xs font-semibold text-slate-800 transition-colors hover:text-[var(--action-blue-hover)] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]"
initial={{ opacity: 0 }}
animate={{
opacity: 1,
transition: { duration: 0.12, ease: [0.16, 1, 0.3, 1] }
}}
exit={{
opacity: 0,
transition: { duration: 0.08, ease: [0.4, 0, 1, 1] }
}}
onMouseDown={(event) => event.preventDefault()}
onClick={speakFromSelection}
>
<span
className="grid h-6 w-6 shrink-0 place-items-center rounded-lg bg-[var(--action-soft)]/75 text-[var(--action-blue-hover)]"
aria-hidden="true"
>
<Volume2 size={15} />
</span>
从这里开始朗读
</motion.button>
) : null}
</AnimatePresence>
</div>,
document.body
)
: null}
</div>
);
}
export function AgentMessageSpeechControls({
speechState,
onSpeak,
onPause,
onResume,
onStop
}: {
speechState: AgentSpeechState;
onSpeak: () => void;
onPause: () => void;
onResume: () => void;
onStop: () => void;
}) {
return (
<div className="flex shrink-0 items-center gap-0.5">
<AgentSpeechVoicePicker />
{speechState === "idle" ? (
<SpeechIconButton label="语音播放" onClick={onSpeak}>
<Volume2 size={16} aria-hidden="true" />
<span className={AGENT_MESSAGE_ACTION_LABEL_CLASS_NAME}>语音播放</span>
</SpeechIconButton>
) : null}
{speechState === "loading" ? (
<>
<SpeechIconButton label="正在生成语音" disabled>
<LoaderCircle size={16} className="motion-safe:animate-spin" aria-hidden="true" />
</SpeechIconButton>
<SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}>
<Square size={16} aria-hidden="true" />
</SpeechIconButton>
</>
) : null}
{speechState === "playing" ? (
<>
<SpeechIconButton label="暂停语音播放" tone="primary" onClick={onPause}>
<Pause size={16} aria-hidden="true" />
<span className={AGENT_MESSAGE_ACTION_LABEL_CLASS_NAME}>暂停</span>
</SpeechIconButton>
<SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}>
<Square size={16} aria-hidden="true" />
</SpeechIconButton>
</>
) : null}
{speechState === "paused" ? (
<>
<SpeechIconButton label="继续语音播放" tone="primary" onClick={onResume}>
<Play size={16} aria-hidden="true" />
<span className={AGENT_MESSAGE_ACTION_LABEL_CLASS_NAME}>继续</span>
</SpeechIconButton>
<SpeechIconButton label="停止语音播放" tone="danger" onClick={onStop}>
<Square size={16} aria-hidden="true" />
</SpeechIconButton>
</>
) : null}
</div>
);
}
function SpeechIconButton({
label,
tone = "default",
disabled = false,
onClick,
children
}: {
label: string;
tone?: "default" | "primary" | "danger";
disabled?: boolean;
onClick?: () => void;
children: ReactNode;
}) {
return (
<Button
type="button"
aria-label={label}
title={label}
disabled={disabled}
onClick={onClick}
size="sm"
variant="ghost"
className={cn(
AGENT_MESSAGE_ACTION_CLASS_NAME,
"shrink-0 disabled:cursor-wait",
tone === "primary" && "text-[var(--action-blue-hover)]",
tone === "danger" && "status-tone-danger text-[var(--status-foreground)]"
)}
>
{children}
</Button>
);
}
@@ -0,0 +1,346 @@
import type { AgentInputDelivery } from "../api/client";
import type { AgentApprovalMode, AgentModelOption } from "../types";
import { AudioLines, CheckCircle2, ChevronDown, Shield, ShieldCheck, Sparkles, Zap } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger } from "@/shared/ui/dropdown-menu";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/ui/cn";
import type { AgentCommandPanelPresentation } from "./agent-command-panel";
const VOICE_WAVE_BARS = [
{ height: 5, duration: 0.72, delay: 0.08 },
{ height: 10, duration: 0.58, delay: 0 },
{ height: 15, duration: 0.66, delay: 0.12 },
{ height: 9, duration: 0.62, delay: 0.04 },
{ height: 4, duration: 0.7, delay: 0.16 }
] as const;
export function VoiceInputButton({
isListening,
disabled,
onClick
}: {
isListening: boolean;
disabled: boolean;
onClick: () => void;
}) {
const reduceMotion = useReducedMotion();
const label = isListening ? "停止语音输入" : "语音输入";
return (
<motion.button
type="button"
aria-label={label}
aria-pressed={isListening}
title={label}
className={cn(
"agent-panel-voice-input group relative grid size-10 shrink-0 place-items-center overflow-visible rounded-full border-0",
"bg-transparent shadow-none",
"transition-[color,background-color,border-color,box-shadow,transform] duration-200",
"focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-0",
"disabled:cursor-not-allowed disabled:opacity-45",
isListening
? "status-tone-danger bg-[var(--status-soft)] text-[var(--status-foreground)] [@media(hover:hover)]:hover:border-[var(--status-border)] [@media(hover:hover)]:hover:brightness-95"
: "text-slate-600 [@media(hover:hover)]:hover:bg-[var(--action-soft)] [@media(hover:hover)]:hover:text-[var(--action-blue-hover)] active:bg-[var(--action-soft-pressed)]"
)}
disabled={disabled}
onClick={onClick}
>
{isListening ? (
<motion.span
data-slot="voice-input-pulse"
className="pointer-events-none absolute inset-0 rounded-full bg-[var(--status-mark)] opacity-20"
aria-hidden="true"
initial={{ opacity: 0, scale: 0.9 }}
animate={
reduceMotion
? { opacity: 0.38, scale: 1 }
: { opacity: [0, 0.4, 0], scale: [0.9, 1, 1.28] }
}
transition={
reduceMotion
? { duration: 0.16 }
: {
duration: 1.7,
ease: "easeOut",
repeat: Infinity,
times: [0, 0.12, 1]
}
}
/>
) : null}
<span className="relative grid h-5 w-5 place-items-center overflow-hidden">
<AnimatePresence initial={false} mode="wait">
{isListening ? (
<motion.span
key="voice-wave"
className="flex h-4 items-center gap-0.5"
aria-hidden="true"
initial={{ opacity: 0, scale: 0.72 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.72 }}
transition={{ duration: 0.16, ease: "easeOut" }}
>
{VOICE_WAVE_BARS.map((bar) => (
<motion.span
key={bar.height}
className="w-px rounded-full bg-[var(--status-mark)]"
style={{ height: bar.height }}
animate={
reduceMotion ? { scaleY: 0.72 } : { scaleY: [0.38, 1, 0.52, 0.82, 0.38] }
}
transition={
reduceMotion
? { duration: 0.16 }
: {
duration: bar.duration,
delay: bar.delay,
ease: "easeInOut",
repeat: Infinity
}
}
aria-hidden="true"
/>
))}
</motion.span>
) : (
<motion.span
key="voice-idle"
className="grid place-items-center"
aria-hidden="true"
initial={{ opacity: 0, scale: 0.72, y: 2 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.72, y: -2 }}
transition={{ duration: 0.16, ease: "easeOut" }}
>
<AudioLines
strokeWidth={2}
className="size-4 transition-transform duration-200 group-hover:scale-105"
/>
</motion.span>
)}
</AnimatePresence>
</span>
</motion.button>
);
}
const AGENT_SELECT_ITEM_CLASS_NAME =
"rounded-lg border border-transparent px-2.5 py-2 focus:border-[var(--action-border-hover)] focus:bg-[var(--action-selection-soft-hover)] data-[highlighted]:border-[var(--action-border-hover)] data-[highlighted]:bg-[var(--action-selection-soft-hover)] motion-reduce:transition-none";
function AgentToolbarCaret() {
return <ChevronDown className="agent-panel-toolbar-caret size-3 shrink-0 opacity-50" aria-hidden="true" />;
}
export function InputDeliverySelect({ presentation, label, value, onValueChange }: {
presentation: AgentCommandPanelPresentation;
label: string;
value: AgentInputDelivery;
onValueChange?: (delivery: AgentInputDelivery) => void;
}) {
return <DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="ghost" size="sm" aria-label={label}
title={value === "queue" ? "当前任务结束后处理" : "在下一步补充当前任务"}
className="agent-panel-toolbar-trigger h-10 shrink-0 gap-1.5 px-2 text-sm font-normal active:scale-95 motion-reduce:transform-none"
disabled={!onValueChange}>
{value === "queue" ? "排队" : "引导"}
<AgentToolbarCaret />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent data-agent-presentation={presentation} side="top" align="end" sideOffset={6}
collisionPadding={12} className="agent-panel-overlay agent-panel-control w-44 rounded-xl p-1.5 shadow-lg">
<DropdownMenuRadioGroup aria-label={label} value={value} onValueChange={(delivery) => {
if (delivery === "steer" || delivery === "queue") onValueChange?.(delivery);
}}>
{([ ["steer", "引导当前任务"], ["queue", "排队处理"] ] as const).map(([delivery, text]) => (
<DropdownMenuRadioItem key={delivery} value={delivery}
className={cn(AGENT_SELECT_ITEM_CLASS_NAME, "min-h-10 pl-8 pr-2.5 text-sm",
value === delivery && "border-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-hover)]")}>
{text}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>;
}
export function ApprovalModeSelect({
presentation,
value,
disabled,
onValueChange
}: {
presentation: AgentCommandPanelPresentation;
value: AgentApprovalMode;
disabled: boolean;
onValueChange?: (mode: AgentApprovalMode) => void;
}) {
const requestApproval = value === "request";
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="agent-panel-toolbar-trigger h-10 shrink-0 gap-1.5 border-0 bg-transparent px-1.5 text-sm text-slate-600 shadow-none data-[state=open]:bg-[var(--action-soft)] data-[state=open]:text-[var(--action-blue-hover)] focus-visible:ring-1 focus-visible:ring-offset-0"
disabled={disabled || !onValueChange}
aria-label="权限批准模式"
title={requestApproval ? "请求批准" : "始终允许"}
>
{requestApproval ? (
<Shield className="size-4" aria-hidden="true" />
) : (
<ShieldCheck className="size-4" aria-hidden="true" />
)}
<span>{requestApproval ? "请求批准" : "始终允许"}</span>
<AgentToolbarCaret />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
data-agent-presentation={presentation}
side="top"
align="start"
sideOffset={8}
className="agent-panel-overlay agent-panel-control w-[220px] rounded-xl p-2 shadow-lg"
>
<DropdownMenuLabel className="px-2 pb-2 pt-1 text-xs text-[var(--ink-muted)]">
权限批准模式
</DropdownMenuLabel>
<DropdownMenuItem
className={cn(
AGENT_SELECT_ITEM_CLASS_NAME,
"items-start",
requestApproval && "border-[var(--surface-border)] bg-[var(--action-soft)]"
)}
onSelect={() => onValueChange?.("request")}
>
<Shield className="mt-0.5 text-[var(--action-blue-hover)]" aria-hidden="true" />
<span>
<span className="block text-sm font-semibold text-slate-800">请求批准</span>
<span className="block text-xs leading-5 text-[var(--ink-muted)]">需要审批的请求逐次确认;只读白名单直接执行</span>
</span>
{requestApproval ? (
<CheckCircle2 className="ml-auto mt-0.5 text-[var(--action-blue-hover)]" aria-hidden="true" />
) : null}
</DropdownMenuItem>
<DropdownMenuItem
className={cn(
AGENT_SELECT_ITEM_CLASS_NAME,
"mt-1 items-start",
!requestApproval &&
"status-tone-success border-[var(--status-border)] bg-[var(--status-soft)]"
)}
onSelect={() => onValueChange?.("always")}
>
<ShieldCheck
className="status-tone-success mt-0.5 text-[var(--status-foreground)]"
aria-hidden="true"
/>
<span>
<span className="block text-sm font-semibold text-slate-800">始终允许</span>
<span className="block text-xs leading-5 text-[var(--ink-muted)]">自动批准本轮工具权限请求,不改变业务权限或安全限制</span>
</span>
{!requestApproval ? (
<CheckCircle2
className="status-tone-success ml-auto mt-0.5 text-[var(--status-foreground)]"
aria-hidden="true"
/>
) : null}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
export function AgentModelSelect({
presentation,
models,
value,
onValueChange
}: {
presentation: AgentCommandPanelPresentation;
models: AgentModelOption[];
value: string;
onValueChange?: (model: string) => void;
}) {
const selectedModel = models.find((model) => model.id === value);
if (!models.length) {
return (
<span className="shrink-0 rounded-md px-2 py-1 text-sm text-[var(--ink-muted)]">模型</span>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="agent-panel-toolbar-trigger h-10 w-11 shrink-0 justify-between gap-1 border-0 bg-transparent px-1 text-sm text-slate-600 shadow-none data-[state=open]:bg-[var(--action-soft)] data-[state=open]:text-[var(--action-blue-hover)] focus-visible:ring-1 focus-visible:ring-offset-0 @[400px]/agent-composer:w-auto @[400px]/agent-composer:max-w-40 @[400px]/agent-composer:gap-1.5 @[400px]/agent-composer:px-1.5"
disabled={!onValueChange}
aria-label="选择 Agent 模型"
aria-description={`当前模型:${selectedModel?.label ?? "模型"}`}
title={selectedModel?.label ?? "模型"}
>
<span className="flex min-w-0 flex-1 items-center gap-1">
<ModelIcon model={selectedModel} />
<span className="hidden truncate @[400px]/agent-composer:inline">{selectedModel?.label ?? "模型"}</span>
</span>
<AgentToolbarCaret />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
data-agent-presentation={presentation}
side="top"
align="end"
sideOffset={8}
className="agent-panel-overlay agent-panel-control w-[230px] rounded-xl p-2 shadow-lg"
>
<DropdownMenuLabel className="px-2 pb-2 pt-1 text-xs text-[var(--ink-muted)]">
模型选择
</DropdownMenuLabel>
<div className="space-y-1">
{models.map((model) => {
const selected = model.id === selectedModel?.id;
return (
<DropdownMenuItem
key={model.id}
className={cn(
AGENT_SELECT_ITEM_CLASS_NAME,
selected && "border-[var(--surface-border)] bg-[var(--action-soft)]"
)}
onSelect={() => onValueChange?.(model.id)}
>
<span className="ml-0.5 shrink-0 pt-0.5">
<ModelIcon model={model} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-slate-800">
{model.label}
</span>
<span className="block truncate text-xs leading-4 text-[var(--ink-muted)]">
{model.description}
</span>
</span>
{selected ? (
<CheckCircle2 size={15} className={presentation === "desktop-dock" ? "text-[var(--action-blue-hover)]" : "text-[var(--action-blue-hover)]"} aria-hidden="true" />
) : null}
</DropdownMenuItem>
);
})}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
function ModelIcon({ model }: { model?: AgentModelOption }) {
const Icon = model?.icon === "bolt" ? Zap : Sparkles;
return <Icon className="size-4" aria-hidden="true" />;
}
@@ -43,7 +43,7 @@ export function AgentPendingInputs({ presentation, inputs, onDeliveryChange, onE
if (!inputs.length) return null;
return (
<section aria-label="待处理消息队列" className="order-first w-full min-w-0 border-b border-[var(--surface-divider)] bg-[var(--surface-reading)]">
<section aria-label="待处理消息队列" className="mb-2 w-full min-w-0 overflow-hidden rounded-xl border border-[var(--surface-divider)] bg-[var(--surface-reading)]">
<p className="px-3 pt-2 text-[11px] leading-5 text-muted-foreground" aria-live="polite">
等待处理 <span className="ml-1 tabular-nums">{inputs.length}</span>
</p>
@@ -0,0 +1,73 @@
import { useState } from "react";
import useSWR from "swr";
import { Check, ChevronDown, SlidersHorizontal } from "lucide-react";
import { fetchSpeechVoiceCatalog } from "../speech-voice-catalog";
import { Button } from "@/shared/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/shared/ui/command";
import { getSpeechVoiceDescription, getSpeechVoiceName, setSelectedSpeechVoice, useSelectedSpeechVoice } from "../speech-voices";
import { AGENT_MESSAGE_ACTION_CLASS_NAME, AGENT_MESSAGE_ACTION_LABEL_CLASS_NAME } from "./agent-chat-message-chrome";
export function AgentSpeechVoicePicker() {
const [open, setOpen] = useState(false);
const selected = useSelectedSpeechVoice();
const { data, error, isLoading, mutate } = useSWR("/api/tts/edge/voices", fetchSpeechVoiceCatalog, {
revalidateOnFocus: false, revalidateOnReconnect: false, dedupingInterval: 60 * 60 * 1_000,
shouldRetryOnError: false
});
const selectedVoice = data?.voices.find((voice) => voice.id === (selected || data.defaultVoice));
const defaultVoice = data?.voices.find((voice) => voice.id === data.defaultVoice);
const selectedName = selectedVoice ? getSpeechVoiceName(selectedVoice) : selected ? "已选音色" : "默认音色";
const choose = (id: string) => { setSelectedSpeechVoice(id); setOpen(false); };
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className={AGENT_MESSAGE_ACTION_CLASS_NAME}
aria-label={`选择朗读音色,当前${selectedName}`} title={`朗读音色:${selectedName}`}>
<SlidersHorizontal size={16} aria-hidden="true" />
<span className={AGENT_MESSAGE_ACTION_LABEL_CLASS_NAME}>音色</span>
<ChevronDown className="size-3" aria-hidden="true" />
</Button>
</PopoverTrigger>
<PopoverContent data-agent-theme="chat" align="end" side="top" collisionPadding={12} className="z-[90] w-80 overflow-hidden p-0" aria-label="选择朗读音色">
<div className="border-b px-3 py-2">
<p className="text-sm font-medium">普通话音色</p>
<p className="text-xs text-muted-foreground">{data ? `${data.voices.length} 种音色,选择后下次播放生效` : "选择后下次播放生效"}</p>
</div>
<Command loop>
<CommandInput placeholder="搜索普通话音色" aria-label="搜索音色" />
<CommandList className="max-h-[min(320px,calc(var(--radix-popover-content-available-height)-180px))] overscroll-contain">
{isLoading ? <p role="status" className="p-3 text-sm text-muted-foreground">正在加载音色…</p> : null}
{error ? <div role="alert" className="p-3 text-sm">
<p>音色列表加载失败</p>
{error instanceof Error ? <p className="mt-1 text-xs text-muted-foreground">{error.message}</p> : null}
<Button variant="link" size="sm" className="px-0" onClick={() => void mutate()}>重新加载音色</Button>
</div> : null}
<CommandEmpty>没有找到匹配的音色</CommandEmpty>
<CommandGroup>
<CommandItem value="default 默认音色" onSelect={() => choose("")}>
<span className="flex-1">默认音色{defaultVoice ? `(${getSpeechVoiceName(defaultVoice)})` : ""}</span>
{!selected ? <Check aria-hidden="true" /> : null}
</CommandItem>
</CommandGroup>
{selected && data && !selectedVoice ? <p role="status" className="px-3 py-2 text-xs text-muted-foreground">原选音色已不可用,请重新选择。</p> : null}
<CommandGroup>
{data?.voices.map((voice) => {
const name = getSpeechVoiceName(voice);
const description = getSpeechVoiceDescription(voice);
return <CommandItem key={voice.id} value={`${voice.id} ${name} ${description} ${voice.name}`} onSelect={() => choose(voice.id)}>
<span className="min-w-0 flex-1">
<span className="block break-words text-sm">{name}</span>
<span className="block break-words text-xs text-muted-foreground">{description}</span>
</span>
{selected === voice.id ? <Check aria-hidden="true" /> : null}
</CommandItem>;
})}
</CommandGroup>
</CommandList>
</Command>
<p className="border-t px-3 py-2 text-xs text-muted-foreground">音色偏好保存在当前浏览器。</p>
</PopoverContent>
</Popover>
);
}
@@ -3,6 +3,7 @@ import { useId, useRef, useState, type RefObject } from "react";
import { Button } from "@/shared/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import { copySelectedText } from "@/shared/ai-elements/paragraph-copy";
import type { AgentTopic } from "../api/client";
type TopicView = { kind: "list" | "create" } | { kind: "rename" | "delete"; topic: AgentTopic };
@@ -48,7 +49,7 @@ export function AgentTopicDialog({ open, initialMode, onOpenChange, triggerRef,
const footerClass = "gap-2 px-6 pb-6 sm:gap-0";
return <Dialog open={open} onOpenChange={(next) => { if (!busy) onOpenChange(next); }}>
<DialogContent data-agent-theme="chat" overlayClassName="agent-history-overlay" showCloseButton={false}
<DialogContent onCopy={copySelectedText} data-agent-theme="chat" overlayClassName="agent-history-overlay" showCloseButton={false}
className="agent-topic-dialog z-[111] flex max-h-[min(80dvh,40rem)] w-[calc(100vw-2rem)] max-w-md flex-col gap-0 overflow-hidden bg-card p-0"
onOpenAutoFocus={(event) => { event.preventDefault(); (inputRef.current ?? newTopicRef.current)?.focus(); }}
onCloseAutoFocus={(event) => { event.preventDefault(); triggerRef.current?.focus(); }}>
@@ -85,7 +85,7 @@ function SafeChartEnvelope({ envelope, embedded = false }: { envelope: Extract<U
return (
<div className={embedded ? "min-w-0" : "agent-panel-nested rounded-xl p-3"}>
<div className="mb-3 flex items-start justify-between gap-3">
<div className={cn("mb-3 flex gap-3", embedded ? "min-w-0 flex-col" : "items-start justify-between")}>
{!embedded ? <div className="min-w-0">
<p className="truncate text-sm font-semibold text-slate-950" title={spec.title}>
{spec.title ?? "Agent 图表"}
@@ -95,11 +95,11 @@ function SafeChartEnvelope({ envelope, embedded = false }: { envelope: Extract<U
{spec.yAxisName ? ` · ${spec.yAxisName}` : ""}
</p>
</div> : null}
<div className="flex shrink-0 flex-wrap justify-end gap-1.5">
<div className={cn("flex flex-wrap gap-1.5", embedded ? "min-w-0 justify-start" : "shrink-0 justify-end")}>
{data.series.slice(0, 3).map((series, index) => (
<span key={series.name} className="flex items-center gap-1 text-xs text-slate-600">
<span className={cn("h-2 w-2 rounded-full", chartColorClass(index))} />
{series.name}
<span key={series.name} className={cn("flex items-center gap-1 text-xs", embedded ? "min-w-0 text-muted-foreground" : "text-slate-600")}>
<span className={cn("h-2 w-2 shrink-0 rounded-full", chartColorClass(index))} />
{embedded ? <span className="min-w-0 break-words [overflow-wrap:anywhere]">{series.name}</span> : series.name}
</span>
))}
</div>
@@ -2,8 +2,11 @@ import { cn } from "@/shared/ui/cn";
import { MessageResponse } from "@/shared/ai-elements/message";
import { defaultRehypePlugins } from "streamdown";
import { artifactLinkComponents, artifactLinksPlugin } from "./artifact-markdown";
import { ReadableMarkdownDiagram, ReadableMarkdownTable } from "@/shared/ai-elements/markdown-reading";
const responseRehypePlugins = [...Object.values(defaultRehypePlugins), artifactLinksPlugin];
const responseComponents = { ...artifactLinkComponents, table: ReadableMarkdownTable };
const responsePlugins = { renderers: [{ language: "mermaid", component: ReadableMarkdownDiagram }] };
type StreamingTokenResponseProps = {
content: string;
@@ -32,7 +35,8 @@ export function StreamingTokenResponse({
mode="streaming"
parseIncompleteMarkdown={true}
rehypePlugins={responseRehypePlugins}
components={artifactLinkComponents}
components={responseComponents}
plugins={responsePlugins}
>
{content}
</MessageResponse>
+3 -8
View File
@@ -1,11 +1,6 @@
import { z } from "zod";
const chartSchema = z.object({
title: z.string().max(200), chart_type: z.enum(["line", "bar"]),
x_data: z.array(z.string().max(128)).min(1).max(100),
series: z.array(z.object({ name: z.string().max(128), data: z.array(z.number().finite()).min(1).max(100) }).strict()).min(1).max(8),
}).strict().refine((value) => value.series.every((series) => series.data.length === value.x_data.length));
import { safeChartInput } from "../presentation/command-schema";
export const frontendChartSchema = safeChartInput;
export const parseFrontendChart = (value: unknown) => {
const parsed = chartSchema.safeParse(value);
const parsed = frontendChartSchema.safeParse(value);
return parsed.success ? parsed.data : null;
};
+81 -15
View File
@@ -7,13 +7,21 @@ import {
} from ".";
type ExecuteAction = (request: FrontendActionRequest, signal: AbortSignal) => Promise<unknown>;
type SubmitResult = (sessionId: string, actionId: string, result: FrontendActionResult) => Promise<void>;
type SubmitResult = (
sessionId: string,
actionId: string,
result: FrontendActionResult
) => Promise<void>;
const ERROR_CODE_PATTERN = /^[A-Z][A-Z0-9_]+$/;
export class FrontendActionExecutor {
private readonly controllers = new Map<string, AbortController>();
private queue: Promise<void> = Promise.resolve();
constructor(private readonly execute: ExecuteAction, private readonly submit: SubmitResult) {}
constructor(
private readonly execute: ExecuteAction,
private readonly submit: SubmitResult
) {}
async handle(value: unknown, activeSessionId: string | null): Promise<void> {
const request = parseFrontendActionRequest(value);
@@ -24,32 +32,90 @@ export class FrontendActionExecutor {
return;
}
if (this.controllers.has(request.actionId)) return;
const result = await this.executeOnce(request);
storeActionResult(request.sessionId, result);
await this.submit(request.sessionId, request.actionId, result);
const controller = new AbortController();
this.controllers.set(request.actionId, controller);
const pending = this.queue.then(async () => {
const result = await this.executeOnce(request, controller);
storeActionResult(request.sessionId, result);
await this.submit(request.sessionId, request.actionId, result);
});
this.queue = pending.catch(() => undefined);
return pending;
}
cancelAll(): void {
for (const controller of this.controllers.values()) controller.abort();
this.controllers.clear();
this.queue = Promise.resolve();
}
private async executeOnce(request: FrontendActionRequest): Promise<FrontendActionResult> {
if (Date.now() >= request.expiresAt) return this.failure(request, "expired", "ACTION_EXPIRED", "frontend action expired before execution");
const controller = new AbortController();
this.controllers.set(request.actionId, controller);
private async executeOnce(
request: FrontendActionRequest,
controller: AbortController
): Promise<FrontendActionResult> {
const remaining = request.expiresAt - Date.now();
const timer =
remaining > 0
? setTimeout(
() => controller.abort(new DOMException("Action expired", "TimeoutError")),
remaining
)
: undefined;
try {
return { version: "frontend-action-result@1", actionId: request.actionId, status: "succeeded", output: await this.execute(request, controller.signal), completedAt: Date.now() };
controller.signal.throwIfAborted();
if (remaining <= 0)
return this.failure(
request,
"expired",
"ACTION_EXPIRED",
"frontend action expired before execution"
);
const output = await this.execute(request, controller.signal);
controller.signal.throwIfAborted();
return {
version: "frontend-action-result@2",
actionId: request.actionId,
status: "succeeded",
output,
completedAt: Date.now()
};
} catch (error) {
const expired =
controller.signal.reason instanceof DOMException &&
controller.signal.reason.name === "TimeoutError";
const message = error instanceof Error ? error.message : "frontend action failed";
const code = controller.signal.aborted ? "ACTION_CANCELLED" : ERROR_CODE_PATTERN.test(message) ? message : "ACTION_FAILED";
return this.failure(request, controller.signal.aborted ? "cancelled" : "failed", code, message);
const code = expired
? "ACTION_EXPIRED"
: controller.signal.aborted
? "ACTION_CANCELLED"
: ERROR_CODE_PATTERN.test(message)
? message
: "ACTION_FAILED";
return this.failure(
request,
expired ? "expired" : controller.signal.aborted ? "cancelled" : "failed",
code,
message
);
} finally {
this.controllers.delete(request.actionId);
clearTimeout(timer);
if (this.controllers.get(request.actionId) === controller)
this.controllers.delete(request.actionId);
}
}
private failure(request: FrontendActionRequest, status: "failed" | "cancelled" | "expired", code: string, message: string): FrontendActionResult {
return { version: "frontend-action-result@1", actionId: request.actionId, status, error: { code, message }, completedAt: Date.now() };
private failure(
request: FrontendActionRequest,
status: "failed" | "cancelled" | "expired",
code: string,
message: string
): FrontendActionResult {
return {
version: "frontend-action-result@2",
actionId: request.actionId,
status,
error: { code, message },
completedAt: Date.now()
};
}
}
@@ -15,11 +15,11 @@ beforeEach(() => {
describe("SCADA frontend actions", () => {
it("accepts registered view actions and rejects unknown action names", () => {
expect(parseFrontendActionRegistry({
schema_version: "frontend-action-registry@1",
schema_version: "frontend-action-registry@2",
actions: [
{ id: "render_scada_analysis", version: "frontend-action@1" },
{ id: "clear_scada_analysis", version: "frontend-action@1" },
{ id: "zoom_to_map", version: "frontend-action@1" }
{ id: "render_scada_analysis", version: "frontend-action@2" },
{ id: "clear_scada_analysis", version: "frontend-action@2" },
{ id: "zoom_to_map", version: "frontend-action@2" }
]
})?.actions.map((action) => action.id)).toEqual([
"render_scada_analysis",
@@ -49,6 +49,7 @@ describe("SCADA frontend actions", () => {
expect(execute).toHaveBeenCalledTimes(1);
expect(submit).toHaveBeenCalledTimes(2);
expect(submit.mock.calls[0][2]).toMatchObject({
version: "frontend-action-result@2",
status: "succeeded",
output: { rendered_ids: ["MP01"], fitted: true }
});
@@ -58,13 +59,37 @@ describe("SCADA frontend actions", () => {
function createRequest(name: string) {
return {
version: "frontend-action@1",
version: "frontend-action@2",
actionId: "action-1",
toolCallId: "call-1",
sessionId: "session-1",
name,
params: { items: [{ device_id: "MP01", level: "high" }] },
command: { action: name, items: [{ device_id: "MP01", level: "high" }] },
issuedAt: Date.now(),
expiresAt: Date.now() + 15_000
};
}
describe("ordered v2 actions", () => {
it("rejects v1 envelopes, legacy name/params and malformed canonical commands", () => {
const request = createRequest("render_scada_analysis");
expect(parseFrontendActionRequest({ ...request, version: "frontend-action@1" })).toBeNull();
expect(parseFrontendActionRequest({ ...request, name: "render_scada_analysis", params: {} })).toBeNull();
expect(parseFrontendActionRequest({ ...request, command: { action: "zoom_to_map", center: [181, 31], zoom: 14 } })).toBeNull();
});
it("keeps commands in stream order and cancels queued work when a session changes", async () => {
let release!: () => void;
const first = new Promise<void>((resolve) => { release = resolve; });
const execute = vi.fn(async (request, signal) => { await first; signal.throwIfAborted(); return { action: request.command.action }; });
const submit = vi.fn(async () => {});
const executor = new FrontendActionExecutor(execute, submit);
const one = executor.handle(createRequest("render_scada_analysis"), "session-1");
const two = executor.handle({ ...createRequest("render_scada_analysis"), actionId: "action-2" }, "session-1");
await Promise.resolve();
expect(execute).toHaveBeenCalledTimes(1);
executor.cancelAll(); release(); await Promise.all([one, two]);
expect(execute).toHaveBeenCalledTimes(1);
expect(submit.mock.calls.map((call) => (call as unknown[])[2])).toEqual([
expect.objectContaining({ status: "cancelled" }), expect.objectContaining({ status: "cancelled" })
]);
});
});
+82 -11
View File
@@ -1,12 +1,83 @@
export const FRONTEND_ACTION_NAMES = ["render_scada_analysis", "clear_scada_analysis", "locate_features", "zoom_to_map", "show_chart", "view_history"] as const;
import { z } from "zod";
import {
PRESENTATION_ACTIONS,
presentationCommandSchema,
type PresentationCommand
} from "../presentation/command";
export { parseFrontendChart } from "./chart";
export type FrontendActionName = (typeof FRONTEND_ACTION_NAMES)[number];
export type FrontendActionRegistry = { schema_version: "frontend-action-registry@1"; actions: Array<{ id: FrontendActionName; version: "frontend-action@1" }> };
export type FrontendActionRequest = { version: "frontend-action@1"; actionId: string; toolCallId: string; sessionId: string; name: FrontendActionName; params: Record<string, unknown>; issuedAt: number; expiresAt: number };
export type FrontendActionResult = { version: "frontend-action-result@1"; actionId: string; status: "succeeded" | "failed" | "cancelled" | "expired"; output?: unknown; error?: { code: string; message: string }; completedAt: number };
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
export function parseFrontendActionRegistry(value: unknown): FrontendActionRegistry | null { if (!isRecord(value) || value.schema_version !== "frontend-action-registry@1" || !Array.isArray(value.actions)) return null; const actions = value.actions.filter((item): item is { id: FrontendActionName; version: "frontend-action@1" } => isRecord(item) && FRONTEND_ACTION_NAMES.includes(item.id as FrontendActionName) && item.version === "frontend-action@1"); return { schema_version: "frontend-action-registry@1", actions }; }
export function parseFrontendActionRequest(value: unknown): FrontendActionRequest | null { if (!isRecord(value) || value.version !== "frontend-action@1" || typeof value.actionId !== "string" || typeof value.toolCallId !== "string" || typeof value.sessionId !== "string" || !FRONTEND_ACTION_NAMES.includes(value.name as FrontendActionName) || !isRecord(value.params) || typeof value.issuedAt !== "number" || typeof value.expiresAt !== "number") return null; return value as FrontendActionRequest; }
const storageKey = (sessionId: string) => `tjwater:frontend-actions:${sessionId}`;
export function readActionResults(sessionId: string): Record<string, FrontendActionResult> { try { const value = JSON.parse(sessionStorage.getItem(storageKey(sessionId)) ?? "{}"); return isRecord(value) ? value as Record<string, FrontendActionResult> : {}; } catch { return {}; } }
export function storeActionResult(sessionId: string, result: FrontendActionResult) { const values = { ...readActionResults(sessionId), [result.actionId]: result }; const bounded = Object.fromEntries(Object.entries(values).sort(([, a], [, b]) => b.completedAt - a.completedAt).slice(0, 100)); sessionStorage.setItem(storageKey(sessionId), JSON.stringify(bounded)); }
export const FRONTEND_ACTION_NAMES = PRESENTATION_ACTIONS;
export type FrontendActionName = PresentationCommand["action"];
export type FrontendActionRegistry = {
schema_version: "frontend-action-registry@2";
actions: Array<{ id: FrontendActionName; version: "frontend-action@2" }>;
};
const identifier = z.string().trim().min(1).max(128);
const requestSchema = z
.object({
version: z.literal("frontend-action@2"),
actionId: identifier,
toolCallId: identifier,
sessionId: identifier,
command: presentationCommandSchema,
issuedAt: z.number().finite().positive(),
expiresAt: z.number().finite().positive(),
risk: z.enum(["view", "reversible", "side_effect"]).optional(),
replayPolicy: z.enum(["never", "explicit"]).optional(),
fallbackText: z.string().max(2000).optional(),
replayOf: identifier.optional()
})
.strict()
.refine((value) => value.expiresAt > value.issuedAt);
export type FrontendActionRequest = z.infer<typeof requestSchema>;
export type FrontendActionResult = {
version: "frontend-action-result@2";
actionId: string;
status: "succeeded" | "failed" | "rejected" | "cancelled" | "expired";
output?: unknown;
error?: { code: string; message: string };
completedAt: number;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const registrySchema = z
.object({
schema_version: z.literal("frontend-action-registry@2"),
actions: z.array(
z
.object({
id: z.enum(PRESENTATION_ACTIONS),
version: z.literal("frontend-action@2"),
risk: z.string().optional(),
timeoutMs: z.number().optional(),
supportedSurfaces: z.array(z.string()).optional()
})
.strict()
)
})
.strict();
export function parseFrontendActionRegistry(value: unknown): FrontendActionRegistry | null {
const parsed = registrySchema.safeParse(value);
return parsed.success ? parsed.data : null;
}
export function parseFrontendActionRequest(value: unknown): FrontendActionRequest | null {
const parsed = requestSchema.safeParse(value);
return parsed.success ? parsed.data : null;
}
const storageKey = (sessionId: string) => `tjwater:frontend-actions:v2:${sessionId}`;
export function readActionResults(sessionId: string): Record<string, FrontendActionResult> {
try {
const value = JSON.parse(sessionStorage.getItem(storageKey(sessionId)) ?? "{}");
return isRecord(value) ? (value as Record<string, FrontendActionResult>) : {};
} catch {
return {};
}
}
export function storeActionResult(sessionId: string, result: FrontendActionResult) {
const values = { ...readActionResults(sessionId), [result.actionId]: result };
const bounded = Object.fromEntries(
Object.entries(values)
.sort(([, a], [, b]) => b.completedAt - a.completedAt)
.slice(0, 100)
);
sessionStorage.setItem(storageKey(sessionId), JSON.stringify(bounded));
}
@@ -0,0 +1,116 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { useAgentSpeechSynthesis } from "./use-agent-voice";
import { setSelectedSpeechVoice } from "../speech-voices";
class MockAudio {
static instances: MockAudio[] = [];
onended: (() => void) | null = null;
onerror: (() => void) | null = null;
play = vi.fn(async () => undefined);
pause = vi.fn();
load = vi.fn();
removeAttribute = vi.fn();
constructor() { MockAudio.instances.push(this); }
}
const fetchMock = vi.fn<typeof fetch>();
const audioResponse = () => new Response(new Blob(["audio"], { type: "audio/mpeg" }));
const errorResponse = () => new Response(JSON.stringify({ error: "语音生成失败" }), { status: 502 });
beforeEach(() => {
setSelectedSpeechVoice("");
MockAudio.instances = [];
fetchMock.mockReset();
vi.stubGlobal("Audio", MockAudio);
vi.stubGlobal("fetch", fetchMock);
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:audio");
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined);
});
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
it("keeps one chosen voice for all chunks, then applies a changed choice to the next playback", async () => {
fetchMock.mockImplementation(async () => audioResponse());
setSelectedSpeechVoice("zh-CN-YunyangNeural");
const { result, unmount } = renderHook(() => useAgentSpeechSynthesis());
await act(() => result.current.speak("message", `${"甲".repeat(300)}。${"乙".repeat(300)}。${"丙".repeat(300)}。`));
setSelectedSpeechVoice("zh-CN-XiaoyiNeural");
await act(async () => { MockAudio.instances[0].onended?.(); });
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3));
expect(fetchMock.mock.calls.map(([, options]) => JSON.parse(String(options?.body)).voice))
.toEqual(["zh-CN-YunyangNeural", "zh-CN-YunyangNeural", "zh-CN-YunyangNeural"]);
await act(() => result.current.speak("new", "Hello"));
expect(JSON.parse(String(fetchMock.mock.lastCall?.[1]?.body)).voice).toBe("zh-CN-XiaoyiNeural");
unmount();
});
it("reports synthesis failure and allows a successful retry", async () => {
const onError = vi.fn();
fetchMock.mockResolvedValueOnce(errorResponse()).mockResolvedValueOnce(audioResponse());
const { result, unmount } = renderHook(() => useAgentSpeechSynthesis(onError));
await act(() => result.current.speak("message", "你好"));
expect(onError).toHaveBeenCalledWith("语音生成失败");
expect(result.current.speechState).toBe("idle");
await act(() => result.current.speak("message", "你好"));
expect(result.current.speechState).toBe("playing");
unmount();
});
it("handles failure of a later chunk without leaving playback loading", async () => {
const onError = vi.fn();
fetchMock.mockResolvedValueOnce(audioResponse()).mockImplementation(async () => errorResponse());
const { result, unmount } = renderHook(() => useAgentSpeechSynthesis(onError));
await act(() => result.current.speak("message", `${"甲".repeat(300)}。${"乙".repeat(300)}。`));
await act(async () => { MockAudio.instances[0].onended?.(); });
await waitFor(() => expect(onError).toHaveBeenCalledWith("语音生成失败"));
expect(result.current.speechState).toBe("idle");
expect(result.current.speakingMessageId).toBeNull();
unmount();
});
it("ignores a late failure belonging to a replaced playback", async () => {
const onError = vi.fn();
let failOld!: (reason: Error) => void;
fetchMock.mockImplementationOnce(() => new Promise((_resolve, reject) => { failOld = reject; }))
.mockResolvedValueOnce(audioResponse());
const { result, unmount } = renderHook(() => useAgentSpeechSynthesis(onError));
let oldPlayback!: Promise<void>;
act(() => { oldPlayback = result.current.speak("old", "旧回复"); });
await act(() => result.current.speak("new", "新回复"));
await act(async () => { failOld(new Error("old request failed")); await oldPlayback; });
expect(result.current.speechState).toBe("playing");
expect(result.current.speakingMessageId).toBe("new");
expect(onError).not.toHaveBeenCalled();
unmount();
});
it("reports media and resume failures and releases playback", async () => {
const onError = vi.fn();
fetchMock.mockImplementation(async () => audioResponse());
const { result, unmount } = renderHook(() => useAgentSpeechSynthesis(onError));
await act(() => result.current.speak("message", "你好"));
act(() => { MockAudio.instances[0].onerror?.(); });
expect(onError).toHaveBeenLastCalledWith("音频播放失败,请重试");
expect(result.current.speechState).toBe("idle");
await act(() => result.current.speak("message", "你好"));
act(() => result.current.pause());
MockAudio.instances[1].play.mockRejectedValueOnce(new DOMException("blocked", "NotAllowedError"));
await act(async () => result.current.resume());
expect(onError).toHaveBeenLastCalledWith("浏览器阻止了语音播放,请再次点击播放");
expect(result.current.speechState).toBe("idle");
unmount();
});
it("cancels a pending request silently when stopped", async () => {
const onError = vi.fn();
fetchMock.mockImplementation((_url, options) => new Promise((_resolve, reject) => {
options?.signal?.addEventListener("abort", () => reject(new DOMException("cancelled", "AbortError")));
}));
const { result, unmount } = renderHook(() => useAgentSpeechSynthesis(onError));
let playback!: Promise<void>;
act(() => { playback = result.current.speak("message", "你好"); });
await act(async () => { result.current.stop(); await playback; });
expect(onError).not.toHaveBeenCalled();
expect(result.current.speechState).toBe("idle");
unmount();
});
+36 -24
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { getSelectedSpeechVoice } from "../speech-voices";
import {
splitSpeechTextIntoChunks,
type AgentSpeakOptions,
@@ -54,7 +55,11 @@ function getServerBrowserCapability() {
return false;
}
export function useAgentSpeechSynthesis() {
export function useAgentSpeechSynthesis(onError?: (message: string) => void) {
const onErrorRef = useRef(onError);
useEffect(() => {
onErrorRef.current = onError;
}, [onError]);
const [speechState, setSpeechState] = useState<AgentSpeechState>("idle");
const [speakingMessageId, setSpeakingMessageId] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
@@ -64,6 +69,7 @@ export function useAgentSpeechSynthesis() {
const chunkUrlCacheRef = useRef<Map<number, string>>(new Map());
const chunkPromisesRef = useRef<Map<number, Promise<string>>>(new Map());
const chunksRef = useRef<string[]>([]);
const playbackVoiceRef = useRef("");
const currentChunkIndexRef = useRef(0);
const playbackTokenRef = useRef(0);
const activeMessageIdRef = useRef<string | null>(null);
@@ -108,6 +114,19 @@ export function useAgentSpeechSynthesis() {
activeMessageIdRef.current = null;
}, [detachAudio]);
const handlePlaybackError = useCallback((error: unknown, playbackToken: number) => {
if (playbackToken !== playbackTokenRef.current) return;
playbackTokenRef.current += 1;
releaseAudio();
setSpeechState("idle");
setSpeakingMessageId(null);
if (error instanceof DOMException && error.name === "AbortError") return;
const message = error instanceof DOMException && error.name === "NotAllowedError"
? "浏览器阻止了语音播放,请再次点击播放"
: error instanceof Error ? error.message : "音频播放失败,请重试";
onErrorRef.current?.(message);
}, [releaseAudio]);
const fetchChunkAudio = useCallback((chunkIndex: number, playbackToken: number) => {
const cachedUrl = chunkUrlCacheRef.current.get(chunkIndex);
if (cachedUrl) return Promise.resolve(cachedUrl);
@@ -123,7 +142,7 @@ export function useAgentSpeechSynthesis() {
const promise = fetch("/api/tts/edge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
body: JSON.stringify({ text, ...(playbackVoiceRef.current ? { voice: playbackVoiceRef.current } : {}) }),
signal: controller.signal
})
.then(async (response) => {
@@ -144,7 +163,7 @@ export function useAgentSpeechSynthesis() {
})
.finally(() => {
fetchControllersRef.current.delete(controller);
chunkPromisesRef.current.delete(chunkIndex);
if (playbackToken === playbackTokenRef.current) chunkPromisesRef.current.delete(chunkIndex);
});
chunkPromisesRef.current.set(chunkIndex, promise);
@@ -179,21 +198,18 @@ export function useAgentSpeechSynthesis() {
return;
}
currentChunkIndexRef.current = nextChunkIndex;
void playChunkRef.current(nextChunkIndex, playbackToken);
void playChunkRef.current(nextChunkIndex, playbackToken)
.catch((error: unknown) => handlePlaybackError(error, playbackToken));
};
audio.onerror = () => {
if (playbackToken !== playbackTokenRef.current) return;
playbackTokenRef.current += 1;
releaseAudio();
setSpeechState("idle");
setSpeakingMessageId(null);
handlePlaybackError(new Error("音频播放失败,请重试"), playbackToken);
};
await audio.play();
if (playbackToken !== playbackTokenRef.current) return;
setSpeechState("playing");
prefetchChunk(chunkIndex + 1, playbackToken);
}, [detachAudio, fetchChunkAudio, prefetchChunk, releaseAudio]);
}, [detachAudio, fetchChunkAudio, handlePlaybackError, prefetchChunk, releaseAudio]);
useEffect(() => {
playChunkRef.current = playChunk;
@@ -211,6 +227,7 @@ export function useAgentSpeechSynthesis() {
playbackTokenRef.current = playbackToken;
releaseAudio();
chunksRef.current = chunks;
playbackVoiceRef.current = getSelectedSpeechVoice();
activeMessageIdRef.current = messageId;
setSpeakingMessageId(messageId);
setSpeechState("loading");
@@ -218,12 +235,9 @@ export function useAgentSpeechSynthesis() {
try {
await playChunk(0, playbackToken);
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return;
releaseAudio();
setSpeechState("idle");
setSpeakingMessageId(null);
handlePlaybackError(error, playbackToken);
}
}, [isSupported, playChunk, releaseAudio]);
}, [isSupported, playChunk, releaseAudio, handlePlaybackError]);
const pause = useCallback(() => {
if (!audioRef.current || speechState !== "playing") return;
@@ -234,15 +248,13 @@ export function useAgentSpeechSynthesis() {
const resume = useCallback(() => {
const audio = audioRef.current;
if (!audio) return;
setSpeakingMessageId(activeMessageIdRef.current);
setSpeechState("playing");
void audio.play().catch(() => {
playbackTokenRef.current += 1;
releaseAudio();
setSpeechState("idle");
setSpeakingMessageId(null);
});
}, [releaseAudio]);
const playbackToken = playbackTokenRef.current;
void audio.play().then(() => {
if (playbackToken !== playbackTokenRef.current) return;
setSpeakingMessageId(activeMessageIdRef.current);
setSpeechState("playing");
}).catch((error: unknown) => handlePlaybackError(error, playbackToken));
}, [handlePlaybackError]);
const stop = useCallback(() => {
playbackTokenRef.current += 1;
+4
View File
@@ -107,7 +107,11 @@ export type { AnswerUiSelection } from "./answer-ui/answer-ui-interaction-contex
export type { AgentSessionPage, AgentHistoryMessagePage, AgentMessagePagination } from "./api/client";
export type { AgentRunStatus } from "./api/client";
export { AnswerViewStateScope } from "./session-state/answer-view-state-scope";
export { useInteractionRequests } from "./hooks/use-interaction-requests";
export { AgentInteractionRequests } from "./components/agent-interaction-request-card";
export { readInteractionReply } from "./api/interaction-requests";
export type { InteractionRequestsController } from "./hooks/use-interaction-requests";
export { presentationCommandSchema, presentationOverlayDataSchema, PRESENTATION_ACTIONS, type PresentationCommand } from "./presentation/command";
@@ -0,0 +1,212 @@
import { z } from "zod";
/** Shared host/native schema factory. Native OpenCode supplies its own Zod instance. */
export function createViewSchemas(zod: typeof z = z, publication = false) {
const z = zod;
const presentationIdentifierSchema = z.string().trim().min(1).max(128);
const chartSchema = z
.object({
title: z.string().max(200),
chart_type: z.enum(["line", "bar"]),
x_data: z.array(z.string().max(128)).min(1).max(100),
series: z
.array(
z
.object({
name: z.string().max(128),
data: z.array(z.number().finite()).min(1).max(100)
})
.strict()
)
.min(1)
.max(8)
})
.strict()
.refine(
(value) => value.series.every((series) => series.data.length === value.x_data.length),
"series must align with x_data"
);
const position = z
.tuple([z.number().finite().min(-180).max(180), z.number().finite().min(-90).max(90)])
.rest(z.number().finite())
.refine((value) => value.length <= 3);
const line = z.array(position).min(2).max(20000);
const ring = z
.array(position)
.min(4)
.max(20000)
.refine((value) => value[0][0] === value.at(-1)![0] && value[0][1] === value.at(-1)![1]);
const geometrySchema = z
.discriminatedUnion("type", [
z.object({ type: z.literal("Point"), coordinates: position }).strict(),
z
.object({ type: z.literal("MultiPoint"), coordinates: z.array(position).min(1).max(20000) })
.strict(),
z.object({ type: z.literal("LineString"), coordinates: line }).strict(),
z
.object({
type: z.literal("MultiLineString"),
coordinates: z.array(line).min(1).max(20000)
})
.strict(),
z
.object({ type: z.literal("Polygon"), coordinates: z.array(ring).min(1).max(20000) })
.strict(),
z
.object({
type: z.literal("MultiPolygon"),
coordinates: z.array(z.array(ring).min(1).max(20000)).min(1).max(20000)
})
.strict()
])
.refine((geometry) => {
const count = (coordinates: unknown): number =>
!Array.isArray(coordinates)
? 0
: typeof coordinates[0] === "number"
? 1
: coordinates.reduce<number>((total, item) => total + count(item), 0);
return count(geometry.coordinates) <= 20000;
});
const presentationOverlayDataSchema = z
.object({
type: z.literal("FeatureCollection"),
features: z
.array(
z
.object({
type: z.literal("Feature"),
id: z.union([z.string(), z.number().finite()]).optional(),
properties: z.record(z.string(), z.unknown()).nullable(),
geometry: geometrySchema
})
.strict()
)
.min(1)
.max(1000)
})
.strict();
const supplyLayerSchema = z.enum([
"junctions",
"pipes",
"valves",
"pumps",
"tanks",
"reservoirs",
"scada"
]);
const scadaItems = z
.array(
z
.object({
device_id: presentationIdentifierSchema,
level: z.enum(["high", "medium", "low", "unrated"])
})
.strict()
)
.min(1)
.max(100);
const overlaySchema = z
.object({
action: z.literal("set_overlay"),
overlay_id: presentationIdentifierSchema,
title: z.string().trim().min(1).max(200),
mode: z.enum(["geometry", "heatmap"]),
evidence_id: presentationIdentifierSchema,
data: publication ? presentationOverlayDataSchema.optional() : presentationOverlayDataSchema
})
.strict();
const variants = [
z
.object({
action: z.literal("locate_features"),
layer: supplyLayerSchema,
feature_ids: z.array(presentationIdentifierSchema).min(1).max(100)
})
.strict(),
z
.object({
action: z.literal("zoom_to_map"),
center: z.tuple([
z.number().finite().min(-180).max(180),
z.number().finite().min(-90).max(90)
]),
zoom: z.number().finite().min(0).max(24)
})
.strict(),
z.object({ action: z.literal("render_scada_analysis"), items: scadaItems }).strict(),
z.object({ action: z.literal("clear_scada_analysis") }).strict(),
z
.object({
action: z.literal("highlight_features"),
layer: supplyLayerSchema,
feature_ids: z.array(presentationIdentifierSchema).min(1).max(100),
effect: z.enum(["steady", "pulse"])
})
.strict(),
z.object({ action: z.literal("clear_highlights") }).strict(),
z
.object({
action: z.literal("open_chart"),
chart_id: presentationIdentifierSchema,
chart: chartSchema,
evidence_refs: z
.array(presentationIdentifierSchema)
.min(publication ? 1 : 0)
.max(100)
})
.strict(),
z.object({ action: z.literal("close_chart"), chart_id: presentationIdentifierSchema }).strict(),
overlaySchema,
z
.object({ action: z.literal("clear_overlay"), overlay_id: presentationIdentifierSchema })
.strict()
] as const;
const command = z.discriminatedUnion("action", [...variants]).superRefine((command, ctx) => {
if (
command.action === "render_scada_analysis" &&
new Set(command.items.map((item) => item.device_id)).size !== command.items.length
) {
ctx.addIssue({ code: "custom", message: "device_id values must be unique" });
}
if (command.action !== "set_overlay" || command.mode !== "heatmap") return;
if (
command.data?.features.some(
(feature) =>
feature.geometry.type !== "Point" ||
(feature.properties?.weight !== undefined &&
(typeof feature.properties.weight !== "number" ||
!Number.isFinite(feature.properties.weight) ||
feature.properties.weight < 0))
)
) {
ctx.addIssue({ code: "custom", message: "投诉热力只接受点和有限非负权重" });
}
});
return {
command,
variants,
safeChartInput: chartSchema,
supplyLayerSchema,
overlayDataSchema: presentationOverlayDataSchema
};
}
export const {
safeChartInput,
supplyLayerSchema,
overlayDataSchema,
variants: viewCommandVariants
} = createViewSchemas();
export const createViewCommandSchema = (options: { publication: boolean }) =>
createViewSchemas(z, options.publication).command;
type Command = z.infer<ReturnType<typeof createViewCommandSchema>>;
export type ViewCommand =
| Exclude<Command, { action: "set_overlay" }>
| (Extract<Command, { action: "set_overlay" }> & { data: z.infer<typeof overlayDataSchema> });
export const viewCommandSchema = createViewCommandSchema({ publication: false }).refine(
(command): command is ViewCommand => command.action !== "set_overlay" || Boolean(command.data),
"resolved overlay data is required"
);
@@ -0,0 +1,16 @@
import { viewCommandSchema, overlayDataSchema } from "./command-schema";
export const presentationCommandSchema = viewCommandSchema;
export const presentationOverlayDataSchema = overlayDataSchema;
export type { ViewCommand as PresentationCommand } from "./command-schema";
export const PRESENTATION_ACTIONS = [
"locate_features",
"zoom_to_map",
"highlight_features",
"clear_highlights",
"open_chart",
"close_chart",
"set_overlay",
"clear_overlay",
"render_scada_analysis",
"clear_scada_analysis"
] as const;
@@ -0,0 +1,4 @@
import { createContext } from "react";
import type { AnswerViewStateScope } from "./answer-view-state-scope";
export const AnswerViewPersistenceContext = createContext<{ scope: AnswerViewStateScope; sessionId: string } | null>(null);
@@ -0,0 +1,9 @@
import type { ReactNode } from "react";
import type { AnswerViewStateScope } from "./answer-view-state-scope";
import { AnswerViewPersistenceContext } from "./answer-view-persistence-context";
export function AnswerViewStateProvider({ scope, sessionId, children }: {
scope?: AnswerViewStateScope; sessionId?: string | null; children: ReactNode;
}) {
return <AnswerViewPersistenceContext.Provider value={scope && sessionId ? { scope, sessionId } : null}>{children}</AnswerViewPersistenceContext.Provider>;
}
@@ -0,0 +1,153 @@
import type { Spec } from "@json-render/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AnswerViewStateApiError, type AnswerViewStateRecord, type AnswerViewStateWrite } from "../api/answer-view-state";
import { answerViewStateDirtyPaths, migrateAnswerViewState } from "../answer-ui/answer-view-state";
import { AnswerViewStateScope, mergeDirty } from "./answer-view-state-scope";
const spec: Spec = { root: "form", elements: { form: { type: "FollowUpForm", props: { title: "资料", fields: [
{ name: "name", label: "姓名", kind: "text" }, { name: "note", label: "说明", kind: "text" }
] }, children: [] } } };
const record = (revision = 1, name = "远端", note = "远端说明", message = "message"): AnswerViewStateRecord => ({
version: "answer-view-state@1", message_id: message, block_id: "block", revision, schema_digest: "digest", updated_at: "2026-10-26T00:00:00Z",
nodes: { form: { type: "FollowUpForm", values: { name, note } } }, block: { evidenceFilter: "all" },
});
function deferred<T>() { let resolve!: (value: T) => void; let reject!: (value: unknown) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; }
const settle = async () => { await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); };
const client = () => ({ getAnswerViewState: vi.fn(async () => record()), putAnswerViewState: vi.fn(async (_session: string, state: AnswerViewStateWrite) => ({ ...record(state.expected_revision + 1), nodes: state.nodes, block: state.block })) });
function change(scope: AnswerViewStateScope, entry: ReturnType<AnswerViewStateScope["entry"]>, value: string) {
const state = structuredClone(entry.snapshot.state);
state.nodes.form = { type: "FollowUpForm", values: { ...(state.nodes.form.type === "FollowUpForm" ? state.nodes.form.values : {}), name: value } };
scope.update(entry, state, ["/nodes/form/values/name"]);
}
describe("answer view state persistence", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("merges a late restore by dirty field and never writes unknown revision zero", async () => {
const api = client(), read = deferred<AnswerViewStateRecord>();
api.getAnswerViewState.mockReturnValue(read.promise);
const scope = new AnswerViewStateScope("actor:project", api), entry = scope.entry("session", "message", "block", spec);
scope.subscribe(entry, () => {});
change(scope, entry, "本地输入");
await vi.advanceTimersByTimeAsync(1000);
expect(api.putAnswerViewState).not.toHaveBeenCalled();
read.resolve(record(8)); await settle();
expect(entry.snapshot.state.nodes.form).toEqual({ type: "FollowUpForm", values: { name: "本地输入", note: "远端说明" } });
await vi.advanceTimersByTimeAsync(500);
expect(api.putAnswerViewState.mock.calls[0][1].expected_revision).toBe(8);
expect(entry.snapshot.status).toBe("saved");
scope.dispose();
});
it("flushes a known old session into its own key while isolating the same block in another message", async () => {
const api = client();
api.getAnswerViewState.mockImplementation(async (_session?: string, message?: string) => record(1, message, "note", message));
const scope = new AnswerViewStateScope("actor:project", api), first = scope.entry("first", "message", "block", spec);
const release = scope.subscribe(first, () => {}); await settle();
change(scope, first, "第一条草稿"); release();
const second = scope.entry("second", "another-message", "block", spec);
scope.subscribe(second, () => {}); await settle();
expect(api.putAnswerViewState.mock.calls[0][0]).toBe("first");
expect(api.putAnswerViewState.mock.calls[0][1].message_id).toBe("message");
expect(second.snapshot.state.nodes.form).toMatchObject({ values: { name: "another-message" } });
expect(scope.entry("first", "different-message", "block", spec)).not.toBe(first);
scope.dispose();
});
it("drops disposed project results and clears its cache", async () => {
const api = client(), read = deferred<AnswerViewStateRecord>(); api.getAnswerViewState.mockReturnValue(read.promise);
const oldScope = new AnswerViewStateScope("actor:old-project", api), old = oldScope.entry("session", "message", "block", spec);
const notify = vi.fn(); oldScope.subscribe(old, notify); oldScope.dispose();
const nextApi = client(), nextScope = new AnswerViewStateScope("actor:new-project", nextApi), next = nextScope.entry("session", "message", "block", spec);
nextScope.subscribe(next, () => {}); await settle();
read.resolve(record(7, "旧项目秘密")); await settle();
expect(notify).toHaveBeenCalledTimes(1);
expect(next.snapshot.state.nodes.form).toMatchObject({ values: { name: "远端" } });
expect(old.snapshot.state.nodes.form).toMatchObject({ values: {} });
nextScope.dispose();
});
it("keeps edits made during save and sends them using the acknowledged next revision", async () => {
const api = client(), write = deferred<AnswerViewStateRecord>(); api.putAnswerViewState.mockReturnValueOnce(write.promise);
const scope = new AnswerViewStateScope("scope", api), entry = scope.entry("session", "message", "block", spec);
scope.subscribe(entry, () => {}); await settle(); change(scope, entry, "第一版");
await vi.advanceTimersByTimeAsync(500); change(scope, entry, "保存中修改");
write.resolve(record(2, "第一版")); await settle();
expect(entry.snapshot.state.nodes.form).toMatchObject({ values: { name: "保存中修改" } });
await vi.advanceTimersByTimeAsync(500);
expect(api.putAnswerViewState.mock.calls[1][1]).toMatchObject({ expected_revision: 2, nodes: { form: { values: { name: "保存中修改" } } } });
expect(entry.snapshot.status).toBe("saved"); scope.dispose();
});
it("retries an ambiguous network failure with exactly the same intent before saving newer edits", async () => {
const api = client(); api.putAnswerViewState.mockRejectedValueOnce(new TypeError("offline"));
const scope = new AnswerViewStateScope("scope", api), entry = scope.entry("session", "message", "block", spec);
scope.subscribe(entry, () => {}); await settle(); change(scope, entry, "第一版"); await vi.advanceTimersByTimeAsync(500);
const payload = structuredClone(api.putAnswerViewState.mock.calls[0][1]);
change(scope, entry, "第二版"); await vi.advanceTimersByTimeAsync(2000);
expect(api.putAnswerViewState).toHaveBeenCalledTimes(1);
scope.retry(entry); await settle();
expect(api.putAnswerViewState.mock.calls[1][1]).toEqual(payload);
await vi.advanceTimersByTimeAsync(500);
expect(api.putAnswerViewState.mock.calls[2][1].request_id).not.toBe(payload.request_id);
expect(api.putAnswerViewState.mock.calls[2][1].nodes.form).toMatchObject({ values: { name: "第二版" } });
scope.dispose();
});
it.each(["ANSWER_VIEW_STATE_CONFLICT", "ANSWER_VIEW_SCHEMA_CHANGED"])("keeps drafts on %s until explicit reload or resave", async (code) => {
const api = client(); api.putAnswerViewState.mockRejectedValueOnce(new AnswerViewStateApiError("conflict", 409, code, record(9, "别的窗口")));
const scope = new AnswerViewStateScope("scope", api), entry = scope.entry("session", "message", "block", spec);
scope.subscribe(entry, () => {}); await settle(); change(scope, entry, "本地草稿"); await vi.advanceTimersByTimeAsync(500);
expect(entry.snapshot.status).toBe("conflict");
expect(entry.snapshot.state.nodes.form).toMatchObject({ values: { name: "本地草稿" } });
await vi.advanceTimersByTimeAsync(5000); expect(api.putAnswerViewState).toHaveBeenCalledTimes(1);
if (code === "ANSWER_VIEW_STATE_CONFLICT") {
scope.reload(entry);
expect(entry.snapshot.state.nodes.form).toMatchObject({ values: { name: "别的窗口" } });
expect(api.putAnswerViewState).toHaveBeenCalledTimes(1);
} else {
scope.resave(entry); await settle();
expect(api.putAnswerViewState.mock.calls[1][1]).toMatchObject({ expected_revision: 9, nodes: { form: { values: { name: "本地草稿" } } } });
}
scope.dispose();
});
it.each([403, 404, 503])("keeps memory drafts and bounds failed restore requests on HTTP %i", async (status) => {
const api = client(); api.getAnswerViewState.mockRejectedValue(new AnswerViewStateApiError("暂不可用", status));
const scope = new AnswerViewStateScope("scope", api), entry = scope.entry("session", "message", "block", spec);
scope.subscribe(entry, () => {}); await settle(); change(scope, entry, "离线草稿"); await settle();
change(scope, entry, "继续编辑"); await vi.advanceTimersByTimeAsync(10_000);
expect(api.getAnswerViewState).toHaveBeenCalledTimes(2);
expect(api.putAnswerViewState).not.toHaveBeenCalled();
expect(entry.snapshot.state.nodes.form).toMatchObject({ values: { name: "继续编辑" } });
expect(entry.snapshot.status).toBe("unsaved"); scope.dispose();
});
it("uses the provider's schema migration exactly once and explicitly resaves at the conflict revision", async () => {
const before: Spec = { root: "table", elements: { table: { type: "Table", children: [], props: { columns: ["名称", "压力"], rows: [["甲", "1"], ["乙", "2"]] } } } };
const after: Spec = { root: "table", elements: { table: { type: "Table", children: [], props: { columns: ["名称"], rows: [["乙"], ["甲"]] } } } };
const original: AnswerViewStateRecord = { ...record(), nodes: { table: { type: "Table", query: "甲", sort: { column: 1, descending: false }, selection: [0] } } };
const migrated = migrateAnswerViewState(before, after, original);
const current: AnswerViewStateRecord = { ...original, ...migrated, revision: 4, schema_digest: "new-digest" };
const api = client(); api.getAnswerViewState.mockResolvedValue(original);
api.putAnswerViewState.mockRejectedValueOnce(new AnswerViewStateApiError("schema changed", 409, "ANSWER_VIEW_SCHEMA_CHANGED", current));
const scope = new AnswerViewStateScope("scope", api), entry = scope.entry("session", "message", "block", before);
scope.subscribe(entry, () => {}); await settle();
scope.entry("session", "message", "block", after);
scope.update(entry, migrated, answerViewStateDirtyPaths(original, migrated));
await vi.advanceTimersByTimeAsync(500);
expect(entry.snapshot.status).toBe("conflict");
scope.resave(entry); await settle();
expect(api.putAnswerViewState.mock.calls[1][1]).toMatchObject({ expected_revision: 4, schema_digest: "new-digest",
nodes: { table: { query: "甲", sort: null, selection: [] } } });
expect(entry.snapshot.state.nodes.table).toMatchObject({ query: "甲", sort: null, selection: [] });
scope.dispose();
});
it("preserves oversized Unicode drafts and does not send a clipped payload", async () => {
const api = client(), scope = new AnswerViewStateScope("scope", api), entry = scope.entry("session", "message", "block", spec);
scope.subscribe(entry, () => {}); await settle(); const text = "水".repeat(12_000); change(scope, entry, text);
await vi.advanceTimersByTimeAsync(500);
expect(api.putAnswerViewState).not.toHaveBeenCalled();
expect(entry.snapshot.state.nodes.form).toMatchObject({ values: { name: text } });
expect(entry.snapshot.error).toContain("32 KiB"); scope.dispose();
});
it("merges escaped form fields and nested chart table paths without replacing siblings", () => {
const local = { nodes: { form: { type: "FollowUpForm" as const, values: { "a/b": "local", constructor: "safe-local" } } }, block: { evidenceFilter: "all" } };
const restored = { nodes: { form: { type: "FollowUpForm" as const, values: { "a/b": "remote", sibling: "keep", constructor: "safe-remote" } } }, block: { evidenceFilter: "all" } };
expect(mergeDirty(restored, local, ["/nodes/form/values/a~1b", "/nodes/form/values/constructor"]).nodes.form).toMatchObject({ values: { "a/b": "local", sibling: "keep", constructor: "safe-local" } });
});
});
@@ -0,0 +1,209 @@
import type { Spec } from "@json-render/core";
import type { AgentApiClient } from "../api/client";
import { AnswerViewStateApiError, type AnswerViewStateRecord, type AnswerViewStateWrite } from "../api/answer-view-state";
import { normalizeAnswerViewState, type AnswerViewState } from "../answer-ui/answer-view-state";
export type AnswerViewSaveStatus = "loading" | "saving" | "saved" | "unsaved" | "conflict";
export type AnswerViewSnapshot = {
state: AnswerViewState; status: AnswerViewSaveStatus; error?: string;
};
type PendingWrite = { payload: AnswerViewStateWrite; change: number };
export type AnswerViewEntry = {
readonly sessionId: string; readonly messageId: string; readonly blockId: string;
spec: Spec; snapshot: AnswerViewSnapshot; remote?: AnswerViewStateRecord;
dirty: Map<string, number>; change: number; listeners: Set<() => void>;
loading?: Promise<void>; saving?: Promise<void>; pending?: PendingWrite;
conflict?: AnswerViewStateRecord; timer?: ReturnType<typeof setTimeout>;
started: boolean; released: boolean;
};
// This cache belongs to one authenticated workbench/project, never to the module.
export class AnswerViewStateScope {
private entries = new Map<string, AnswerViewEntry>();
private disposed = false;
private invalidIdentity?: string;
private leases = 0;
constructor(readonly scope: string, private readonly client: Pick<AgentApiClient, "getAnswerViewState" | "putAnswerViewState">) {}
retain() { this.leases++; }
release() { this.leases--; queueMicrotask(() => { if (this.leases === 0) this.dispose(); }); }
dispose = () => {
this.disposed = true;
for (const entry of this.entries.values()) { clearTimeout(entry.timer); entry.listeners.clear(); }
this.entries.clear();
};
entry(sessionId: string, messageId: string, blockId: string, spec: Spec): AnswerViewEntry {
const key = JSON.stringify([this.scope, sessionId, messageId, blockId]);
let entry = this.entries.get(key);
if (!entry) {
entry = { sessionId, messageId, blockId, spec, snapshot: { state: normalizeAnswerViewState(spec), status: this.invalidIdentity ? "unsaved" : "loading", error: this.invalidIdentity },
dirty: new Map(), change: 0, listeners: new Set(), started: false, released: false };
this.entries.set(key, entry);
}
entry.spec = spec;
return entry;
}
subscribe(entry: AnswerViewEntry, listener: () => void, enabled = true) {
entry.listeners.add(listener);
entry.released = false;
if (enabled && !entry.started) { entry.started = true; void this.restore(entry); }
return () => {
entry.listeners.delete(listener);
if (!entry.listeners.size) {
entry.released = true;
clearTimeout(entry.timer);
// Preserve the captured entry and client when the active session changes.
if (entry.started && !entry.snapshot.error && !entry.conflict) void this.save(entry);
}
};
}
update(entry: AnswerViewEntry, state: AnswerViewState, paths: string[]) {
if (this.disposed || !paths.length) return;
entry.change++;
for (const path of paths) entry.dirty.set(path, entry.change);
this.publish(entry, { ...entry.snapshot, state: normalizeAnswerViewState(entry.spec, state), status: entry.conflict ? "conflict" : entry.snapshot.status === "unsaved" ? "unsaved" : entry.loading ? "loading" : entry.saving ? "saving" : "unsaved" });
if (!entry.started) return;
// One bounded re-read on the first user edit can recover a final-message save race.
if (!entry.remote && !entry.loading && entry.change === 1) { void this.restore(entry); return; }
if (entry.remote && !entry.pending && !entry.conflict && !entry.saving) this.schedule(entry);
}
retry(entry: AnswerViewEntry) {
if (this.disposed || !entry.started || entry.conflict) return;
if (!entry.remote) void this.restore(entry);
else void this.save(entry);
}
reload(entry: AnswerViewEntry) {
if (this.disposed) return;
// The UI explicitly confirms replacing local edits before calling this.
entry.dirty.clear(); entry.pending = undefined;
const current = entry.conflict;
entry.conflict = undefined;
if (current) {
entry.remote = current;
this.publish(entry, { state: normalizeAnswerViewState(entry.spec, current), status: "saved" });
} else void this.restore(entry);
}
resave(entry: AnswerViewEntry) {
if (this.disposed || !entry.conflict) return;
entry.remote = entry.conflict;
entry.conflict = undefined; entry.pending = undefined;
entry.snapshot = { ...entry.snapshot, state: normalizeAnswerViewState(entry.spec, entry.snapshot.state) };
// Explicit intent: keep every field in the visible local draft.
entry.change++;
for (const path of statePaths(entry.snapshot.state)) entry.dirty.set(path, entry.change);
void this.save(entry);
}
private publish(entry: AnswerViewEntry, snapshot: AnswerViewSnapshot) {
if (this.disposed) return;
entry.snapshot = snapshot;
entry.listeners.forEach((listener) => listener());
}
private fail(entry: AnswerViewEntry, error: unknown) {
if (this.disposed) return;
if (error instanceof AnswerViewStateApiError && error.code === "ANSWER_VIEW_IDENTITY_CHANGED") {
this.invalidIdentity = error.message;
for (const cached of this.entries.values()) {
cached.dirty.clear(); cached.pending = undefined; cached.remote = undefined; cached.conflict = undefined;
this.publish(cached, { state: normalizeAnswerViewState(cached.spec), status: "unsaved", error: error.message });
}
this.dispose();
} else this.publish(entry, { ...entry.snapshot, status: "unsaved", error: errorMessage(error) });
}
private schedule(entry: AnswerViewEntry) {
clearTimeout(entry.timer);
entry.timer = setTimeout(() => void this.save(entry), 500);
}
private async restore(entry: AnswerViewEntry) {
if (this.disposed || !entry.started || entry.loading || entry.saving) return;
this.publish(entry, { ...entry.snapshot, status: "loading", error: undefined });
const task = (async () => {
try {
const remote = await this.client.getAnswerViewState(entry.sessionId, entry.messageId, entry.blockId);
if (this.disposed) return;
checkIdentity(entry, remote);
entry.remote = remote;
const restored = normalizeAnswerViewState(entry.spec, remote);
const state = normalizeAnswerViewState(entry.spec, mergeDirty(restored, entry.snapshot.state, entry.dirty.keys()));
this.publish(entry, { state, status: entry.dirty.size ? "unsaved" : "saved" });
} catch (error) {
this.fail(entry, error);
}
})();
entry.loading = task;
await task;
entry.loading = undefined;
if (!this.disposed && entry.remote && entry.dirty.size) {
if (entry.released) void this.save(entry); else this.schedule(entry);
}
}
private async save(entry: AnswerViewEntry) {
clearTimeout(entry.timer);
if (this.disposed || !entry.started || entry.loading || entry.saving || entry.conflict || !entry.remote || !entry.dirty.size) return;
const pending = entry.pending ?? {
payload: { message_id: entry.messageId, block_id: entry.blockId, schema_digest: entry.remote.schema_digest,
expected_revision: entry.remote.revision, request_id: crypto.randomUUID(),
nodes: structuredClone(entry.snapshot.state.nodes), block: { ...entry.snapshot.state.block } },
change: entry.change,
};
if (new TextEncoder().encode(JSON.stringify(pending.payload)).byteLength > 32 * 1024) {
this.publish(entry, { ...entry.snapshot, status: "unsaved", error: "交互状态超过 32 KiB,当前草稿仅保留在内存中。" });
return;
}
entry.pending = pending;
this.publish(entry, { ...entry.snapshot, status: "saving", error: undefined });
let succeeded = false;
const task = (async () => {
try {
const remote = await this.client.putAnswerViewState(entry.sessionId, pending.payload);
if (this.disposed) return;
checkIdentity(entry, remote);
entry.remote = remote; entry.pending = undefined;
for (const [path, change] of entry.dirty) if (change <= pending.change) entry.dirty.delete(path);
const state = normalizeAnswerViewState(entry.spec, mergeDirty(normalizeAnswerViewState(entry.spec, remote), entry.snapshot.state, entry.dirty.keys()));
this.publish(entry, { state, status: entry.dirty.size ? "unsaved" : "saved" });
succeeded = true;
} catch (error) {
if (error instanceof AnswerViewStateApiError && error.status === 409 && error.current
&& error.current.message_id === entry.messageId && error.current.block_id === entry.blockId) {
entry.conflict = error.current;
this.publish(entry, { ...entry.snapshot, status: "conflict", error: error.code === "ANSWER_VIEW_SCHEMA_CHANGED"
? "回答内容已更新,当前草稿尚未保存。" : "另一个窗口已更新交互状态,当前草稿尚未保存。" });
} else this.fail(entry, error);
}
})();
entry.saving = task;
await task;
entry.saving = undefined;
if (!this.disposed && succeeded && entry.dirty.size) {
if (entry.released) void this.save(entry); else this.schedule(entry);
}
}
}
function checkIdentity(entry: AnswerViewEntry, remote: AnswerViewStateRecord) {
if (remote.message_id !== entry.messageId || remote.block_id !== entry.blockId) throw new Error("交互状态身份不匹配,请重试");
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : "交互状态未保存,请重试";
}
function statePaths(state: AnswerViewState) {
return ["/block/evidenceFilter", ...Object.keys(state.nodes).map((key) => `/nodes/${key}`)];
}
export function mergeDirty(restored: AnswerViewState, local: AnswerViewState, paths: Iterable<string>): AnswerViewState {
const result = structuredClone(restored);
for (const path of paths) {
const keys = path.split("/").slice(1).map((key) => key.replaceAll("~1", "/").replaceAll("~0", "~"));
if (!keys.length || !["nodes", "block"].includes(keys[0])) continue;
let source: unknown = local;
let target = result as unknown as Record<string, unknown>;
for (const key of keys.slice(0, -1)) {
source = source && typeof source === "object" && Object.hasOwn(source, key) ? (source as Record<string, unknown>)[key] : undefined;
if (!Object.hasOwn(target, key) || !target[key] || typeof target[key] !== "object") {
Object.defineProperty(target, key, { value: {}, enumerable: true, configurable: true, writable: true });
}
target = target[key] as Record<string, unknown>;
}
const field = keys.at(-1)!;
const value = source && typeof source === "object" && Object.hasOwn(source, field) ? (source as Record<string, unknown>)[field] : undefined;
if (value === undefined) delete target[field]; else Object.defineProperty(target, field, { value: structuredClone(value), enumerable: true, configurable: true, writable: true });
}
return result;
}
@@ -0,0 +1,32 @@
import { act } from "react";
import { createRoot } from "react-dom/client";
import { expect, it, vi } from "vitest";
import type { Spec } from "@json-render/core";
import type { AnswerViewStateRecord, AnswerViewStateWrite } from "../api/answer-view-state";
import { AnswerViewStateScope } from "./answer-view-state-scope";
import { useAnswerViewState } from "./use-answer-view-state";
it("keeps rerenders debounced, flushes a switched session and retains drafts across component remounts", async () => {
vi.useFakeTimers();
const root = createRoot(document.createElement("div"));
const spec: Spec = { root: "form", elements: { form: { type: "FollowUpForm", children: [], props: { fields: [{ name: "note", kind: "text" }] } } } };
const remote = (session: string): AnswerViewStateRecord => ({ version: "answer-view-state@1", message_id: "message", block_id: "block", revision: 0,
schema_digest: "digest", updated_at: null, nodes: { form: { type: "FollowUpForm", values: { note: session } } }, block: { evidenceFilter: "all" } });
const client = { getAnswerViewState: vi.fn(async (session: string) => remote(session)),
putAnswerViewState: vi.fn(async (session: string, state: AnswerViewStateWrite) => ({ ...remote(session), revision: state.expected_revision + 1, nodes: state.nodes })) };
const scope = new AnswerViewStateScope("actor:project", client);
let current!: ReturnType<typeof useAnswerViewState>;
function Probe({ session }: { session: string }) { current = useAnswerViewState(scope, session, "message", "block", spec); return null; }
try {
await act(async () => root.render(<Probe session="first" />));
await act(async () => current.update({ ...current.state, nodes: { form: { type: "FollowUpForm", values: { note: "draft" } } } }, ["/nodes/form/values/note"]));
await act(async () => root.render(<Probe session="first" />));
expect(client.putAnswerViewState).not.toHaveBeenCalled();
await act(async () => root.render(<Probe session="second" />));
expect(client.putAnswerViewState.mock.calls[0][0]).toBe("first");
expect(current.state.nodes.form).toMatchObject({ values: { note: "second" } });
await act(async () => root.render(<Probe session="first" />));
expect(current.state.nodes.form).toMatchObject({ values: { note: "draft" } });
expect(client.getAnswerViewState).toHaveBeenCalledTimes(2);
} finally { await act(async () => root.unmount()); scope.dispose(); vi.useRealTimers(); }
});
@@ -0,0 +1,16 @@
import { useCallback, useContext, useSyncExternalStore } from "react";
import type { Spec } from "@json-render/core";
import type { AnswerViewStateScope } from "./answer-view-state-scope";
import { AnswerViewPersistenceContext } from "./answer-view-persistence-context";
export function useAnswerViewPersistence() { return useContext(AnswerViewPersistenceContext); }
export function useAnswerViewState(scope: AnswerViewStateScope, sessionId: string, messageId: string, blockId: string, spec: Spec, enabled = true) {
const entry = scope.entry(sessionId, messageId, blockId, spec);
const snapshot = useSyncExternalStore(
useCallback((listener: () => void) => scope.subscribe(entry, listener, enabled), [scope, entry, enabled]),
() => entry.snapshot,
() => entry.snapshot,
);
return { ...snapshot, update: scope.update.bind(scope, entry), retry: () => scope.retry(entry),
reload: () => scope.reload(entry), resave: () => scope.resave(entry) };
}
@@ -0,0 +1,18 @@
import { afterEach, expect, it, vi } from "vitest";
import { fetchSpeechVoiceCatalog } from "./speech-voice-catalog";
afterEach(() => vi.unstubAllGlobals());
it("preserves a service failure message for a useful retry explanation", async () => {
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ error: "音色列表暂时不可用,请稍后重试" }), { status: 503 })));
await expect(fetchSpeechVoiceCatalog("/api/tts/edge/voices")).rejects.toThrow("音色列表暂时不可用,请稍后重试");
});
it("explains an incompatible cached catalog or an HTML response without exposing parser details", async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({ voices: [], defaultVoice: "old" })))
.mockResolvedValueOnce(new Response("<!doctype html>"));
vi.stubGlobal("fetch", fetchMock);
for (let attempt = 0; attempt < 2; attempt += 1) {
await expect(fetchSpeechVoiceCatalog("/api/tts/edge/voices")).rejects.toThrow("音色列表格式不匹配,请刷新页面后重试");
}
});
@@ -0,0 +1,14 @@
import { speechVoiceCatalogSchema } from "@/shared/tts/voices";
export async function fetchSpeechVoiceCatalog(url: string) {
const response = await fetch(url, { cache: "no-store" });
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
const message = payload && typeof payload === "object" && "error" in payload
&& typeof payload.error === "string" ? payload.error : "音色服务暂时不可用,请稍后重试";
throw new Error(message);
}
const catalog = speechVoiceCatalogSchema.safeParse(payload);
if (!catalog.success) throw new Error("音色列表格式不匹配,请刷新页面后重试");
return catalog.data;
}
+49
View File
@@ -0,0 +1,49 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, expect, it, vi } from "vitest";
import { getSelectedSpeechVoice, getSpeechVoiceDescription, getSpeechVoiceName, setSelectedSpeechVoice, SPEECH_VOICE_STORAGE_KEY, useSelectedSpeechVoice } from "./speech-voices";
beforeEach(() => setSelectedSpeechVoice(""));
it("shares a saved voice across selectors and restores it from browser storage", () => {
const first = renderHook(useSelectedSpeechVoice);
const second = renderHook(useSelectedSpeechVoice);
act(() => setSelectedSpeechVoice("zh-CN-YunyangNeural"));
expect(first.result.current).toBe("zh-CN-YunyangNeural");
expect(second.result.current).toBe("zh-CN-YunyangNeural");
expect(localStorage.getItem(SPEECH_VOICE_STORAGE_KEY)).toBe("zh-CN-YunyangNeural");
act(() => {
localStorage.setItem(SPEECH_VOICE_STORAGE_KEY, "zh-CN-XiaoyiNeural");
window.dispatchEvent(new StorageEvent("storage", { key: SPEECH_VOICE_STORAGE_KEY }));
});
expect(first.result.current).toBe("zh-CN-XiaoyiNeural");
first.unmount(); second.unmount();
});
it("ignores malformed stored values and resets to the server default", () => {
localStorage.setItem(SPEECH_VOICE_STORAGE_KEY, "unsafe<xml>");
expect(getSelectedSpeechVoice()).toBe("");
localStorage.setItem(SPEECH_VOICE_STORAGE_KEY, "en-US-AriaNeural");
expect(getSelectedSpeechVoice()).toBe("");
setSelectedSpeechVoice("zh-CN-YunyangNeural");
setSelectedSpeechVoice("");
expect(getSelectedSpeechVoice()).toBe("");
expect(localStorage.getItem(SPEECH_VOICE_STORAGE_KEY)).toBeNull();
});
it("provides Chinese labels and gender descriptions for Mandarin voices", () => {
const voice = { id: "zh-CN-YunyangNeural", name: "Yunyang", locale: "zh-CN" as const, gender: "Male" as const };
expect(getSpeechVoiceName(voice)).toBe("云扬");
expect(getSpeechVoiceDescription(voice)).toContain("男声");
expect(getSpeechVoiceDescription(voice)).toContain("普通话");
});
it("keeps the choice for this page if browser storage cannot be written", () => {
const write = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("quota exceeded"); });
try {
setSelectedSpeechVoice("zh-CN-YunyangNeural");
expect(getSelectedSpeechVoice()).toBe("zh-CN-YunyangNeural");
} finally {
write.mockRestore();
setSelectedSpeechVoice("");
}
});
+52
View File
@@ -0,0 +1,52 @@
import { useSyncExternalStore } from "react";
import { speechVoiceIdSchema, type SpeechVoice } from "@/shared/tts/voices";
export const SPEECH_VOICE_STORAGE_KEY = "tjwater.agent.speech-voice";
const VOICE_CHANGE_EVENT = "tjwater:speech-voice-change";
let memoryVoice = "";
let useMemoryPreference = false;
export function getSelectedSpeechVoice(): string {
let value: string;
try { value = useMemoryPreference ? memoryVoice : localStorage.getItem(SPEECH_VOICE_STORAGE_KEY) ?? ""; }
catch { value = memoryVoice; }
return speechVoiceIdSchema.safeParse(value).success ? value : "";
}
export function setSelectedSpeechVoice(value: string) {
const voice = speechVoiceIdSchema.safeParse(value).success ? value : "";
memoryVoice = voice;
try {
if (voice) localStorage.setItem(SPEECH_VOICE_STORAGE_KEY, voice);
else localStorage.removeItem(SPEECH_VOICE_STORAGE_KEY);
useMemoryPreference = false;
} catch { useMemoryPreference = true; }
window.dispatchEvent(new Event(VOICE_CHANGE_EVENT));
}
function subscribe(onChange: () => void) {
window.addEventListener(VOICE_CHANGE_EVENT, onChange);
window.addEventListener("storage", onChange);
return () => {
window.removeEventListener(VOICE_CHANGE_EVENT, onChange);
window.removeEventListener("storage", onChange);
};
}
export function useSelectedSpeechVoice() {
return useSyncExternalStore(subscribe, getSelectedSpeechVoice, () => "");
}
const chineseNames: Record<string, string> = {
"zh-CN-XiaoxiaoNeural": "晓晓", "zh-CN-XiaoyiNeural": "晓伊",
"zh-CN-YunxiNeural": "云希", "zh-CN-YunyangNeural": "云扬",
"zh-CN-YunjianNeural": "云健", "zh-CN-YunxiaNeural": "云夏"
};
export function getSpeechVoiceName(voice: SpeechVoice) {
return chineseNames[voice.id] ?? voice.id.replace(`${voice.locale}-`, "").replace(/Neural$/, "");
}
export function getSpeechVoiceDescription(voice: SpeechVoice) {
return `普通话 · ${voice.gender === "Female" ? "女声" : "男声"}`;
}
+8
View File
@@ -18,6 +18,14 @@ describe("Agent 语音文本处理", () => {
expect(chunks.join("")).toContain("乙");
});
it("短句后接长句时每段仍不超过接口的 520 字限制", () => {
const text = `${"甲".repeat(100)}。${"乙".repeat(450)}。`;
const chunks = splitSpeechTextIntoChunks(text);
expect(chunks).toHaveLength(2);
expect(chunks.every((chunk) => chunk.length <= 520)).toBe(true);
expect(chunks.join("")).toBe(text);
});
it("移除会干扰朗读的 Markdown 标记", () => {
expect(stripSpeechMarkdown("## 分析结果\n**水位** [详情](https://example.com)")).toBe("分析结果\n水位 详情");
});
+1 -6
View File
@@ -1,6 +1,5 @@
const compactWhitespace = (value: string) => value.replace(/\s+/g, " ").trim();
const MAX_SPEECH_CHUNK_LENGTH = 520;
const MIN_SPEECH_CHUNK_LENGTH = 180;
const SPEECH_SENTENCE_PATTERN = /[^。!?!?;;\n]+(?:[。!?!?;;]+|(?=\n|$))/g;
export type AgentSpeechState = "idle" | "loading" | "playing" | "paused";
@@ -93,11 +92,7 @@ export function splitSpeechTextIntoChunks(text: string): string[] {
}
const candidate = currentChunk ? `${currentChunk}${segment}` : segment;
if (
currentChunk &&
candidate.length > MAX_SPEECH_CHUNK_LENGTH &&
currentChunk.length >= MIN_SPEECH_CHUNK_LENGTH
) {
if (currentChunk && candidate.length > MAX_SPEECH_CHUNK_LENGTH) {
flush();
currentChunk = segment;
continue;
@@ -1,3 +1,4 @@
import { webMercatorToLngLat } from "@/features/map/core";
export type TrustedMapAction =
| {
type: "locate_features";
@@ -131,12 +132,7 @@ function normalizeCenter(x: number, y: number, sourceCrs: unknown): [number, num
return [x, y];
}
function webMercatorToLngLat(x: number, y: number): [number, number] {
const lng = (x / WEB_MERCATOR_RADIUS) * (180 / Math.PI);
const lat = (2 * Math.atan(Math.exp(y / WEB_MERCATOR_RADIUS)) - Math.PI / 2) * (180 / Math.PI);
return [lng, lat];
}
function stringValue(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
@@ -180,7 +176,6 @@ const ALLOWED_LAYER_GROUP_IDS = new Set([...SUPPLY_SOURCE_IDS, "simulation"]);
const ALLOWED_LOCATE_LAYERS = new Set([
...SUPPLY_SOURCE_IDS
]);
const WEB_MERCATOR_RADIUS = 6378137;
const LOCATE_ID_PARAM_KEYS = [
"feature_ids",