feat: expand intelligent investigation workspace
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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" })
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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("");
|
||||
}
|
||||
});
|
||||
@@ -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" ? "女声" : "男声"}`;
|
||||
}
|
||||
@@ -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 +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",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
const WEB_MERCATOR_RADIUS = 6378137;
|
||||
|
||||
export 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];
|
||||
}
|
||||
@@ -64,3 +64,4 @@ export {
|
||||
export { MapScaleLine } from "./components/scale-line";
|
||||
export { MapToolbar, type MapToolbarItem } from "./components/toolbar";
|
||||
export { MapZoom } from "./components/zoom";
|
||||
export { webMercatorToLngLat } from "./coordinates";
|
||||
|
||||
@@ -1,66 +1,77 @@
|
||||
import { z } from "zod";
|
||||
import type { FeatureCollection, Geometry } from "geojson";
|
||||
import { parseFrontendChart } from "@/features/agent";
|
||||
import { investigationMapTargetSchema } from "./investigation-report";
|
||||
import {
|
||||
presentationCommandSchema,
|
||||
presentationOverlayDataSchema,
|
||||
type PresentationCommand
|
||||
} from "@/features/agent";
|
||||
|
||||
const id = z.string().trim().min(1).max(128);
|
||||
const chartSchema = z.custom<NonNullable<ReturnType<typeof parseFrontendChart>>>((value) => parseFrontendChart(value) !== null);
|
||||
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;
|
||||
});
|
||||
export const investigationOverlayDataSchema = 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 overlaySchema = z.object({
|
||||
action: z.literal("set_overlay"), overlay_id: id, title: z.string().trim().min(1).max(200),
|
||||
mode: z.enum(["geometry", "heatmap"]), evidence_id: z.string().trim().min(1), data: investigationOverlayDataSchema
|
||||
}).strict();
|
||||
const commandSchema = z.discriminatedUnion("action", [
|
||||
z.object({ action: z.literal("highlight_features"), layer: investigationMapTargetSchema.shape.source_id,
|
||||
feature_ids: z.array(id).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: id, chart: chartSchema, evidence_refs: z.array(z.string().trim().min(1)).min(1).max(100) }).strict(),
|
||||
z.object({ action: z.literal("close_chart"), chart_id: id }).strict(),
|
||||
overlaySchema,
|
||||
z.object({ action: z.literal("clear_overlay"), overlay_id: id }).strict()
|
||||
]).superRefine((command, ctx) => {
|
||||
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: "投诉热力只接受点和有限非负权重" });
|
||||
}
|
||||
});
|
||||
|
||||
export const investigationPresentationEventSchema = z.object({
|
||||
task_id: z.string().uuid(), run_id: z.string().uuid(), execution_generation: z.number().int().positive(), command: commandSchema
|
||||
});
|
||||
export const investigationPresentationCursorSchema = z.object({
|
||||
task_id: z.string().uuid(), execution_generation: z.number().int().positive(),
|
||||
active_run: z.object({ run_id: z.string().uuid(), generation: z.number().int().positive(),
|
||||
status: z.enum(["queued", "claimed", "running", "succeeded", "failed", "timed_out", "cancelled"]) }).strict().nullable(),
|
||||
event_cursor: z.number().int().nonnegative()
|
||||
}).strict();
|
||||
export type InvestigationPresentationCommand = z.infer<typeof commandSchema>;
|
||||
export const investigationOverlayDataSchema = presentationOverlayDataSchema;
|
||||
export const investigationPresentationEventSchema = z
|
||||
.object({
|
||||
task_id: z.string().uuid(),
|
||||
run_id: z.string().uuid(),
|
||||
execution_generation: z.number().int().positive(),
|
||||
command: presentationCommandSchema.refine(
|
||||
(command) => command.action !== "open_chart" || command.evidence_refs.length > 0,
|
||||
"task charts require evidence references"
|
||||
)
|
||||
})
|
||||
.strict();
|
||||
export const investigationPresentationCursorSchema = z
|
||||
.object({
|
||||
task_id: z.string().uuid(),
|
||||
execution_generation: z.number().int().positive(),
|
||||
active_run: z
|
||||
.object({
|
||||
run_id: z.string().uuid(),
|
||||
generation: z.number().int().positive(),
|
||||
status: z.enum([
|
||||
"queued",
|
||||
"claimed",
|
||||
"running",
|
||||
"succeeded",
|
||||
"failed",
|
||||
"timed_out",
|
||||
"cancelled"
|
||||
])
|
||||
})
|
||||
.strict()
|
||||
.nullable(),
|
||||
event_cursor: z.number().int().nonnegative()
|
||||
})
|
||||
.strict();
|
||||
export type InvestigationPresentationCommand = PresentationCommand;
|
||||
export type InvestigationPresentationEvent = z.infer<typeof investigationPresentationEventSchema>;
|
||||
export type InvestigationPresentationCursor = z.infer<typeof investigationPresentationCursorSchema>;
|
||||
export type InvestigationOverlay = Extract<InvestigationPresentationCommand, { action: "set_overlay" }> & { visible: boolean };
|
||||
export type InvestigationOverlay = Extract<
|
||||
InvestigationPresentationCommand,
|
||||
{ action: "set_overlay" }
|
||||
> & { visible: boolean };
|
||||
export type InvestigationOverlayData = FeatureCollection<Geometry>;
|
||||
|
||||
export const investigationPresentationHistoryPageSchema = z
|
||||
.object({
|
||||
task_id: z.string().uuid(),
|
||||
event_cursor: z.number().int().nonnegative(),
|
||||
items: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
event_seq: z.number().int().positive(),
|
||||
run_id: z.string().uuid(),
|
||||
execution_generation: z.number().int().positive(),
|
||||
occurred_at: z.string().datetime({ offset: true }),
|
||||
command: z.unknown()
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
.max(200),
|
||||
next_after: z.number().int().positive().nullable()
|
||||
})
|
||||
.strict();
|
||||
export type InvestigationPresentationHistoryEvent = z.infer<
|
||||
typeof investigationPresentationHistoryPageSchema
|
||||
>["items"][number] & { task_id: string };
|
||||
export const presentationEventKey = (taskId: string, sequence: number) =>
|
||||
`task:${taskId}:event:${sequence}`;
|
||||
|
||||
@@ -59,6 +59,15 @@ describe("investigation report read contract", () => {
|
||||
expect(value.diagram?.node_refs[0]?.action_refs).toEqual([]);
|
||||
expect(value.data_gaps?.[0]?.node_id).toBeNull();
|
||||
});
|
||||
it("distinguishes waiting for dependencies from human feedback without inventing missing states", () => {
|
||||
const value = investigationReportSchema.parse({ ...base, diagram: {
|
||||
title: "等待结果", mermaid: "flowchart LR\nA[外部回执] --> B[人工复核] --> C[下一步]",
|
||||
node_refs: [{ node_id: "A", label: "外部回执", status: "waiting" },
|
||||
{ node_id: "B", label: "人工复核", status: "waiting_human" },
|
||||
{ node_id: "C", label: "下一步" }]
|
||||
} });
|
||||
expect(value.diagram?.node_refs.map((node) => node.status)).toEqual(["waiting", "waiting_human", undefined]);
|
||||
});
|
||||
it.each([
|
||||
{ outcome: "other" },
|
||||
{ stage: "interim" },
|
||||
|
||||
@@ -34,7 +34,7 @@ export const investigationDiagramSchema = z
|
||||
.object({
|
||||
node_id: z.string().regex(/^[A-Za-z_][A-Za-z0-9_-]{0,79}$/),
|
||||
label: text,
|
||||
status: z.enum(["pending", "running", "completed", "waiting_human", "failed", "skipped", "cancelled"]).nullish(),
|
||||
status: z.enum(["pending", "running", "completed", "waiting", "waiting_human", "failed", "skipped", "cancelled"]).nullish(),
|
||||
map_targets: z.array(investigationMapTargetSchema).max(100).optional(),
|
||||
evidence_refs: z.array(text).default([]),
|
||||
action_refs: z.array(z.string().uuid()).default([])
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
fetchOperationalTaskEvidenceSnapshot,
|
||||
fetchOperationalTaskPresentationHistory,
|
||||
fetchOperationalTaskNotifications,
|
||||
fetchOperationalTaskRuns,
|
||||
parseOperationalTask,
|
||||
parseOperationalTaskSummary,
|
||||
parseOperationalTaskNotification
|
||||
} from "./operational-task-client";
|
||||
|
||||
@@ -72,6 +74,36 @@ describe("operational task contract", () => {
|
||||
expect(url.searchParams.get("offset")).toBe("0");
|
||||
});
|
||||
|
||||
it("accepts the Agent instance owner emitted for task runs", () => {
|
||||
const {
|
||||
latest_plan: _latestPlan,
|
||||
latest_report: _latestReport,
|
||||
evidence: _evidence,
|
||||
actions: _actions,
|
||||
permissions: _permissions,
|
||||
...summary
|
||||
} = task();
|
||||
const run = {
|
||||
run_id: id, task_id: id, generation: 2, execution_request_id: id,
|
||||
status: "running", revision: 1, agent_session_id: "session",
|
||||
agent_instance_id: "supply-agent-1", lease_expires_at: time, heartbeat_at: time,
|
||||
started_at: time, finished_at: null, failure_code: null, failure_detail: null,
|
||||
dispatch_attempts: 1, next_dispatch_at: time, created_at: time, updated_at: time
|
||||
};
|
||||
|
||||
expect(parseOperationalTaskSummary({ ...summary, active_run: run }).active_run)
|
||||
.toHaveProperty("agent_instance_id", "supply-agent-1");
|
||||
});
|
||||
|
||||
it("accepts only system-data work order provenance", () => {
|
||||
const snapshot = {
|
||||
work_order_id: id, revision: 1, title: "投诉工单", summary: "待核查", status: "open",
|
||||
priority: "normal", assignee: null, scheduled_at: null, work_order_type: "water_quality_complaint",
|
||||
location: "供水小区", scenario_code: null, origin: "system_data", updated_at: time
|
||||
};
|
||||
expect(parseOperationalTask({ ...task(), work_order_snapshot: snapshot }).work_order_snapshot?.origin).toBe("system_data");
|
||||
expect(() => parseOperationalTask({ ...task(), work_order_snapshot: { ...snapshot, origin: "synthetic" } })).toThrow();
|
||||
});
|
||||
it("accepts execution generation on both plan and report", () => {
|
||||
const parsed = parseOperationalTask(task());
|
||||
expect(parsed.latest_plan).toHaveProperty("execution_generation", 2);
|
||||
@@ -190,3 +222,22 @@ it("reads persisted MCP evidence without expanding task access-request scopes",
|
||||
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ ...snapshot, source: "arbitrary_tool" })));
|
||||
await expect(fetchOperationalTaskEvidenceSnapshot(id, "mcp:fixture")).rejects.toThrow();
|
||||
});
|
||||
|
||||
|
||||
it("reads all presentation pages at one boundary and rejects mismatched or stalled cursors", async () => {
|
||||
const record = (event_seq: number) => ({ event_seq, run_id: id, execution_generation: 1, occurred_at: time, command: { action: "clear_highlights" } });
|
||||
const fetch = vi.fn().mockResolvedValueOnce(Response.json({ task_id: id, event_cursor: 30, items: [record(7)], next_after: 7 }))
|
||||
.mockResolvedValueOnce(Response.json({ task_id: id, event_cursor: 30, items: [record(20)], next_after: null }));
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
expect((await fetchOperationalTaskPresentationHistory(id, 30)).map((item) => item.event_seq)).toEqual([7, 20]);
|
||||
expect(fetch.mock.calls.map(([url]) => new URL(url).searchParams.get("after"))).toEqual(["0", "7"]);
|
||||
expect(fetch.mock.calls.every(([url, init]) => new URL(url).searchParams.get("through") === "30" && !init.body)).toBe(true);
|
||||
for (const page of [
|
||||
{ task_id: id, event_cursor: 29, items: [], next_after: null },
|
||||
{ task_id: id, event_cursor: 30, items: [], next_after: 7 },
|
||||
{ task_id: "50000000-0000-4000-8000-000000000001", event_cursor: 30, items: [], next_after: null }
|
||||
]) {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => Response.json(page)));
|
||||
await expect(fetchOperationalTaskPresentationHistory(id, 30)).rejects.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { investigationReportSchema, investigationDiagramSchema } from "./investigation-report";
|
||||
import { investigationPresentationCursorSchema } from "./investigation-presentation";
|
||||
import { investigationPresentationCursorSchema, investigationPresentationHistoryPageSchema, type InvestigationPresentationHistoryEvent } from "./investigation-presentation";
|
||||
import type { WorkbenchAccess } from "./request-context";
|
||||
import { env } from "@/shared/config/env";
|
||||
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
|
||||
@@ -191,6 +191,7 @@ const taskPermissionsSchema = z.object({
|
||||
can_execute_work_orders: z.boolean(),
|
||||
can_decide_commands: z.boolean(),
|
||||
can_execute_commands: z.boolean(),
|
||||
can_answer_questions: z.boolean().optional(),
|
||||
can_decide_access: z.boolean().default(false)
|
||||
}).strict();
|
||||
const externalWorkOrderActivitySchema = z.object({
|
||||
@@ -221,9 +222,25 @@ const externalWorkOrderSnapshotSchema = z.preprocess((value) => {
|
||||
scenario_code: z.string().min(1).nullable(),
|
||||
feedback_requirements: z.array(z.string().min(1)).default([]),
|
||||
activity: z.array(externalWorkOrderActivitySchema).default([]),
|
||||
origin: z.literal("synthetic"),
|
||||
origin: z.literal("system_data"),
|
||||
updated_at: workbenchDateTimeSchema
|
||||
}).strict());
|
||||
const taskQuestionAnswerSchema = z.object({ question_id: z.string().min(1).max(128),
|
||||
selected_option_ids: z.array(z.string().min(1).max(128)).max(12), custom_text: z.string().max(2000) }).strict();
|
||||
const taskQuestionRequestSchema = z.object({
|
||||
question_request_id: z.string().uuid(), task_id: z.string().uuid(), run_id: z.string().uuid(),
|
||||
execution_generation: z.number().int().positive(), source_session_id: z.string(),
|
||||
status: z.enum(["pending", "answered", "cancelled"]), version: z.number().int().positive(),
|
||||
created_at: workbenchDateTimeSchema, answered_at: workbenchDateTimeSchema.nullable(), answered_by: z.string().nullable(),
|
||||
questions: z.array(z.object({ question_id: z.string().min(1).max(128), title: z.string().min(1).max(200), body: z.string().min(1).max(2000),
|
||||
options: z.array(z.object({ id: z.string().min(1).max(128), label: z.string().min(1).max(200) }).strict()).max(12),
|
||||
mode: z.enum(["single", "multiple"]), allow_custom: z.boolean(), required: z.boolean() }).strict()).min(1).max(3),
|
||||
answers: z.array(taskQuestionAnswerSchema).max(3)
|
||||
}).strict();
|
||||
export type TaskQuestionRequest = z.infer<typeof taskQuestionRequestSchema>;
|
||||
export type TaskQuestionAnswer = z.infer<typeof taskQuestionAnswerSchema>;
|
||||
export type AnswerTaskQuestion = (questionId: string, version: number, answers: TaskQuestionAnswer[], requestId: string) => Promise<unknown>;
|
||||
|
||||
const operationalTaskSchema = taskSummarySchema.extend({
|
||||
latest_plan: planSnapshotSchema.nullable(),
|
||||
latest_report: reportRevisionSchema.nullable(),
|
||||
@@ -231,6 +248,7 @@ const operationalTaskSchema = taskSummarySchema.extend({
|
||||
evidence: z.array(evidenceSchema),
|
||||
actions: z.array(actionSchema),
|
||||
access_requests: z.array(accessRequestSchema).default([]),
|
||||
questions: z.array(taskQuestionRequestSchema).optional(),
|
||||
permissions: taskPermissionsSchema,
|
||||
work_order_snapshot: externalWorkOrderSnapshotSchema.nullable().default(null)
|
||||
}).strict();
|
||||
@@ -252,7 +270,7 @@ const notificationSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
lifecycle_status: z.enum(["active", "resolved"]).default("active"),
|
||||
subject_type: z.enum(["task", "run", "access_request", "action"]).default("task"),
|
||||
subject_type: z.enum(["task", "run", "access_request", "action", "question"]).default("task"),
|
||||
subject_id: z.string().uuid().nullable().default(null),
|
||||
report_reference: z.object({ execution_generation: z.number().int().positive(), revision: z.number().int().positive() }).strict().nullish(),
|
||||
resolved_at: workbenchDateTimeSchema.nullable().default(null),
|
||||
@@ -451,6 +469,39 @@ export async function fetchOperationalTaskPresentationCursor(taskId: string, get
|
||||
));
|
||||
}
|
||||
|
||||
export async function fetchOperationalTaskPresentationHistory(
|
||||
taskId: string,
|
||||
through: number,
|
||||
getAccessToken?: WorkbenchAccess,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
const items: InvestigationPresentationHistoryEvent[] = [];
|
||||
let after = 0;
|
||||
while (true) {
|
||||
signal?.throwIfAborted();
|
||||
const page = investigationPresentationHistoryPageSchema.parse(
|
||||
await operationalTaskRequest(
|
||||
`/operational-tasks/${encodeURIComponent(taskId)}/presentation-events?after=${after}&through=${through}&limit=100`,
|
||||
getAccessToken,
|
||||
{ signal }
|
||||
)
|
||||
);
|
||||
if (page.task_id !== taskId || page.event_cursor !== through)
|
||||
throw new Error("调查展示历史与所选任务或游标不一致");
|
||||
let previous = after;
|
||||
for (const item of page.items) {
|
||||
if (item.event_seq <= previous || item.event_seq > through)
|
||||
throw new Error("调查展示历史顺序不合法");
|
||||
previous = item.event_seq;
|
||||
items.push({ ...item, task_id: taskId });
|
||||
}
|
||||
if (page.next_after === null) return items;
|
||||
if (page.next_after !== previous || previous <= after)
|
||||
throw new Error("调查展示历史分页没有前进");
|
||||
after = previous;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createOperationalTask(input: CreateOperationalTaskInput, getAccessToken?: WorkbenchAccess) {
|
||||
return operationalTaskSchema.parse(await operationalTaskRequest(
|
||||
"/operational-tasks",
|
||||
@@ -572,3 +623,13 @@ export async function fetchOperationalTaskEvidenceSnapshot(taskId: string, evide
|
||||
export function createOperationalRequestId() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
|
||||
export async function answerOperationalTaskQuestion(taskId: string, questionId: string,
|
||||
version: number, answers: TaskQuestionAnswer[], requestId: string, getAccessToken?: WorkbenchAccess) {
|
||||
return operationalTaskSchema.parse(await operationalTaskRequest(
|
||||
`/operational-tasks/${encodeURIComponent(taskId)}/questions/${encodeURIComponent(questionId)}/answer`,
|
||||
getAccessToken, { method: "POST", body: JSON.stringify({ request_id: requestId,
|
||||
expected_version: version, answers: z.array(taskQuestionAnswerSchema).max(3).parse(answers) }) }
|
||||
));
|
||||
}
|
||||
|
||||
@@ -130,6 +130,35 @@ function taskPanel(task: OperationalTask, overrides: Partial<ComponentProps<type
|
||||
return { ...props, rerender };
|
||||
}
|
||||
|
||||
it("keeps fixed question answers in the report and focuses them from a data gap", () => {
|
||||
const task: OperationalTask = { ...taskBase, actions: [], active_run: null, lifecycle_status: "waiting_human",
|
||||
questions: [{ question_request_id: "70000000-0000-4000-8000-000000000001", task_id: taskBase.task_id,
|
||||
run_id: "70000000-0000-4000-8000-000000000002", source_session_id: "s", execution_generation: taskBase.execution_generation,
|
||||
status: "pending", version: 1, created_at: taskBase.created_at, answered_at: null, answered_by: null, answers: [],
|
||||
questions: [{ question_id: "sampling", title: "是否有采样记录", body: "请补充采样信息", options: [], mode: "single", allow_custom: true, required: true }] }],
|
||||
latest_report: { ...taskBase.latest_report!, report: { ...taskBase.latest_report!.report,
|
||||
data_gaps: [{ gap_id: "sampling", missing_data: "采样记录", scope: "事件时段", impact: "待核验",
|
||||
required_data: "采样记录", evidence_refs: ["e-1"], blocking: false }] } }
|
||||
};
|
||||
const props = taskPanel(task);
|
||||
const questions = screen.getByRole("region", { name: "调查提问" });
|
||||
questions.scrollIntoView = vi.fn();
|
||||
fireEvent.click(screen.getByRole("button", { name: "回答当前调查问题" }));
|
||||
expect(questions).toHaveFocus();
|
||||
expect(questions.scrollIntoView).toHaveBeenCalled();
|
||||
expect(props.onContinue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("labels free discussion separately when no fixed question is pending", () => {
|
||||
const props = taskPanel({ ...taskBase, latest_report: { ...taskBase.latest_report!, report: {
|
||||
...taskBase.latest_report!.report, data_gaps: [{ gap_id: "sampling", missing_data: "采样记录", scope: "事件时段",
|
||||
impact: "待核验", required_data: "采样记录", evidence_refs: ["e-1"], blocking: false }]
|
||||
} } });
|
||||
expect(screen.queryByRole("button", { name: "补充事实" })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "讨论此缺口" }));
|
||||
expect(props.onContinue).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("references the selected report round instead of the latest task round, including legacy reports", () => {
|
||||
const task = { ...taskBase, execution_generation: 2 };
|
||||
const { rerender, ...props } = taskPanel(task);
|
||||
@@ -191,7 +220,7 @@ it("opens the execution record bound to the selected report round", async () =>
|
||||
fireEvent.click(await screen.findByRole("button", { name: /第 1 轮 · 报告第 1 版/ }));
|
||||
expect(screen.getByText("调查任务 · 第 1 轮", { exact: true })).toBeVisible();
|
||||
expect(screen.queryByText("调查任务 · 第 2 轮", { exact: true })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "执行记录", hidden: true }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "执行记录" }));
|
||||
|
||||
await waitFor(() => expect(onViewExecutionHistory).toHaveBeenCalledWith({
|
||||
executionGeneration: 1,
|
||||
|
||||
@@ -75,6 +75,7 @@ type AgentExecutionPlanPanelProps = {
|
||||
onReconcile: (actionId: string, expectedVersion: number) => Promise<unknown>;
|
||||
onLoadEvidenceSnapshot: (evidenceId: string) => Promise<OperationalTaskDataSnapshot>;
|
||||
onRefresh: () => void;
|
||||
onAnswerQuestion?: import("../api/operational-task-client").AnswerTaskQuestion;
|
||||
onLoadReports?: (offset: number) => Promise<OperationalTaskReportPage>;
|
||||
onReportViewed?: (generation: number, revision: number) => void;
|
||||
focusActionId?: string | null;
|
||||
@@ -94,11 +95,12 @@ export function AgentExecutionPlanPanel({
|
||||
onLoadEvidenceSnapshot,
|
||||
onRefresh,
|
||||
onLoadReports,
|
||||
onAnswerQuestion,
|
||||
onReportViewed,
|
||||
focusActionId
|
||||
}: AgentExecutionPlanPanelProps) {
|
||||
if (task.task_type === "event_investigation") return <InvestigationTaskReport key={task.task_id} task={task}
|
||||
onContinue={onContinue} onViewExecutionHistory={onViewExecutionHistory}
|
||||
onAnswerQuestion={onAnswerQuestion} onContinue={onContinue} onViewExecutionHistory={onViewExecutionHistory}
|
||||
onLoadReports={onLoadReports} onReportViewed={onReportViewed} focusActionId={focusActionId} onLocate={onLocate}
|
||||
renderAction={(action) => <HumanActionCard key={action.action_id} task={task} action={action} onDecision={onDecision}
|
||||
onExecute={onExecute} onDraftUpdate={onDraftUpdate} onConfirmDispatch={onConfirmDispatch} onReconcile={onReconcile} onRefresh={onRefresh} />}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { formatLocalMonthDayTime } from "@/shared/utils/date-time";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Archive, BarChart3, ChevronRight, LoaderCircle, RefreshCw } from "lucide-react";
|
||||
import type { WorkbenchAccess } from "../api/request-context";
|
||||
@@ -148,7 +149,7 @@ export function AnalysisHistorySection({
|
||||
<StatusBadge tone={statusTone(item.status)}>{item.statusLabel}</StatusBadge>
|
||||
</span>
|
||||
<span className="mt-1 block truncate text-[11px] leading-4 text-[var(--ink-muted)]">
|
||||
{item.runTypeLabel} · {formatHistoryTime(item.createdAt)} · {item.createdBy}
|
||||
{item.runTypeLabel} · {formatLocalMonthDayTime(item.createdAt)} · {item.createdBy}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[11px] leading-4 text-[var(--ink-muted)]">
|
||||
{item.hasResults ? "已有持久化结果" : "仅有运行记录"}
|
||||
@@ -187,15 +188,7 @@ function statusTone(status: string): StatusTone {
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function formatHistoryTime(value: string) {
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : "分析成果历史请求失败";
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { Target, TriangleAlert, X } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import type { DetailFeature } from "../types";
|
||||
import {
|
||||
getFeatureInsightMetrics,
|
||||
getLocalizedFeatureProperties
|
||||
} from "../utils/feature-properties";
|
||||
import { FeaturePropertyList } from "./feature-property-list";
|
||||
|
||||
type FeatureInsightPanelProps = {
|
||||
feature: DetailFeature | null;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function FeatureInsightPanel({ feature, onClose }: FeatureInsightPanelProps) {
|
||||
const metrics = useMemo(() => {
|
||||
if (!feature) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return getFeatureInsightMetrics(feature.layer, feature.properties);
|
||||
}, [feature]);
|
||||
|
||||
const propertyEntries = useMemo(() => {
|
||||
if (!feature) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return getLocalizedFeatureProperties(feature.properties);
|
||||
}, [feature]);
|
||||
|
||||
return (
|
||||
<aside className="acrylic-panel pointer-events-auto flex h-full min-h-0 flex-col rounded border">
|
||||
<div className="border-b border-[var(--surface-divider)]/80 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium tracking-[0.14em] text-[var(--ink-muted)]">要素详情</p>
|
||||
<h2 className="mt-1 text-lg font-semibold text-slate-950">
|
||||
{feature ? feature.title : "选择一个管网对象"}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭详情"
|
||||
title="关闭详情"
|
||||
onClick={onClose}
|
||||
className="surface-control grid h-9 w-9 place-items-center rounded border text-[var(--ink-muted)] hover:text-slate-900"
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
{feature ? feature.subtitle : "点击地图上的管线或节点后,这里会展示属性和 Agent 解释。"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto p-4">
|
||||
{feature ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{metrics.map(({ label, value }) => (
|
||||
<div key={label} className="surface-reading rounded border p-3">
|
||||
<p className="text-xs text-[var(--ink-muted)]">{label}</p>
|
||||
<p className="mt-1 truncate text-sm font-semibold text-slate-900 tabular-nums">{value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="material-tone-agent mt-4 rounded border border-[var(--surface-border)] p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-[var(--action-blue-pressed)]">
|
||||
<TriangleAlert size={16} aria-hidden="true" />
|
||||
Agent 解释
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-700">
|
||||
该对象来自 GeoServer MVT。当前仅执行前端预览,不会写入样式、模型参数或调度工单。
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mt-4">
|
||||
<h3 className="text-sm font-semibold text-slate-900">属性</h3>
|
||||
<div className="mt-2">
|
||||
<FeaturePropertyList entries={propertyEntries} />
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||
<div className="surface-reading grid h-12 w-12 place-items-center rounded border text-[var(--ink-muted)]">
|
||||
<Target size={22} aria-hidden="true" />
|
||||
</div>
|
||||
<p className="mt-3 text-sm font-medium text-slate-800">等待地图选择</p>
|
||||
<p className="mt-1 max-w-[260px] text-sm leading-6 text-[var(--ink-muted)]">
|
||||
选择要素后,可查看基础属性、模拟入口与 Agent 会话联动结果。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
type IconButtonProps = {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
export function IconButton({ label, children, onClick, active = false }: IconButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={onClick}
|
||||
className={`grid h-10 w-10 place-items-center rounded border transition ${
|
||||
active
|
||||
? "border-slate-800 bg-slate-900 text-white"
|
||||
: "surface-control border-transparent text-slate-700 hover:bg-[var(--surface-reading)]"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { parseInvestigationDevJsonl } from "../utils/investigation-dev-preview";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import "@/test/radix-select-test-setup";
|
||||
import { createInvestigationDevCase } from "../utils/investigation-dev-cases";
|
||||
import { InvestigationDevTools } from "./investigation-dev-tools";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("investigation Dev case selection", () => {
|
||||
it("previews a loaded case in one click without an investigation or separate validation step", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onPreview = vi.fn();
|
||||
render(
|
||||
<InvestigationDevTools
|
||||
task={null}
|
||||
mapReady
|
||||
charts={0}
|
||||
overlay={null}
|
||||
activity={[]}
|
||||
onSample={(id = "combined") => createInvestigationDevCase(id, [121.48, 31.1])}
|
||||
onPreview={onPreview}
|
||||
onClear={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const cases = screen.getByRole("combobox", { name: "JSONL 测试案例" });
|
||||
expect(cases).toBeEnabled();
|
||||
await user.click(cases);
|
||||
await user.click(screen.getByRole("option", { name: "候选管段对比" }));
|
||||
expect((screen.getByLabelText("调查展示 JSONL") as HTMLTextAreaElement).value).toContain(
|
||||
"DEMO-PIPE-01"
|
||||
);
|
||||
const execute = screen.getByRole("button", { name: "执行本地预览" });
|
||||
expect(execute).toBeEnabled();
|
||||
await user.click(execute);
|
||||
expect(onPreview).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ commands: [] }),
|
||||
expect.any(String)
|
||||
);
|
||||
expect(onPreview).toHaveBeenCalledWith(
|
||||
parseInvestigationDevJsonl(createInvestigationDevCase("candidates_table", [121.48, 31.1])),
|
||||
"候选管段对比"
|
||||
);
|
||||
expect(screen.getByRole("region", { name: "JSONL 检查结果" })).toHaveTextContent("展示面板");
|
||||
expect(screen.queryByText("合成候选管段对比")).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "载入合成样例" }));
|
||||
expect(cases).toHaveTextContent("候选管段对比");
|
||||
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||
});
|
||||
it("previews data without a map but waits before executing map commands", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onPreview = vi.fn();
|
||||
render(
|
||||
<InvestigationDevTools
|
||||
task={null}
|
||||
mapReady={false}
|
||||
charts={0}
|
||||
overlay={null}
|
||||
activity={[]}
|
||||
onSample={vi.fn()}
|
||||
onPreview={onPreview}
|
||||
onClear={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const input = screen.getByLabelText("调查展示 JSONL");
|
||||
await user.click(input);
|
||||
await user.paste(createInvestigationDevCase("data_only", [121.48, 31.1]));
|
||||
await user.click(screen.getByRole("button", { name: "校验 JSONL" }));
|
||||
const execute = screen.getByRole("button", { name: "执行本地预览" });
|
||||
expect(execute).toBeEnabled();
|
||||
await user.click(execute);
|
||||
expect(onPreview).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ commands: [] }),
|
||||
expect.any(String)
|
||||
);
|
||||
await user.clear(input);
|
||||
await user.paste(createInvestigationDevCase("impact_area", [121.48, 31.1]));
|
||||
await user.click(screen.getByRole("button", { name: "校验 JSONL" }));
|
||||
expect(execute).toBeEnabled();
|
||||
await user.click(execute);
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("地图尚未就绪");
|
||||
expect(onPreview).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it.each(["invalid_json", "invalid_command", "invalid_chart"] as const)(
|
||||
"validates %s on execute and never runs a partial batch",
|
||||
async (id) => {
|
||||
const user = userEvent.setup();
|
||||
const onPreview = vi.fn();
|
||||
render(
|
||||
<InvestigationDevTools
|
||||
task={null}
|
||||
mapReady
|
||||
charts={0}
|
||||
overlay={null}
|
||||
activity={[]}
|
||||
onSample={(caseId = "combined") => createInvestigationDevCase(caseId, [121.48, 31.1])}
|
||||
onPreview={onPreview}
|
||||
onClear={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const input = screen.getByLabelText("调查展示 JSONL");
|
||||
await user.click(input);
|
||||
await user.paste(createInvestigationDevCase(id, [121.48, 31.1]));
|
||||
const execute = screen.getByRole("button", { name: "执行本地预览" });
|
||||
expect(execute).toBeEnabled();
|
||||
await user.click(execute);
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
id === "invalid_json" ? "第 2 行" : "第 1 行"
|
||||
);
|
||||
expect(onPreview).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("region", { name: "JSONL 本地预览" })).not.toBeInTheDocument();
|
||||
await user.clear(input);
|
||||
await user.paste(createInvestigationDevCase("data_only", [121.48, 31.1]));
|
||||
await user.click(execute);
|
||||
expect(onPreview).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ commands: [] }),
|
||||
expect.any(String)
|
||||
);
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,410 @@
|
||||
import { useId, useMemo, useRef, useState } from "react";
|
||||
import { Check, Download, FlaskConical, Play, RotateCcw } from "lucide-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { Disclosure } from "@/shared/ui/disclosure";
|
||||
import { FieldSelect } from "@/shared/ui/field-select";
|
||||
import {
|
||||
investigationDevCases,
|
||||
type InvestigationDevCaseId
|
||||
} from "../utils/investigation-dev-cases";
|
||||
import type { OperationalTask } from "../api/operational-task-client";
|
||||
import {
|
||||
investigationActionLabels,
|
||||
investigationReportJsonl,
|
||||
investigationReportUiSummary,
|
||||
parseInvestigationDevJsonl,
|
||||
type InvestigationDevPreview,
|
||||
type InvestigationPresentationActivity
|
||||
} from "../utils/investigation-dev-preview";
|
||||
|
||||
export type InvestigationDevToolsProps = {
|
||||
task: OperationalTask | null;
|
||||
mapReady: boolean;
|
||||
charts: number;
|
||||
overlay: string | null;
|
||||
activity: InvestigationPresentationActivity[];
|
||||
onSample: (caseId?: InvestigationDevCaseId) => string;
|
||||
onPreview: (preview: InvestigationDevPreview, label?: string) => Promise<void>;
|
||||
onClear: () => void;
|
||||
};
|
||||
|
||||
export function InvestigationDevTools({
|
||||
task,
|
||||
mapReady,
|
||||
charts,
|
||||
overlay,
|
||||
activity,
|
||||
onSample,
|
||||
onPreview,
|
||||
onClear
|
||||
}: InvestigationDevToolsProps) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [input, setInput] = useState("");
|
||||
const [caseId, setCaseId] = useState<InvestigationDevCaseId | undefined>();
|
||||
const [checked, setChecked] = useState<InvestigationDevPreview | null>(null);
|
||||
const [preview, setPreview] = useState<InvestigationDevPreview | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [selectedRecord, setSelectedRecord] = useState(0);
|
||||
const previewActive = useRef(false);
|
||||
const inputId = useId();
|
||||
const summary = useMemo(() => investigationReportUiSummary(task), [task]);
|
||||
|
||||
function replaceInput(value: string, nextCaseId?: InvestigationDevCaseId) {
|
||||
setInput(value);
|
||||
setCaseId(nextCaseId);
|
||||
setChecked(null);
|
||||
setPreview(null);
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
setSelectedRecord(0);
|
||||
}
|
||||
function clearLoadedPreview() {
|
||||
if (!previewActive.current) return;
|
||||
onClear();
|
||||
previewActive.current = false;
|
||||
}
|
||||
function loadCase(nextCaseId: InvestigationDevCaseId) {
|
||||
if (running || !mapReady) return;
|
||||
clearLoadedPreview();
|
||||
replaceInput(onSample(nextCaseId), nextCaseId);
|
||||
}
|
||||
const selectedCase = investigationDevCases.find((item) => item.id === caseId);
|
||||
|
||||
function validate() {
|
||||
try {
|
||||
const parsed = parseInvestigationDevJsonl(input);
|
||||
setChecked(parsed);
|
||||
setError(null);
|
||||
setMessage(
|
||||
`已识别 ${parsed.commands.length} 条展示指令、${parsed.blocks.length} 个界面块、${parsed.records.length} 条调查数据。`
|
||||
);
|
||||
return parsed;
|
||||
} catch (cause) {
|
||||
setChecked(null);
|
||||
setError(cause instanceof Error ? cause.message : "JSONL 校验失败。");
|
||||
setMessage(null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async function run() {
|
||||
if (running || !input.trim()) return;
|
||||
const parsed = validate();
|
||||
if (!parsed) return;
|
||||
if (parsed.commands.length > 0 && !mapReady) {
|
||||
setError("地图尚未就绪,请稍后再执行本地预览。");
|
||||
setMessage(null);
|
||||
return;
|
||||
}
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
setPreview(parsed);
|
||||
previewActive.current = true;
|
||||
try {
|
||||
await onPreview(parsed, selectedCase?.label ?? "JSONL");
|
||||
setMessage(
|
||||
`本地预览完成:${parsed.commands.length} 条展示指令、${parsed.blocks.length} 个界面块、${parsed.records.length} 条调查数据。`
|
||||
);
|
||||
} catch (cause) {
|
||||
if (!(cause instanceof DOMException && cause.name === "AbortError"))
|
||||
setError(cause instanceof Error ? cause.message : "本地展示失败。");
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
function download() {
|
||||
if (!checked) return;
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([`${input.trim()}\n`], { type: "application/x-ndjson;charset=utf-8" })
|
||||
);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `investigation-preview-${task?.execution_generation ?? "local"}.jsonl`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-4" data-testid="investigation-dev-tools">
|
||||
<div className="space-y-1 border-b border-border pb-4 text-xs leading-5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-[11px] font-medium text-muted-foreground">当前调查</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-8 px-0 text-xs"
|
||||
aria-expanded={open}
|
||||
aria-controls="investigation-dev-lab"
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<FlaskConical size={14} />
|
||||
{open ? "收起动作测试" : "测试地图、图表与 JSONL"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="break-words text-sm font-semibold text-foreground">
|
||||
{task ? task.title : "尚未选择调查"}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
{task
|
||||
? `第 ${task.execution_generation} 轮 · 实时图表 ${charts} · 报告图表 ${summary.charts} · 表格 ${summary.tables} · 地图入口 ${summary.maps}`
|
||||
: "可直接载入、校验和执行本地案例;选择调查后可观察实时展示。"}
|
||||
</p>
|
||||
{task?.latest_report ? (
|
||||
<p className="text-muted-foreground">
|
||||
已保存报告第 {task.latest_report.revision} 版 · 第{" "}
|
||||
{task.latest_report.execution_generation ?? "未知"} 轮
|
||||
{task.latest_report.execution_generation !== task.execution_generation
|
||||
? ",上一轮内容"
|
||||
: ""}
|
||||
</p>
|
||||
) : null}
|
||||
{overlay ? <p className="break-words text-muted-foreground">覆盖层:{overlay}</p> : null}
|
||||
{summary.invalid ? (
|
||||
<p className="text-[var(--status-warning-foreground)]">
|
||||
{summary.invalid} 个报告界面块校验未通过。
|
||||
</p>
|
||||
) : null}
|
||||
{task && !task.latest_report ? (
|
||||
<p className="text-muted-foreground">本轮尚无报告,实时联动只处理选中后的新指令。</p>
|
||||
) : null}
|
||||
</div>
|
||||
{open ? (
|
||||
<div id="investigation-dev-lab" className="min-w-0 space-y-3">
|
||||
<h3 className="text-xs font-semibold">01 准备数据</h3>
|
||||
<p className="text-[11px] leading-5 text-muted-foreground">
|
||||
仅在当前浏览器预览,不写入任务或触发调查。合成样例不代表现场事实。
|
||||
</p>
|
||||
<FieldSelect
|
||||
label="JSONL 测试案例"
|
||||
placeholder="选择案例,立即载入 JSONL"
|
||||
value={caseId ?? ""}
|
||||
onValueChange={(value) => {
|
||||
const nextCase = investigationDevCases.find((item) => item.id === value);
|
||||
if (nextCase) loadCase(nextCase.id);
|
||||
}}
|
||||
options={investigationDevCases.map((item) => ({ value: item.id, label: item.label }))}
|
||||
disabled={!mapReady || running}
|
||||
contentClassName="workbench-shadcn-surface"
|
||||
/>
|
||||
{!mapReady ? (
|
||||
<p className="text-[11px] text-muted-foreground">地图正在初始化,就绪后可载入案例。</p>
|
||||
) : null}
|
||||
{selectedCase ? (
|
||||
<p aria-label="案例预期结果" className="text-xs leading-5 text-muted-foreground">
|
||||
预期:{selectedCase.expected}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-[11px] leading-5 text-muted-foreground">
|
||||
切换即载入,执行时自动校验;已执行的本地预览会清除。
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
disabled={!mapReady || running}
|
||||
onClick={() => loadCase(caseId ?? "combined")}
|
||||
>
|
||||
载入合成样例
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
disabled={!task || !summary.blocks || running}
|
||||
onClick={() => {
|
||||
if (task) {
|
||||
clearLoadedPreview();
|
||||
replaceInput(investigationReportJsonl(task));
|
||||
}
|
||||
}}
|
||||
>
|
||||
载入当前报告
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1.5 border-t border-border pt-4">
|
||||
<h3 className="pb-2 text-xs font-semibold">02 校验与执行</h3>
|
||||
<label htmlFor={inputId} className="text-xs font-medium">
|
||||
调查展示 JSONL
|
||||
</label>
|
||||
<Textarea
|
||||
id={inputId}
|
||||
value={input}
|
||||
disabled={running}
|
||||
onChange={(event) => replaceInput(event.target.value)}
|
||||
rows={5}
|
||||
maxLength={200_000}
|
||||
spellCheck={false}
|
||||
className="min-h-28 resize-y font-mono text-[11px] leading-5"
|
||||
placeholder="每行一条展示指令、界面块或带来源的调查数据"
|
||||
/>
|
||||
</div>
|
||||
<Disclosure
|
||||
label="JSONL 格式说明"
|
||||
className="[&>summary]:min-h-8 [&>summary]:text-muted-foreground"
|
||||
>
|
||||
<p className="pb-2 text-[11px] leading-5 text-muted-foreground">
|
||||
presentation 执行受控展示,ui_block 预览界面;candidate
|
||||
等调查数据仅展示,不自动操作地图。
|
||||
</p>
|
||||
</Disclosure>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
disabled={!input.trim() || running}
|
||||
onClick={validate}
|
||||
>
|
||||
<Check size={14} />
|
||||
校验 JSONL
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="text-xs"
|
||||
disabled={!input.trim() || running}
|
||||
onClick={() => void run()}
|
||||
>
|
||||
<Play size={14} />
|
||||
{running ? "正在预览" : "执行本地预览"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
点击执行会自动校验当前 JSONL,整批校验通过后才预览;也可单独校验。
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border pt-4">
|
||||
<h3 className="text-xs font-semibold">03 检查结果</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-xs"
|
||||
disabled={!checked || running}
|
||||
onClick={download}
|
||||
>
|
||||
<Download size={14} />
|
||||
下载测试 JSONL
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-xs"
|
||||
onClick={() => {
|
||||
onClear();
|
||||
previewActive.current = false;
|
||||
setPreview(null);
|
||||
setMessage("已清除当前浏览器的临时展示。");
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
清除临时展示
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{message ? (
|
||||
<p role="status" className="break-words text-xs leading-5 text-muted-foreground">
|
||||
{message}
|
||||
</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p
|
||||
role="alert"
|
||||
className="break-words text-xs leading-5 text-[var(--status-danger-foreground)]"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{preview ? (
|
||||
<section aria-label="JSONL 检查结果" className="min-w-0 space-y-3 overflow-x-auto">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
业务结果已在展示面板打开,可切换动作历史回看。
|
||||
</p>
|
||||
<Disclosure label="原始数据检查">
|
||||
{preview.records.length ? (
|
||||
<div className="max-h-48 overflow-auto">
|
||||
<table className="w-full text-left text-[11px] leading-5">
|
||||
<caption className="sr-only">已识别的调查数据,仅供预览</caption>
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="py-1 pr-3 font-medium">类型</th>
|
||||
<th className="py-1 pr-3 font-medium">对象</th>
|
||||
<th className="py-1 pr-3 font-medium">来源</th>
|
||||
<th className="py-1 text-right font-medium">证据</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.records.map((record, index) => (
|
||||
<tr key={index} className="border-b last:border-0">
|
||||
<td className="break-all py-1.5 pr-3">{String(record.record_type)}</td>
|
||||
<td className="py-1.5 pr-3">
|
||||
<button
|
||||
type="button"
|
||||
className="break-all text-left underline underline-offset-2 focus-visible:outline-2 focus-visible:outline-[var(--action-focus-ring)]"
|
||||
aria-label={`查看第 ${index + 1} 条调查数据`}
|
||||
aria-pressed={selectedRecord === index}
|
||||
onClick={() => setSelectedRecord(index)}
|
||||
>
|
||||
{String(
|
||||
record.element_id ??
|
||||
record.device_id ??
|
||||
record.action_id ??
|
||||
record.item ??
|
||||
`记录 ${index + 1}`
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-1.5 pr-3">{String(record.origin)}</td>
|
||||
<td className="py-1.5 text-right tabular-nums">
|
||||
{(record.source_evidence_refs as string[]).length}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<pre
|
||||
aria-label="调查数据详情"
|
||||
className="mt-2 max-h-36 overflow-auto whitespace-pre-wrap break-all rounded-md bg-muted p-2 font-mono text-[11px] leading-5"
|
||||
>
|
||||
{JSON.stringify(preview.records[selectedRecord], null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</Disclosure>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{activity.length ? (
|
||||
<section aria-label="最近展示动作" className="space-y-2 border-t border-border pt-4">
|
||||
<h3 className="text-xs font-semibold">最近展示动作</h3>
|
||||
<ol
|
||||
aria-label="动作识别记录"
|
||||
className="space-y-1 border-l border-border pl-3 text-[11px] leading-5"
|
||||
>
|
||||
{activity.slice(-3).map((item) => (
|
||||
<li key={`${item.source}-${item.id}`} className="break-words">
|
||||
<span className="text-muted-foreground">
|
||||
{item.source === "preview" ? "本地测试" : "实时指令"} ·{" "}
|
||||
</span>
|
||||
{investigationActionLabels[item.action]} ·{" "}
|
||||
{item.status === "completed"
|
||||
? "已应用"
|
||||
: item.status === "error"
|
||||
? "未完成"
|
||||
: "展示中"}
|
||||
{item.message ? (
|
||||
<span className="text-[var(--status-warning-foreground)]">:{item.message}</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, type ReactNode, type SVGProps } from "react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type SVGProps } from "react";
|
||||
import { RotateCcw, ZoomIn, ZoomOut, type LucideIcon } from "lucide-react";
|
||||
import { MessageResponse } from "@/shared/ai-elements/message";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
INVESTIGATION_DIAGRAM_FONT,
|
||||
investigationDiagramConfig,
|
||||
investigationDiagramPlugins,
|
||||
investigationDiagramStates,
|
||||
investigationDiagramNodeId as getNodeId,
|
||||
investigationNodeState,
|
||||
paintInvestigationDiagramStates,
|
||||
styleInvestigationDiagram
|
||||
} from "../utils/investigation-diagram-style";
|
||||
|
||||
@@ -41,33 +45,42 @@ function DiagramUnavailable() {
|
||||
);
|
||||
}
|
||||
|
||||
const getNodeId = (node: Element | null | undefined) =>
|
||||
node?.getAttribute("data-id") ?? node?.id.match(/(?:^|-)flowchart-(.+)-\d+$/)?.[1];
|
||||
|
||||
export function InvestigationDiagramView({
|
||||
diagram,
|
||||
renderDetails,
|
||||
onAsk,
|
||||
running = false,
|
||||
onLocate
|
||||
}: {
|
||||
diagram: InvestigationDiagram;
|
||||
renderDetails: (node: InvestigationDiagram["node_refs"][number]) => ReactNode;
|
||||
onAsk?: (node: InvestigationDiagram["node_refs"][number]) => void;
|
||||
running?: boolean;
|
||||
onLocate?: (targets: InvestigationMapTarget[]) => Promise<unknown> | void;
|
||||
}) {
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const diagramRoot = useRef<HTMLDivElement>(null);
|
||||
const pointerStart = useRef<{ nodeId: string; x: number; y: number } | null>(null);
|
||||
const selected = diagram.node_refs.find((node) => node.node_id === selectedNodeId);
|
||||
const displayedStates = [...new Set(diagram.node_refs.map((node) => investigationNodeState(node, running)))];
|
||||
if (!displayedStates.length) displayedStates.push("unknown");
|
||||
const [fontsReady, setFontsReady] = useState(
|
||||
() => typeof document === "undefined" || !document.fonts
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
const root = diagramRoot.current;
|
||||
if (!root) return;
|
||||
const paint = () => paintInvestigationDiagramStates(root, diagram, running);
|
||||
paint();
|
||||
// Mermaid renders asynchronously. Reapply the latest snapshot when it
|
||||
// replaces the SVG; observing children avoids reacting to our own styles.
|
||||
const observer = new MutationObserver(paint);
|
||||
observer.observe(root, { childList: true, subtree: true });
|
||||
return () => observer.disconnect();
|
||||
}, [diagram, running]);
|
||||
useEffect(() => {
|
||||
if (!document.fonts) return;
|
||||
let active = true;
|
||||
void document.fonts
|
||||
.load(`500 15px ${INVESTIGATION_DIAGRAM_FONT}`, diagram.mermaid)
|
||||
.load(`500 18px ${INVESTIGATION_DIAGRAM_FONT}`, diagram.mermaid)
|
||||
.catch(() => undefined)
|
||||
.then(() => {
|
||||
if (active) setFontsReady(true);
|
||||
@@ -82,6 +95,7 @@ export function InvestigationDiagramView({
|
||||
核查方向与判断分支。可放大查看,点击节点阅读关联依据。
|
||||
</p>
|
||||
<div
|
||||
ref={diagramRoot}
|
||||
className="investigation-diagram mt-3"
|
||||
onPointerDownCapture={(event) => {
|
||||
const node = (event.target as Element).closest?.(".node");
|
||||
@@ -104,6 +118,7 @@ export function InvestigationDiagramView({
|
||||
>
|
||||
{fontsReady ? (
|
||||
<MessageResponse
|
||||
key={diagram.mermaid}
|
||||
icons={diagramControlIcons}
|
||||
plugins={investigationDiagramPlugins}
|
||||
mermaid={{
|
||||
@@ -118,36 +133,25 @@ export function InvestigationDiagramView({
|
||||
<p className="p-4 text-xs text-[var(--ink-muted)]">正在排版调查图示。</p>
|
||||
)}
|
||||
</div>
|
||||
{running && diagram.node_refs.some((node) => node.status === "running") ? (
|
||||
<p aria-label="图示执行状态" className="mt-2 text-xs text-[var(--action-blue)]">蓝色:正在执行</p>
|
||||
) : null}
|
||||
{diagram.node_refs.some((node) => node.evidence_refs.length || node.action_refs.length) ? (
|
||||
<div
|
||||
className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-[var(--ink-muted)]"
|
||||
aria-label="图示颜色说明"
|
||||
>
|
||||
{diagram.node_refs.some(
|
||||
(node) => node.evidence_refs.length && !node.action_refs.length
|
||||
) ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<div
|
||||
className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-[var(--ink-muted)]"
|
||||
aria-label="图示状态说明"
|
||||
>
|
||||
{displayedStates.map((key) => {
|
||||
const state = investigationDiagramStates[key];
|
||||
return (
|
||||
<span key={key} className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2 w-2 rounded-sm border border-[#6B9E8B] bg-[#EDF7F4]"
|
||||
className="h-2.5 w-3.5 rounded-sm border"
|
||||
style={{ backgroundColor: state.fill, borderColor: state.stroke,
|
||||
borderStyle: state.dash === "0" ? "solid" : "dashed" }}
|
||||
/>
|
||||
引用证据
|
||||
{state.label}
|
||||
</span>
|
||||
) : null}
|
||||
{diagram.node_refs.some((node) => node.action_refs.length) ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2 w-2 rounded-sm border border-[#C4A168] bg-[#FFF6E8]"
|
||||
/>
|
||||
关联业务动作
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{diagram.node_refs.length > 0 ? (
|
||||
<div aria-label="图中关联信息" className="mt-3 flex flex-wrap gap-1.5">
|
||||
{diagram.node_refs.map((node) => (
|
||||
@@ -161,6 +165,7 @@ export function InvestigationDiagramView({
|
||||
}
|
||||
>
|
||||
{node.label}
|
||||
<span className="text-[var(--ink-muted)]"> · {investigationDiagramStates[investigationNodeState(node, running)].label}</span>
|
||||
{node.evidence_refs.length ? ` · ${node.evidence_refs.length} 条依据` : ""}
|
||||
</button>
|
||||
))}
|
||||
@@ -172,16 +177,9 @@ export function InvestigationDiagramView({
|
||||
aria-label={`节点详情 ${selected.label}`}
|
||||
>
|
||||
<p className="text-sm font-medium text-[var(--ink)]">{selected.label}</p>
|
||||
{selected.status ? <p className="text-xs text-[var(--ink-muted)]">{!running && selected.status === "running" ? "记录时执行中" : {
|
||||
pending: "尚未开始", running: "正在执行", completed: "已完成", waiting_human: "等待人工反馈", failed: "执行失败", skipped: "已跳过", cancelled: "已取消"
|
||||
}[selected.status]}</p> : null}
|
||||
<p className="text-xs text-[var(--ink-muted)]">{investigationDiagramStates[investigationNodeState(selected, running)].label}</p>
|
||||
{renderDetails(selected)}
|
||||
{onLocate && selected.map_targets?.length ? <Button size="sm" variant="outline" onClick={() => { close(false); onLocate(selected.map_targets!); }}>在地图查看</Button> : null}
|
||||
{onAsk ? (
|
||||
<Button size="sm" variant="outline" onClick={() => { close(false); onAsk(selected); }}>
|
||||
围绕此节点提问
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { InvestigationExpandCard } from "./investigation-expand-card";
|
||||
|
||||
describe("investigation card reading", () => {
|
||||
it("freezes the reading revision until the reader requests the latest content, then restores focus", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<InvestigationExpandCard title="当前结果" preview={<p>结论一预览</p>} revision="1"><p>结论一正文</p></InvestigationExpandCard>);
|
||||
const trigger = screen.getByRole("button", { name: "展开当前结果" });
|
||||
await user.click(trigger);
|
||||
const dialog = screen.getByRole("dialog", { name: "当前结果" });
|
||||
expect(document.body.querySelector(".investigation-detail-backdrop")).toBeInTheDocument();
|
||||
rerender(<InvestigationExpandCard title="当前结果" preview={<p>结论二预览</p>} revision="2"><p>结论二正文</p></InvestigationExpandCard>);
|
||||
expect(dialog).toHaveTextContent("结论一正文");
|
||||
expect(dialog).not.toHaveTextContent("结论二正文");
|
||||
expect(within(dialog).getByRole("status")).toHaveTextContent("当前保留正在阅读的版本");
|
||||
await user.click(within(dialog).getByRole("button", { name: "查看最新内容" }));
|
||||
expect(dialog).toHaveTextContent("结论二正文");
|
||||
expect(within(dialog).queryByRole("status")).not.toBeInTheDocument();
|
||||
await user.keyboard("{Escape}");
|
||||
await waitFor(() => expect(trigger).toHaveFocus());
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("moves the same live control into the reader and retains a visible inert preview", async () => {
|
||||
const user = userEvent.setup();
|
||||
function Controls() {
|
||||
const [count, setCount] = useState(0);
|
||||
return <button type="button" onClick={() => setCount((value) => value + 1)}>缩放 {count}</button>;
|
||||
}
|
||||
render(<InvestigationExpandCard title="调查脉络" preview={null} reuseContent wide><Controls /></InvestigationExpandCard>);
|
||||
const control = screen.getByRole("button", { name: "缩放 0" });
|
||||
await user.click(control);
|
||||
await user.click(screen.getByRole("button", { name: "展开调查脉络" }));
|
||||
const dialog = screen.getByRole("dialog", { name: "调查脉络" });
|
||||
expect(within(dialog).getByRole("button", { name: "缩放 1" })).toBe(control);
|
||||
const snapshot = document.querySelector("[data-reading-preview]")!;
|
||||
expect(snapshot).toBeVisible();
|
||||
expect(snapshot).toHaveAttribute("aria-hidden", "true");
|
||||
expect(snapshot).toHaveAttribute("inert");
|
||||
await user.click(control);
|
||||
await user.click(within(dialog).getByRole("button", { name: "关闭详情" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
expect(screen.getByRole("button", { name: "缩放 2" })).toBe(control);
|
||||
expect(document.querySelector("[data-reading-preview]")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("allows a follow-up to own focus when it explicitly closes without restoring the trigger", async () => {
|
||||
const user = userEvent.setup();
|
||||
function Fixture() {
|
||||
const target = useRef<HTMLInputElement>(null);
|
||||
return <><label>继续输入<input ref={target} /></label>
|
||||
<InvestigationExpandCard title="主要依据" preview={<p>依据预览</p>}>
|
||||
{(close) => <button type="button" onClick={() => { close(false); requestAnimationFrame(() => target.current?.focus()); }}>继续核查</button>}
|
||||
</InvestigationExpandCard></>;
|
||||
}
|
||||
render(<Fixture />);
|
||||
await user.click(screen.getByRole("button", { name: "展开主要依据" }));
|
||||
await user.click(screen.getByRole("button", { name: "继续核查" }));
|
||||
await waitFor(() => expect(screen.getByLabelText("继续输入")).toHaveFocus());
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useCallback, useId, useRef, useState, type ReactNode, type Ref } from "react";
|
||||
import { useCallback, useRef, useState, type ReactNode, type Ref } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import { Maximize2, X } from "lucide-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Dialog, DialogClose, DialogContent, DialogTitle, DialogTrigger } from "@/shared/ui/dialog";
|
||||
import { createReadingPreview } from "@/shared/ui/reading-preview";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
|
||||
/** Report-specific progressive disclosure. The source stays mounted while reading. */
|
||||
@@ -27,7 +28,6 @@ export function InvestigationExpandCard({
|
||||
reuseContent?: boolean;
|
||||
headingAccessory?: ReactNode;
|
||||
}) {
|
||||
const id = useId();
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const restoreFocusRef = useRef(true);
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -35,6 +35,7 @@ export function InvestigationExpandCard({
|
||||
const [reading, setReading] = useState<{ revision: string; content: typeof children } | null>(null);
|
||||
const [contentHost] = useState(() => reuseContent ? document.createElement("div") : null);
|
||||
const previewSlot = useRef<HTMLDivElement | null>(null);
|
||||
const snapshot = useRef<HTMLElement | null>(null);
|
||||
const attachPreview = useCallback((slot: HTMLDivElement | null) => {
|
||||
previewSlot.current = slot;
|
||||
if (slot && contentHost && !contentHost.isConnected) slot.appendChild(contentHost);
|
||||
@@ -43,24 +44,22 @@ export function InvestigationExpandCard({
|
||||
const preview = previewSlot.current;
|
||||
if (!preview || !contentHost) return;
|
||||
if (slot) {
|
||||
// Reserve only the vacated inline space while expanded. The graph itself
|
||||
// keeps its natural height, including after content updates or resizing.
|
||||
// Use the layout height, not a bounding box scaled by the card animation.
|
||||
preview.style.height = getComputedStyle(preview).height;
|
||||
snapshot.current?.remove();
|
||||
snapshot.current = createReadingPreview(contentHost);
|
||||
preview.append(snapshot.current);
|
||||
slot.appendChild(contentHost);
|
||||
} else {
|
||||
snapshot.current?.remove();
|
||||
snapshot.current = null;
|
||||
preview.appendChild(contentHost);
|
||||
preview.style.removeProperty("height");
|
||||
}
|
||||
}, [contentHost]);
|
||||
const reduced = useReducedMotion();
|
||||
const duration = reduced || keyboard ? 0 : 0.24;
|
||||
const transition = { duration, ease: [0.22, 1, 0.36, 1] as const };
|
||||
const close = (restoreFocus = true) => { restoreFocusRef.current = restoreFocus; setOpen(false); };
|
||||
const content = reading?.content ?? children;
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={(next) => {
|
||||
<Dialog open={open} onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (next) {
|
||||
restoreFocusRef.current = true;
|
||||
@@ -68,69 +67,52 @@ export function InvestigationExpandCard({
|
||||
onOpen?.();
|
||||
}
|
||||
}}>
|
||||
<motion.section
|
||||
<section
|
||||
ref={contentRef}
|
||||
aria-label={label}
|
||||
className={cn("investigation-report-section investigation-expand-card", className)}
|
||||
layoutId={reduced || keyboard ? undefined : id}
|
||||
layoutDependency={open}
|
||||
transition={transition}
|
||||
style={{ borderRadius: 12 }}
|
||||
>
|
||||
<div className="investigation-card-heading">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-[var(--ink)]">{title}</h3>
|
||||
{headingAccessory}
|
||||
</div>
|
||||
<Dialog.Trigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<Button ref={triggerRef} variant="ghost" size="icon-sm" aria-label={triggerLabel} disabled={disabled}
|
||||
title={triggerLabel} onClick={(event) => setKeyboard(event.detail === 0)}>
|
||||
<Maximize2 size={15} aria-hidden />
|
||||
</Button>
|
||||
</Dialog.Trigger>
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
{/* Contain child margins so the occupied and vacated slots measure alike. */}
|
||||
{contentHost ? <div ref={attachPreview} className="flow-root" /> : preview}
|
||||
</motion.section>
|
||||
</section>
|
||||
{contentHost ? createPortal(typeof children === "function" ? children(close) : children, contentHost) : null}
|
||||
<Dialog.Portal forceMount>
|
||||
<AnimatePresence initial={false}>
|
||||
{open ? (
|
||||
<Dialog.Overlay forceMount asChild key="backdrop">
|
||||
<motion.div className="investigation-detail-backdrop"
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={transition} />
|
||||
</Dialog.Overlay>
|
||||
) : null}
|
||||
{open ? (
|
||||
<div className="investigation-detail-positioner" key="detail">
|
||||
<Dialog.Content forceMount asChild aria-describedby={undefined}
|
||||
onEscapeKeyDown={(event) => { event.stopPropagation(); setKeyboard(true); }}
|
||||
onCloseAutoFocus={(event) => { event.preventDefault(); if (restoreFocusRef.current) triggerRef.current?.focus({ preventScroll: true }); }}>
|
||||
<motion.section className={cn("investigation-detail-panel", wide && "investigation-detail-panel-wide")}
|
||||
layoutId={reduced || keyboard ? undefined : id} layoutDependency={open}
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
transition={transition} style={{ borderRadius: 12 }}>
|
||||
<header className="investigation-detail-heading">
|
||||
<Dialog.Title className="text-base font-semibold text-[var(--ink)]">{title}</Dialog.Title>
|
||||
<Dialog.Close asChild>
|
||||
<Button variant="ghost" size="icon-sm" aria-label="关闭详情"><X size={17} aria-hidden /></Button>
|
||||
</Dialog.Close>
|
||||
</header>
|
||||
<div className="investigation-detail-scroll">
|
||||
{contentHost ? <div ref={attachDetail} /> : typeof content === "function" ? content(close) : content}
|
||||
</div>
|
||||
{reading && revision !== undefined && reading.revision !== revision ? (
|
||||
<div className="investigation-report-update" role="status">
|
||||
<span>已有更新,当前保留正在阅读的版本。</span>
|
||||
<Button variant="outline" size="sm" onClick={() => setReading({ revision, content: children })}>查看最新内容</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</motion.section>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
<DialogContent
|
||||
className={cn("investigation-detail-panel bg-card", wide && "investigation-detail-panel-wide", keyboard && "investigation-detail-no-motion")}
|
||||
overlayClassName={cn("investigation-detail-backdrop", keyboard && "investigation-detail-no-motion")}
|
||||
data-agent-theme="chat" aria-describedby={undefined} showCloseButton={false}
|
||||
onEscapeKeyDown={(event) => { event.stopPropagation(); setKeyboard(true); }}
|
||||
onCloseAutoFocus={(event) => { event.preventDefault(); if (restoreFocusRef.current) triggerRef.current?.focus({ preventScroll: true }); }}>
|
||||
<header className="investigation-detail-heading border-b border-border">
|
||||
<DialogTitle className="text-base leading-6 tracking-normal">{title}</DialogTitle>
|
||||
<DialogClose asChild>
|
||||
<Button variant="ghost" size="icon-sm" aria-label="关闭详情"><X size={17} aria-hidden /></Button>
|
||||
</DialogClose>
|
||||
</header>
|
||||
<div className="investigation-detail-scroll">
|
||||
<motion.div initial={reduced || keyboard ? false : { opacity: 0, y: 4 }} animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}>
|
||||
{contentHost ? <div ref={attachDetail} /> : typeof content === "function" ? content(close) : content}
|
||||
</motion.div>
|
||||
</div>
|
||||
{reading && revision !== undefined && reading.revision !== revision ? (
|
||||
<div className="investigation-report-update" role="status">
|
||||
<span>已有更新,当前保留正在阅读的版本。</span>
|
||||
<Button variant="outline" size="sm" onClick={() => setReading({ revision, content: children })}>查看最新内容</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { forwardRef, useId, useState, type ReactNode } from "react";
|
||||
import { AnimatePresence, motion, useIsPresent } from "motion/react";
|
||||
import { ChevronDown, FileText } from "lucide-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
import {
|
||||
presentationFade,
|
||||
presentationTransition
|
||||
} from "../utils/investigation-presentation-motion";
|
||||
|
||||
// Forward the element ref so AnimatePresence can remove a card without jumping its siblings.
|
||||
export const PresentationItem = forwardRef<
|
||||
HTMLDivElement,
|
||||
{ immediate: boolean; children: ReactNode }
|
||||
>(function PresentationItem({ immediate, children }, ref) {
|
||||
const present = useIsPresent();
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
layout={immediate ? false : "position"}
|
||||
{...presentationFade(immediate)}
|
||||
inert={!present || undefined}
|
||||
aria-hidden={!present || undefined}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
export const PresentationViewport = forwardRef<
|
||||
HTMLDivElement,
|
||||
{ immediate: boolean; id: string; children: ReactNode }
|
||||
>(function PresentationViewport({ immediate, id, children }, ref) {
|
||||
const present = useIsPresent();
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
{...presentationFade(immediate)}
|
||||
inert={!present || undefined}
|
||||
aria-hidden={!present || undefined}
|
||||
id={present ? id : undefined}
|
||||
data-investigation-content={present ? "" : undefined}
|
||||
className="relative min-h-0 space-y-3 overflow-y-auto overscroll-contain p-3 [scrollbar-gutter:stable]"
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
export function PresentationReferences({
|
||||
entries,
|
||||
immediate
|
||||
}: {
|
||||
entries: Array<{ id: string; text: string }>;
|
||||
immediate: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const id = useId();
|
||||
return (
|
||||
<div className="mt-3 border-t border-border pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-10 w-full justify-between gap-2 px-0 text-xs font-normal text-muted-foreground"
|
||||
aria-expanded={open}
|
||||
aria-controls={id}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<FileText size={13} aria-hidden />
|
||||
查看依据<span className="tabular-nums">{entries.length}</span>
|
||||
</span>
|
||||
<motion.span
|
||||
className="inline-flex"
|
||||
animate={{ rotate: open ? 180 : 0 }}
|
||||
transition={presentationTransition(immediate)}
|
||||
>
|
||||
<ChevronDown size={13} aria-hidden />
|
||||
</motion.span>
|
||||
</Button>
|
||||
<AnimatePresence initial={false}>
|
||||
{open ? (
|
||||
<motion.ul
|
||||
key="references"
|
||||
id={id}
|
||||
{...presentationFade(immediate)}
|
||||
className="space-y-2 pb-2 pt-1 text-xs leading-5 text-muted-foreground"
|
||||
>
|
||||
{entries.map((entry) => (
|
||||
<li key={entry.id} className="break-words [overflow-wrap:anywhere]">
|
||||
{entry.text}
|
||||
</li>
|
||||
))}
|
||||
</motion.ul>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +1,354 @@
|
||||
import { useState } from "react";
|
||||
import { Eye, EyeOff, LocateFixed, X } from "lucide-react";
|
||||
import { AgentChartView } from "@/features/agent";
|
||||
import { useId, useState } from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { ChevronDown, Eye, EyeOff, LocateFixed, MapPinned, Pause, X } from "lucide-react";
|
||||
import { AgentChartView, type AnswerUiBlock } from "@/features/agent";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Disclosure } from "@/shared/ui/disclosure";
|
||||
import { Card, CardContent, CardHeader } from "@/shared/ui/card";
|
||||
import {
|
||||
PresentationItem,
|
||||
PresentationViewport,
|
||||
PresentationReferences
|
||||
} from "./investigation-presentation-motion";
|
||||
import {
|
||||
presentationFade,
|
||||
presentationTransition
|
||||
} from "../utils/investigation-presentation-motion";
|
||||
import type { OperationalTask } from "../api/operational-task-client";
|
||||
import type { InvestigationOverlay } from "../api/investigation-presentation";
|
||||
import type { InvestigationChart } from "../hooks/use-investigation-presentation";
|
||||
|
||||
import { PresentationHistorySwitcher } from "./presentation-history-switcher";
|
||||
import { PresentationBusinessResults } from "./presentation-business-results";
|
||||
import type { PresentationHistoryControls } from "../utils/presentation-view-history";
|
||||
import type { InvestigationMapTarget } from "../api/investigation-report";
|
||||
|
||||
type Props = {
|
||||
task: OperationalTask; charts: InvestigationChart[]; overlay: InvestigationOverlay | null;
|
||||
cameraPaused: boolean; status: "connecting" | "live" | "disconnected" | "error"; error: string | null;
|
||||
width: number; relatedLayers: Array<{ id: string; label: string; visible: boolean }>;
|
||||
onResumeCamera: () => void; onCloseChart: (id: string) => void;
|
||||
onToggleOverlay: () => void; onFitOverlay: () => void;
|
||||
blocks?: AnswerUiBlock[];
|
||||
records?: Array<Record<string, unknown>>;
|
||||
evidence?: Array<{ evidence_id: string; summary: string }>;
|
||||
history?: PresentationHistoryControls;
|
||||
onLocateTargets?: (targets: InvestigationMapTarget[]) => Promise<unknown>;
|
||||
scope?: string;
|
||||
embedded?: boolean;
|
||||
task: OperationalTask | null;
|
||||
localPreview?: boolean;
|
||||
charts: InvestigationChart[];
|
||||
overlay: InvestigationOverlay | null;
|
||||
cameraPaused: boolean;
|
||||
status: "connecting" | "live" | "history" | "disconnected" | "error";
|
||||
error: string | null;
|
||||
width: number;
|
||||
relatedLayers: Array<{ id: string; label: string; visible: boolean }>;
|
||||
onResumeCamera: () => void;
|
||||
onCloseChart: (id: string) => void;
|
||||
onToggleOverlay: () => void;
|
||||
onFitOverlay: () => void;
|
||||
onToggleLayer: (id: string, visible: boolean) => void;
|
||||
};
|
||||
|
||||
export function InvestigationPresentationPanel({ task, charts, overlay, cameraPaused, status, error, width, relatedLayers,
|
||||
onResumeCamera, onCloseChart, onToggleOverlay, onFitOverlay, onToggleLayer }: Props) {
|
||||
const statusLabels = {
|
||||
connecting: "连接中",
|
||||
live: "实时跟随",
|
||||
history: "已载入历史",
|
||||
disconnected: "连接中断",
|
||||
error: "部分未完成"
|
||||
};
|
||||
|
||||
export function InvestigationPresentationPanel({
|
||||
scope = "investigation",
|
||||
embedded = false,
|
||||
task,
|
||||
blocks = [],
|
||||
records = [],
|
||||
evidence,
|
||||
history,
|
||||
onLocateTargets,
|
||||
localPreview = false,
|
||||
charts,
|
||||
overlay,
|
||||
cameraPaused,
|
||||
status,
|
||||
error,
|
||||
width,
|
||||
relatedLayers,
|
||||
onResumeCamera,
|
||||
onCloseChart,
|
||||
onToggleOverlay,
|
||||
onFitOverlay,
|
||||
onToggleLayer
|
||||
}: Props) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const references = (ids: string[]) => <Disclosure label="查看依据" className="mt-2">
|
||||
{ids.map((id) => <p key={id} className="py-1 text-xs leading-5 text-[var(--ink-muted)]">{task.evidence.find((item) => item.evidence_id === id)?.summary ?? `关联资料:${id}`}</p>)}
|
||||
</Disclosure>;
|
||||
return <aside aria-label="调查实时展示" data-testid="investigation-presentation-panel"
|
||||
className="surface-reading pointer-events-auto absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] left-3 z-40 max-h-[55dvh] overflow-y-auto rounded-xl border border-[var(--surface-divider)] p-3 text-[var(--ink)] shadow-xs"
|
||||
style={{ width, maxWidth: "calc(100% - 24px)" }}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm font-semibold">调查实时展示</p>
|
||||
<Button variant="ghost" size="sm" onClick={() => setCollapsed(!collapsed)}>{collapsed ? "展开展示" : "收起展示"}</Button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-[var(--ink-muted)]">{status === "connecting" ? "连接中" : status === "disconnected" ? "连接中断" : status === "error" ? "部分展示未完成" : "跟随所选调查"}</p>
|
||||
{error ? <p role="status" className="mt-2 text-xs text-[var(--status-warning-foreground)]">{error}</p> : null}
|
||||
{cameraPaused ? <div className="mt-2 flex flex-wrap items-center gap-2 text-xs"><span>镜头跟随已暂停</span><Button size="sm" variant="outline" onClick={onResumeCamera}>恢复跟随</Button></div> : null}
|
||||
{!collapsed ? <>
|
||||
{relatedLayers.length ? <div aria-label="调查相关图层" className="mt-2 flex flex-wrap gap-1">
|
||||
{relatedLayers.map((layer) => <Button key={layer.id} variant="outline" size="sm" aria-pressed={layer.visible} onClick={() => onToggleLayer(layer.id, !layer.visible)}>{layer.visible ? <Eye size={14} aria-hidden /> : <EyeOff size={14} aria-hidden />}{layer.label}</Button>)}
|
||||
</div> : null}
|
||||
{overlay ? <section aria-label="调查覆盖层" className="mt-3 border-t border-[var(--surface-divider)] pt-3">
|
||||
<p className="text-sm font-medium">{overlay.title}</p>
|
||||
<p className="mt-1 text-xs text-[var(--ink-muted)]">{overlay.mode === "heatmap" ? "记录集中度,颜色不表示已确认影响范围" : "调查范围与关联空间资料"}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={onToggleOverlay}>{overlay.visible ? "隐藏覆盖层" : "显示覆盖层"}</Button>
|
||||
<Button size="sm" variant="outline" onClick={onFitOverlay}><LocateFixed size={14} aria-hidden />定位覆盖层</Button>
|
||||
const [keyboardAction, setKeyboardAction] = useState(false);
|
||||
const reducedMotion = useReducedMotion();
|
||||
const immediate = Boolean(reducedMotion) || keyboardAction;
|
||||
const contentId = useId();
|
||||
const reviewing = history?.selectedId != null;
|
||||
const selected = history?.entries.find((entry) => entry.id === history.selectedId);
|
||||
const pending = selected ? history!.entries.filter((entry) => entry.id > selected.id).length : 0;
|
||||
const title = scope === "chat" ? "对话展示" : localPreview ? "本地预览展示" : "调查实时展示";
|
||||
const references = (ids: string[]) =>
|
||||
ids.length ? (
|
||||
<PresentationReferences
|
||||
immediate={immediate}
|
||||
entries={ids.map((id) => ({
|
||||
id,
|
||||
text:
|
||||
(evidence ?? task?.evidence)?.find((item) => item.evidence_id === id)?.summary ??
|
||||
(localPreview && id === "dev:synthetic"
|
||||
? "合成测试数据,未保存为调查证据。"
|
||||
: `关联资料:${id}`)
|
||||
}))}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<motion.aside
|
||||
layout={immediate ? false : "size"}
|
||||
{...presentationFade(immediate)}
|
||||
onKeyDownCapture={() => setKeyboardAction(true)}
|
||||
onPointerDownCapture={() => setKeyboardAction(false)}
|
||||
aria-label={title}
|
||||
data-testid="investigation-presentation-panel"
|
||||
data-presentation-scope={scope}
|
||||
data-investigation-collapsed={collapsed}
|
||||
className={`workbench-shadcn-surface pointer-events-auto ${embedded ? "relative shrink-0" : "absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] left-3 z-40"} flex max-h-[55dvh] flex-col overflow-hidden rounded-xl border border-border bg-background text-foreground shadow-lg`}
|
||||
style={{ width, maxWidth: embedded ? "100%" : "calc(100% - 24px)" }}
|
||||
>
|
||||
<motion.header
|
||||
layout={immediate ? false : "position"}
|
||||
transition={presentationTransition(immediate)}
|
||||
className="shrink-0 border-b border-border px-3 py-2.5"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<h2 className="text-sm font-semibold leading-6">{title}</h2>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-medium">
|
||||
{reviewing
|
||||
? "历史回看"
|
||||
: scope === "chat"
|
||||
? "对话联动"
|
||||
: localPreview
|
||||
? "本地测试"
|
||||
: statusLabels[status]}
|
||||
</Badge>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
图表 {charts.length} · 范围 {overlay ? 1 : 0} · 数据 {records.length}
|
||||
</span>
|
||||
{reviewing && pending > 0 ? (
|
||||
<span role="status" className="text-[11px] text-muted-foreground">
|
||||
后续 {pending} 个动作
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
aria-label={collapsed ? "展开展示" : "收起展示"}
|
||||
title={collapsed ? "展开展示" : "收起展示"}
|
||||
aria-expanded={!collapsed}
|
||||
aria-controls={contentId}
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
>
|
||||
<motion.span
|
||||
className="inline-flex"
|
||||
animate={{ rotate: collapsed ? 180 : 0 }}
|
||||
transition={presentationTransition(immediate)}
|
||||
>
|
||||
<ChevronDown size={16} aria-hidden />
|
||||
</motion.span>
|
||||
</Button>
|
||||
</div>
|
||||
{references([overlay.evidence_id])}
|
||||
</section> : null}
|
||||
{charts.map((item) => <section key={item.chart_id} data-investigation-chart={item.chart_id} className="mt-3 border-t border-[var(--surface-divider)] pt-3">
|
||||
<div className="mb-2 flex justify-end"><Button variant="ghost" size="icon" aria-label={`关闭图表 ${item.chart.title}`} onClick={() => onCloseChart(item.chart_id)}><X size={16} /></Button></div>
|
||||
<AgentChartView chart={item.chart} />
|
||||
{references(item.evidence_refs)}
|
||||
</section>)}
|
||||
</> : null}
|
||||
</aside>;
|
||||
<p
|
||||
className={
|
||||
history?.entries.length
|
||||
? "sr-only"
|
||||
: "mt-1.5 text-[11px] leading-4 text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{selected
|
||||
? `${selected.taskTitle ? `${selected.taskTitle} · ` : ""}${new Date(selected.createdAt).toLocaleTimeString("zh-CN", { hour12: false })} · ${selected.label}${pending ? ` · 后续 ${pending} 个动作` : ""}`
|
||||
: scope === "chat"
|
||||
? "展示当前对话的地图操作与结果"
|
||||
: localPreview
|
||||
? "仅当前浏览器,不写入调查任务"
|
||||
: status === "history"
|
||||
? "已载入所选调查保存的展示结果"
|
||||
: "跟随所选调查,展示地图操作与结果"}
|
||||
</p>
|
||||
{history?.entries.length ? <PresentationHistorySwitcher {...history} /> : null}
|
||||
</motion.header>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{!collapsed ? (
|
||||
<PresentationViewport
|
||||
key={history?.selectedId ?? "current"}
|
||||
immediate={immediate}
|
||||
id={contentId}
|
||||
>
|
||||
{selected?.message ? (
|
||||
<p role="status" className="text-xs leading-5 text-muted-foreground">
|
||||
{selected.message}
|
||||
</p>
|
||||
) : null}
|
||||
{!reviewing && !localPreview && error ? (
|
||||
<p
|
||||
role="status"
|
||||
className="rounded-md border border-border px-2.5 py-2 text-xs leading-5 text-muted-foreground"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{cameraPaused ? (
|
||||
<PresentationItem key="camera" immediate={immediate}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md bg-muted/50 px-2.5 py-2 text-xs">
|
||||
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
|
||||
<Pause size={13} aria-hidden />
|
||||
镜头跟随已暂停
|
||||
</span>
|
||||
<Button type="button" size="sm" variant="outline" onClick={onResumeCamera}>
|
||||
{localPreview || scope === "chat" ? "恢复自动定位" : "恢复跟随"}
|
||||
</Button>
|
||||
</div>
|
||||
</PresentationItem>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
{relatedLayers.length ? (
|
||||
<section aria-label="调查相关图层" className="space-y-2">
|
||||
<h3 className="text-[11px] font-medium text-muted-foreground">相关图层</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{relatedLayers.map((layer) => (
|
||||
<Button
|
||||
key={layer.id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-auto min-h-8 whitespace-normal text-left"
|
||||
disabled={reviewing}
|
||||
aria-pressed={layer.visible}
|
||||
onClick={() => onToggleLayer(layer.id, !layer.visible)}
|
||||
>
|
||||
{layer.visible ? (
|
||||
<Eye size={14} aria-hidden />
|
||||
) : (
|
||||
<EyeOff size={14} aria-hidden />
|
||||
)}
|
||||
{layer.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{overlay ? (
|
||||
<PresentationItem key={`overlay-${overlay.overlay_id}`} immediate={immediate}>
|
||||
<Card role="region" aria-label="调查覆盖层" className="border-border shadow-none">
|
||||
<CardHeader className="space-y-1.5 p-3 pb-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<MapPinned
|
||||
size={15}
|
||||
className="mt-0.5 shrink-0 text-muted-foreground"
|
||||
aria-hidden
|
||||
/>
|
||||
<h3 className="min-w-0 break-words text-xs font-semibold leading-5 [overflow-wrap:anywhere]">
|
||||
{overlay.title}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-[11px] leading-5 text-muted-foreground">
|
||||
{localPreview
|
||||
? "本地空间预览,范围以输入 JSONL 为准"
|
||||
: overlay.mode === "heatmap"
|
||||
? "记录集中度,颜色不表示已确认影响范围"
|
||||
: "调查范围与关联空间资料"}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 pb-2 pt-0">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={reviewing}
|
||||
onClick={onToggleOverlay}
|
||||
>
|
||||
{overlay.visible ? (
|
||||
<EyeOff size={14} aria-hidden />
|
||||
) : (
|
||||
<Eye size={14} aria-hidden />
|
||||
)}
|
||||
{overlay.visible ? "隐藏覆盖层" : "显示覆盖层"}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={onFitOverlay}>
|
||||
<LocateFixed size={14} aria-hidden />
|
||||
定位覆盖层
|
||||
</Button>
|
||||
</div>
|
||||
{references([overlay.evidence_id])}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PresentationItem>
|
||||
) : null}
|
||||
{charts.map((item) => (
|
||||
<PresentationItem key={item.chart_id} immediate={immediate}>
|
||||
<Card
|
||||
data-investigation-chart={item.chart_id}
|
||||
role="group"
|
||||
aria-label={item.chart.title}
|
||||
className="border-border shadow-none"
|
||||
>
|
||||
<CardHeader className="flex-row items-start justify-between gap-2 space-y-0 p-3 pb-2">
|
||||
<div className="min-w-0">
|
||||
<h3 className="break-words text-xs font-semibold leading-5 [overflow-wrap:anywhere]">
|
||||
{item.chart.title}
|
||||
</h3>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||
{item.chart.chart_type === "bar" ? "柱状图" : "折线图"} ·{" "}
|
||||
{item.chart.x_data.length} 个
|
||||
{item.chart.chart_type === "bar" ? "类别" : "时间点"}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
disabled={reviewing}
|
||||
aria-label={`关闭图表 ${item.chart.title}`}
|
||||
onClick={() => onCloseChart(item.chart_id)}
|
||||
>
|
||||
<X size={14} aria-hidden />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 pb-2 pt-0">
|
||||
<AgentChartView chart={item.chart} embedded />
|
||||
{references(item.evidence_refs)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PresentationItem>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
<PresentationBusinessResults
|
||||
blocks={blocks}
|
||||
records={records}
|
||||
reviewing={reviewing}
|
||||
onLocateTargets={onLocateTargets}
|
||||
/>
|
||||
{!charts.length &&
|
||||
!overlay &&
|
||||
!blocks.length &&
|
||||
!records.length &&
|
||||
!relatedLayers.length ? (
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
当前没有展示结果,可切换历史动作回看地图与结果。
|
||||
</p>
|
||||
) : null}
|
||||
</PresentationViewport>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</motion.aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OperationalTask, TaskQuestionRequest } from "../api/operational-task-client";
|
||||
import { InvestigationQuestions } from "./investigation-questions";
|
||||
|
||||
const question: TaskQuestionRequest = {
|
||||
question_request_id: "70000000-0000-4000-8000-000000000001", task_id: "70000000-0000-4000-8000-000000000002",
|
||||
run_id: "70000000-0000-4000-8000-000000000003", source_session_id: "s", execution_generation: 1,
|
||||
status: "pending", version: 1, created_at: "2026-10-30T01:00:00Z", answered_at: null, answered_by: null, answers: [],
|
||||
questions: [{ question_id: "flow", title: "流量依据", body: "是否有流量记录?", options: [{ id: "unknown", label: "目前未知" }], mode: "single", allow_custom: true, required: true }]
|
||||
};
|
||||
const task = { questions: [question], execution_generation: 1, lifecycle_status: "waiting_human", active_run: null,
|
||||
permissions: { can_answer_questions: true } } as OperationalTask;
|
||||
|
||||
describe("investigation questions", () => {
|
||||
it("requires an answer, retains input after failure and retries the same request id", async () => {
|
||||
const onAnswer = vi.fn().mockRejectedValueOnce(new Error("网络失败")).mockResolvedValueOnce({});
|
||||
render(<InvestigationQuestions task={task} onAnswer={onAnswer} />);
|
||||
const submit = screen.getByRole("button", { name: "提交回答并继续调查" });
|
||||
expect(submit).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole("radio", { name: "目前未知" }));
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "请保留为未知" } });
|
||||
fireEvent.click(submit);
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("网络失败");
|
||||
expect(screen.getByRole("textbox")).toHaveValue("请保留为未知");
|
||||
fireEvent.click(submit);
|
||||
await waitFor(() => expect(onAnswer).toHaveBeenCalledTimes(2));
|
||||
expect(onAnswer.mock.calls[0]).toEqual(onAnswer.mock.calls[1]);
|
||||
expect(onAnswer.mock.calls[0].slice(0, 3)).toEqual([question.question_request_id, 1,
|
||||
[{ question_id: "flow", selected_option_ids: ["unknown"], custom_text: "请保留为未知" }]]);
|
||||
expect(await screen.findByText(/回答已保存,调查已排队继续/)).toBeVisible();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ ...task, permissions: { ...task.permissions, can_answer_questions: false } },
|
||||
{ ...task, active_run: { status: "running" } },
|
||||
{ ...task, execution_generation: 2 }
|
||||
])("does not answer without permission or for active/stale runs", (value) => {
|
||||
render(<InvestigationQuestions task={value as OperationalTask} onAnswer={vi.fn()} />);
|
||||
expect(screen.getByRole("radio")).toBeDisabled();
|
||||
expect(screen.getByRole("button")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows stored option labels and text after reload without another answer action", () => {
|
||||
render(<InvestigationQuestions task={{ ...task, questions: [{ ...question, status: "answered", version: 2,
|
||||
answers: [{ question_id: "flow", selected_option_ids: ["unknown"], custom_text: "没有记录" }] }] }} />);
|
||||
expect(screen.getByText("目前未知;没有记录")).toBeVisible();
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useRef, useState, type Ref } from "react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Checkbox } from "@/shared/ui/checkbox";
|
||||
import { Radio } from "@/shared/ui/radio";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { createOperationalRequestId, type AnswerTaskQuestion, type OperationalTask,
|
||||
type TaskQuestionAnswer, type TaskQuestionRequest } from "../api/operational-task-client";
|
||||
|
||||
export function InvestigationQuestions({ task, onAnswer, sectionRef }: {
|
||||
task: OperationalTask; onAnswer?: AnswerTaskQuestion; sectionRef?: Ref<HTMLElement>;
|
||||
}) {
|
||||
const questions = task.questions ?? [];
|
||||
if (!questions.length) return null;
|
||||
return <section ref={sectionRef} tabIndex={-1} aria-label="调查提问" className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-foreground">补充回答</h3>
|
||||
<p className="text-xs leading-5 text-muted-foreground">在本报告中选择或填写答案,提交后继续当前调查。</p>
|
||||
{questions.map((request) => <TaskQuestionCard key={`${request.question_request_id}:${request.version}`}
|
||||
request={request} onAnswer={onAnswer}
|
||||
canAnswer={Boolean(task.permissions.can_answer_questions && task.lifecycle_status === "waiting_human"
|
||||
&& !task.active_run && task.execution_generation === request.execution_generation)} />)}
|
||||
</section>;
|
||||
}
|
||||
|
||||
function TaskQuestionCard({ request, onAnswer, canAnswer }: {
|
||||
request: TaskQuestionRequest; onAnswer?: AnswerTaskQuestion; canAnswer: boolean;
|
||||
}) {
|
||||
const [answers, setAnswers] = useState<TaskQuestionAnswer[]>(() => request.questions.map((q) => ({
|
||||
question_id: q.question_id, selected_option_ids: [], custom_text: ""
|
||||
})));
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const attempt = useRef<{ body: string; id: string } | null>(null);
|
||||
const pending = request.status === "pending" && !submitted;
|
||||
const disabled = !canAnswer || !onAnswer || busy || !pending;
|
||||
const valid = request.questions.every((q, index) => !q.required || answers[index].selected_option_ids.length
|
||||
|| (q.allow_custom && answers[index].custom_text.trim()));
|
||||
const update = (index: number, patch: Partial<TaskQuestionAnswer>) => setAnswers((current) =>
|
||||
current.map((answer, i) => i === index ? { ...answer, ...patch } : answer));
|
||||
const submit = async () => {
|
||||
if (disabled || !valid) return;
|
||||
const body = JSON.stringify(answers);
|
||||
if (attempt.current?.body !== body) attempt.current = { body, id: createOperationalRequestId() };
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
await onAnswer!(request.question_request_id, request.version, answers, attempt.current.id);
|
||||
setSubmitted(true);
|
||||
} catch (cause) { setError(cause instanceof Error ? cause.message : "提交失败,请重试"); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
return <article aria-label="调查需要补充的信息" aria-busy={busy} className="space-y-4 rounded-xl border border-border bg-card p-4 text-card-foreground">
|
||||
<p role="status" className="text-xs text-muted-foreground">第 {request.execution_generation} 轮调查 · {submitted || request.status === "answered"
|
||||
? "回答已保存" : request.status === "cancelled" ? "提问已失效" : "等待回答"}</p>
|
||||
{request.questions.map((question, index) => {
|
||||
const answer = answers[index];
|
||||
const saved = request.answers.find((item) => item.question_id === question.question_id);
|
||||
return <fieldset key={question.question_id} disabled={disabled} className="min-w-0 space-y-2">
|
||||
<legend className="text-sm font-medium">{question.title}{question.required && pending ? "(必填)" : ""}</legend>
|
||||
<p className="whitespace-pre-wrap break-words text-sm text-muted-foreground">{question.body}</p>
|
||||
{pending ? <>
|
||||
{question.options.map((option) => <label key={option.id} className="flex min-h-11 items-center gap-2 rounded-lg border border-border p-2 text-sm">
|
||||
{question.mode === "single" ? <Radio name={`${request.question_request_id}:${question.question_id}`}
|
||||
checked={answer.selected_option_ids.includes(option.id)} onChange={() => update(index, { selected_option_ids: [option.id] })} />
|
||||
: <Checkbox checked={answer.selected_option_ids.includes(option.id)} onChange={(event) => update(index, {
|
||||
selected_option_ids: event.target.checked ? [...answer.selected_option_ids, option.id] : answer.selected_option_ids.filter((id) => id !== option.id)
|
||||
})} />}
|
||||
<span className="min-w-0 break-words">{option.label}</span>
|
||||
</label>)}
|
||||
{question.allow_custom ? <Textarea aria-label={`${question.title}:补充说明`} maxLength={2000} value={answer.custom_text}
|
||||
placeholder="填写回答或补充说明" onChange={(event) => update(index, { custom_text: event.target.value })} /> : null}
|
||||
</> : <p className="whitespace-pre-wrap break-words text-sm">{saved
|
||||
? [...question.options.filter((o) => saved.selected_option_ids.includes(o.id)).map((o) => o.label), saved.custom_text].filter(Boolean).join(";") || "未填写"
|
||||
: submitted ? "回答已保存,调查已排队继续。" : "该提问不再接受回答。"}</p>}
|
||||
</fieldset>;
|
||||
})}
|
||||
{error ? <p role="alert" className="text-sm text-destructive">{error}</p> : null}
|
||||
{pending ? <div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">回答将作为用户补充信息供调查参考;提交后继续调查。</p>
|
||||
<Button type="button" size="sm" disabled={disabled || !valid} onClick={() => void submit()}>{busy ? "正在提交…" : "提交回答并继续调查"}</Button>
|
||||
</div> : null}
|
||||
</article>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { InvestigationReportBlocks } from "./investigation-report-blocks";
|
||||
import type { OperationalTask, OperationalTaskReport } from "../api/operational-task-client";
|
||||
@@ -22,20 +22,16 @@ const snapshot: OperationalTaskReport = {
|
||||
};
|
||||
|
||||
describe("formal report interactive content", () => {
|
||||
it("uses saved evidence facts and quotes the selected report version without dispatching work", () => {
|
||||
it("shows saved evidence facts without quoting or dispatching work", async () => {
|
||||
const onContinue = vi.fn();
|
||||
render(<InvestigationReportBlocks task={task} snapshot={snapshot} onContinue={onContinue} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /监测依据/ }));
|
||||
expect(screen.getByText("保存的演练压力,不代表现场事实。")).toBeVisible();
|
||||
await waitFor(() => expect(screen.getByText("保存的演练压力,不代表现场事实。")).toBeVisible());
|
||||
expect(screen.queryByText(/模型填写/)).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "针对这部分提问" }));
|
||||
expect(onContinue).toHaveBeenCalledWith(expect.objectContaining({
|
||||
context: { report_revision: 3 }, reportGeneration: 2,
|
||||
reference: { title: "演练调查报告 · 报告第 3 版 · 监测依据",
|
||||
text: expect.stringContaining("保存的监测来源\n类别:演练数据") },
|
||||
}));
|
||||
expect(onContinue.mock.calls[0][0].reference.text).toContain("报告第 3 版 · 第 2 轮");
|
||||
expect(onContinue.mock.calls[0][0].reference.text).toContain("引用依据:保存的监测来源(saved-evidence)");
|
||||
expect(screen.queryByText("引用依据:保存的监测来源")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /加入分析|针对.*提问/ })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
|
||||
expect(onContinue).not.toHaveBeenCalled();
|
||||
});
|
||||
it("keeps unsupported content readable through its fallback and renders no executable nodes", () => {
|
||||
const next = structuredClone(snapshot);
|
||||
@@ -44,7 +40,28 @@ describe("formal report interactive content", () => {
|
||||
const { container } = render(<InvestigationReportBlocks task={task} snapshot={next} onContinue={vi.fn()} />);
|
||||
expect(screen.getByText("分析待核验。")).toBeVisible();
|
||||
expect(container.querySelector("iframe")).toBeNull();
|
||||
expect(screen.getByText("引用依据:保存的监测来源")).toBeVisible();
|
||||
});
|
||||
it("keeps references not already represented by an evidence card", () => {
|
||||
const next = structuredClone(snapshot);
|
||||
next.report.ui_blocks![0].evidence_refs.push("other-evidence");
|
||||
const expandedTask = { evidence: [...task.evidence, { ...task.evidence[0], evidence_id: "other-evidence", source_label: "另一条监测来源" }] };
|
||||
render(<InvestigationReportBlocks task={expandedTask} snapshot={next} onContinue={vi.fn()} />);
|
||||
expect(screen.getByText("保存的监测来源")).toBeVisible();
|
||||
expect(screen.getByText("引用依据:另一条监测来源")).toBeVisible();
|
||||
expect(screen.queryByText("引用依据:保存的监测来源、另一条监测来源")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps evidence references for tables without evidence cards", () => {
|
||||
const next = structuredClone(snapshot);
|
||||
next.report.ui_blocks![0].block.spec = { root: "table", elements: { table: {
|
||||
type: "Table", children: [], props: { title: "监测记录", columns: ["对象"], rows: [["北区"]] },
|
||||
} } };
|
||||
render(<InvestigationReportBlocks task={task} snapshot={next} onContinue={vi.fn()} />);
|
||||
expect(screen.getByRole("table")).toHaveTextContent("北区");
|
||||
expect(screen.getByText("引用依据:保存的监测来源")).toBeVisible();
|
||||
});
|
||||
|
||||
it("turns report form input into a versioned conversation draft", async () => {
|
||||
const next = structuredClone(snapshot);
|
||||
next.report.ui_blocks![0].block.spec = { root: "form", elements: { form: {
|
||||
@@ -58,5 +75,9 @@ describe("formal report interactive content", () => {
|
||||
expect(await screen.findByText("资料已带入对话,请确认后发送。")).toBeVisible();
|
||||
expect(onContinue.mock.calls[0][0].reference.text).toContain("位置:演练位置");
|
||||
expect(onContinue.mock.calls[0][0].context).toEqual({ report_revision: 3 });
|
||||
expect(onContinue.mock.calls[0][0].reportGeneration).toBe(2);
|
||||
expect(onContinue.mock.calls[0][0].reference.title).toBe("演练调查报告 · 报告第 3 版 · 补充资料");
|
||||
expect(onContinue.mock.calls[0][0].reference.text).toContain("报告第 3 版 · 第 2 轮");
|
||||
expect(onContinue.mock.calls[0][0].reference.text).toContain("引用依据:保存的监测来源(saved-evidence)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ export function InvestigationReportBlocks({ task, snapshot, onContinue, onLocate
|
||||
return <section className="investigation-report-section space-y-4" aria-label="交互分析">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">数据与分析</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">探索报告中的数据,选择内容后可引用到对话。</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">查看报告中的数据与依据;定位和补充资料使用各自入口。</p>
|
||||
</div>
|
||||
{blocks.map((item) => {
|
||||
const parsed = parseAnswerUiBlock(item.block);
|
||||
@@ -49,14 +49,17 @@ export function InvestigationReportBlocks({ task, snapshot, onContinue, onLocate
|
||||
} } : { type: "Text", children: [], props: { text: "这条引用证据当前不可用。", tone: "warning" } }];
|
||||
}))
|
||||
} } : null;
|
||||
const shownEvidence = new Set(block ? Object.values(block.spec.elements)
|
||||
.filter((node) => node.type === "Evidence").map((node) => node.props.evidence_id) : []);
|
||||
const remainingRefs = item.evidence_refs.filter((id) => !shownEvidence.has(id));
|
||||
return <div key={`${snapshot.revision}:${item.block.block_id}`} className="space-y-2">
|
||||
{block ? <AnswerUiView block={block} onAsk={quote} submitMode="draft"
|
||||
{block ? <AnswerUiView block={block} submitMode="draft"
|
||||
onSubmit={(text) => quote({ title: "补充资料", text })}
|
||||
onLocate={onLocate ? (target) => onLocate(target.feature_ids.map((id) => ({ source_id: target.layer, feature_id: id }))) : undefined} />
|
||||
: <p className="whitespace-pre-wrap rounded-lg border p-3 text-sm leading-6">{item.block.fallback_text}</p>}
|
||||
<p className="text-[11px] leading-5 text-muted-foreground">
|
||||
引用依据:{evidence.map((entry) => entry.source_label).join("、") || "关联记录当前不可用"}
|
||||
</p>
|
||||
{remainingRefs.length ? <p className="text-[11px] leading-5 text-muted-foreground">
|
||||
引用依据:{[...new Set(remainingRefs.map((id) => evidence.find((entry) => entry.evidence_id === id)?.source_label ?? `${id}(记录当前不可用)`))].join("、")}
|
||||
</p> : null}
|
||||
</div>;
|
||||
})}
|
||||
</section>;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { InvestigationQuestions } from "./investigation-questions";
|
||||
import { History, LoaderCircle, MessageSquare } from "lucide-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Disclosure } from "@/shared/ui/disclosure";
|
||||
@@ -34,6 +35,7 @@ type Props = {
|
||||
onViewExecutionHistory?: (target: TaskExecutionHistoryTarget, knownRunId?: string) => Promise<void> | void;
|
||||
renderAction: (action: OperationalTaskAction) => ReactNode;
|
||||
renderEvidence: (evidence: OperationalTaskEvidence) => ReactNode;
|
||||
onAnswerQuestion?: import("../api/operational-task-client").AnswerTaskQuestion;
|
||||
onLoadReports?: (offset: number) => Promise<OperationalTaskReportPage>;
|
||||
onReportViewed?: (generation: number, revision: number) => void;
|
||||
focusActionId?: string | null;
|
||||
@@ -82,6 +84,12 @@ function investigationTimeline(
|
||||
detail: snapshot.report.current_conclusion
|
||||
});
|
||||
}
|
||||
task.questions?.forEach((question) => {
|
||||
items.push({ id: `question:${question.question_request_id}`, occurredAt: question.created_at,
|
||||
title: "调查提出补充问题", detail: question.questions.map((item) => item.title).join(";") });
|
||||
if (question.answered_at) items.push({ id: `answer:${question.question_request_id}`, occurredAt: question.answered_at,
|
||||
title: "已收到调查回答", detail: "回答已保存为用户补充信息,调查接续处理。" });
|
||||
});
|
||||
task.actions.forEach((action) => {
|
||||
const title = actionTitle(action);
|
||||
items.push({
|
||||
@@ -133,10 +141,14 @@ export function InvestigationTaskReport({
|
||||
renderAction,
|
||||
renderEvidence,
|
||||
onLoadReports,
|
||||
onAnswerQuestion,
|
||||
onReportViewed,
|
||||
focusActionId,
|
||||
onLocate
|
||||
}: Props) {
|
||||
const questionsRef = useRef<HTMLElement>(null);
|
||||
const hasPendingQuestions = task.questions?.some((request) => request.status === "pending"
|
||||
&& request.execution_generation === task.execution_generation) ?? false;
|
||||
// Null follows live task content. Only a deliberate history selection freezes a report.
|
||||
const [selection, setSelection] = useState<ReportSelection | null>(null);
|
||||
const [history, setHistory] = useState<OperationalTaskReport[]>([]);
|
||||
@@ -313,6 +325,8 @@ export function InvestigationTaskReport({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<InvestigationQuestions task={task} onAnswer={onAnswerQuestion} sectionRef={questionsRef} />
|
||||
|
||||
<InvestigationReportNavigation reportRef={reportRef} sections={sections} />
|
||||
|
||||
{latestAvailable ? (
|
||||
@@ -489,14 +503,19 @@ export function InvestigationTaskReport({
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
discuss(
|
||||
`我要补充“${formatInvestigationText(gap.missing_data)}”相关的现场事实。请说明需要的记录范围和时间信息。`,
|
||||
{ gap_id: gap.gap_id }
|
||||
)
|
||||
}
|
||||
onClick={() => {
|
||||
if (hasPendingQuestions) {
|
||||
questionsRef.current?.scrollIntoView({ block: "start" });
|
||||
questionsRef.current?.focus({ preventScroll: true });
|
||||
} else {
|
||||
discuss(
|
||||
`请帮我讨论“${formatInvestigationText(gap.missing_data)}”这个缺口的查询范围和补齐途径。`,
|
||||
{ gap_id: gap.gap_id }
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
补充事实
|
||||
{hasPendingQuestions ? "回答当前调查问题" : "讨论此缺口"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -548,12 +567,6 @@ export function InvestigationTaskReport({
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
onAsk={(node) =>
|
||||
discuss(
|
||||
`请解释调查脉络中的“${node.label}”及其关联依据。`,
|
||||
node.evidence_refs[0] ? { evidence_id: node.evidence_refs[0] } : undefined
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -50,6 +50,24 @@ describe("MapDevPanel", () => {
|
||||
expect(onFollowChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("keeps map inputs when switching between investigation and map tools", async () => {
|
||||
const user = userEvent.setup();
|
||||
const commands = createCommands();
|
||||
render(<MapDevPanel commands={commands} controllerState={createControllerState()}
|
||||
detailFeature={null} onClose={vi.fn()} onInvestigationFollowChange={vi.fn()} />);
|
||||
expect(screen.getByRole("tab", { name: "调查测试" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.queryByRole("button", { name: "定位并高亮" })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("tab", { name: "地图调试" }));
|
||||
const featureId = screen.getByLabelText("要素 ID(可多个)");
|
||||
await user.clear(featureId);
|
||||
await user.type(featureId, "DEMO-PIPE-03");
|
||||
await user.click(screen.getByRole("tab", { name: "调查测试" }));
|
||||
expect(screen.queryByRole("button", { name: "重置地图调试效果" })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("tab", { name: "地图调试" }));
|
||||
expect(screen.getByLabelText("要素 ID(可多个)")).toHaveValue("DEMO-PIPE-03");
|
||||
expect(commands.resetAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates a pipe with its signed velocity", async () => {
|
||||
const user = userEvent.setup();
|
||||
const commands = createCommands();
|
||||
|
||||
@@ -6,25 +6,27 @@ import { Disclosure } from "@/shared/ui/disclosure";
|
||||
import { FieldSelect } from "@/shared/ui/field-select";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
|
||||
import {
|
||||
LAYER_GROUP_GEOMETRIES,
|
||||
type LayerGroupId,
|
||||
type LayerGroupStylePatch
|
||||
} from "../map/layer-group-style";
|
||||
import { MAP_STYLE_TOKENS } from "../map/map-colors";
|
||||
import { MAX_MAP_FEATURE_IDS, MAX_MAP_FEATURE_ID_LENGTH } from "../map/map-feature-query";
|
||||
import { getNumericFeatureProperties } from "../map/value-label";
|
||||
import type {
|
||||
FeatureBatchTarget,
|
||||
WorkbenchMapCommands,
|
||||
WorkbenchMapControllerState
|
||||
} from "../map/workbench-map-controller";
|
||||
import { MAX_MAP_FEATURE_IDS, MAX_MAP_FEATURE_ID_LENGTH } from "../map/map-feature-query";
|
||||
import {
|
||||
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
|
||||
SUPPLY_LAYER_CATALOG,
|
||||
type AvailableWaterNetworkSourceId
|
||||
} from "../map/sources";
|
||||
import type { DetailFeature } from "../types";
|
||||
import { InvestigationDevTools, type InvestigationDevToolsProps } from "./investigation-dev-tools";
|
||||
|
||||
type MapDevPanelProps = {
|
||||
commands: WorkbenchMapCommands;
|
||||
@@ -34,6 +36,7 @@ type MapDevPanelProps = {
|
||||
onClose: () => void;
|
||||
investigationFollow?: boolean;
|
||||
onInvestigationFollowChange?: (enabled: boolean) => void;
|
||||
investigationTools?: InvestigationDevToolsProps;
|
||||
};
|
||||
|
||||
const GROUP_LABELS = Object.fromEntries(
|
||||
@@ -109,8 +112,10 @@ export function MapDevPanel({
|
||||
flowDataSource = "mock",
|
||||
onClose,
|
||||
investigationFollow = true,
|
||||
onInvestigationFollowChange
|
||||
onInvestigationFollowChange,
|
||||
investigationTools
|
||||
}: MapDevPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState(onInvestigationFollowChange ? "investigation" : "map");
|
||||
const [sourceId, setSourceId] = useState<AvailableWaterNetworkSourceId>("pipes");
|
||||
const [featureId, setFeatureId] = useState("440681");
|
||||
const [property, setProperty] = useState("diameter");
|
||||
@@ -241,17 +246,17 @@ export function MapDevPanel({
|
||||
return (
|
||||
<aside
|
||||
aria-label="地图开发面板"
|
||||
className="acrylic-panel pointer-events-auto absolute right-3 top-[4.5rem] z-40 hidden max-h-[calc(100dvh-5.5rem)] w-[400px] max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden rounded-2xl border lg:flex"
|
||||
className="workbench-shadcn-surface bg-background text-foreground shadow-lg pointer-events-auto absolute right-3 top-[4.5rem] z-40 hidden max-h-[calc(100dvh-5.5rem)] w-[400px] max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden rounded-2xl border lg:flex"
|
||||
data-testid="map-dev-panel"
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-[var(--surface-divider)]/70 px-4 py-3">
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-4 py-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-foreground">
|
||||
地图调试{" "}
|
||||
<span className="ml-1 text-[11px] font-medium text-[var(--ink-muted)]">Dev Panel</span>
|
||||
开发测试{" "}
|
||||
<span className="ml-1 text-[11px] font-medium text-muted-foreground">Dev Panel</span>
|
||||
</h2>
|
||||
<p className="mt-1 text-[11px] leading-4 text-[var(--ink-muted)]">
|
||||
按功能展开 · 设置仅本次页面会话生效
|
||||
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">
|
||||
调查联动验证与地图工具 · 仅当前页面生效
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -265,429 +270,436 @@ export function MapDevPanel({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 [scrollbar-gutter:stable]">
|
||||
{controllerState.errorCode ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="status-tone-danger my-3 break-words rounded-xl border border-[var(--status-border)] bg-[var(--status-soft)] px-3 py-2 text-xs font-medium text-[var(--status-foreground)]"
|
||||
>
|
||||
{ERROR_LABELS[controllerState.errorCode] ?? controllerState.errorCode}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{onInvestigationFollowChange ? (
|
||||
<PanelSection
|
||||
title="调查联动"
|
||||
description="选中调查后,自动跟随新的高亮、图表与影响范围展示。"
|
||||
initiallyOpen
|
||||
status={investigationFollow ? "已开启" : "已关闭"}
|
||||
>
|
||||
<ToggleField
|
||||
label="跟随所选调查"
|
||||
checked={investigationFollow}
|
||||
onChange={onInvestigationFollowChange}
|
||||
/>
|
||||
</PanelSection>
|
||||
) : null}
|
||||
|
||||
<PanelSection
|
||||
title="要素定位"
|
||||
description="批量读取同一业务源的要素,支持高亮全部目标或缩放到整体范围。"
|
||||
initiallyOpen
|
||||
status={
|
||||
controllerState.pending ? "执行中" : controllerState.targets.length ? `已高亮 ${controllerState.targets.length} 个` : undefined
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<FieldSelect
|
||||
label="业务源"
|
||||
value={sourceId}
|
||||
onValueChange={(value) => {
|
||||
setSourceId(value as AvailableWaterNetworkSourceId);
|
||||
setFeatureId("");
|
||||
}}
|
||||
options={AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((id) => ({
|
||||
value: id,
|
||||
label: GROUP_LABELS[id]
|
||||
}))}
|
||||
/>
|
||||
<Field label="要素 ID(可多个)">
|
||||
<Textarea
|
||||
value={featureId}
|
||||
onChange={(event) => setFeatureId(event.target.value)}
|
||||
rows={3}
|
||||
placeholder="例如:440681, 440682 440683"
|
||||
aria-describedby="map-dev-feature-ids-help"
|
||||
aria-invalid={invalidIds}
|
||||
className="resize-y text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<p id="map-dev-feature-ids-help" className="text-[11px] leading-5 text-muted-foreground">
|
||||
用逗号、空格或换行分隔,自动去重。已输入 {featureIds.length} / {MAX_MAP_FEATURE_IDS} 个。
|
||||
</p>
|
||||
{invalidIds ? <p role="alert" className="text-xs text-destructive">单次最多 {MAX_MAP_FEATURE_IDS} 个,每个 ID 最长 {MAX_MAP_FEATURE_ID_LENGTH} 字符。</p> : null}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!detailFeature}
|
||||
onClick={useSelectedFeature}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
使用当前地图选中
|
||||
</Button>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!canLocate}
|
||||
onClick={() => void commands.highlight(target)}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
<Crosshair size={14} />
|
||||
仅高亮
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!canLocate}
|
||||
onClick={() => void commands.zoomToFeature(target)}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
<ZoomIn size={14} />
|
||||
缩放到要素
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!canLocate}
|
||||
onClick={() => void commands.locateAndHighlight(target)}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
定位并高亮
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => commands.clearHighlight()}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
清除高亮
|
||||
</Button>
|
||||
{controllerState.targets.length ? (
|
||||
<StatusText>
|
||||
已高亮 {controllerState.targets.length} 个要素:{controllerState.targets.map((item) => `${item.sourceId}:${item.featureId}`).join("、")}
|
||||
</StatusText>
|
||||
) : null}
|
||||
{controllerState.targetResult ? (
|
||||
<StatusText tone={controllerState.targetResult.missingIds.length ? "warning" : "success"}>
|
||||
本次命中 {controllerState.targetResult.featureIds.length} 个
|
||||
{controllerState.targetResult.missingIds.length ? `,未找到 ${controllerState.targetResult.missingIds.length} 个:${controllerState.targetResult.missingIds.join("、")}` : ",全部找到"}
|
||||
</StatusText>
|
||||
) : null}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection
|
||||
title="水流调试"
|
||||
status={controllerState.flowVisible ? "已开启" : "未开启"}
|
||||
description={
|
||||
flowDataSource === "realtime"
|
||||
? "已读取后端最近 15 分钟的管道流速;可用有符号流速覆盖单管方向。"
|
||||
: "后端流速不可用,开启时使用正反向演示流向;有符号流速可覆盖单管方向。"
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={controllerState.pending || controllerState.flowVisible}
|
||||
onClick={() => void commands.setFlowVisible(true)}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
开启水流
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!controllerState.flowVisible}
|
||||
onClick={() => void commands.setFlowVisible(false)}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
关闭水流
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="管道 ID">
|
||||
<Input
|
||||
value={flowPipeId}
|
||||
onChange={(event) => setFlowPipeId(event.target.value)}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="有符号流速">
|
||||
<Input
|
||||
type="number"
|
||||
step="any"
|
||||
value={flowVelocity}
|
||||
onChange={(event) => setFlowVelocity(event.target.value)}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!validFlowCalculation || controllerState.pending}
|
||||
onClick={applyFlowCalculation}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
更新该管道方向
|
||||
</Button>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={sourceId !== "pipes" || featureIds.length !== 1}
|
||||
onClick={() => setFlowPipeId(featureIds[0])}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
使用 Feature ID
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void commands.clearPipeFlowCalculations()}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
清空流速
|
||||
</Button>
|
||||
</div>
|
||||
{flowSummary.total > 0 ? (
|
||||
<StatusText>
|
||||
{flowDataSource === "realtime" ? "后端实时流速" : "演示流向"} · 已赋值{" "}
|
||||
{flowSummary.total} · 正向 {flowSummary.forward} · 反向 {flowSummary.reverse} · 停止{" "}
|
||||
{flowSummary.stopped}
|
||||
</StatusText>
|
||||
) : controllerState.flowVisible ? (
|
||||
<StatusText>演示流向已启用 · 正反向交错显示</StatusText>
|
||||
) : (
|
||||
<StatusText>开启后显示演示流向</StatusText>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection
|
||||
title="图层样式"
|
||||
status={
|
||||
controllerState.styledGroups.length > 0
|
||||
? `${controllerState.styledGroups.length} 组已覆盖`
|
||||
: undefined
|
||||
}
|
||||
description="调整业务图层的颜色、透明度和尺寸,交互语义色保持不变。"
|
||||
>
|
||||
<FieldSelect
|
||||
label="业务图层组"
|
||||
value={groupId}
|
||||
onValueChange={(value) => selectStyleGroup(value as LayerGroupId)}
|
||||
options={STYLE_GROUP_IDS.map((id) => ({ value: id, label: GROUP_LABELS[id] }))}
|
||||
/>
|
||||
{geometry === "line" ? (
|
||||
<>
|
||||
<ColorField
|
||||
label="颜色"
|
||||
value={String(draft.color)}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, color: value }))}
|
||||
/>
|
||||
<RangeField
|
||||
label="透明度"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={Number(draft.opacity)}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))}
|
||||
/>
|
||||
<RangeField
|
||||
label="宽度倍率"
|
||||
min={0.5}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={Number(draft.widthMultiplier)}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, widthMultiplier: value }))
|
||||
}
|
||||
/>
|
||||
<FieldSelect
|
||||
label="线型"
|
||||
value={String(draft.dash)}
|
||||
onValueChange={(value) => setDraft((current) => ({ ...current, dash: value }))}
|
||||
options={[
|
||||
{ value: "original", label: "原始" },
|
||||
{ value: "solid", label: "实线" },
|
||||
{ value: "dashed", label: "虚线" }
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<ColorField
|
||||
label="填充"
|
||||
value={String(draft.fillColor)}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, fillColor: value }))}
|
||||
/>
|
||||
<ColorField
|
||||
label="描边"
|
||||
value={String(draft.strokeColor)}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, strokeColor: value }))}
|
||||
/>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="shrink-0 border-b border-border px-4 py-2">
|
||||
<TabsList aria-label="开发测试类型" className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="investigation" disabled={!onInvestigationFollowChange}>调查测试</TabsTrigger>
|
||||
<TabsTrigger value="map">地图调试</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<TabsContent value="investigation" hidden={activeTab !== "investigation"} forceMount className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 data-[state=inactive]:hidden [scrollbar-gutter:stable]">
|
||||
{onInvestigationFollowChange ? (
|
||||
<section aria-label="调查联动" className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<ToggleField label="跟随所选调查" checked={investigationFollow} onChange={onInvestigationFollowChange} />
|
||||
<p className="text-xs leading-5 text-muted-foreground">选中调查后,跟随新的地图高亮、图表和影响范围指令。</p>
|
||||
</div>
|
||||
<RangeField
|
||||
label="透明度"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={Number(draft.opacity)}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))}
|
||||
{investigationTools ? <InvestigationDevTools key={`${investigationTools.task?.task_id}:${investigationTools.task?.execution_generation}`} {...investigationTools} /> : null}
|
||||
</section>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
<TabsContent value="map" hidden={activeTab !== "map"} forceMount className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 data-[state=inactive]:hidden [scrollbar-gutter:stable]">
|
||||
{controllerState.errorCode ? (
|
||||
<div role="alert" className="status-tone-danger my-3 break-words rounded-lg border border-[var(--status-border)] bg-[var(--status-soft)] px-3 py-2 text-xs text-[var(--status-foreground)]">
|
||||
{ERROR_LABELS[controllerState.errorCode] ?? controllerState.errorCode}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<PanelSection
|
||||
title="要素定位"
|
||||
description="批量读取同一业务源的要素,支持高亮全部目标或缩放到整体范围。"
|
||||
initiallyOpen
|
||||
status={
|
||||
controllerState.pending ? "执行中" : controllerState.targets.length ? `已高亮 ${controllerState.targets.length} 个` : undefined
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<FieldSelect
|
||||
contentClassName="workbench-shadcn-surface"
|
||||
label="业务源"
|
||||
value={sourceId}
|
||||
onValueChange={(value) => {
|
||||
setSourceId(value as AvailableWaterNetworkSourceId);
|
||||
setFeatureId("");
|
||||
}}
|
||||
options={AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((id) => ({
|
||||
value: id,
|
||||
label: GROUP_LABELS[id]
|
||||
}))}
|
||||
/>
|
||||
<RangeField
|
||||
label="半径倍率"
|
||||
min={0.5}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={Number(draft.radiusMultiplier)}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, radiusMultiplier: value }))
|
||||
}
|
||||
/>
|
||||
<RangeField
|
||||
label="描边倍率"
|
||||
min={0.5}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={Number(draft.strokeMultiplier)}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, strokeMultiplier: value }))
|
||||
}
|
||||
/>
|
||||
{groupId !== "junctions" ? (
|
||||
<Field label="要素 ID(可多个)">
|
||||
<Textarea
|
||||
value={featureId}
|
||||
onChange={(event) => setFeatureId(event.target.value)}
|
||||
rows={3}
|
||||
placeholder="例如:440681, 440682 440683"
|
||||
aria-describedby="map-dev-feature-ids-help"
|
||||
aria-invalid={invalidIds}
|
||||
className="resize-y text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<p id="map-dev-feature-ids-help" className="text-[11px] leading-5 text-muted-foreground">
|
||||
用逗号、空格或换行分隔,自动去重。已输入 {featureIds.length} / {MAX_MAP_FEATURE_IDS} 个。
|
||||
</p>
|
||||
{invalidIds ? <p role="alert" className="text-xs text-destructive">单次最多 {MAX_MAP_FEATURE_IDS} 个,每个 ID 最长 {MAX_MAP_FEATURE_ID_LENGTH} 字符。</p> : null}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!detailFeature}
|
||||
onClick={useSelectedFeature}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
使用当前地图选中
|
||||
</Button>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!canLocate}
|
||||
onClick={() => void commands.highlight(target)}
|
||||
variant="outline"
|
||||
className="w-full text-xs"
|
||||
>
|
||||
<Crosshair size={14} />
|
||||
仅高亮
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!canLocate}
|
||||
onClick={() => void commands.zoomToFeature(target)}
|
||||
variant="outline"
|
||||
className="w-full text-xs"
|
||||
>
|
||||
<ZoomIn size={14} />
|
||||
缩放到要素
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!canLocate}
|
||||
onClick={() => void commands.locateAndHighlight(target)}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
定位并高亮
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => commands.clearHighlight()}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
清除高亮
|
||||
</Button>
|
||||
{controllerState.targets.length ? (
|
||||
<StatusText>
|
||||
已高亮 {controllerState.targets.length} 个要素:{controllerState.targets.map((item) => `${item.sourceId}:${item.featureId}`).join("、")}
|
||||
</StatusText>
|
||||
) : null}
|
||||
{controllerState.targetResult ? (
|
||||
<StatusText tone={controllerState.targetResult.missingIds.length ? "warning" : "success"}>
|
||||
本次命中 {controllerState.targetResult.featureIds.length} 个
|
||||
{controllerState.targetResult.missingIds.length ? `,未找到 ${controllerState.targetResult.missingIds.length} 个:${controllerState.targetResult.missingIds.join("、")}` : ",全部找到"}
|
||||
</StatusText>
|
||||
) : null}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection
|
||||
title="水流调试"
|
||||
status={controllerState.flowVisible ? "已开启" : "未开启"}
|
||||
description={
|
||||
flowDataSource === "realtime"
|
||||
? "已读取后端最近 15 分钟的管道流速;可用有符号流速覆盖单管方向。"
|
||||
: "后端流速不可用,开启时使用正反向演示流向;有符号流速可覆盖单管方向。"
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={controllerState.pending || controllerState.flowVisible}
|
||||
onClick={() => void commands.setFlowVisible(true)}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
开启水流
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!controllerState.flowVisible}
|
||||
onClick={() => void commands.setFlowVisible(false)}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
关闭水流
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="管道 ID">
|
||||
<Input
|
||||
value={flowPipeId}
|
||||
onChange={(event) => setFlowPipeId(event.target.value)}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="有符号流速">
|
||||
<Input
|
||||
type="number"
|
||||
step="any"
|
||||
value={flowVelocity}
|
||||
onChange={(event) => setFlowVelocity(event.target.value)}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!validFlowCalculation || controllerState.pending}
|
||||
onClick={applyFlowCalculation}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
更新该管道方向
|
||||
</Button>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={sourceId !== "pipes" || featureIds.length !== 1}
|
||||
onClick={() => setFlowPipeId(featureIds[0])}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
使用 Feature ID
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void commands.clearPipeFlowCalculations()}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
清空流速
|
||||
</Button>
|
||||
</div>
|
||||
{flowSummary.total > 0 ? (
|
||||
<StatusText>
|
||||
{flowDataSource === "realtime" ? "后端实时流速" : "演示流向"} · 已赋值{" "}
|
||||
{flowSummary.total} · 正向 {flowSummary.forward} · 反向 {flowSummary.reverse} · 停止{" "}
|
||||
{flowSummary.stopped}
|
||||
</StatusText>
|
||||
) : controllerState.flowVisible ? (
|
||||
<StatusText>演示流向已启用 · 正反向交错显示</StatusText>
|
||||
) : (
|
||||
<StatusText>开启后显示演示流向</StatusText>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection
|
||||
title="图层样式"
|
||||
status={
|
||||
controllerState.styledGroups.length > 0
|
||||
? `${controllerState.styledGroups.length} 组已覆盖`
|
||||
: undefined
|
||||
}
|
||||
description="调整业务图层的颜色、透明度和尺寸,交互语义色保持不变。"
|
||||
>
|
||||
<FieldSelect
|
||||
contentClassName="workbench-shadcn-surface"
|
||||
label="业务图层组"
|
||||
value={groupId}
|
||||
onValueChange={(value) => selectStyleGroup(value as LayerGroupId)}
|
||||
options={STYLE_GROUP_IDS.map((id) => ({ value: id, label: GROUP_LABELS[id] }))}
|
||||
/>
|
||||
{geometry === "line" ? (
|
||||
<>
|
||||
<ColorField
|
||||
label="颜色"
|
||||
value={String(draft.color)}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, color: value }))}
|
||||
/>
|
||||
<RangeField
|
||||
label="图标倍率"
|
||||
label="透明度"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={Number(draft.opacity)}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))}
|
||||
/>
|
||||
<RangeField
|
||||
label="宽度倍率"
|
||||
min={0.5}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={Number(draft.iconMultiplier)}
|
||||
value={Number(draft.widthMultiplier)}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, iconMultiplier: value }))
|
||||
setDraft((current) => ({ ...current, widthMultiplier: value }))
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button type="button" onClick={applyLayerStyle} className="w-full text-xs">
|
||||
应用图层样式
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => commands.resetLayerGroupStyle(groupId)}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
重置本组
|
||||
</Button>
|
||||
</div>
|
||||
{controllerState.styledGroups.includes(groupId) ? (
|
||||
<StatusText>已覆盖当前图层组样式</StatusText>
|
||||
) : null}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection
|
||||
title="数值标注"
|
||||
description="作用于所选图层的全部可见要素,使用原生 MVT 标注并自动避让。"
|
||||
status={
|
||||
hasActiveLabels
|
||||
? `${Object.keys(controllerState.valueLabels).length} 组已标注`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<FieldSelect
|
||||
label="标注图层组"
|
||||
value={groupId}
|
||||
onValueChange={(value) => selectStyleGroup(value as LayerGroupId)}
|
||||
options={STYLE_GROUP_IDS.map((id) => ({ value: id, label: GROUP_LABELS[id] }))}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<ToggleField label="数值" checked={showValueLabels} onChange={setShowValueLabels} />
|
||||
<ToggleField label="设施 ID" checked={showFeatureIds} onChange={setShowFeatureIds} />
|
||||
</div>
|
||||
{showValueLabels ? (
|
||||
<>
|
||||
<FieldSelect
|
||||
label="数值属性"
|
||||
value={property}
|
||||
onValueChange={setProperty}
|
||||
options={LABEL_PRESETS[groupId].options}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="精度 0-3">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
<FieldSelect
|
||||
contentClassName="workbench-shadcn-surface"
|
||||
label="线型"
|
||||
value={String(draft.dash)}
|
||||
onValueChange={(value) => setDraft((current) => ({ ...current, dash: value }))}
|
||||
options={[
|
||||
{ value: "original", label: "原始" },
|
||||
{ value: "solid", label: "实线" },
|
||||
{ value: "dashed", label: "虚线" }
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<ColorField
|
||||
label="填充"
|
||||
value={String(draft.fillColor)}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, fillColor: value }))}
|
||||
/>
|
||||
<ColorField
|
||||
label="描边"
|
||||
value={String(draft.strokeColor)}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, strokeColor: value }))}
|
||||
/>
|
||||
</div>
|
||||
<RangeField
|
||||
label="透明度"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={Number(draft.opacity)}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, opacity: value }))}
|
||||
/>
|
||||
<RangeField
|
||||
label="半径倍率"
|
||||
min={0.5}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={Number(draft.radiusMultiplier)}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, radiusMultiplier: value }))
|
||||
}
|
||||
/>
|
||||
<RangeField
|
||||
label="描边倍率"
|
||||
min={0.5}
|
||||
max={3}
|
||||
step={0.1}
|
||||
value={Number(draft.strokeMultiplier)}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, strokeMultiplier: value }))
|
||||
}
|
||||
/>
|
||||
{groupId !== "junctions" ? (
|
||||
<RangeField
|
||||
label="图标倍率"
|
||||
min={0.5}
|
||||
max={3}
|
||||
value={precision}
|
||||
onChange={(event) => setPrecision(Number(event.target.value))}
|
||||
className={inputClassName}
|
||||
step={0.1}
|
||||
value={Number(draft.iconMultiplier)}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({ ...current, iconMultiplier: value }))
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="单位">
|
||||
<Input
|
||||
maxLength={16}
|
||||
value={unit}
|
||||
onChange={(event) => setUnit(event.target.value)}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<RangeField
|
||||
label="字号"
|
||||
min={10}
|
||||
max={18}
|
||||
step={1}
|
||||
value={labelSize}
|
||||
onChange={setLabelSize}
|
||||
/>
|
||||
<ColorField label="文字颜色" value={labelColor} onChange={setLabelColor} />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!showValueLabels && !showFeatureIds}
|
||||
onClick={applyLabels}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
应用标注
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!hasActiveLabels}
|
||||
onClick={() => {
|
||||
setShowValueLabels(false);
|
||||
setShowFeatureIds(false);
|
||||
commands.clearValueLabel();
|
||||
}}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
清除全部标注
|
||||
</Button>
|
||||
</div>
|
||||
{activeLabel ? (
|
||||
<StatusText>
|
||||
已标注全部 {GROUP_LABELS[groupId]} Feature · {activeLabel.showId ? "ID + " : ""}
|
||||
{activeLabel.showValue ? activeLabel.property : "仅 ID"}
|
||||
</StatusText>
|
||||
) : null}
|
||||
</PanelSection>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button type="button" onClick={applyLayerStyle} className="w-full text-xs">
|
||||
应用图层样式
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => commands.resetLayerGroupStyle(groupId)}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
重置本组
|
||||
</Button>
|
||||
</div>
|
||||
{controllerState.styledGroups.includes(groupId) ? (
|
||||
<StatusText>已覆盖当前图层组样式</StatusText>
|
||||
) : null}
|
||||
</PanelSection>
|
||||
|
||||
<div className="shrink-0 border-t border-[var(--surface-divider)]/70 p-3">
|
||||
<PanelSection
|
||||
title="数值标注"
|
||||
description="作用于所选图层的全部可见要素,使用原生 MVT 标注并自动避让。"
|
||||
status={
|
||||
hasActiveLabels
|
||||
? `${Object.keys(controllerState.valueLabels).length} 组已标注`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<FieldSelect
|
||||
contentClassName="workbench-shadcn-surface"
|
||||
label="标注图层组"
|
||||
value={groupId}
|
||||
onValueChange={(value) => selectStyleGroup(value as LayerGroupId)}
|
||||
options={STYLE_GROUP_IDS.map((id) => ({ value: id, label: GROUP_LABELS[id] }))}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<ToggleField label="数值" checked={showValueLabels} onChange={setShowValueLabels} />
|
||||
<ToggleField label="设施 ID" checked={showFeatureIds} onChange={setShowFeatureIds} />
|
||||
</div>
|
||||
{showValueLabels ? (
|
||||
<>
|
||||
<FieldSelect
|
||||
contentClassName="workbench-shadcn-surface"
|
||||
label="数值属性"
|
||||
value={property}
|
||||
onValueChange={setProperty}
|
||||
options={LABEL_PRESETS[groupId].options}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Field label="精度 0-3">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={3}
|
||||
value={precision}
|
||||
onChange={(event) => setPrecision(Number(event.target.value))}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="单位">
|
||||
<Input
|
||||
maxLength={16}
|
||||
value={unit}
|
||||
onChange={(event) => setUnit(event.target.value)}
|
||||
className={inputClassName}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<RangeField
|
||||
label="字号"
|
||||
min={10}
|
||||
max={18}
|
||||
step={1}
|
||||
value={labelSize}
|
||||
onChange={setLabelSize}
|
||||
/>
|
||||
<ColorField label="文字颜色" value={labelColor} onChange={setLabelColor} />
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!showValueLabels && !showFeatureIds}
|
||||
onClick={applyLabels}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
应用标注
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!hasActiveLabels}
|
||||
onClick={() => {
|
||||
setShowValueLabels(false);
|
||||
setShowFeatureIds(false);
|
||||
commands.clearValueLabel();
|
||||
}}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
清除全部标注
|
||||
</Button>
|
||||
</div>
|
||||
{activeLabel ? (
|
||||
<StatusText>
|
||||
已标注全部 {GROUP_LABELS[groupId]} Feature · {activeLabel.showId ? "ID + " : ""}
|
||||
{activeLabel.showValue ? activeLabel.property : "仅 ID"}
|
||||
</StatusText>
|
||||
) : null}
|
||||
</PanelSection>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="shrink-0 border-t border-border p-3" hidden={activeTab !== "map"}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => commands.resetAll()}
|
||||
@@ -718,12 +730,12 @@ function PanelSection({
|
||||
return (
|
||||
<Disclosure
|
||||
initiallyOpen={initiallyOpen}
|
||||
className="border-b border-[var(--surface-divider)]/70 py-1 last:border-b-0 [&>summary]:px-2 [&>summary]:text-foreground"
|
||||
className="border-b border-border py-2 last:border-b-0 [&>summary]:px-2 [&>summary]:text-foreground [&>summary]:text-sm"
|
||||
label={
|
||||
<span className="flex min-w-0 items-center justify-between gap-2">
|
||||
<span className="font-semibold">{title}</span>
|
||||
{status ? (
|
||||
<span className="shrink-0 text-[11px] font-normal text-[var(--ink-muted)]">
|
||||
<span className="shrink-0 text-[11px] font-normal text-muted-foreground">
|
||||
{status}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -731,7 +743,7 @@ function PanelSection({
|
||||
}
|
||||
>
|
||||
<div className="space-y-3 px-2 pb-3 pt-1">
|
||||
<p className="text-[11px] leading-5 text-[var(--ink-muted)]">{description}</p>
|
||||
<p className="text-[11px] leading-5 text-muted-foreground">{description}</p>
|
||||
{children}
|
||||
</div>
|
||||
</Disclosure>
|
||||
@@ -741,7 +753,7 @@ function PanelSection({
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="block min-w-0">
|
||||
<span className="mb-1.5 block text-xs font-medium text-[var(--ink-muted)]">{label}</span>
|
||||
<span className="mb-1.5 block text-xs font-medium text-muted-foreground">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
@@ -757,7 +769,7 @@ function ToggleField({
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex min-h-10 cursor-pointer items-center gap-2 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)]/80 px-2.5 py-2 text-xs font-medium text-foreground">
|
||||
<label className="flex min-h-10 cursor-pointer items-center gap-2 rounded-lg border border-input bg-background px-2.5 py-2 text-xs font-medium text-foreground">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
@@ -778,7 +790,7 @@ function ColorField({
|
||||
}) {
|
||||
return (
|
||||
<Field label={label}>
|
||||
<div className="flex min-h-10 items-center gap-2 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)]/80 px-2">
|
||||
<div className="flex min-h-10 items-center gap-2 rounded-lg border border-input bg-background px-2">
|
||||
<input
|
||||
type="color"
|
||||
value={value}
|
||||
@@ -786,7 +798,7 @@ function ColorField({
|
||||
aria-label={label}
|
||||
className="h-10 w-10 shrink-0 cursor-pointer rounded border-0 bg-transparent p-1 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--action-blue)]"
|
||||
/>
|
||||
<span className="text-xs font-medium tabular-nums text-[var(--ink-muted)]">{value}</span>
|
||||
<span className="text-xs font-medium tabular-nums text-muted-foreground">{value}</span>
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
@@ -835,4 +847,4 @@ function StatusText({ children, tone = "success" }: { children: React.ReactNode;
|
||||
}
|
||||
|
||||
const inputClassName =
|
||||
"h-10 w-full rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)]/80 px-2.5 text-xs text-foreground transition-colors";
|
||||
"h-10 w-full rounded-lg border border-input bg-background px-2.5 text-xs text-foreground transition-colors";
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { AnswerUiView, type AnswerUiBlock } from "@/features/agent";
|
||||
import { Card, CardContent, CardHeader } from "@/shared/ui/card";
|
||||
import type { InvestigationMapTarget } from "../api/investigation-report";
|
||||
|
||||
type Props = {
|
||||
blocks: AnswerUiBlock[];
|
||||
records: Array<Record<string, unknown>>;
|
||||
reviewing: boolean;
|
||||
onLocateTargets?: (targets: InvestigationMapTarget[]) => Promise<unknown>;
|
||||
};
|
||||
|
||||
export function PresentationBusinessResults({
|
||||
blocks,
|
||||
records,
|
||||
reviewing,
|
||||
onLocateTargets
|
||||
}: Props) {
|
||||
if (!blocks.length && !records.length) return null;
|
||||
return (
|
||||
<section aria-label="展示结果" className="min-w-0 space-y-3">
|
||||
{blocks.map((block) => (
|
||||
<AnswerUiView
|
||||
key={block.block_id}
|
||||
block={block}
|
||||
disabled={reviewing}
|
||||
onLocate={
|
||||
onLocateTargets && !reviewing
|
||||
? (target) =>
|
||||
onLocateTargets(
|
||||
target.feature_ids.map((feature_id) => ({
|
||||
source_id: target.layer,
|
||||
feature_id
|
||||
}))
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{!blocks.length && records.length ? (
|
||||
<Card className="border-border shadow-none">
|
||||
<CardHeader className="p-3 pb-2">
|
||||
<h3 className="text-xs font-semibold">调查数据 · {records.length} 条</h3>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto px-3 pb-3 pt-0">
|
||||
<table className="w-full text-left text-[11px] leading-5">
|
||||
<caption className="sr-only">调查数据摘要</caption>
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="py-1 pr-3 font-medium">类型</th>
|
||||
<th className="py-1 pr-3 font-medium">对象</th>
|
||||
<th className="py-1 font-medium">来源</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((record, index) => (
|
||||
<tr key={index} className="border-b last:border-0">
|
||||
<td className="break-all py-1.5 pr-3">{String(record.record_type)}</td>
|
||||
<td className="break-all py-1.5 pr-3">
|
||||
{String(
|
||||
record.element_id ??
|
||||
record.device_id ??
|
||||
record.action_id ??
|
||||
record.item ??
|
||||
`记录 ${index + 1}`
|
||||
)}
|
||||
</td>
|
||||
<td className="break-all py-1.5">{String(record.origin)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import "@/test/radix-select-test-setup";
|
||||
import { PresentationViewHistory } from "../utils/presentation-view-history";
|
||||
import { PresentationHistorySwitcher } from "./presentation-history-switcher";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function entries() {
|
||||
const history = new PresentationViewHistory();
|
||||
history.record("压力曲线", "preview", null);
|
||||
history.update({ ...history.current, records: [{ element_id: "V1" }] });
|
||||
history.record("阀门列表", "preview", null);
|
||||
return history.entries;
|
||||
}
|
||||
|
||||
it("merges the latest result into current and skips its duplicate when going back", async () => {
|
||||
const user = userEvent.setup();
|
||||
const history = entries();
|
||||
const onSelect = vi.fn();
|
||||
render(
|
||||
<PresentationHistorySwitcher
|
||||
entries={history}
|
||||
currentEntryId={history[1].id}
|
||||
selectedId={null}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
);
|
||||
await user.click(screen.getByRole("combobox", { name: "切换展示动作" }));
|
||||
expect(screen.getAllByRole("option")).toHaveLength(2);
|
||||
expect(screen.getByRole("option", { name: /当前(最新).*阀门列表/ })).toBeVisible();
|
||||
expect(screen.getByRole("option", { name: /1\. 压力曲线/ })).toBeVisible();
|
||||
expect(screen.queryByRole("option", { name: /^2\. 阀门列表/ })).not.toBeInTheDocument();
|
||||
await user.keyboard("{Escape}");
|
||||
await user.click(screen.getByRole("button", { name: "上一个动作" }));
|
||||
expect(onSelect).toHaveBeenCalledWith(history[0].id);
|
||||
});
|
||||
|
||||
it("keeps the final historical result selectable when current has been cleared", async () => {
|
||||
const user = userEvent.setup();
|
||||
const history = entries();
|
||||
const onSelect = vi.fn();
|
||||
render(
|
||||
<PresentationHistorySwitcher
|
||||
entries={history}
|
||||
currentEntryId={null}
|
||||
selectedId={null}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
);
|
||||
await user.click(screen.getByRole("combobox", { name: "切换展示动作" }));
|
||||
expect(screen.getAllByRole("option").map((option) => option.textContent)).toEqual([
|
||||
"当前(最新) · 2 个动作",
|
||||
"2. 阀门列表 · 完成",
|
||||
"1. 压力曲线 · 完成"
|
||||
]);
|
||||
await user.click(screen.getByRole("option", { name: /^2\. 阀门列表/ }));
|
||||
expect(onSelect).toHaveBeenCalledWith(history[1].id);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { ChevronLeft, ChevronRight, Radio } from "lucide-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select";
|
||||
import type { PresentationHistoryControls } from "../utils/presentation-view-history";
|
||||
|
||||
const statusLabels = { running: "进行中", completed: "完成", error: "未完成", cancelled: "已取消" };
|
||||
|
||||
export function PresentationHistorySwitcher({
|
||||
entries,
|
||||
currentEntryId,
|
||||
selectedId,
|
||||
onSelect
|
||||
}: PresentationHistoryControls) {
|
||||
const currentEntry = entries.find((entry) => entry.id === currentEntryId);
|
||||
const available = entries.filter(
|
||||
(entry) => entry.status !== "running" && entry.id !== currentEntryId
|
||||
);
|
||||
const index =
|
||||
selectedId === null
|
||||
? available.length
|
||||
: available.findIndex((entry) => entry.id === selectedId);
|
||||
return (
|
||||
<div className="mt-2 flex min-w-0 items-center gap-1" role="group" aria-label="展示动作历史">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="size-8 shrink-0"
|
||||
aria-label="上一个动作"
|
||||
disabled={index <= 0}
|
||||
onClick={() => onSelect(available[index - 1].id)}
|
||||
>
|
||||
<ChevronLeft size={14} aria-hidden />
|
||||
</Button>
|
||||
<Select
|
||||
value={selectedId === null ? "current" : String(selectedId)}
|
||||
onValueChange={(value) => onSelect(value === "current" ? null : Number(value))}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label="切换展示动作"
|
||||
className="h-8 min-w-0 flex-1 px-2 text-xs [&>span]:truncate"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
sideOffset={4}
|
||||
className="workbench-shadcn-surface z-[90] max-w-[calc(100vw-24px)]"
|
||||
>
|
||||
<SelectItem value="current" className="text-xs">
|
||||
当前(最新)
|
||||
{currentEntry ? ` · ${entries.indexOf(currentEntry) + 1}. ${currentEntry.label}` : ""} ·{" "}
|
||||
{entries.length} 个动作
|
||||
</SelectItem>
|
||||
{entries
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((entry, position) =>
|
||||
entry.id === currentEntryId ? null : (
|
||||
<SelectItem
|
||||
key={entry.id}
|
||||
value={String(entry.id)}
|
||||
disabled={entry.status === "running"}
|
||||
className="whitespace-normal text-xs [&>span:last-child]:break-words"
|
||||
>
|
||||
{entries.length - position}. {entry.label} · {statusLabels[entry.status]}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="size-8 shrink-0"
|
||||
aria-label="下一个动作"
|
||||
disabled={selectedId === null}
|
||||
onClick={() => onSelect(index + 1 < available.length ? available[index + 1].id : null)}
|
||||
>
|
||||
<ChevronRight size={14} aria-hidden />
|
||||
</Button>
|
||||
{selectedId !== null ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 gap-1 px-2 text-[11px]"
|
||||
onClick={() => onSelect(null)}
|
||||
>
|
||||
<Radio size={12} aria-hidden />
|
||||
回到当前
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,6 @@ import type {
|
||||
} from "../api/operational-task-client";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { formatDateTime } from "@/shared/utils/date-time";
|
||||
import { Disclosure } from "@/shared/ui/disclosure";
|
||||
import {
|
||||
StatusBadge,
|
||||
StatusIcon as SemanticStatusIcon,
|
||||
@@ -115,6 +114,7 @@ type ConditionDetailPanelProps = {
|
||||
workOrderDataSource?: WorkbenchWorkOrderDataSource;
|
||||
onContinueConversation: (discussion?: import("../api/investigation-report").InvestigationDiscussion) => Promise<boolean | void> | boolean | void;
|
||||
onViewTaskExecutionHistory?: (taskId: string, target: TaskExecutionHistoryTarget, knownRunId?: string) => Promise<void> | void;
|
||||
onAnswerTaskQuestion?: (taskId: string, ...args: Parameters<import("../api/operational-task-client").AnswerTaskQuestion>) => Promise<unknown>;
|
||||
onTaskDecision?: (taskId: string, actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
|
||||
onTaskExecute?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
|
||||
onTaskDraftUpdate?: (taskId: string, actionId: string, expectedVersion: number, payload: Record<string, unknown>) => Promise<unknown>;
|
||||
@@ -171,6 +171,7 @@ export function ConditionDetailPanel({
|
||||
onContinueConversation,
|
||||
onViewTaskExecutionHistory,
|
||||
onTaskDecision,
|
||||
onAnswerTaskQuestion,
|
||||
onTaskExecute,
|
||||
onTaskDraftUpdate,
|
||||
onTaskConfirmDispatch,
|
||||
@@ -200,6 +201,7 @@ export function ConditionDetailPanel({
|
||||
onContinueConversation={onContinueConversation}
|
||||
onViewTaskExecutionHistory={onViewTaskExecutionHistory}
|
||||
onTaskDecision={onTaskDecision}
|
||||
onAnswerTaskQuestion={onAnswerTaskQuestion}
|
||||
onTaskExecute={onTaskExecute}
|
||||
onTaskDraftUpdate={onTaskDraftUpdate}
|
||||
onTaskConfirmDispatch={onTaskConfirmDispatch}
|
||||
@@ -237,6 +239,7 @@ function ConditionRecordDetailPanel({
|
||||
onContinueConversation,
|
||||
onViewTaskExecutionHistory,
|
||||
onTaskDecision,
|
||||
onAnswerTaskQuestion,
|
||||
onTaskExecute,
|
||||
onTaskDraftUpdate,
|
||||
onTaskConfirmDispatch,
|
||||
@@ -255,6 +258,7 @@ function ConditionRecordDetailPanel({
|
||||
onContinueConversation: ConditionDetailPanelProps["onContinueConversation"];
|
||||
onViewTaskExecutionHistory?: ConditionDetailPanelProps["onViewTaskExecutionHistory"];
|
||||
onTaskDecision?: ConditionDetailPanelProps["onTaskDecision"];
|
||||
onAnswerTaskQuestion?: ConditionDetailPanelProps["onAnswerTaskQuestion"];
|
||||
onTaskExecute?: ConditionDetailPanelProps["onTaskExecute"];
|
||||
onTaskDraftUpdate?: ConditionDetailPanelProps["onTaskDraftUpdate"];
|
||||
onTaskConfirmDispatch?: ConditionDetailPanelProps["onTaskConfirmDispatch"];
|
||||
@@ -322,6 +326,7 @@ function ConditionRecordDetailPanel({
|
||||
<AgentExecutionPlanPanel
|
||||
onLocate={onLocate}
|
||||
task={condition.operationalTask}
|
||||
onAnswerQuestion={onAnswerTaskQuestion ? (...args) => onAnswerTaskQuestion(condition.operationalTask!.task_id, ...args) : undefined}
|
||||
onLoadReports={onLoadTaskReports ? (offset) => onLoadTaskReports(condition.operationalTask!.task_id, offset) : undefined}
|
||||
onReportViewed={onReportViewed ? (generation, revision) => onReportViewed(condition.operationalTask!.task_id, generation, revision) : undefined}
|
||||
focusActionId={focusActionId}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
X,
|
||||
XCircle
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { MAP_TOOL_PANEL_SURFACE_CLASS_NAME, showMapNotice } from "@/features/map/core";
|
||||
import { isGeneratedScheduledConditionSessionId } from "../data/scheduled-conditions";
|
||||
@@ -87,6 +86,7 @@ type ScheduledConditionFeedProps = {
|
||||
onSubmitPrompt: (prompt: string) => void | Promise<void>;
|
||||
onOpenTaskCollaboration?: (taskId: string, discussion?: InvestigationDiscussion) => Promise<void>;
|
||||
onViewTaskExecutionHistory?: (taskId: string, target: TaskExecutionHistoryTarget, knownRunId?: string) => Promise<void>;
|
||||
onAnswerTaskQuestion?: (taskId: string, ...args: Parameters<import("../api/operational-task-client").AnswerTaskQuestion>) => Promise<unknown>;
|
||||
onTaskDecision?: (taskId: string, actionId: string, expectedVersion: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
|
||||
onTaskExecute?: (taskId: string, actionId: string, expectedVersion: number) => Promise<unknown>;
|
||||
onTaskDraftUpdate?: (taskId: string, actionId: string, expectedVersion: number, payload: Record<string, unknown>) => Promise<unknown>;
|
||||
@@ -129,6 +129,7 @@ export function ScheduledConditionFeed({
|
||||
onOpenTaskCollaboration,
|
||||
onViewTaskExecutionHistory,
|
||||
onTaskDecision,
|
||||
onAnswerTaskQuestion,
|
||||
onTaskExecute,
|
||||
onTaskDraftUpdate,
|
||||
onTaskConfirmDispatch,
|
||||
@@ -211,7 +212,6 @@ export function ScheduledConditionFeed({
|
||||
: [...recentConditions, ...futureWorkOrders],
|
||||
[futureWorkOrders, recentConditions, taskCategory]
|
||||
);
|
||||
const filterActive = taskFilter !== CONDITION_FILTER_ALL || statusFilter !== CONDITION_FILTER_ALL;
|
||||
const totalConditionCount = allRecentConditions.length + allFutureWorkOrders.length;
|
||||
const filteredConditionCount = recentConditions.length + futureWorkOrders.length;
|
||||
const headerSummary = loading && backendLoading
|
||||
@@ -559,6 +559,7 @@ export function ScheduledConditionFeed({
|
||||
onContinueConversation={(discussion) => handleContinueConversation(selectedCondition, discussion)}
|
||||
onViewTaskExecutionHistory={onViewTaskExecutionHistory}
|
||||
onTaskDecision={onTaskDecision}
|
||||
onAnswerTaskQuestion={onAnswerTaskQuestion}
|
||||
onTaskExecute={onTaskExecute}
|
||||
onTaskDraftUpdate={onTaskDraftUpdate}
|
||||
onTaskConfirmDispatch={onTaskConfirmDispatch}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import type { OperationalTask, OperationalTaskDecision } from "../api/operational-task-client";
|
||||
import { AccessRequestCard, HumanActionCard } from "./agent-execution-plan-panel";
|
||||
import { investigationStatus } from "../utils/investigation-presentation";
|
||||
|
||||
type TaskConversationReviewProps = {
|
||||
task: OperationalTask;
|
||||
onAccessDecision: (requestId: string, version: number, decision: "approved" | "rejected", scopes: string[]) => Promise<unknown>;
|
||||
onActionDecision: (actionId: string, version: number, decision: OperationalTaskDecision, comment?: string) => Promise<unknown>;
|
||||
onActionExecute: (actionId: string, version: number) => Promise<unknown>;
|
||||
onDraftUpdate: (actionId: string, version: number, payload: Record<string, unknown>) => Promise<unknown>;
|
||||
onConfirmDispatch: (actionId: string, version: number) => Promise<unknown>;
|
||||
onActionReconcile: (actionId: string, version: number) => Promise<unknown>;
|
||||
onRefresh: () => void;
|
||||
};
|
||||
|
||||
export function TaskConversationReview({ task, onAccessDecision, onActionDecision, onActionExecute,
|
||||
onDraftUpdate, onConfirmDispatch, onActionReconcile, onRefresh }: TaskConversationReviewProps) {
|
||||
const pendingAccess = (task.task_type === "event_investigation" ? [] : task.access_requests).filter((request) => request.status === "pending");
|
||||
const pendingActions = task.actions.filter((action) => action.approval_status === "pending"
|
||||
|| action.approval_status === "approved" && ["not_submitted", "submitted", "unknown"].includes(action.execution_status));
|
||||
const recentFacts = task.evidence.filter((item) => item.kind === "human_input")
|
||||
.sort((a, b) => b.recorded_at.localeCompare(a.recorded_at)).slice(0, 3);
|
||||
return <section aria-label="任务协作状态" className="surface-reading space-y-2 rounded-lg border border-[var(--surface-divider)] p-3">
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-[var(--ink)]"><ShieldCheck size={14} aria-hidden="true" />{task.title}</div>
|
||||
<p className="text-xs leading-5 text-[var(--ink-muted)]">当前第 {task.execution_generation} 轮 · {investigationStatus(task)}</p>
|
||||
{recentFacts.length > 0 ? <details>
|
||||
<summary className="cursor-pointer py-1 text-xs text-[var(--ink-muted)]">最近已记录的人工事实 · {recentFacts.length} 条</summary>
|
||||
<ul className="mt-1 space-y-2 text-xs leading-5 text-[var(--ink)]">{recentFacts.map((item) => <li key={item.evidence_id}>
|
||||
<p>{item.summary}</p><p className="text-[var(--ink-muted)]">{item.source_label ?? "人工输入"} · 记录于 {new Date(item.recorded_at).toLocaleString("zh-CN", { hour12: false })}</p>
|
||||
{item.observed_at ? <p className="text-[var(--ink-muted)]">现场时间 {new Date(item.observed_at).toLocaleString("zh-CN", { hour12: false })}</p> : null}
|
||||
</li>)}</ul>
|
||||
</details> : null}
|
||||
{pendingAccess.map((request) => <AccessRequestCard key={request.access_request_id} request={request}
|
||||
nodeTitle={task.latest_plan?.plan.nodes.find((node) => node.id === request.node_id)?.title}
|
||||
canDecide={task.permissions.can_decide_access}
|
||||
onDecision={(requestId, version, decision, scopes) => onAccessDecision(requestId, version, decision, scopes)}
|
||||
onRefresh={onRefresh} />)}
|
||||
{pendingActions.map((action) => <HumanActionCard key={action.action_id} task={task} action={action}
|
||||
onDecision={onActionDecision} onExecute={onActionExecute} onDraftUpdate={onDraftUpdate}
|
||||
onConfirmDispatch={onConfirmDispatch} onReconcile={onActionReconcile} onRefresh={onRefresh} />)}
|
||||
</section>;
|
||||
}
|
||||
@@ -52,26 +52,27 @@ export function WorkbenchSection({
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
data-slot="workbench-section"
|
||||
className={cn(
|
||||
"overflow-hidden rounded-[14px] border border-[var(--surface-divider)]/90 bg-[var(--surface-reading)] shadow-[0_1px_2px_rgba(15,23,42,0.06)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex min-h-14 items-start gap-3 border-b border-[var(--surface-divider)]/75 bg-[var(--surface-control)] px-3.5 py-3">
|
||||
<div data-slot="workbench-section-header" className="flex min-h-14 items-start gap-3 border-b border-[var(--surface-divider)]/75 bg-[var(--surface-control)] px-3.5 py-3">
|
||||
{icon ? (
|
||||
<span className={cn("mt-0.5 grid size-7 shrink-0 place-items-center rounded-lg", sectionTone[tone])}>
|
||||
{icon}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div data-slot="workbench-section-heading" className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<h3 className="text-[13px] font-semibold leading-5 text-slate-900">{title}</h3>
|
||||
{meta}
|
||||
</div>
|
||||
{description ? <p className="mt-0.5 text-xs leading-5 text-[var(--ink-muted)]">{description}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex shrink-0 items-center gap-1">{actions}</div> : null}
|
||||
{actions ? <div data-slot="workbench-section-actions" className="flex shrink-0 items-center gap-1">{actions}</div> : null}
|
||||
</div>
|
||||
<div className={cn("p-3.5", bodyClassName)}>{children}</div>
|
||||
</section>
|
||||
@@ -93,6 +94,8 @@ export function WorkbenchNotice({
|
||||
const Icon = noticeIcon[tone];
|
||||
return (
|
||||
<div
|
||||
data-slot="workbench-notice"
|
||||
data-tone={tone}
|
||||
className={cn(
|
||||
"flex min-w-0 gap-2.5 rounded-r-lg border-l-2 px-3 py-2.5 text-xs leading-5",
|
||||
noticeTone[tone],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ChatTransport, UIMessageChunk } from "ai";
|
||||
import type { AgentApiClient } from "@/features/agent";
|
||||
import { AgentSessionStreamError, AgentStreamDisconnectedError, type AgentApiClient } from "@/features/agent";
|
||||
import { WorkbenchSessionResources } from "./workbench-session-resources";
|
||||
import { createAgentRuntime, mergeHistoryMessages, resumeAgentRuntime, runtimeRunning } from "./agent-session-runtime";
|
||||
import type { AgentUiMessage } from "./agent-session-message-state";
|
||||
@@ -8,6 +8,81 @@ import type { AgentUiMessage } from "./agent-session-message-state";
|
||||
const message = (id: string, text = id): AgentUiMessage => ({ id, role: "assistant", parts: [{ type: "text", text }] });
|
||||
|
||||
describe("independent conversation runtimes", () => {
|
||||
it("retries a disconnected subscription and preserves the draft, pages and reading position", async () => {
|
||||
vi.useFakeTimers();
|
||||
const resources = new WorkbenchSessionResources();
|
||||
const streamSession = vi.fn(async (_id, options) => {
|
||||
const complete = streamSession.mock.calls.length > 1;
|
||||
options.onEvent({ type: "state", sessionId: "a", isStreaming: !complete,
|
||||
messages: [message("b", complete ? "recovered" : "partial")], runStatus: complete ? "completed" : "running" });
|
||||
});
|
||||
const transport = { sendMessages: vi.fn() } as unknown as ChatTransport<AgentUiMessage>;
|
||||
const runtime = createAgentRuntime(resources, "project:a", { streamSession } as unknown as AgentApiClient, transport, {
|
||||
id: "a", sessionId: "a", title: "核查", createdAt: 1, updatedAt: 1, purpose: "ordinary_chat", isTitleManuallyEdited: false, messages: [message("older"), message("b")]
|
||||
});
|
||||
resources.activeAgent = runtime;
|
||||
runtime.draft = "unsent";
|
||||
runtime.readingPosition = { messageId: "older", offset: 12, atBottom: false };
|
||||
try {
|
||||
resumeAgentRuntime(resources, runtime);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(streamSession).toHaveBeenCalledTimes(1);
|
||||
expect(runtimeRunning(runtime)).toBe(true);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(streamSession).toHaveBeenCalledTimes(2);
|
||||
expect(runtime.chat.messages).toEqual([message("older"), message("b", "recovered")]);
|
||||
expect(runtime.draft).toBe("unsent");
|
||||
expect(runtime.readingPosition).toEqual({ messageId: "older", offset: 12, atBottom: false });
|
||||
expect(transport.sendMessages).not.toHaveBeenCalled();
|
||||
expect(runtime.controller).toBeUndefined();
|
||||
} finally { resources.dispose(); vi.useRealTimers(); }
|
||||
});
|
||||
|
||||
it("stops retrying after permission loss or resource disposal", async () => {
|
||||
vi.useFakeTimers();
|
||||
for (const forbidden of [true, false]) {
|
||||
const resources = new WorkbenchSessionResources();
|
||||
const streamSession = vi.fn(async () => { throw forbidden ? new AgentSessionStreamError("forbidden", 403) : new TypeError("offline"); });
|
||||
const runtime = createAgentRuntime(resources, "project:a", { streamSession } as unknown as AgentApiClient, {} as ChatTransport<AgentUiMessage>);
|
||||
runtime.sessionId = "a";
|
||||
resources.activeAgent = runtime;
|
||||
try {
|
||||
resumeAgentRuntime(resources, runtime);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
if (forbidden) expect(runtime.controller).toBeUndefined();
|
||||
resources.dispose();
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(streamSession).toHaveBeenCalledTimes(1);
|
||||
} finally { resources.dispose(); }
|
||||
}
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("recovers an interrupted Responses stream through a GET subscription without resending the prompt", async () => {
|
||||
const resources = new WorkbenchSessionResources();
|
||||
let controller!: ReadableStreamDefaultController<UIMessageChunk>;
|
||||
const sendMessages = vi.fn(async () => new ReadableStream<UIMessageChunk>({ start(value) { controller = value; } }));
|
||||
const streamSession = vi.fn(async (_id, options) => options.onEvent({ type: "state", sessionId: "a", isStreaming: false,
|
||||
runStatus: "completed", messages: [message("answer", "server completed")] }));
|
||||
const runtime = createAgentRuntime(resources, "project:a", { streamSession } as unknown as AgentApiClient,
|
||||
{ sendMessages, reconnectToStream: async () => null });
|
||||
runtime.sessionId = "a";
|
||||
resources.activeAgent = runtime;
|
||||
try {
|
||||
const request = runtime.chat.sendMessage({ text: "核查压力" });
|
||||
await vi.waitFor(() => expect(controller).toBeDefined());
|
||||
controller.enqueue({ type: "start", messageId: "answer" });
|
||||
controller.enqueue({ type: "text-start", id: "text" });
|
||||
controller.enqueue({ type: "text-delta", id: "text", delta: "partial" });
|
||||
await vi.waitFor(() => expect(runtime.chat.messages.at(-1)?.parts).toContainEqual(expect.objectContaining({ text: "partial" })));
|
||||
controller.error(new AgentStreamDisconnectedError("connection dropped"));
|
||||
await request;
|
||||
await vi.waitFor(() => expect(streamSession).toHaveBeenCalledTimes(1));
|
||||
expect(sendMessages).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.chat.messages.at(-1)).toEqual(message("answer", "server completed"));
|
||||
expect(runtime.error).toBeUndefined();
|
||||
} finally { resources.dispose(); }
|
||||
});
|
||||
it("keeps the original response streaming after selecting another conversation", async () => {
|
||||
const resources = new WorkbenchSessionResources();
|
||||
let controller!: ReadableStreamDefaultController<UIMessageChunk>;
|
||||
@@ -54,8 +129,7 @@ describe("independent conversation runtimes", () => {
|
||||
streamSession: vi.fn(async (_id, options) => {
|
||||
options.onEvent({ type: "state", sessionId: "a", messages: [message("b", "new"), message("c")], isStreaming: true });
|
||||
options.onEvent({ type: "frontend_action", sessionId: "a", data: {
|
||||
version: "frontend-action@1", sessionId: "a", actionId: "action-a", toolCallId: "call-a", name: "locate_features",
|
||||
params: {}, issuedAt: Date.now(), expiresAt: Date.now() + 15_000,
|
||||
version: "frontend-action@2", sessionId: "a", actionId: "action-a", toolCallId: "call-a", command: { action: "locate_features", layer: "scada", feature_ids: ["D1"] }, issuedAt: Date.now(), expiresAt: Date.now() + 15_000,
|
||||
} });
|
||||
options.onEvent({ type: "done", sessionId: "a", data: {} });
|
||||
}),
|
||||
@@ -74,3 +148,24 @@ describe("independent conversation runtimes", () => {
|
||||
resources.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[JSON.stringify({ code: "INTERACTION_PENDING", message: "请先回答或取消当前提问。" }), "waiting_input"],
|
||||
["Connection refused", "error"],
|
||||
[JSON.stringify({ code: "OTHER_FAILURE" }), "error"]
|
||||
] as const)("keeps a pending question distinct from transport failure: %s", async (message, expected) => {
|
||||
const resources = new WorkbenchSessionResources();
|
||||
const transport: ChatTransport<AgentUiMessage> = {
|
||||
sendMessages: async () => { throw new Error(message); },
|
||||
reconnectToStream: async () => null
|
||||
};
|
||||
const runtime = createAgentRuntime(resources, "project:a", {} as AgentApiClient, transport);
|
||||
resources.activeAgent = runtime;
|
||||
const onError = vi.fn();
|
||||
runtime.handlers = { onData: vi.fn(), onEvent: vi.fn(), onError, onFinish: vi.fn() };
|
||||
try {
|
||||
await runtime.chat.sendMessage({ text: "定位到公司上报地址" });
|
||||
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message }));
|
||||
expect(runtime.runStatus).toBe(expected);
|
||||
} finally { resources.dispose(); }
|
||||
});
|
||||
|
||||
@@ -157,7 +157,7 @@ async function rejectBackgroundAction(resources: WorkbenchSessionResources, runt
|
||||
if (!resources.disposed) showMapNotice({ id: `agent-background-display-${runtime.key}`, tone: "info",
|
||||
title: "后台对话需要展示结果", message: `回到“${runtime.title}”后重新请求地图或工作区展示。` });
|
||||
await runtime.client.submitFrontendActionResult(request.sessionId, request.actionId, {
|
||||
version: "frontend-action-result@1", actionId: request.actionId, status: "failed",
|
||||
version: "frontend-action-result@2", actionId: request.actionId, status: "failed",
|
||||
error: { code: "SESSION_NOT_ACTIVE", message: "返回对应会话后重新请求地图或工作区操作" }, completedAt: Date.now(),
|
||||
}).catch(() => { runtime.error = "后台展示操作回执失败"; });
|
||||
}
|
||||
|
||||
@@ -158,3 +158,44 @@ describe("live investigation presentation following", () => {
|
||||
} finally { oldDisplay.resolve(); test.follower.stop(); }
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it("buffers live events while saved history is loading and drains only after that boundary", async () => {
|
||||
const ready = deferred<void>();
|
||||
const loadHistory = vi.fn(async () => ready.promise);
|
||||
const test = setup({ loadHistory });
|
||||
test.follower.start(); test.channel.publish({ type: "open" });
|
||||
try {
|
||||
await vi.waitFor(() => expect(loadHistory).toHaveBeenCalledTimes(1));
|
||||
test.channel.publish({ type: "events", events: [event(11, openChart)] });
|
||||
expect(test.execute).not.toHaveBeenCalled();
|
||||
ready.resolve();
|
||||
await vi.waitFor(() => expect(test.execute).toHaveBeenCalledTimes(1));
|
||||
} finally { test.follower.stop(); }
|
||||
});
|
||||
|
||||
it("loads saved history without live following and aborts an unfinished history load on stop", async () => {
|
||||
const ready = deferred<void>();
|
||||
const loadHistory = vi.fn(async (_cursor: InvestigationPresentationCursor, _signal: AbortSignal) => ready.promise);
|
||||
const subscribe = vi.fn();
|
||||
const test = setup({ follow: false, loadHistory, subscribe });
|
||||
test.follower.start();
|
||||
await vi.waitFor(() => expect(loadHistory).toHaveBeenCalledTimes(1));
|
||||
expect(subscribe).not.toHaveBeenCalled();
|
||||
test.follower.stop();
|
||||
expect(loadHistory.mock.calls[0][1].aborted).toBe(true);
|
||||
ready.resolve();
|
||||
expect(test.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
it("reads saved history even before the live event stream opens", async () => {
|
||||
const loadHistory = vi.fn(async () => undefined);
|
||||
const test = setup({ loadHistory });
|
||||
test.follower.start();
|
||||
try {
|
||||
await vi.waitFor(() => expect(loadHistory).toHaveBeenCalledTimes(1));
|
||||
await vi.waitFor(() => expect(test.onStatus).toHaveBeenCalledWith("history"));
|
||||
expect(test.execute).not.toHaveBeenCalled();
|
||||
} finally { test.follower.stop(); }
|
||||
});
|
||||
|
||||
@@ -7,22 +7,35 @@ import {
|
||||
import type { WorkbenchEvent } from "../api/workbench-sync-client";
|
||||
import type { WorkbenchStreamNotification } from "./workbench-event-channel";
|
||||
|
||||
export type InvestigationIdentity = Pick<InvestigationPresentationCursor, "task_id" | "execution_generation" | "active_run">;
|
||||
export type InvestigationIdentity = Pick<
|
||||
InvestigationPresentationCursor,
|
||||
"task_id" | "execution_generation" | "active_run"
|
||||
>;
|
||||
type Options = {
|
||||
taskId: string;
|
||||
subscribe: (listener: (notification: WorkbenchStreamNotification) => void) => () => void;
|
||||
fetchCursor: (signal: AbortSignal) => Promise<InvestigationPresentationCursor>;
|
||||
fetchIdentity: (signal: AbortSignal) => Promise<InvestigationIdentity>;
|
||||
execute: (command: InvestigationPresentationCommand, signal: AbortSignal) => Promise<void>;
|
||||
execute: (
|
||||
command: InvestigationPresentationCommand,
|
||||
signal: AbortSignal,
|
||||
event: InvestigationPresentationEvent & { event_seq: number }
|
||||
) => Promise<void>;
|
||||
loadHistory?: (cursor: InvestigationPresentationCursor, signal: AbortSignal) => Promise<void>;
|
||||
follow?: boolean;
|
||||
clear: () => void;
|
||||
stopPulse: () => void;
|
||||
onStatus: (status: "connecting" | "live" | "disconnected" | "error", error?: string) => void;
|
||||
onStatus: (
|
||||
status: "connecting" | "live" | "history" | "disconnected" | "error",
|
||||
error?: string
|
||||
) => void;
|
||||
};
|
||||
type Pending = { sequence: number; event: InvestigationPresentationEvent };
|
||||
|
||||
/** No replay: a fresh authorized baseline is taken after every connection, with the listener already attached. */
|
||||
/** Bind a read-only history snapshot before draining later live events; subscribing first prevents gaps. */
|
||||
export class InvestigationPresentationFollower {
|
||||
private unsubscribe?: () => void;
|
||||
private streamConnected = false;
|
||||
private binding: AbortController | null = null;
|
||||
private work = new AbortController();
|
||||
private identity: InvestigationIdentity | null = null;
|
||||
@@ -35,39 +48,72 @@ export class InvestigationPresentationFollower {
|
||||
|
||||
constructor(private options: Options) {}
|
||||
|
||||
start() { this.unsubscribe = this.options.subscribe((notification) => this.receive(notification)); }
|
||||
start() {
|
||||
if (this.options.follow === false) void this.connect();
|
||||
else {
|
||||
this.unsubscribe = this.options.subscribe((notification) => this.receive(notification));
|
||||
// Saved results remain readable when the SSE connection is not available.
|
||||
if (this.options.loadHistory && !this.binding) void this.connect();
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.streamConnected = false;
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = undefined;
|
||||
this.disconnect();
|
||||
}
|
||||
|
||||
discardPendingMapCommands() {
|
||||
this.pending = this.pending.filter(({ event }) =>
|
||||
event.command.action === "open_chart" || event.command.action === "close_chart"
|
||||
this.pending = this.pending.filter(
|
||||
({ event }) => event.command.action === "open_chart" || event.command.action === "close_chart"
|
||||
);
|
||||
}
|
||||
|
||||
updateIdentity(identity: InvestigationIdentity) {
|
||||
if (identity.task_id !== this.options.taskId || this.identity && identity.execution_generation < this.identity.execution_generation) return;
|
||||
const changed = this.identity && (identity.execution_generation !== this.identity.execution_generation
|
||||
|| identity.active_run && this.identity.active_run && identity.active_run.run_id !== this.identity.active_run.run_id);
|
||||
if (
|
||||
identity.task_id !== this.options.taskId ||
|
||||
(this.identity && identity.execution_generation < this.identity.execution_generation)
|
||||
)
|
||||
return;
|
||||
const changed =
|
||||
this.identity &&
|
||||
(identity.execution_generation !== this.identity.execution_generation ||
|
||||
(identity.active_run &&
|
||||
this.identity.active_run &&
|
||||
identity.active_run.run_id !== this.identity.active_run.run_id));
|
||||
if (changed) {
|
||||
this.work.abort();
|
||||
this.work = new AbortController();
|
||||
this.options.clear();
|
||||
}
|
||||
// A finished run may no longer be active; retain its identity to finish already received close commands.
|
||||
this.identity = identity.active_run || !this.identity || identity.execution_generation !== this.identity.execution_generation
|
||||
? identity : { ...identity, active_run: this.identity.active_run ? { ...this.identity.active_run, status: "succeeded" } : null };
|
||||
this.identity =
|
||||
identity.active_run ||
|
||||
!this.identity ||
|
||||
identity.execution_generation !== this.identity.execution_generation
|
||||
? identity
|
||||
: {
|
||||
...identity,
|
||||
active_run: this.identity.active_run
|
||||
? { ...this.identity.active_run, status: "succeeded" }
|
||||
: null
|
||||
};
|
||||
if (!identity.active_run || identity.active_run.status !== "running") this.options.stopPulse();
|
||||
void this.drain();
|
||||
}
|
||||
|
||||
private receive(notification: WorkbenchStreamNotification) {
|
||||
if (notification.type === "open") { void this.connect(); return; }
|
||||
if (notification.type !== "events") { this.disconnect(); return; }
|
||||
if (notification.type === "open") {
|
||||
this.streamConnected = true;
|
||||
void this.connect();
|
||||
return;
|
||||
}
|
||||
if (notification.type !== "events") {
|
||||
this.streamConnected = false;
|
||||
this.disconnect();
|
||||
return;
|
||||
}
|
||||
if (!this.binding) return;
|
||||
for (const event of notification.events) this.add(event);
|
||||
void this.drain();
|
||||
@@ -75,11 +121,27 @@ export class InvestigationPresentationFollower {
|
||||
|
||||
private add(event: WorkbenchEvent) {
|
||||
if (!this.binding) return;
|
||||
if (event.topic !== "operational.presentation.requested" || event.metadata.task_id !== this.options.taskId) return;
|
||||
if (event.event_seq <= (this.baseline ?? -1) || event.event_seq <= this.lastSeen || this.pending.some((item) => item.sequence === event.event_seq)) return;
|
||||
if (
|
||||
event.topic !== "operational.presentation.requested" ||
|
||||
event.metadata.task_id !== this.options.taskId
|
||||
)
|
||||
return;
|
||||
if (
|
||||
event.event_seq <= (this.baseline ?? -1) ||
|
||||
event.event_seq <= this.lastSeen ||
|
||||
this.pending.some((item) => item.sequence === event.event_seq)
|
||||
)
|
||||
return;
|
||||
const parsed = investigationPresentationEventSchema.safeParse(event.metadata);
|
||||
if (!parsed.success) { this.options.onStatus("error", "调查展示参数未通过校验"); return; }
|
||||
if (this.pending.length >= 1000) { this.disconnect(); this.options.onStatus("error", "调查展示积压,请关闭并重新开启跟随"); return; }
|
||||
if (!parsed.success) {
|
||||
this.options.onStatus("error", "调查展示参数未通过校验");
|
||||
return;
|
||||
}
|
||||
if (this.pending.length >= 1000) {
|
||||
this.disconnect();
|
||||
this.options.onStatus("error", "调查展示积压,请关闭并重新开启跟随");
|
||||
return;
|
||||
}
|
||||
this.pending.push({ sequence: event.event_seq, event: parsed.data });
|
||||
this.pending.sort((a, b) => a.sequence - b.sequence);
|
||||
}
|
||||
@@ -97,11 +159,18 @@ export class InvestigationPresentationFollower {
|
||||
this.baseline = cursor.event_cursor;
|
||||
this.lastSeen = cursor.event_cursor;
|
||||
this.pending = this.pending.filter((item) => item.sequence > cursor.event_cursor);
|
||||
await this.options.loadHistory?.(cursor, controller.signal);
|
||||
if (controller.signal.aborted || epoch !== this.epoch) return;
|
||||
this.updateIdentity(cursor);
|
||||
this.options.onStatus("live");
|
||||
this.options.onStatus(
|
||||
this.streamConnected && this.options.follow !== false ? "live" : "history"
|
||||
);
|
||||
void this.drain();
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) { this.disconnect(); this.options.onStatus("error", error instanceof Error ? error.message : "调查展示连接失败"); }
|
||||
if (!controller.signal.aborted) {
|
||||
this.disconnect();
|
||||
this.options.onStatus("error", error instanceof Error ? error.message : "调查展示连接失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,7 +198,11 @@ export class InvestigationPresentationFollower {
|
||||
const identity = await this.options.fetchIdentity(binding.signal);
|
||||
if (!binding.signal.aborted) this.updateIdentity(identity);
|
||||
} catch (error) {
|
||||
if (!binding.signal.aborted) this.options.onStatus("error", error instanceof Error ? error.message : "调查运行状态核验失败");
|
||||
if (!binding.signal.aborted)
|
||||
this.options.onStatus(
|
||||
"error",
|
||||
error instanceof Error ? error.message : "调查运行状态核验失败"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,24 +213,41 @@ export class InvestigationPresentationFollower {
|
||||
try {
|
||||
while (this.pending.length && epoch === this.epoch) {
|
||||
const next = this.pending[0];
|
||||
if (next.sequence <= this.lastSeen || next.event.execution_generation < this.identity!.execution_generation) { this.pending.shift(); continue; }
|
||||
if (next.event.execution_generation !== this.identity!.execution_generation || next.event.run_id !== this.identity!.active_run?.run_id) {
|
||||
if (
|
||||
next.sequence <= this.lastSeen ||
|
||||
next.event.execution_generation < this.identity!.execution_generation
|
||||
) {
|
||||
this.pending.shift();
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
next.event.execution_generation !== this.identity!.execution_generation ||
|
||||
next.event.run_id !== this.identity!.active_run?.run_id
|
||||
) {
|
||||
await this.resolveIdentity(next.event);
|
||||
if (epoch !== this.epoch) break;
|
||||
if (this.pending[0] !== next) continue;
|
||||
if (next.event.execution_generation !== this.identity!.execution_generation || next.event.run_id !== this.identity!.active_run?.run_id) break;
|
||||
if (
|
||||
next.event.execution_generation !== this.identity!.execution_generation ||
|
||||
next.event.run_id !== this.identity!.active_run?.run_id
|
||||
)
|
||||
break;
|
||||
}
|
||||
this.pending.shift();
|
||||
this.lastSeen = next.sequence;
|
||||
const signal = this.work.signal;
|
||||
try {
|
||||
const command = next.event.command.action === "highlight_features" && this.identity!.active_run?.status !== "running"
|
||||
? { ...next.event.command, effect: "steady" as const } : next.event.command;
|
||||
await this.options.execute(command, signal);
|
||||
const command =
|
||||
next.event.command.action === "highlight_features" &&
|
||||
this.identity!.active_run?.status !== "running"
|
||||
? { ...next.event.command, effect: "steady" as const }
|
||||
: next.event.command;
|
||||
await this.options.execute(command, signal, { ...next.event, event_seq: next.sequence });
|
||||
if (!signal.aborted && epoch === this.epoch) this.options.onStatus("live");
|
||||
if (this.identity!.active_run?.status !== "running") this.options.stopPulse();
|
||||
} catch (error) {
|
||||
if (!signal.aborted && epoch === this.epoch) this.options.onStatus("error", error instanceof Error ? error.message : "调查展示失败");
|
||||
if (!signal.aborted && epoch === this.epoch)
|
||||
this.options.onStatus("error", error instanceof Error ? error.message : "调查展示失败");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -28,7 +28,67 @@ describe("recent activities for Agent suggestions", () => {
|
||||
|
||||
describe("recommendation cache lifecycle", () => {
|
||||
let root: Root | undefined;
|
||||
afterEach(async () => { if (root) await act(async () => root!.unmount()); root = undefined; vi.restoreAllMocks(); });
|
||||
afterEach(async () => { if (root) await act(async () => root!.unmount()); root = undefined; vi.useRealTimers(); vi.restoreAllMocks(); });
|
||||
it("retries failures after cache expiry with a bounded budget and supports manual retry", async () => {
|
||||
vi.useFakeTimers();
|
||||
root = createRoot(document.createElement("div"));
|
||||
const request = vi.fn<Parameters<typeof useAgentPromptSuggestions>[0]["request"]>().mockRejectedValue(new Error("unavailable"));
|
||||
const empty: never[] = [];
|
||||
let result: ReturnType<typeof useAgentPromptSuggestions>;
|
||||
function Probe() {
|
||||
result = useAgentPromptSuggestions({ enabled: true, model: "fixture/model", cacheScope: "a", sessionId: null,
|
||||
request, alerts: empty, alertDataSource: "backend", conditions: empty, runtimeDataSource: "backend", artifact: null, sessions: empty });
|
||||
return null;
|
||||
}
|
||||
await act(async () => root!.render(createElement(WorkbenchSessionProvider, null, createElement(Probe))));
|
||||
expect(result!.unavailable).toBe(true);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(30_000); });
|
||||
expect(request).toHaveBeenCalledTimes(3);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(300_000); });
|
||||
expect(request).toHaveBeenCalledTimes(3);
|
||||
request.mockResolvedValueOnce([{ question: "核查压力数据", activityId: null }]);
|
||||
await act(async () => result!.retry());
|
||||
expect(request).toHaveBeenCalledTimes(4);
|
||||
expect(result!.suggestions[0]?.question).toBe("核查压力数据");
|
||||
expect(result!.unavailable).toBe(false);
|
||||
});
|
||||
it("retries cached failures on service recovery while preserving successful recommendations", async () => {
|
||||
root = createRoot(document.createElement("div"));
|
||||
const request = vi.fn<Parameters<typeof useAgentPromptSuggestions>[0]["request"]>()
|
||||
.mockRejectedValueOnce(new Error("offline")).mockResolvedValue([{ question: "核查压力数据", activityId: null }]);
|
||||
const empty: never[] = [];
|
||||
function Probe({ recoveryVersion }: { recoveryVersion: number }) {
|
||||
useAgentPromptSuggestions({ enabled: true, model: "fixture/model", cacheScope: "a", recoveryVersion, sessionId: null,
|
||||
request, alerts: empty, alertDataSource: "backend", conditions: empty, runtimeDataSource: "backend", artifact: null, sessions: empty });
|
||||
return null;
|
||||
}
|
||||
for (const recoveryVersion of [0, 1, 2]) {
|
||||
await act(async () => root!.render(createElement(WorkbenchSessionProvider, null, createElement(Probe, { recoveryVersion }))));
|
||||
}
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("waits for a model and cancels automatic retries when the panel is disabled", async () => {
|
||||
vi.useFakeTimers();
|
||||
root = createRoot(document.createElement("div"));
|
||||
const request = vi.fn(async () => { throw new Error("unavailable"); });
|
||||
const empty: never[] = [];
|
||||
function Probe({ model, enabled }: { model: string; enabled: boolean }) {
|
||||
useAgentPromptSuggestions({ enabled, model, cacheScope: "a", sessionId: null,
|
||||
request, alerts: empty, alertDataSource: "backend", conditions: empty, runtimeDataSource: "backend", artifact: null, sessions: empty });
|
||||
return null;
|
||||
}
|
||||
const render = async (model: string, enabled: boolean) => act(async () => root!.render(createElement(WorkbenchSessionProvider, null, createElement(Probe, { model, enabled }))));
|
||||
await render("", true);
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
await render("fixture/model", true);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
await render("fixture/model", false);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(90_000); });
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("reuses recommendations across history refresh, panel reopen and a new empty conversation", async () => {
|
||||
const container = document.createElement("div");
|
||||
root = createRoot(container);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { AgentApiClient, AgentChatSessionSummary, AgentPromptSuggestion, AgentRecentActivity } from "@/features/agent";
|
||||
import type { WorkbenchAlert, ScheduledConditionItem, WorkbenchAlertDataSource, WorkbenchRuntimeDataSource } from "../types";
|
||||
import type { AnalysisArtifact } from "../workspace/workspace-model";
|
||||
@@ -46,11 +46,12 @@ export function collectRecentAgentActivities(input: ActivityInputs, now = Date.n
|
||||
return [...new Map([...alerts, ...tasks, ...analyses, ...sessions].map((item) => [item.id, item])).values()];
|
||||
}
|
||||
|
||||
export function useAgentPromptSuggestions({ enabled, model, cacheScope = "project", request, alerts, alertDataSource, conditions, runtimeDataSource, artifact, sessions }: ActivityInputs & {
|
||||
export function useAgentPromptSuggestions({ enabled, model, cacheScope = "project", recoveryVersion = 0, request, alerts, alertDataSource, conditions, runtimeDataSource, artifact, sessions }: ActivityInputs & {
|
||||
enabled: boolean;
|
||||
model: string;
|
||||
sessionId: string | null;
|
||||
cacheScope?: string;
|
||||
recoveryVersion?: number;
|
||||
request: AgentApiClient["generatePromptSuggestions"];
|
||||
}) {
|
||||
const { cache } = useWorkbenchSessionResources();
|
||||
@@ -64,13 +65,22 @@ export function useAgentPromptSuggestions({ enabled, model, cacheScope = "projec
|
||||
const activitySnapshot = useRef(activities);
|
||||
activitySnapshot.current = activities;
|
||||
const key = JSON.stringify(["suggestions", cacheScope, model, digest]);
|
||||
const [retryVersion, setRetryVersion] = useState(0);
|
||||
const [refreshVersion, setRefreshVersion] = useState(0);
|
||||
const attempts = useRef({ key, count: 0, recoveryVersion, retryVersion });
|
||||
const retry = useCallback(() => setRetryVersion((version) => version + 1), []);
|
||||
const [state, setState] = useState<{ scope: string; suggestions: DisplaySuggestion[]; loading: boolean; unavailable: boolean }>({
|
||||
scope: cacheScope, suggestions: [], loading: false, unavailable: false,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (!enabled || !model.trim()) return;
|
||||
let disposed = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const previous = cache.peek<DisplaySuggestion[]>(key);
|
||||
const recovered = attempts.current.recoveryVersion !== recoveryVersion || attempts.current.retryVersion !== retryVersion;
|
||||
if (attempts.current.key !== key || recovered) attempts.current = { key, count: 0, recoveryVersion, retryVersion };
|
||||
const attempt = attempts.current;
|
||||
const context = activitySnapshot.current;
|
||||
const apply = (suggestions: DisplaySuggestion[]) => {
|
||||
if (!disposed) setState({ scope: cacheScope, suggestions, loading: false, unavailable: false });
|
||||
};
|
||||
@@ -78,7 +88,7 @@ export function useAgentPromptSuggestions({ enabled, model, cacheScope = "projec
|
||||
else setState((current) => ({ scope: cacheScope,
|
||||
suggestions: current.scope === cacheScope ? current.suggestions : [], loading: true, unavailable: false }));
|
||||
void cache.load(key, async () => {
|
||||
const context = activitySnapshot.current;
|
||||
attempt.count++;
|
||||
const suggestions = await request(context, model, AbortSignal.timeout(60_000));
|
||||
const sourceById = new Map(context.map((item) => [item.id, item]));
|
||||
return suggestions.map((item) => {
|
||||
@@ -87,11 +97,17 @@ export function useAgentPromptSuggestions({ enabled, model, cacheScope = "projec
|
||||
? `${source.origin === "demo" ? "演示" : ""}${activityLabels[source.kind]} · ${timeFormatter.format(new Date(source.occurred_at))}`
|
||||
: "供水任务建议" };
|
||||
});
|
||||
}, 10 * 60_000, { errorTtl: 30_000 }).then(apply).catch(() => {
|
||||
if (!disposed) setState((current) => ({ scope: cacheScope,
|
||||
}, 10 * 60_000, { errorTtl: 30_000, force: recovered && !!previous?.error }).then(apply).catch(() => {
|
||||
if (disposed) return;
|
||||
setState((current) => ({ scope: cacheScope,
|
||||
suggestions: current.scope === cacheScope ? current.suggestions : [], loading: false, unavailable: true }));
|
||||
// Cache expiry alone does not rerender the panel. Retry transient failures
|
||||
// here, with a bounded budget; reopening still reuses the shared cache.
|
||||
if (attempt.count < 3) {
|
||||
timer = setTimeout(() => setRefreshVersion((version) => version + 1), Math.max(1, (cache.peek(key)?.expiresAt ?? Date.now() + 30_000) - Date.now()));
|
||||
}
|
||||
});
|
||||
return () => { disposed = true; };
|
||||
}, [cache, cacheScope, digest, enabled, key, model, request]);
|
||||
return state.scope === cacheScope ? state : { suggestions: [], loading: enabled, unavailable: false };
|
||||
return () => { disposed = true; clearTimeout(timer); };
|
||||
}, [cache, cacheScope, digest, enabled, key, model, recoveryVersion, refreshVersion, request, retryVersion]);
|
||||
return { ...(state.scope === cacheScope ? state : { suggestions: [], loading: enabled, unavailable: false }), retry };
|
||||
}
|
||||
|
||||
@@ -1,40 +1,110 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import { executePresentationCommand } from "../map/presentation-command-executor";
|
||||
import type { FeatureCollection } from "geojson";
|
||||
import type { Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
|
||||
import type { WorkbenchAccess } from "../api/request-context";
|
||||
import { fetchOperationalTask, fetchOperationalTaskPresentationCursor, type OperationalTask } from "../api/operational-task-client";
|
||||
import {
|
||||
fetchOperationalTask,
|
||||
fetchOperationalTaskPresentationCursor,
|
||||
fetchOperationalTaskPresentationHistory,
|
||||
type OperationalTask
|
||||
} from "../api/operational-task-client";
|
||||
import type { InvestigationMapTarget } from "../api/investigation-report";
|
||||
import type { InvestigationOverlay, InvestigationPresentationCommand } from "../api/investigation-presentation";
|
||||
import {
|
||||
presentationEventKey,
|
||||
type InvestigationPresentationEvent
|
||||
} from "../api/investigation-presentation";
|
||||
import { hydrateInvestigationPresentationArchive } from "../utils/investigation-presentation-archive";
|
||||
import { presentationCommandLabel } from "../utils/presentation-command-label";
|
||||
import type {
|
||||
InvestigationPresentationCommand
|
||||
} from "../api/investigation-presentation";
|
||||
import { InvestigationMapPresentation } from "../map/investigation-map-presentation";
|
||||
import type { WorkbenchMapController } from "../map/workbench-map-controller";
|
||||
import type { WorkbenchSyncState } from "./use-workbench-sync";
|
||||
import { InvestigationPresentationFollower } from "./investigation-presentation-follower";
|
||||
import {
|
||||
type InvestigationDevPreview,
|
||||
type InvestigationPresentationActivity
|
||||
} from "../utils/investigation-dev-preview";
|
||||
import {
|
||||
PresentationViewHistory,
|
||||
emptyPresentationView,
|
||||
type PresentationView
|
||||
} from "../utils/presentation-view-history";
|
||||
import {
|
||||
capturePresentationCamera,
|
||||
fitPresentationCamera,
|
||||
presentationHistoryPorts,
|
||||
renderPresentationSnapshot
|
||||
} from "../map/presentation-history-ports";
|
||||
|
||||
export type InvestigationChart = Extract<InvestigationPresentationCommand, { action: "open_chart" }>;
|
||||
import {
|
||||
createInvestigationDevCase,
|
||||
type InvestigationDevCaseId
|
||||
} from "../utils/investigation-dev-cases";
|
||||
|
||||
export type InvestigationChart = Extract<
|
||||
InvestigationPresentationCommand,
|
||||
{ action: "open_chart" }
|
||||
>;
|
||||
type Options = {
|
||||
enabled: boolean; task: OperationalTask | null; projectId: string | null;
|
||||
sync: WorkbenchSyncState; getAccessToken?: WorkbenchAccess;
|
||||
mapRef: RefObject<MapLibreMap | null>; mapReady: boolean;
|
||||
controller: WorkbenchMapController; getPadding: () => PaddingOptions;
|
||||
scope?: "investigation" | "chat" | "preview";
|
||||
scopeId?: string | null;
|
||||
enabled: boolean;
|
||||
task: OperationalTask | null;
|
||||
projectId: string | null;
|
||||
sync: WorkbenchSyncState;
|
||||
getAccessToken?: WorkbenchAccess;
|
||||
mapRef: RefObject<MapLibreMap | null>;
|
||||
mapReady: boolean;
|
||||
controller: WorkbenchMapController;
|
||||
getPadding: () => PaddingOptions;
|
||||
onClearLayers: () => void;
|
||||
};
|
||||
|
||||
function frame(signal: AbortSignal) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
signal.throwIfAborted();
|
||||
const abort = () => { cancelAnimationFrame(id); reject(signal.reason); };
|
||||
const id = requestAnimationFrame(() => { signal.removeEventListener("abort", abort); resolve(); });
|
||||
const abort = () => {
|
||||
cancelAnimationFrame(id);
|
||||
reject(signal.reason);
|
||||
};
|
||||
const id = requestAnimationFrame(() => {
|
||||
signal.removeEventListener("abort", abort);
|
||||
resolve();
|
||||
});
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/** Separate commands need a rendered frame; an open and close in one SSE batch must not collapse into one React update. */
|
||||
async function waitForChart(chartId: string, signal: AbortSignal) {
|
||||
async function waitForChart(
|
||||
chartId: string,
|
||||
signal: AbortSignal,
|
||||
scope: string,
|
||||
rendering = () => true
|
||||
) {
|
||||
const deadline = Date.now() + 5000;
|
||||
while (Date.now() < deadline) {
|
||||
await frame(signal);
|
||||
const element = [...document.querySelectorAll<HTMLElement>("[data-investigation-chart]")].find((item) => item.dataset.investigationChart === chartId);
|
||||
if (!rendering()) return;
|
||||
const panel = document.querySelector<HTMLElement>(`[data-presentation-scope="${scope}"]`);
|
||||
// A deliberate collapse defers rendering, rather than failing a valid command.
|
||||
if (panel?.dataset.investigationCollapsed === "true") return;
|
||||
const element = [
|
||||
...(panel?.querySelectorAll<HTMLElement>("[data-investigation-chart]") ?? [])
|
||||
].find(
|
||||
(item) => item.dataset.investigationChart === chartId && !item.closest('[aria-hidden="true"]')
|
||||
);
|
||||
if (element && element.getBoundingClientRect().height > 0) {
|
||||
const viewport = panel?.querySelector<HTMLElement>("[data-investigation-content]");
|
||||
if (viewport) {
|
||||
const plot = element.querySelector("svg[role=img]") ?? element;
|
||||
const overflow =
|
||||
plot.getBoundingClientRect().bottom - viewport.getBoundingClientRect().bottom + 12;
|
||||
if (overflow > 0) viewport.scrollTop += overflow;
|
||||
}
|
||||
const visibleUntil = Date.now() + 500;
|
||||
while (Date.now() < visibleUntil) await frame(signal);
|
||||
return;
|
||||
@@ -46,123 +116,484 @@ async function waitForChart(chartId: string, signal: AbortSignal) {
|
||||
export function useInvestigationPresentation(options: Options) {
|
||||
const valuesRef = useRef(options);
|
||||
valuesRef.current = options;
|
||||
const [charts, setCharts] = useState<InvestigationChart[]>([]);
|
||||
const [overlay, setOverlay] = useState<InvestigationOverlay | null>(null);
|
||||
const storeRef = useRef(new PresentationViewHistory());
|
||||
const [, refresh] = useState(0);
|
||||
const publish = useCallback(() => refresh((value) => value + 1), []);
|
||||
const [localPreview, setLocalPreview] = useState(false);
|
||||
const localPreviewRef = useRef(false);
|
||||
const [cameraPaused, setCameraPaused] = useState(false);
|
||||
const cameraPausedRef = useRef(false);
|
||||
const [status, setStatus] = useState<"connecting" | "live" | "disconnected" | "error">("disconnected");
|
||||
const [status, setStatus] = useState<
|
||||
"connecting" | "live" | "history" | "disconnected" | "error"
|
||||
>("disconnected");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [highlightLayers, setHighlightLayers] = useState<string[]>([]);
|
||||
const [activity, setActivity] = useState<InvestigationPresentationActivity[]>([]);
|
||||
const activityIdRef = useRef(0);
|
||||
const previewRef = useRef<AbortController | null>(null);
|
||||
const followerRef = useRef<InvestigationPresentationFollower | null>(null);
|
||||
const manualRequestRef = useRef<AbortController | null>(null);
|
||||
const mapCommandsRef = useRef(new AbortController());
|
||||
const scene = useMemo(() => new InvestigationMapPresentation(() => options.mapRef.current, () => valuesRef.current.getPadding()), [options.mapRef]);
|
||||
const scene = useMemo(
|
||||
() =>
|
||||
new InvestigationMapPresentation(
|
||||
() => options.mapRef.current,
|
||||
() => valuesRef.current.getPadding(),
|
||||
options.scope
|
||||
),
|
||||
[options.mapRef, options.scope]
|
||||
);
|
||||
const taskId = options.task?.task_id ?? null;
|
||||
const projectRef = useRef(options.projectId);
|
||||
const subscribe = options.sync.subscribePresentationEvents;
|
||||
const source = useCallback(
|
||||
(): InvestigationPresentationActivity["source"] =>
|
||||
valuesRef.current.scope === "chat"
|
||||
? "chat"
|
||||
: valuesRef.current.scope === "preview"
|
||||
? "preview"
|
||||
: "live",
|
||||
[]
|
||||
);
|
||||
const update = useCallback(
|
||||
(updater: (view: PresentationView) => PresentationView) => {
|
||||
storeRef.current.update(updater(storeRef.current.current));
|
||||
publish();
|
||||
},
|
||||
[publish]
|
||||
);
|
||||
const record = useCallback(
|
||||
(label: string, origin = source()) => {
|
||||
storeRef.current.record(label, origin, valuesRef.current.task?.title ?? null, {
|
||||
taskId: valuesRef.current.task?.task_id
|
||||
});
|
||||
publish();
|
||||
},
|
||||
[publish, source]
|
||||
);
|
||||
const selectHistory = useCallback(
|
||||
(id: number | null) => {
|
||||
const store = storeRef.current;
|
||||
if (store.selectedId === null)
|
||||
store.update({
|
||||
...store.current,
|
||||
camera:
|
||||
capturePresentationCamera(valuesRef.current.mapRef.current) ?? store.current.camera
|
||||
});
|
||||
if (!store.select(id === store.currentEntryId ? null : id)) return;
|
||||
publish();
|
||||
const map = valuesRef.current.mapRef.current;
|
||||
if (map && valuesRef.current.mapReady) renderPresentationSnapshot(scene, map, store.visible);
|
||||
},
|
||||
[publish, scene]
|
||||
);
|
||||
|
||||
const clearMapOverlays = useCallback((discardPending = true) => {
|
||||
mapCommandsRef.current.abort();
|
||||
mapCommandsRef.current = new AbortController();
|
||||
manualRequestRef.current?.abort();
|
||||
if (discardPending) followerRef.current?.discardPendingMapCommands();
|
||||
scene.clear(); setOverlay(null); setHighlightLayers([]);
|
||||
}, [scene]);
|
||||
|
||||
const clearMapOverlays = useCallback(
|
||||
(discardPending = true) => {
|
||||
mapCommandsRef.current.abort();
|
||||
mapCommandsRef.current = new AbortController();
|
||||
manualRequestRef.current?.abort();
|
||||
previewRef.current?.abort();
|
||||
if (discardPending) followerRef.current?.discardPendingMapCommands();
|
||||
storeRef.current.select(null);
|
||||
scene.clear();
|
||||
update((view) => ({
|
||||
...view,
|
||||
highlights: { type: "FeatureCollection", features: [] },
|
||||
highlightLayers: [],
|
||||
overlay: null,
|
||||
scada: null
|
||||
}));
|
||||
},
|
||||
[scene, update]
|
||||
);
|
||||
const clear = useCallback(() => {
|
||||
// New task generations retain their first queued map command.
|
||||
clearMapOverlays(false); setCharts([]);
|
||||
localPreviewRef.current = false;
|
||||
setLocalPreview(false);
|
||||
clearMapOverlays(false);
|
||||
update(() => ({
|
||||
...emptyPresentationView(),
|
||||
camera: capturePresentationCamera(valuesRef.current.mapRef.current)
|
||||
}));
|
||||
valuesRef.current.onClearLayers();
|
||||
}, [clearMapOverlays]);
|
||||
}, [clearMapOverlays, update]);
|
||||
|
||||
const executeCommand = useCallback(
|
||||
async (
|
||||
command: InvestigationPresentationCommand,
|
||||
signal: AbortSignal,
|
||||
origin: InvestigationPresentationActivity["source"],
|
||||
event?: InvestigationPresentationEvent & { event_seq: number }
|
||||
) => {
|
||||
if (command.action !== "open_chart" && command.action !== "close_chart")
|
||||
signal = AbortSignal.any([signal, mapCommandsRef.current.signal]);
|
||||
signal.throwIfAborted();
|
||||
const store = storeRef.current;
|
||||
const eventKey = event ? presentationEventKey(event.task_id, event.event_seq) : undefined;
|
||||
if (eventKey && store.entries.some((entry) => entry.key === eventKey))
|
||||
return { recorded: true, duplicate: true };
|
||||
update((view) => ({
|
||||
...view,
|
||||
evidence:
|
||||
valuesRef.current.task?.evidence.map(({ evidence_id, summary }) => ({
|
||||
evidence_id,
|
||||
summary
|
||||
})) ?? [],
|
||||
camera:
|
||||
store.selectedId === null && (!view.camera || cameraPausedRef.current)
|
||||
? (capturePresentationCamera(valuesRef.current.mapRef.current) ?? view.camera)
|
||||
: view.camera
|
||||
}));
|
||||
// Cleanup changes current but does not create a selectable historical result.
|
||||
const historyId = command.action.startsWith("clear_")
|
||||
? null
|
||||
: store.begin(
|
||||
presentationCommandLabel(command, store.current),
|
||||
origin,
|
||||
valuesRef.current.task?.title ?? null,
|
||||
{ key: eventKey, taskId: valuesRef.current.task?.task_id }
|
||||
);
|
||||
const id = ++activityIdRef.current;
|
||||
setActivity((current) => [
|
||||
...current,
|
||||
{ id, action: command.action, source: origin, status: "running" }
|
||||
]);
|
||||
let actionSnapshot = store.current;
|
||||
const apply = (updater: (view: PresentationView) => PresentationView) => {
|
||||
update(updater);
|
||||
actionSnapshot = store.current;
|
||||
};
|
||||
try {
|
||||
const result = await executePresentationCommand(
|
||||
command,
|
||||
signal,
|
||||
presentationHistoryPorts({
|
||||
controller: valuesRef.current.controller,
|
||||
scene,
|
||||
map: () => valuesRef.current.mapRef.current,
|
||||
padding: () => valuesRef.current.getPadding(),
|
||||
update: apply,
|
||||
rendering: () => store.selectedId === null,
|
||||
cameraAllowed: () => !cameraPausedRef.current,
|
||||
waitForChart: (chartId, nextSignal) =>
|
||||
waitForChart(
|
||||
chartId,
|
||||
nextSignal,
|
||||
valuesRef.current.scope ?? "investigation",
|
||||
() => store.selectedId === null
|
||||
),
|
||||
frame
|
||||
})
|
||||
);
|
||||
signal.throwIfAborted();
|
||||
if (historyId !== null) store.finish(historyId, "completed", undefined, actionSnapshot);
|
||||
publish();
|
||||
setActivity((current) =>
|
||||
current.map((item) => (item.id === id ? { ...item, status: "completed" } : item))
|
||||
);
|
||||
return store.selectedId === null
|
||||
? result
|
||||
: {
|
||||
...result,
|
||||
displayed: false,
|
||||
fitted: false,
|
||||
recorded: historyId !== null,
|
||||
viewing_history: true
|
||||
};
|
||||
} catch (cause) {
|
||||
const message = signal.aborted
|
||||
? "展示已取消"
|
||||
: cause instanceof Error
|
||||
? cause.message
|
||||
: "展示失败";
|
||||
if (historyId !== null)
|
||||
store.finish(historyId, signal.aborted ? "cancelled" : "error", message, actionSnapshot);
|
||||
publish();
|
||||
setActivity((current) =>
|
||||
current.map((item) => (item.id === id ? { ...item, status: "error", message } : item))
|
||||
);
|
||||
throw cause;
|
||||
}
|
||||
},
|
||||
[scene, publish, update]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectRef.current !== options.projectId) {
|
||||
projectRef.current = options.projectId;
|
||||
storeRef.current.reset();
|
||||
setActivity([]);
|
||||
}
|
||||
clear();
|
||||
}, [clear, taskId, options.projectId, options.task?.execution_generation, options.scopeId]);
|
||||
useEffect(() => {
|
||||
manualRequestRef.current?.abort();
|
||||
cameraPausedRef.current = false;
|
||||
setCameraPaused(false);
|
||||
if (!options.enabled || !taskId) { clear(); return; }
|
||||
if (!taskId || (options.scope && options.scope !== "investigation") || !options.mapReady) {
|
||||
if (!localPreviewRef.current) clear();
|
||||
return;
|
||||
}
|
||||
const follower = new InvestigationPresentationFollower({
|
||||
taskId, subscribe,
|
||||
fetchCursor: (signal) => fetchOperationalTaskPresentationCursor(taskId, options.getAccessToken, signal),
|
||||
taskId,
|
||||
follow: options.enabled,
|
||||
loadHistory: async (cursor, signal) => {
|
||||
const task = valuesRef.current.task;
|
||||
if (!task || task.task_id !== taskId) return;
|
||||
const events = await fetchOperationalTaskPresentationHistory(
|
||||
taskId,
|
||||
cursor.event_cursor,
|
||||
valuesRef.current.getAccessToken,
|
||||
signal
|
||||
);
|
||||
signal.throwIfAborted();
|
||||
const next = await hydrateInvestigationPresentationArchive({
|
||||
events,
|
||||
task,
|
||||
history: storeRef.current,
|
||||
signal,
|
||||
createPorts: (staging) =>
|
||||
presentationHistoryPorts({
|
||||
controller: valuesRef.current.controller,
|
||||
scene,
|
||||
map: () => valuesRef.current.mapRef.current,
|
||||
padding: () => valuesRef.current.getPadding(),
|
||||
update: (updater) => staging.update(updater(staging.current)),
|
||||
rendering: () => false,
|
||||
cameraAllowed: () => true,
|
||||
waitForChart: async () => undefined,
|
||||
frame: async (nextSignal) => nextSignal.throwIfAborted()
|
||||
})
|
||||
});
|
||||
signal.throwIfAborted();
|
||||
storeRef.current.update(next);
|
||||
publish();
|
||||
const map = valuesRef.current.mapRef.current;
|
||||
if (map && storeRef.current.selectedId === null)
|
||||
renderPresentationSnapshot(scene, map, next);
|
||||
await frame(signal);
|
||||
},
|
||||
subscribe,
|
||||
fetchCursor: (signal) =>
|
||||
fetchOperationalTaskPresentationCursor(taskId, options.getAccessToken, signal),
|
||||
fetchIdentity: (signal) => fetchOperationalTask(taskId, options.getAccessToken, signal),
|
||||
clear,
|
||||
stopPulse: () => scene.stopPulse(),
|
||||
onStatus: (next, message) => { setStatus(next); setError(message ?? null); },
|
||||
execute: async (command, signal) => {
|
||||
const sourceSignal = signal;
|
||||
if (command.action !== "open_chart" && command.action !== "close_chart") {
|
||||
signal = AbortSignal.any([signal, mapCommandsRef.current.signal]);
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
try {
|
||||
if (command.action === "highlight_features") {
|
||||
const collection = await valuesRef.current.controller.queryPresentationFeatures(command.layer, command.feature_ids, signal);
|
||||
signal.throwIfAborted();
|
||||
scene.highlight(collection, command.effect === "pulse", !cameraPausedRef.current);
|
||||
setHighlightLayers([command.layer]);
|
||||
} else if (command.action === "clear_highlights") {
|
||||
scene.clearHighlights(); setHighlightLayers([]);
|
||||
} else if (command.action === "open_chart") {
|
||||
setCharts((current) => [...current.filter((item) => item.chart_id !== command.chart_id).slice(-5), command]);
|
||||
await waitForChart(command.chart_id, signal);
|
||||
} else if (command.action === "close_chart") {
|
||||
setCharts((current) => current.filter((item) => item.chart_id !== command.chart_id));
|
||||
await frame(signal);
|
||||
} else if (command.action === "set_overlay") {
|
||||
const next = { ...command, visible: true };
|
||||
scene.setOverlay(next, !cameraPausedRef.current); setOverlay(next);
|
||||
} else {
|
||||
scene.clearOverlay(command.overlay_id);
|
||||
setOverlay((current) => current?.overlay_id === command.overlay_id ? null : current);
|
||||
}
|
||||
} catch (cause) {
|
||||
if (signal.aborted && !sourceSignal.aborted) return;
|
||||
throw cause;
|
||||
}
|
||||
onStatus: (next, message) => {
|
||||
setStatus(!valuesRef.current.enabled && next === "live" ? "history" : next);
|
||||
setError(message ?? null);
|
||||
},
|
||||
execute: async (command, signal, event) => {
|
||||
await executeCommand(command, signal, "live", event);
|
||||
}
|
||||
});
|
||||
followerRef.current = follower;
|
||||
follower.start();
|
||||
return () => { follower.stop(); if (followerRef.current === follower) followerRef.current = null; };
|
||||
}, [clear, options.enabled, options.getAccessToken, options.projectId, scene, subscribe, taskId]);
|
||||
|
||||
return () => {
|
||||
follower.stop();
|
||||
if (followerRef.current === follower) followerRef.current = null;
|
||||
};
|
||||
}, [
|
||||
clear,
|
||||
executeCommand,
|
||||
options.enabled,
|
||||
options.mapReady,
|
||||
options.scope,
|
||||
options.task?.execution_generation,
|
||||
options.getAccessToken,
|
||||
options.projectId,
|
||||
publish,
|
||||
scene,
|
||||
subscribe,
|
||||
taskId
|
||||
]);
|
||||
useEffect(
|
||||
() => () => {
|
||||
previewRef.current?.abort();
|
||||
},
|
||||
[taskId, options.projectId, options.task?.execution_generation, options.scopeId]
|
||||
);
|
||||
useEffect(() => {
|
||||
if (options.task) followerRef.current?.updateIdentity(options.task);
|
||||
}, [options.task]);
|
||||
|
||||
const pauseCamera = useCallback(() => {
|
||||
cameraPausedRef.current = true; setCameraPaused(true);
|
||||
if (storeRef.current.selectedId !== null) return;
|
||||
cameraPausedRef.current = true;
|
||||
setCameraPaused(true);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const map = options.mapRef.current;
|
||||
if (!map || !options.mapReady || !options.enabled) return;
|
||||
if (
|
||||
!map ||
|
||||
!options.mapReady ||
|
||||
(!options.enabled && !localPreview && options.scope !== "chat")
|
||||
)
|
||||
return;
|
||||
const canvas = map.getCanvasContainer();
|
||||
// Canvas input excludes programmatic fitBounds/easeTo and also covers touch/keyboard navigation.
|
||||
canvas.addEventListener("pointerdown", pauseCamera);
|
||||
canvas.addEventListener("wheel", pauseCamera, { passive: true });
|
||||
canvas.addEventListener("keydown", pauseCamera);
|
||||
return () => { canvas.removeEventListener("pointerdown", pauseCamera); canvas.removeEventListener("wheel", pauseCamera); canvas.removeEventListener("keydown", pauseCamera); };
|
||||
}, [options.enabled, options.mapReady, options.mapRef, pauseCamera]);
|
||||
useEffect(() => () => { manualRequestRef.current?.abort(); scene.destroy(); }, [scene]);
|
||||
return () => {
|
||||
canvas.removeEventListener("pointerdown", pauseCamera);
|
||||
canvas.removeEventListener("wheel", pauseCamera);
|
||||
canvas.removeEventListener("keydown", pauseCamera);
|
||||
};
|
||||
}, [options.enabled, options.mapReady, options.mapRef, pauseCamera, localPreview, options.scope]);
|
||||
useEffect(
|
||||
() => () => {
|
||||
manualRequestRef.current?.abort();
|
||||
scene.destroy();
|
||||
},
|
||||
[scene]
|
||||
);
|
||||
|
||||
const locateTargets = useCallback(async (targets: InvestigationMapTarget[]) => {
|
||||
manualRequestRef.current?.abort();
|
||||
const controller = new AbortController(); manualRequestRef.current = controller;
|
||||
pauseCamera();
|
||||
const groups = new Map<InvestigationMapTarget["source_id"], string[]>();
|
||||
for (const target of targets) groups.set(target.source_id, [...groups.get(target.source_id) ?? [], target.feature_id]);
|
||||
const collections = await Promise.all([...groups].map(([source, ids]) => valuesRef.current.controller.queryPresentationFeatures(source, ids, controller.signal)));
|
||||
controller.signal.throwIfAborted();
|
||||
const data: FeatureCollection = { type: "FeatureCollection", features: collections.flatMap((collection) => collection.features) };
|
||||
if (!data.features.length) throw new Error("未找到关联地图要素");
|
||||
scene.highlight(data, false, true); setHighlightLayers([...groups.keys()]);
|
||||
}, [pauseCamera, scene]);
|
||||
const locateTargets = useCallback(
|
||||
async (targets: InvestigationMapTarget[]) => {
|
||||
if (storeRef.current.selectedId !== null)
|
||||
throw new Error("请先回到当前,再执行新的要素定位。");
|
||||
manualRequestRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
manualRequestRef.current = controller;
|
||||
pauseCamera();
|
||||
const groups = new Map<InvestigationMapTarget["source_id"], string[]>();
|
||||
for (const target of targets)
|
||||
groups.set(target.source_id, [...(groups.get(target.source_id) ?? []), target.feature_id]);
|
||||
const collections = await Promise.all(
|
||||
[...groups].map(([layer, ids]) =>
|
||||
valuesRef.current.controller.queryPresentationFeatures(layer, ids, controller.signal)
|
||||
)
|
||||
);
|
||||
controller.signal.throwIfAborted();
|
||||
const data: FeatureCollection = {
|
||||
type: "FeatureCollection",
|
||||
features: collections.flatMap((collection) => collection.features)
|
||||
};
|
||||
if (!data.features.length) throw new Error("未找到关联地图要素");
|
||||
if (storeRef.current.selectedId === null) scene.highlight(data, false, true);
|
||||
update((view) => ({
|
||||
...view,
|
||||
highlights: structuredClone(data),
|
||||
highlightLayers: [...groups.keys()],
|
||||
camera: fitPresentationCamera(
|
||||
valuesRef.current.mapRef.current,
|
||||
data,
|
||||
valuesRef.current.getPadding()
|
||||
)
|
||||
}));
|
||||
record("定位关联要素");
|
||||
},
|
||||
[pauseCamera, scene, update, record]
|
||||
);
|
||||
|
||||
const runPreview = useCallback(
|
||||
async (
|
||||
input: InvestigationDevPreview | InvestigationPresentationCommand[],
|
||||
label = "本地结果"
|
||||
) => {
|
||||
const preview = Array.isArray(input) ? { commands: input, blocks: [], records: [] } : input;
|
||||
if (preview.commands.length > 0 && !valuesRef.current.mapReady)
|
||||
throw new Error("请等待地图就绪后执行展示指令。");
|
||||
localPreviewRef.current = true;
|
||||
setLocalPreview(true);
|
||||
previewRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
previewRef.current = controller;
|
||||
update((view) => ({
|
||||
...view,
|
||||
blocks: structuredClone(preview.blocks),
|
||||
records: structuredClone(preview.records)
|
||||
}));
|
||||
if (preview.blocks.length || preview.records.length) record(`展示结果 · ${label}`, "preview");
|
||||
for (const command of preview.commands) {
|
||||
controller.signal.throwIfAborted();
|
||||
await executeCommand(command, controller.signal, "preview");
|
||||
}
|
||||
},
|
||||
[executeCommand, update, record]
|
||||
);
|
||||
const createSample = useCallback((caseId: InvestigationDevCaseId = "combined") => {
|
||||
const map = valuesRef.current.mapRef.current;
|
||||
if (!map) return "";
|
||||
const task = valuesRef.current.task;
|
||||
const progress =
|
||||
task?.latest_progress?.execution_generation === task?.execution_generation
|
||||
? task?.latest_progress
|
||||
: null;
|
||||
const target = progress?.progress.diagram?.node_refs.flatMap(
|
||||
(node) => node.map_targets ?? []
|
||||
)[0];
|
||||
return createInvestigationDevCase(
|
||||
caseId,
|
||||
map.getCenter().toArray() as [number, number],
|
||||
target
|
||||
);
|
||||
}, []);
|
||||
|
||||
const taskEntries =
|
||||
!options.scope || options.scope === "investigation"
|
||||
? storeRef.current.entries.filter((entry) => entry.taskId === taskId)
|
||||
: storeRef.current.entries;
|
||||
const visible = storeRef.current.visible;
|
||||
const reviewing = storeRef.current.selectedId !== null;
|
||||
return {
|
||||
charts, overlay, cameraPaused, status, error, highlightLayers, clearMapOverlays,
|
||||
charts: visible.charts,
|
||||
blocks: visible.blocks,
|
||||
records: visible.records,
|
||||
overlay: visible.overlay,
|
||||
evidence: visible.evidence,
|
||||
highlightLayers: visible.highlightLayers,
|
||||
cameraPaused: reviewing ? false : cameraPaused,
|
||||
status,
|
||||
error,
|
||||
activity,
|
||||
localPreview,
|
||||
hasHistory: taskEntries.length > 0,
|
||||
history: {
|
||||
currentEntryId: storeRef.current.currentEntryId,
|
||||
entries: taskEntries,
|
||||
selectedId: storeRef.current.selectedId,
|
||||
onSelect: selectHistory
|
||||
},
|
||||
executeCommand,
|
||||
runPreview,
|
||||
createSample,
|
||||
clearPreview: clear,
|
||||
clearMapOverlays,
|
||||
pauseCamera,
|
||||
resumeCamera: () => { cameraPausedRef.current = false; setCameraPaused(false); },
|
||||
resumeCamera: () => {
|
||||
cameraPausedRef.current = false;
|
||||
setCameraPaused(false);
|
||||
},
|
||||
locateTargets,
|
||||
closeChart: (id: string) => setCharts((current) => current.filter((item) => item.chart_id !== id)),
|
||||
toggleOverlay: () => { if (overlay) { scene.showOverlay(!overlay.visible); setOverlay({ ...overlay, visible: !overlay.visible }); } },
|
||||
fitOverlay: () => { pauseCamera(); scene.fitOverlay(); }
|
||||
closeChart: (id: string) => {
|
||||
if (!reviewing)
|
||||
void executeCommand(
|
||||
{ action: "close_chart", chart_id: id },
|
||||
new AbortController().signal,
|
||||
source()
|
||||
).catch(() => undefined);
|
||||
},
|
||||
toggleOverlay: () => {
|
||||
if (reviewing || !visible.overlay) return;
|
||||
const next = { ...visible.overlay, visible: !visible.overlay.visible };
|
||||
scene.showOverlay(next.visible);
|
||||
update((view) => ({ ...view, overlay: next }));
|
||||
record(next.visible ? "显示覆盖层" : "隐藏覆盖层");
|
||||
},
|
||||
fitOverlay: () => {
|
||||
pauseCamera();
|
||||
scene.fitOverlay();
|
||||
if (!reviewing && visible.overlay) {
|
||||
update((view) => ({
|
||||
...view,
|
||||
camera: fitPresentationCamera(
|
||||
valuesRef.current.mapRef.current,
|
||||
visible.overlay!.data,
|
||||
valuesRef.current.getPadding()
|
||||
)
|
||||
}));
|
||||
record("定位覆盖层");
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ function task(taskType: OperationalTask["task_type"]): OperationalTask {
|
||||
scenario_code: null,
|
||||
feedback_requirements: [],
|
||||
activity: [],
|
||||
origin: "synthetic",
|
||||
origin: "system_data",
|
||||
updated_at: "2026-09-23T09:00:00Z"
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { WorkbenchAccess } from "../api/request-context";
|
||||
import {
|
||||
acknowledgeOperationalTaskNotification,
|
||||
answerOperationalTaskQuestion,
|
||||
type TaskQuestionAnswer,
|
||||
createOperationalRequestId,
|
||||
createOperationalTask,
|
||||
decideOperationalTaskAction,
|
||||
@@ -317,6 +319,13 @@ export function useOperationalTasks(
|
||||
}
|
||||
}, [notifications, acknowledgeNotification]);
|
||||
|
||||
const answerQuestion = useCallback(async (taskId: string, questionId: string, version: number,
|
||||
answers: TaskQuestionAnswer[], requestId: string) => {
|
||||
const updated = await answerOperationalTaskQuestion(taskId, questionId, version, answers, requestId, getAccessToken);
|
||||
setTasks((current) => current.map((item) => item.task_id === taskId ? updated : item));
|
||||
return updated;
|
||||
}, [getAccessToken]);
|
||||
|
||||
return {
|
||||
error: error ?? sync.error,
|
||||
loading,
|
||||
@@ -325,6 +334,7 @@ export function useOperationalTasks(
|
||||
notifications,
|
||||
refresh,
|
||||
createInvestigation,
|
||||
answerQuestion,
|
||||
decideAction,
|
||||
executeAction,
|
||||
reconcileAction,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
useInteractionRequests,
|
||||
useAgentConnection,
|
||||
FrontendActionExecutor,
|
||||
AnswerViewStateScope,
|
||||
agentTaskDiscussionContextSchema,
|
||||
type AgentTaskDiscussionContext,
|
||||
agentBootstrapKey,
|
||||
@@ -103,6 +104,12 @@ export function useWorkbenchAgent({
|
||||
const projectScopeKey = `project:${getWorkbenchProject(getAccessToken)?.project_id ?? "default"}`;
|
||||
const scopeKey = projectScopeKey;
|
||||
const { connectionStatus, recheck: checkAgentConnection, recoveryVersion } = useAgentConnection(scopeClient, scopeKey);
|
||||
const answerStateScope = useMemo(() => new AnswerViewStateScope(`${resources.id}:${scopeKey}`, scopeClient), [resources.id, scopeClient, scopeKey]);
|
||||
useEffect(() => {
|
||||
answerStateScope.retain();
|
||||
resources.disposers.add(answerStateScope.dispose);
|
||||
return () => { resources.disposers.delete(answerStateScope.dispose); answerStateScope.release(); };
|
||||
}, [answerStateScope, resources]);
|
||||
const history = useAgentHistory(scopeClient, scopeKey);
|
||||
const topics = useAgentTopics(scopeClient, scopeKey);
|
||||
const runtimeSummaries: AgentChatSessionSummary[] = agentRuntimes(resources).filter((entry) => entry.scope === scopeKey && entry.sessionId && entry.purpose !== "task_execution")
|
||||
@@ -184,7 +191,7 @@ export function useWorkbenchAgent({
|
||||
try {
|
||||
await target.chat.sendMessage(alreadyPresent ? undefined : replyMessage, { body: {
|
||||
session_id: owningSessionId, interaction_id: interactionId, model: selectedModel || undefined,
|
||||
capabilities: frontendActionEnabledRef.current ? ["host-question@1", "frontend-action@1"] : ["host-question@1"],
|
||||
capabilities: frontendActionEnabledRef.current ? ["host-question@1", "frontend-action@2"] : ["host-question@1"],
|
||||
approval_mode: approvalMode, task_context: target.taskDiscussion?.context,
|
||||
} });
|
||||
} catch (error) { target.runStatus = "error"; throw error; }
|
||||
@@ -344,8 +351,14 @@ export function useWorkbenchAgent({
|
||||
[refreshInteractionRequests, refreshAgentMessageInbox, applySessionId, claimEnvelope, handleCredentialRefresh, handleFrontendAction, onUiEnvelope]
|
||||
);
|
||||
|
||||
const synchronizeModelCatalog = useCallback((catalog: Awaited<ReturnType<AgentApiClient["getModels"]>>) => {
|
||||
// A background request from an old project must not replace this project's controls.
|
||||
if (resources.disposed || resources.activeAgent?.scope !== scopeKey) return;
|
||||
setModelOptions(catalog.models);
|
||||
setSelectedModel((current) => resolveAgentModelSelection(catalog, current));
|
||||
}, [resources, scopeKey]);
|
||||
const transport = useMemo(() => createWorkbenchAgentTransport(scopeClient, getAccessToken,
|
||||
getProjectId), [getAccessToken, getProjectId, scopeClient]);
|
||||
getProjectId, synchronizeModelCatalog), [getAccessToken, getProjectId, scopeClient, synchronizeModelCatalog]);
|
||||
const defaultRuntime = useMemo(() => {
|
||||
const created = createAgentRuntime(resources, scopeKey, scopeClient, transport);
|
||||
return created;
|
||||
@@ -768,7 +781,7 @@ export function useWorkbenchAgent({
|
||||
const loaded = await projectClient.loadSession(id, { messageLimit: 40 });
|
||||
if (!loaded) throw new Error("任务协作会话未能打开,请重试。");
|
||||
target = createAgentRuntime(resources, projectScopeKey, projectClient,
|
||||
createWorkbenchAgentTransport(projectClient, getAccessToken, getProjectId), loaded);
|
||||
createWorkbenchAgentTransport(projectClient, getAccessToken, getProjectId, synchronizeModelCatalog), loaded);
|
||||
if (loaded.isStreaming || loaded.runStatus === "running") resumeAgentRuntime(resources, target);
|
||||
}
|
||||
const titleBeforeOpen = titlesBeforeOpen.get(target);
|
||||
@@ -781,7 +794,7 @@ export function useWorkbenchAgent({
|
||||
activateRuntime(target);
|
||||
setPromptFocusRequest((current) => current + 1);
|
||||
return id;
|
||||
}, [activateRuntime, getAccessToken, getProjectId, projectScopeKey, resources]);
|
||||
}, [activateRuntime, getAccessToken, getProjectId, projectScopeKey, resources, synchronizeModelCatalog]);
|
||||
|
||||
const openTaskExecutionHistory = useCallback(async (taskId: string, runId: string, executionSessionId: string) => {
|
||||
const normalizedTaskId = taskId.trim();
|
||||
@@ -809,7 +822,7 @@ export function useWorkbenchAgent({
|
||||
resources,
|
||||
projectScopeKey,
|
||||
projectClient,
|
||||
createWorkbenchAgentTransport(projectClient, getAccessToken, getProjectId),
|
||||
createWorkbenchAgentTransport(projectClient, getAccessToken, getProjectId, synchronizeModelCatalog),
|
||||
loaded
|
||||
);
|
||||
} else {
|
||||
@@ -833,7 +846,7 @@ export function useWorkbenchAgent({
|
||||
activateRuntime(target);
|
||||
setStatusLabel(loaded.runStatus === "running" ? "调查执行记录自动同步中" : "调查执行记录已打开");
|
||||
return loaded.sessionId;
|
||||
}, [activateRuntime, getAccessToken, getProjectId, projectScopeKey, resources]);
|
||||
}, [activateRuntime, getAccessToken, getProjectId, projectScopeKey, resources, synchronizeModelCatalog]);
|
||||
|
||||
useEffect(() => {
|
||||
const target = runtimeRef.current;
|
||||
@@ -1084,7 +1097,7 @@ export function useWorkbenchAgent({
|
||||
topic_id: !activeSessionId ? runtimeRef.current?.topicId ?? undefined : undefined,
|
||||
model: selectedModel || undefined,
|
||||
approval_mode: approvalMode,
|
||||
capabilities: frontendActionEnabledRef.current ? ["frontend-action@1", "host-question@1"] : ["host-question@1"],
|
||||
capabilities: frontendActionEnabledRef.current ? ["frontend-action@2", "host-question@1"] : ["host-question@1"],
|
||||
task_context: taskDiscussion?.sessionId === sessionIdRef.current ? taskDiscussion.context : undefined
|
||||
}
|
||||
}
|
||||
@@ -1108,7 +1121,7 @@ export function useWorkbenchAgent({
|
||||
setStatusLabel("Agent 正在调查水质投诉事件");
|
||||
void target.chat.sendMessage({ text: prompt }, { body: {
|
||||
session_id: id, model: selectedModel || undefined, approval_mode: approvalMode, trigger_context: trigger,
|
||||
capabilities: frontendActionEnabledRef.current ? ["frontend-action@1", "host-question@1"] : ["host-question@1"],
|
||||
capabilities: frontendActionEnabledRef.current ? ["frontend-action@2", "host-question@1"] : ["host-question@1"],
|
||||
} }).catch(() => {});
|
||||
return { sessionId: id, reused: reserved.event_duplicate === true };
|
||||
}, [activateRuntime, approvalMode, resources, scopeClient, scopeKey, selectedModel, transport]);
|
||||
@@ -1185,6 +1198,7 @@ export function useWorkbenchAgent({
|
||||
|
||||
return {
|
||||
apiClient: clientRef.current,
|
||||
answerStateScope,
|
||||
contentReference: runtime.reference,
|
||||
setContentReference: (value: import("@/features/agent").AnswerUiSelection | undefined) => {
|
||||
const active = resources.activeAgent;
|
||||
@@ -1313,7 +1327,7 @@ function prefersReducedMotion() {
|
||||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
}
|
||||
|
||||
function createWorkbenchAgentTransport(client: AgentApiClient, access?: WorkbenchAccess, getProjectId?: () => Promise<string>) {
|
||||
function createWorkbenchAgentTransport(client: AgentApiClient, access?: WorkbenchAccess, getProjectId?: () => Promise<string>, onModels?: (catalog: Awaited<ReturnType<AgentApiClient["getModels"]>>) => void) {
|
||||
return new ResponsesChatTransport<AgentUiMessage>({
|
||||
api: `${env.TJWATER_AGENT_URL.replace(/\/$/, "")}/api/v1/responses`,
|
||||
headers: async (): Promise<Record<string, string>> => {
|
||||
@@ -1323,12 +1337,13 @@ function createWorkbenchAgentTransport(client: AgentApiClient, access?: Workbenc
|
||||
},
|
||||
async prepareSendMessagesRequest({ messages, body }) {
|
||||
const catalog = await client.getModels();
|
||||
onModels?.(catalog);
|
||||
const model = resolveAgentModelSelection(catalog, readBodyString(body, "model") ?? "");
|
||||
if (!model) throw new Error("Agent 后端当前没有可用模型,请检查模型配置。");
|
||||
return { body: createResponseRequest(messages, {
|
||||
conversation: readBodyString(body, "session_id"), interactionId: readBodyString(body, "interaction_id"), model, topicId: readBodyString(body, "topic_id"),
|
||||
approvalMode: readBodyString(body, "approval_mode") ?? "request",
|
||||
capabilities: Array.isArray(body?.capabilities) ? body.capabilities.filter((value): value is string => value === "frontend-action@1" || value === "host-question@1") : [], triggerContext: body?.trigger_context, taskContext: body?.task_context,
|
||||
capabilities: Array.isArray(body?.capabilities) ? body.capabilities.filter((value): value is string => value === "frontend-action@2" || value === "host-question@1") : [], triggerContext: body?.trigger_context, taskContext: body?.task_context,
|
||||
}) };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -628,8 +628,7 @@ function addMissingDataDependentLayers(
|
||||
layers: StyleSpecification["layers"],
|
||||
beforeLayerCandidates: readonly string[],
|
||||
availableSourceIdSet: ReadonlySet<GeoServerWaterNetworkSourceId>,
|
||||
layerVisibility: Record<string, boolean>,
|
||||
workspace?: string
|
||||
layerVisibility: Record<string, boolean>
|
||||
) {
|
||||
const beforeLayerId = beforeLayerCandidates.find((layerId) => map.getLayer(layerId));
|
||||
layers.forEach((layer) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { toLocalDateKey } from "@/shared/utils/date-time";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getWorkbenchProject, type WorkbenchAccess } from "../api/request-context";
|
||||
import { env } from "@/shared/config/env";
|
||||
@@ -249,7 +250,7 @@ export function useWorkbenchRuntimeData(
|
||||
: backendRuntimeActive
|
||||
? "backend" as const
|
||||
: "mock" as const,
|
||||
operationalDate: formatLocalDateKey(operationalDate),
|
||||
operationalDate: toLocalDateKey(operationalDate),
|
||||
operationalCursorTime: operationalDate.toISOString(),
|
||||
scheduledConditionsLoading,
|
||||
realtimePipeFlows,
|
||||
@@ -258,10 +259,3 @@ export function useWorkbenchRuntimeData(
|
||||
scenarios: !allowDemoScenarios && resolvedRuntimeData.scenarioDataSource === "mock" ? [] : resolvedRuntimeData.scenarios
|
||||
};
|
||||
}
|
||||
|
||||
function formatLocalDateKey(value: Date) {
|
||||
const year = value.getFullYear();
|
||||
const month = String(value.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(value.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
@@ -15,13 +15,11 @@ import { ReadingWorkspaceProvider } from "@/shared/ui/reading-workspace";
|
||||
import {
|
||||
AgentCommandPanel,
|
||||
AgentPersona,
|
||||
parseFrontendChart,
|
||||
toTrustedMapAction,
|
||||
type FrontendActionRequest,
|
||||
type UIEnvelopePayload,
|
||||
type AgentUiResult,
|
||||
type AgentWorkspaceContextItem,
|
||||
type AnswerUiSelection
|
||||
type AgentWorkspaceContextItem
|
||||
} from "@/features/agent";
|
||||
import {
|
||||
MapErrorNotice,
|
||||
@@ -35,7 +33,6 @@ import {
|
||||
type MapSourceStatus
|
||||
} from "@/features/map/core";
|
||||
import { env } from "@/shared/config/env";
|
||||
import { waitForAgentPresentation } from "./map/agent-presentation";
|
||||
import { getWorkbenchProject, type WorkbenchAccess } from "./api/request-context";
|
||||
import type { WorkbenchProjectSelection } from "./components/workbench-top-bar-menus";
|
||||
import { AgentTaskTicker } from "./components/agent-task-ticker";
|
||||
@@ -48,7 +45,6 @@ import { InvestigationPresentationPanel } from "./components/investigation-prese
|
||||
import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
|
||||
import { FeaturePopover } from "./components/feature-popover";
|
||||
import { ScheduledConditionFeed } from "./components/scheduled-condition-feed";
|
||||
import { TaskConversationReview } from "./components/task-conversation-review";
|
||||
import type { InvestigationDiscussion } from "./api/investigation-report";
|
||||
import { fetchOperationalTaskRuns, type TaskExecutionHistoryTarget } from "./api/operational-task-client";
|
||||
import { WorkbenchAgentPanels } from "./components/workbench-agent-panels";
|
||||
@@ -104,7 +100,6 @@ import {
|
||||
import { getResponsivePadding, getResponsiveWorkbenchPadding } from "./map/camera";
|
||||
import { exportMapViewImage } from "./map/export-view";
|
||||
import { setMapLayersVisibility } from "./map/layer-visibility";
|
||||
import { parseScadaAnalysisItems } from "./map/scada-analysis";
|
||||
import {
|
||||
BASE_LAYER_OPTIONS,
|
||||
INITIAL_LAYER_VISIBILITY,
|
||||
@@ -116,7 +111,6 @@ import {
|
||||
import { waterNetworkToolbarItems } from "./map/toolbar-config";
|
||||
import type {
|
||||
DetailFeature,
|
||||
ScheduledConditionItem,
|
||||
ScheduledConditionRecord,
|
||||
WorkbenchAlert,
|
||||
WorkbenchUser
|
||||
@@ -520,6 +514,16 @@ function MapWorkbenchContent({
|
||||
onClearLayers: restorePresentationLayers,
|
||||
getPadding: () => mapRef.current ? getResponsiveWorkbenchPadding(mapRef.current, leftPanelOpen, rightPanelOpen, rightPanelExpanded, agentPanelWidth) : { top: 48, right: 48, bottom: 48, left: 48 }
|
||||
});
|
||||
const previewPresentation = useInvestigationPresentation({
|
||||
scope: "preview", enabled: false, task: selectedInvestigationTask, projectId: projectPresentation.projectId,
|
||||
sync: workbenchSync, getAccessToken, mapRef, mapReady, controller: mapController,
|
||||
onClearLayers: () => undefined, getPadding: () => mapRef.current ? getResponsiveWorkbenchPadding(mapRef.current, leftPanelOpen, rightPanelOpen, rightPanelExpanded, agentPanelWidth) : { top: 48, right: 48, bottom: 48, left: 48 }
|
||||
});
|
||||
const chatPresentation = useInvestigationPresentation({
|
||||
scope: "chat", scopeId: agent.sessionId, enabled: false, task: null, projectId: projectPresentation.projectId,
|
||||
sync: workbenchSync, getAccessToken, mapRef, mapReady, controller: mapController,
|
||||
onClearLayers: () => undefined, getPadding: () => mapRef.current ? getResponsiveWorkbenchPadding(mapRef.current, leftPanelOpen, rightPanelOpen, rightPanelExpanded, agentPanelWidth) : { top: 48, right: 48, bottom: 48, left: 48 }
|
||||
});
|
||||
const geocoding = useWorkbenchGeocoding({
|
||||
picking: geocodingPickActive,
|
||||
onPickingChange: (picking) => {
|
||||
@@ -739,7 +743,7 @@ function MapWorkbenchContent({
|
||||
}
|
||||
const presentationLayerIds = new Set([
|
||||
...presentation.highlightLayers,
|
||||
...(selectedInvestigationTask?.latest_progress?.progress.diagram?.node_refs.flatMap((node) => node.map_targets?.map((target) => target.source_id) ?? []) ?? [])
|
||||
...(presentation.history.selectedId === null ? selectedInvestigationTask?.latest_progress?.progress.diagram?.node_refs.flatMap((node) => node.map_targets?.map((target) => target.source_id) ?? []) ?? [] : [])
|
||||
].flatMap((id) => id === "scada" ? ["scada-pressure", "scada-flow"] : [id]));
|
||||
const presentationRelatedLayers = layerControlItems.filter((item) => presentationLayerIds.has(item.id)).map((item) => ({ id: item.id, label: item.label, visible: item.visible }));
|
||||
|
||||
@@ -942,56 +946,7 @@ function MapWorkbenchContent({
|
||||
}
|
||||
|
||||
async function handleFrontendAction(request: FrontendActionRequest, signal: AbortSignal) {
|
||||
signal.throwIfAborted();
|
||||
if (request.name === "locate_features") {
|
||||
const action = toTrustedMapAction("locate_features", request.params);
|
||||
if (!action || action.type !== "locate_features" || !action.layer) throw new Error("INVALID_MAP_TARGET");
|
||||
const result = await mapController.locateFeatures(action.layer as Parameters<typeof mapController.locateFeatures>[0], action.featureIds, signal);
|
||||
return result;
|
||||
}
|
||||
if (request.name === "zoom_to_map") {
|
||||
const action = toTrustedMapAction("zoom_to_map", request.params);
|
||||
if (!action || action.type !== "zoom_to_map") throw new Error("INVALID_MAP_CAMERA");
|
||||
const map = mapRef.current;
|
||||
if (!map || !mapReady) throw new Error("MAP_NOT_READY");
|
||||
map.jumpTo({ center: action.center, zoom: action.zoom });
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
signal.throwIfAborted();
|
||||
return { fitted: true };
|
||||
}
|
||||
if (request.name === "show_chart" || request.name === "view_history") {
|
||||
const chart = parseFrontendChart(request.name === "view_history" ? request.params.chart : request.params);
|
||||
if (!chart) throw new Error("INVALID_CHART_DATA");
|
||||
const data = { x_data: chart.x_data, series: chart.series };
|
||||
const envelope: AgentUiResult["envelope"] = request.name === "view_history"
|
||||
? { kind: "registered_component", schemaVersion: "agent-ui@1", component: "HistoryPanel", surface: "side_panel", props: { feature_id: request.params.element_id, metric: chart.title, start_time: request.params.start, end_time: request.params.end }, data }
|
||||
: { kind: "chart", schemaVersion: "agent-ui@1", grammar: "echarts-safe-subset", surface: "side_panel", spec: { title: chart.title, chart_type: chart.chart_type }, data };
|
||||
setAgentUiResults((current) => [...current.slice(-5), { id: request.actionId, sessionId: request.sessionId, createdAt: Date.now(), envelope }]);
|
||||
await waitForAgentPresentation(request.actionId, signal);
|
||||
return { displayed: true, series_count: chart.series.length };
|
||||
}
|
||||
if (request.name === "render_scada_analysis") {
|
||||
const items = parseScadaAnalysisItems(request.params.items);
|
||||
if (!items) throw new Error("INVALID_SCADA_ANALYSIS");
|
||||
const result = await mapController.renderScadaAnalysis(items, signal);
|
||||
const counts = result.level_counts;
|
||||
showMapNotice({
|
||||
tone: "success",
|
||||
title: "Agent SCADA 分析已渲染",
|
||||
message: `已渲染 ${result.rendered_ids.length} 个,高 ${counts.high}、中 ${counts.medium}、低 ${counts.low}${result.missing_ids.length ? `,缺失 ${result.missing_ids.length} 个` : ""}。`
|
||||
});
|
||||
return result;
|
||||
}
|
||||
if (request.name === "clear_scada_analysis") {
|
||||
const result = mapController.clearScadaAnalysis();
|
||||
showMapNotice({
|
||||
tone: "info",
|
||||
title: "Agent SCADA 分析已清除",
|
||||
message: "地图已移除当前会话的 SCADA 分析结果。"
|
||||
});
|
||||
return result;
|
||||
}
|
||||
throw new Error("UNSUPPORTED_FRONTEND_ACTION");
|
||||
return chatPresentation.executeCommand(request.command, signal, "chat");
|
||||
}
|
||||
|
||||
async function handleStartNewAgentSession() {
|
||||
@@ -1135,6 +1090,8 @@ function MapWorkbenchContent({
|
||||
function handleClearMapOverlays() {
|
||||
mapController.clearOverlays();
|
||||
presentation.clearMapOverlays();
|
||||
chatPresentation.clearMapOverlays();
|
||||
previewPresentation.clearMapOverlays();
|
||||
handleClearSelection();
|
||||
dispatchWorkspaceContext({ type: "clear-spatial-feature" });
|
||||
measurement.clear();
|
||||
@@ -1215,7 +1172,7 @@ function MapWorkbenchContent({
|
||||
}
|
||||
return items;
|
||||
}, [artifactWorkspace.activeArtifact, selectedOperationalEvent?.lane, workspaceContext]);
|
||||
const collaborationReviewTask = agent.sessionPurpose !== "ordinary_chat" && agent.sessionTaskId
|
||||
const collaborationTask = agent.sessionPurpose !== "ordinary_chat" && agent.sessionTaskId
|
||||
? operationalTasks.tasks.find((item) => item.task_id === agent.sessionTaskId)
|
||||
: undefined;
|
||||
const promptSuggestions = useAgentPromptSuggestions({
|
||||
@@ -1225,6 +1182,7 @@ function MapWorkbenchContent({
|
||||
model: agent.selectedModel,
|
||||
sessionId: agent.sessionId,
|
||||
request: agent.generatePromptSuggestions,
|
||||
recoveryVersion: agent.connectionRecoveryVersion,
|
||||
alerts: workbenchAlerts,
|
||||
alertDataSource,
|
||||
conditions: scheduledConditions,
|
||||
@@ -1263,7 +1221,9 @@ function MapWorkbenchContent({
|
||||
promptSuggestions: promptSuggestions.suggestions,
|
||||
promptSuggestionsLoading: promptSuggestions.loading,
|
||||
promptSuggestionsUnavailable: promptSuggestions.unavailable,
|
||||
onRetryPromptSuggestions: promptSuggestions.retry,
|
||||
activeSessionId: agent.sessionId,
|
||||
answerStateScope: agent.answerStateScope,
|
||||
qaOnly: agent.sessionPurpose === "task_collaboration" || agent.sessionPurpose === "task_execution",
|
||||
executionHistory: agent.sessionPurpose === "task_execution",
|
||||
statusLabel: agent.statusLabel,
|
||||
@@ -1289,14 +1249,6 @@ function MapWorkbenchContent({
|
||||
currentProjectName: getWorkbenchProject(getAccessToken)?.name,
|
||||
approvalMode: agent.approvalMode,
|
||||
uiResults: activeAgentUiResults,
|
||||
reviewPanel: collaborationReviewTask ? <TaskConversationReview task={collaborationReviewTask}
|
||||
onAccessDecision={(requestId, version, decision, scopes) => operationalTasks.decideAccess(collaborationReviewTask.task_id, requestId, version, decision, scopes)}
|
||||
onActionDecision={(actionId, version, decision, comment) => operationalTasks.decideAction(collaborationReviewTask.task_id, actionId, version, decision, comment)}
|
||||
onActionExecute={(actionId, version) => operationalTasks.executeAction(collaborationReviewTask.task_id, actionId, version)}
|
||||
onDraftUpdate={(actionId, version, payload) => operationalTasks.updateWorkOrderDraft(collaborationReviewTask.task_id, actionId, version, payload)}
|
||||
onConfirmDispatch={(actionId, version) => operationalTasks.confirmWorkOrderDispatch(collaborationReviewTask.task_id, actionId, version)}
|
||||
onActionReconcile={(actionId, version) => operationalTasks.reconcileAction(collaborationReviewTask.task_id, actionId, version)}
|
||||
onRefresh={operationalTasks.refresh} /> : undefined,
|
||||
workspaceContext: agentWorkspaceContext,
|
||||
prompt: agent.promptDraft,
|
||||
contentReference: agent.contentReference,
|
||||
@@ -1304,16 +1256,20 @@ function MapWorkbenchContent({
|
||||
agent.setContentReference(value);
|
||||
if (value) readingWorkspace?.close();
|
||||
},
|
||||
promptReference: collaborationReviewTask ? {
|
||||
title: collaborationReviewTask.title,
|
||||
placeholder: "可询问调查进展、讨论报告或补充信息",
|
||||
detail: [
|
||||
agent.taskDiscussionContext?.report_revision && agent.taskDiscussionReportGeneration
|
||||
? `第 ${agent.taskDiscussionReportGeneration} 轮` : null,
|
||||
agent.taskDiscussionContext?.report_revision ? `报告第 ${agent.taskDiscussionContext.report_revision} 版` : null,
|
||||
agent.taskDiscussionContext?.evidence_id ? "已引用证据" : null,
|
||||
agent.taskDiscussionContext?.gap_id ? "已引用数据缺口" : null
|
||||
].filter(Boolean).join(" · ")
|
||||
promptReference: collaborationTask ? {
|
||||
title: collaborationTask.title,
|
||||
placeholder: agent.sessionPurpose === "task_execution"
|
||||
? "追加调试问题,发送后进入独立调试会话"
|
||||
: "可询问调查进展、讨论报告或补充信息",
|
||||
detail: agent.sessionPurpose === "task_execution"
|
||||
? "发送后进入调试分支 · 原始记录保持不变"
|
||||
: [
|
||||
agent.taskDiscussionContext?.report_revision && agent.taskDiscussionReportGeneration
|
||||
? `第 ${agent.taskDiscussionReportGeneration} 轮` : null,
|
||||
agent.taskDiscussionContext?.report_revision ? `报告第 ${agent.taskDiscussionContext.report_revision} 版` : null,
|
||||
agent.taskDiscussionContext?.evidence_id ? "已引用证据" : null,
|
||||
agent.taskDiscussionContext?.gap_id ? "已引用数据缺口" : null
|
||||
].filter(Boolean).join(" · ")
|
||||
} : undefined,
|
||||
onRefreshHistory: agent.refreshSessionHistory,
|
||||
onStartNewSession: handleStartNewAgentSession,
|
||||
@@ -1332,12 +1288,12 @@ function MapWorkbenchContent({
|
||||
onSubmitPrompt: agent.submitPrompt,
|
||||
onLocateAnswerFeatures: async (target) => {
|
||||
if (readingWorkspace?.active) readingWorkspace.setView(businessWorkspaceWidth >= 960 ? "map_split" : "map_only");
|
||||
return mapController.locateFeatures(target.layer, target.feature_ids, new AbortController().signal);
|
||||
return chatPresentation.executeCommand({ action: "locate_features", layer: target.layer, feature_ids: target.feature_ids }, new AbortController().signal, "chat");
|
||||
},
|
||||
onStopPrompt: agent.stopPrompt,
|
||||
onReplyPermission: agent.replyPermission,
|
||||
onReplyQuestion: agent.replyQuestion,
|
||||
onRejectQuestion: agent.rejectQuestion
|
||||
onStopPrompt: agent.sessionPurpose === "task_execution" ? undefined : agent.stopPrompt,
|
||||
onReplyPermission: agent.sessionPurpose === "task_execution" ? undefined : agent.replyPermission,
|
||||
onReplyQuestion: agent.sessionPurpose === "task_execution" ? undefined : agent.replyQuestion,
|
||||
onRejectQuestion: agent.sessionPurpose === "task_execution" ? undefined : agent.rejectQuestion
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -1397,7 +1353,7 @@ function MapWorkbenchContent({
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
|
||||
{analysisResultsPanelOpen || peripheralSamplePanelOpen ? (
|
||||
{analysisResultsPanelOpen ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-map-interaction-shield
|
||||
@@ -1501,13 +1457,21 @@ function MapWorkbenchContent({
|
||||
}
|
||||
mapOverlay={
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 top-14">
|
||||
{investigationFollow && selectedInvestigationTask && !devPanelOpen ? <InvestigationPresentationPanel
|
||||
task={selectedInvestigationTask} charts={presentation.charts} overlay={presentation.overlay} cameraPaused={presentation.cameraPaused}
|
||||
status={presentation.status} error={presentation.error}
|
||||
width={Math.min(380, Math.max(180, businessWorkspaceWidth - (rightPanelOpen ? (rightPanelExpanded ? (viewportWidth >= 1536 ? 960 : 880) : 432) + 64 : 64) - 24))}
|
||||
relatedLayers={presentationRelatedLayers} onToggleLayer={handleTogglePresentationLayer}
|
||||
onResumeCamera={presentation.resumeCamera} onCloseChart={presentation.closeChart} onToggleOverlay={presentation.toggleOverlay} onFitOverlay={presentation.fitOverlay}
|
||||
/> : null}
|
||||
<div className="pointer-events-none absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] left-3 z-40 flex max-h-[calc(100dvh-9rem-var(--workbench-timeline-height))] max-w-[calc(100%-24px)] flex-col gap-2 overflow-y-auto">
|
||||
<AnimatePresence initial={false}>
|
||||
{[
|
||||
{ scope: "investigation", surface: presentation, task: selectedInvestigationTask, visible: Boolean(selectedInvestigationTask) || presentation.hasHistory },
|
||||
{ scope: "chat", surface: chatPresentation, task: null, visible: chatPresentation.hasHistory || chatPresentation.charts.length > 0 || Boolean(chatPresentation.overlay) || chatPresentation.highlightLayers.length > 0 },
|
||||
{ scope: "preview", surface: previewPresentation, task: null, visible: previewPresentation.hasHistory || previewPresentation.localPreview }
|
||||
].filter((item) => item.visible).map(({ scope, surface, task }) => <InvestigationPresentationPanel
|
||||
key={scope} scope={scope} embedded blocks={surface.blocks} records={surface.records} evidence={surface.evidence} history={surface.history} onLocateTargets={surface.locateTargets} task={task} localPreview={scope === "preview"} charts={surface.charts} overlay={surface.overlay} cameraPaused={surface.cameraPaused}
|
||||
status={scope === "chat" ? "live" : surface.status} error={surface.error}
|
||||
width={Math.min(380, Math.max(180, businessWorkspaceWidth - (rightPanelOpen ? (rightPanelExpanded ? (viewportWidth >= 1536 ? 960 : 880) : 432) + 64 : 64) - 24))}
|
||||
relatedLayers={scope === "investigation" ? presentationRelatedLayers : []} onToggleLayer={handleTogglePresentationLayer}
|
||||
onResumeCamera={surface.resumeCamera} onCloseChart={surface.closeChart} onToggleOverlay={surface.toggleOverlay} onFitOverlay={surface.fitOverlay}
|
||||
/>)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
{!devPanelOpen ? (
|
||||
<>
|
||||
<div className="pointer-events-auto absolute right-3 top-10 hidden lg:block">
|
||||
@@ -1577,6 +1541,7 @@ function MapWorkbenchContent({
|
||||
onLoadTaskEvidenceSnapshot={operationalTasks.loadEvidenceSnapshot}
|
||||
onTaskRefresh={operationalTasks.refresh}
|
||||
onLoadTaskReports={operationalTasks.loadReports}
|
||||
onAnswerTaskQuestion={operationalTasks.answerQuestion}
|
||||
onReportViewed={operationalTasks.markReportRead}
|
||||
onRefresh={workbenchSync.refresh}
|
||||
/>
|
||||
@@ -1591,6 +1556,12 @@ function MapWorkbenchContent({
|
||||
flowDataSource={flowDataSource}
|
||||
investigationFollow={investigationFollow}
|
||||
onInvestigationFollowChange={setInvestigationFollow}
|
||||
investigationTools={{
|
||||
task: selectedInvestigationTask, mapReady,
|
||||
charts: previewPresentation.charts.length, overlay: previewPresentation.overlay?.title ?? null,
|
||||
activity: [...presentation.activity, ...previewPresentation.activity], onSample: previewPresentation.createSample,
|
||||
onPreview: previewPresentation.runPreview, onClear: previewPresentation.clearPreview
|
||||
}}
|
||||
onClose={() => setDevPanelOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1735,6 +1706,7 @@ function MapWorkbenchContent({
|
||||
onLoadTaskEvidenceSnapshot={operationalTasks.loadEvidenceSnapshot}
|
||||
onTaskRefresh={operationalTasks.refresh}
|
||||
onLoadTaskReports={operationalTasks.loadReports}
|
||||
onAnswerTaskQuestion={operationalTasks.answerQuestion}
|
||||
onReportViewed={operationalTasks.markReportRead}
|
||||
onRefresh={workbenchSync.refresh}
|
||||
/>
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
export const waitForAgentPresentation = async (id: string, signal: AbortSignal) => {
|
||||
const deadline = Date.now() + 5000;
|
||||
while (Date.now() < deadline) {
|
||||
signal.throwIfAborted();
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
const element = [...document.querySelectorAll<HTMLElement>("[data-agent-ui-result]")].find((item) => item.dataset.agentUiResult === id);
|
||||
if (element && element.getBoundingClientRect().height > 0) return;
|
||||
}
|
||||
throw new Error("PRESENTATION_NOT_VISIBLE");
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user