feat(agent): support Responses steering and project-aware tools

Use the dedicated TJWATER_AGENT_URL and Responses stream protocol. Keep running
turns active while users steer or queue follow-ups and edit pending delivery.
Refresh the model catalog before generation and preserve approval mode selection.

Integrate scoped investigation messaging/presentation, geographic place selection,
current Agent composer styles and supporting workbench contracts.

Validation: 77 focused Vitest checks, 21 browser cases, lint, typecheck and
production build. Preserve direct Server/Agent routing and project authentication.
This commit is contained in:
2026-10-19 18:46:58 +08:00
parent 2c5bf15cce
commit ec23d44b02
131 changed files with 6663 additions and 1204 deletions
@@ -1,9 +1,11 @@
import { useEffect, useState, type ReactNode } from "react";
import { FileText, History, MessageSquare } from "lucide-react";
import { MessageSquare } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { Disclosure } from "@/shared/ui/disclosure";
import { StatusBadge } from "@/shared/ui/status";
import type { InvestigationDiagram, InvestigationDiscussion } from "../api/investigation-report";
import { formatDateTime as dateLabel } from "@/shared/utils/date-time";
import { formatInvestigationText } from "../utils/investigation-text";
import type { InvestigationDiagram, InvestigationDiscussion, InvestigationMapTarget } from "../api/investigation-report";
import type {
OperationalTask,
OperationalTaskAction,
@@ -17,6 +19,8 @@ import {
isPendingInvestigationAction
} from "../utils/investigation-presentation";
import { InvestigationDiagramView } from "./investigation-diagram-view";
import { InvestigationExpandCard } from "./investigation-expand-card";
import { useReportUpdateMotion } from "../hooks/use-report-update-motion";
type ReportSelection = { snapshot: OperationalTaskReport; diagram: InvestigationDiagram | null };
type Props = {
@@ -27,9 +31,9 @@ type Props = {
onLoadReports?: (offset: number) => Promise<OperationalTaskReportPage>;
onReportViewed?: (generation: number, revision: number) => void;
focusActionId?: string | null;
onLocate?: (targets: InvestigationMapTarget[]) => void;
};
const dateLabel = (value: string) => new Date(value).toLocaleString("zh-CN", { hour12: false });
const roundLabel = (snapshot: OperationalTaskReport) =>
snapshot.execution_generation == null ? "历史报告" : `第 ${snapshot.execution_generation} 轮`;
const actionTitle = (action: OperationalTaskAction) =>
@@ -123,27 +127,35 @@ export function InvestigationTaskReport({
renderEvidence,
onLoadReports,
onReportViewed,
focusActionId
focusActionId,
onLocate
}: Props) {
const [selection, setSelection] = useState<ReportSelection | null>(() =>
task.latest_report
? { snapshot: task.latest_report, diagram: investigationDiagram(task, task.latest_report) }
: null
);
// 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[]>([]);
const [historyOffset, setHistoryOffset] = useState<number | null>(0);
const [historyLoading, setHistoryLoading] = useState(false);
const [historyError, setHistoryError] = useState<string | null>(null);
const snapshot = selection?.snapshot ?? null;
const live = selection === null;
const snapshot = selection?.snapshot ?? task.latest_report;
const report = snapshot?.report;
const diagram = selection?.diagram ?? (snapshot ? null : investigationDiagram(task, null));
const progress = task.latest_progress?.execution_generation === task.execution_generation ? task.latest_progress : null;
// A current final report owns its archived graph. During investigation, a
// progress snapshot owns its whole graph, including an explicit null. A prior
// round's final report must never hide the new round's progress.
const currentFinalReport = snapshot?.execution_generation === task.execution_generation
&& report?.stage === "final";
const diagram = live
? progress && !currentFinalReport ? progress.progress.diagram : investigationDiagram(task, snapshot)
: selection.diagram;
const diagramRunning = Boolean(live && !currentFinalReport && progress && task.active_run?.run_id === progress.run_id && task.active_run.status === "running");
const previousRound = Boolean(
snapshot && snapshot.execution_generation !== task.execution_generation
);
const olderRevision = Boolean(
snapshot && task.latest_report && snapshot.revision !== task.latest_report.revision
);
const latestAvailable = Boolean(task.latest_report && (!snapshot || olderRevision));
const latestAvailable = !live;
const insufficient = report?.outcome === "insufficient_data";
const currentInsufficient = task.latest_report?.execution_generation === task.execution_generation
&& task.latest_report?.report.outcome === "insufficient_data";
@@ -153,14 +165,8 @@ export function InvestigationTaskReport({
const majorEvidence = (snapshot ? evidence : task.evidence).slice(0, 3);
const remainingEvidence = (snapshot ? evidence : task.evidence).slice(3);
const timeline = investigationTimeline(task, snapshot);
useEffect(() => {
if (!selection && task.latest_report)
setSelection({
snapshot: task.latest_report,
diagram: investigationDiagram(task, task.latest_report)
});
}, [selection, task]);
const reportRef = useReportUpdateMotion(task.task_id,
`${snapshot?.execution_generation}:${snapshot?.revision}:${progress?.run_id}:${progress?.revision}`, live);
useEffect(() => {
if (snapshot?.execution_generation == null || !onReportViewed) return;
@@ -182,6 +188,7 @@ export function InvestigationTaskReport({
const discuss = (prompt: string, extra?: { evidence_id?: string; gap_id?: string }) =>
onContinue({
prompt,
reportGeneration: snapshot?.execution_generation,
context: { ...(snapshot ? { report_revision: snapshot.revision } : {}), ...extra }
});
const selectReport = (item: OperationalTaskReport) =>
@@ -202,7 +209,7 @@ export function InvestigationTaskReport({
};
return (
<article aria-label="调查任务报告" className="investigation-report surface-reading">
<article ref={reportRef} aria-label="调查任务报告" className="investigation-report surface-reading">
<header className="investigation-report-header">
<div className="min-w-0">
<p className="text-xs text-[var(--ink-muted)]">
@@ -227,8 +234,9 @@ export function InvestigationTaskReport({
<Button
variant="outline"
size="sm"
title="打开调查协作对话,引用此任务,不会直接发送。"
onClick={() =>
onContinue({ context: snapshot ? { report_revision: snapshot.revision } : {} })
onContinue({ context: snapshot ? { report_revision: snapshot.revision } : {}, reportGeneration: snapshot?.execution_generation })
}
>
<MessageSquare size={14} aria-hidden />
@@ -238,19 +246,16 @@ export function InvestigationTaskReport({
{latestAvailable ? (
<div className="investigation-report-update" role="status">
<span>第 {task.latest_report!.execution_generation ?? "未知"} 轮有新报告</span>
<Button variant="outline" size="sm" onClick={() => selectReport(task.latest_report!)}>
查看新报告
<span>正在阅读历史报告{olderRevision ? ",已有新报告" : ""}</span>
<Button variant="outline" size="sm" onClick={() => setSelection(null)}>
返回实时内容
</Button>
</div>
) : null}
<section aria-label="任务报告" className="investigation-report-section">
<InvestigationExpandCard title="当前结果" label="任务报告" disabled={!report}
revision={`${snapshot?.execution_generation}:${snapshot?.revision}`} preview={<>
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="flex items-center gap-2 text-sm font-semibold text-[var(--ink)]">
<FileText size={15} aria-hidden />
当前结果
</h3>
{report ? (
<StatusBadge tone={insufficient ? "warning" : "neutral"}>
{insufficient
@@ -282,37 +287,42 @@ export function InvestigationTaskReport({
? "调查结论"
: "历史报告内容"}
</p>
<p className="mt-1 text-sm leading-6 text-[var(--ink)]">{report.current_conclusion}</p>
<p className="investigation-conclusion-preview mt-1 text-sm leading-6 text-[var(--ink)]">{formatInvestigationText(report.current_conclusion)}</p>
<p className="mt-2 text-xs leading-5 text-[var(--ink-muted)]">
结论边界:{report.conclusion_boundary}
结论边界:{formatInvestigationText(report.conclusion_boundary)}
</p>
{insufficient ? (
<p className="mt-2 text-sm leading-6 text-[var(--status-warning-foreground)]">
结束原因:{report.stop_reason}
结束原因:{formatInvestigationText(report.stop_reason)}
</p>
) : null}
<p className="mt-2 text-xs text-[var(--ink-muted)]">
正在阅读:{roundLabel(snapshot)} · 报告第 {snapshot.revision} 版 ·{" "}
{dateLabel(snapshot.published_at)}
</p>
{!insufficient &&
(report.recommendations.length > 0 || report.pending_items.length > 0) ? (
<Disclosure label="判断说明与建议" className="mt-2">
{report.recommendations.length > 0 ? (
<ReportItems title="处置建议" items={report.recommendations} />
) : null}
{report.pending_items.length > 0 ? (
<ReportItems title="待核实问题" items={report.pending_items} />
) : null}
</Disclosure>
) : null}
</>
) : (
<p className="mt-3 text-sm leading-6 text-[var(--ink-muted)]">
尚未发布本轮调查报告。已保存的调查资料可在下方查看。
</p>
)}
</section>
</>}>
{report && snapshot ? <>
<p className="text-xs text-[var(--ink-muted)]">{roundLabel(snapshot)} · 报告第 {snapshot.revision} 版 · {dateLabel(snapshot.published_at)}</p>
<p className="mt-3 whitespace-pre-wrap text-sm leading-7 text-[var(--ink)]">{formatInvestigationText(report.current_conclusion)}</p>
<p className="mt-3 text-sm leading-6 text-[var(--ink-muted)]">结论边界:{formatInvestigationText(report.conclusion_boundary)}</p>
{insufficient ? <p className="mt-3 text-sm leading-6 text-[var(--status-warning-foreground)]">结束原因:{formatInvestigationText(report.stop_reason)}</p> : <>
{report.recommendations.length > 0 ? <ReportItems title="处置建议" items={report.recommendations} /> : null}
{report.pending_items.length > 0 ? <ReportItems title="待核实问题" items={report.pending_items} /> : null}
</>}
</> : <p className="text-sm text-[var(--ink-muted)]">尚未发布本轮调查报告。</p>}
</InvestigationExpandCard>
{live ? <section aria-label="当前调查进展" className="investigation-report-section">
<h3 className="text-sm font-semibold text-[var(--ink)]">当前调查进展</h3>
<p className="mt-2 whitespace-pre-wrap break-words text-sm leading-6 text-[var(--ink)]">{formatInvestigationText(progress?.progress.summary ?? "尚未发布本轮调查进展。")}</p>
{progress ? <p className="mt-1 text-xs text-[var(--ink-muted)]">实时内容 · 第 {progress.execution_generation} 轮 · 更新 {dateLabel(progress.published_at)}</p> : null}
</section> : null}
{pendingActions.length > 0 ? (
<section aria-label="当前需处理" className="investigation-report-section">
@@ -387,13 +397,13 @@ export function InvestigationTaskReport({
</h3>
{report.data_gaps.map((gap) => (
<div key={gap.gap_id} className="mt-3">
<p className="text-sm font-medium leading-6 text-[var(--ink)]">{gap.missing_data}</p>
<p className="text-sm leading-6 text-[var(--ink-muted)]">判断影响:{gap.impact}</p>
<p className="text-sm font-medium leading-6 text-[var(--ink)]">{formatInvestigationText(gap.missing_data)}</p>
<p className="text-sm leading-6 text-[var(--ink-muted)]">判断影响:{formatInvestigationText(gap.impact)}</p>
<div className="mt-1 flex flex-wrap items-center gap-x-4">
<Disclosure label="查看查询依据">
<div className="space-y-1 py-2 text-xs leading-5 text-[var(--ink-muted)]">
<p>查询范围:{gap.scope}</p>
<p>需补齐:{gap.required_data}</p>
<p>查询范围:{formatInvestigationText(gap.scope)}</p>
<p>需补齐:{formatInvestigationText(gap.required_data)}</p>
{gap.evidence_refs.map((id) => (
<EvidenceReference
key={id}
@@ -409,7 +419,7 @@ export function InvestigationTaskReport({
size="sm"
onClick={() =>
discuss(
`我要补充“${gap.missing_data}”相关的现场事实。请说明需要的记录范围和时间信息。`,
`我要补充“${formatInvestigationText(gap.missing_data)}”相关的现场事实。请说明需要的记录范围和时间信息。`,
{ gap_id: gap.gap_id }
)
}
@@ -433,8 +443,10 @@ export function InvestigationTaskReport({
{diagram ? (
<InvestigationDiagramView
key={`${snapshot?.revision ?? "plan"}:${diagram.mermaid}`}
key={live ? `live:${task.task_id}` : `report:${snapshot?.revision}`}
diagram={diagram}
running={diagramRunning}
onLocate={onLocate}
renderDetails={(node) => (
<>
{node.evidence_refs.map((id) => (
@@ -473,22 +485,43 @@ export function InvestigationTaskReport({
/>
) : null}
<section aria-label="主要依据" className="investigation-report-section">
<h3 className="text-sm font-semibold text-[var(--ink)]">主要依据</h3>
<InvestigationExpandCard title="主要依据" disabled={task.evidence.length === 0} preview={<>
<div className="investigation-evidence-preview">
{majorEvidence.map((item) => <div key={item.evidence_id}>
<p className="text-sm leading-6 text-[var(--ink)]">{formatInvestigationText(item.summary)}</p>
<p className="text-xs leading-5 text-[var(--ink-muted)]">{item.source_label} · {dateLabel(item.observed_at ?? item.recorded_at)}</p>
<Button type="button" variant="outline" size="sm" className="mt-2" onClick={() => discuss(
`请解释“${formatInvestigationText(item.summary)}”这条证据的范围、来源和判断影响。`, { evidence_id: item.evidence_id }
)}><MessageSquare size={14} aria-hidden />围绕此证据提问</Button>
</div>)}
</div>
<p className="mt-2 text-xs text-[var(--ink-muted)]">
{(snapshot ? evidence : task.evidence).length > 0
? `共 ${(snapshot ? evidence : task.evidence).length} 条引用依据,展开查看来源与原始记录。`
: "尚无可展示的引用证据。"}
</p>
</>}>
{(close) => <>
{majorEvidence.map((item) => (
<div key={item.evidence_id} className="mt-2">
{renderEvidence(item)}
<button
{onLocate && diagram?.node_refs.some((node) => node.evidence_refs.includes(item.evidence_id) && node.map_targets?.length) ? (
<button type="button" className="investigation-text-action" onClick={() => { close(false); onLocate(diagram.node_refs.filter((node) => node.evidence_refs.includes(item.evidence_id)).flatMap((node) => node.map_targets ?? [])); }}>在地图查看</button>
) : null}
<Button
type="button"
className="investigation-text-action"
onClick={() =>
discuss(`请解释“${item.summary}”这条证据的范围、来源和判断影响。`, {
variant="outline"
size="sm"
className="mt-2"
onClick={() => { close(false);
discuss(`请解释“${formatInvestigationText(item.summary)}”这条证据的范围、来源和判断影响。`, {
evidence_id: item.evidence_id
})
}
});
}}
>
<MessageSquare size={14} aria-hidden />
围绕此证据提问
</button>
</Button>
</div>
))}
{(snapshot ? evidence : task.evidence).length === 0 ? (
@@ -499,17 +532,20 @@ export function InvestigationTaskReport({
{remainingEvidence.map((item) => (
<div key={item.evidence_id}>
{renderEvidence(item)}
<button
<Button
type="button"
className="investigation-text-action"
onClick={() =>
discuss(`请解释“${item.summary}”的来源和判断影响。`, {
variant="outline"
size="sm"
className="mt-2"
onClick={() => { close(false);
discuss(`请解释“${formatInvestigationText(item.summary)}”的来源和判断影响。`, {
evidence_id: item.evidence_id
})
}
});
}}
>
<MessageSquare size={14} aria-hidden />
围绕此证据提问
</button>
</Button>
</div>
))}
</Disclosure>
@@ -522,45 +558,31 @@ export function InvestigationTaskReport({
.map(renderEvidence)}
</Disclosure>
) : null}
</section>
</>}
</InvestigationExpandCard>
<section className="investigation-report-section">
<Disclosure label="任务时间线">
<p className="pb-2 text-xs leading-5 text-[var(--ink-muted)]">
按时间倒序汇总当前任务记录;报告事件对应当前正在阅读的版本。
</p>
<ol aria-label="任务时间线记录" className="investigation-timeline">
{timeline.map((item) => (
<li key={item.id} className="investigation-timeline-item">
<time dateTime={item.occurredAt} className="block text-[11px] text-[var(--ink-muted)]">
{dateLabel(item.occurredAt)}
</time>
<p className="text-xs font-medium leading-5 text-[var(--ink)]">{item.title}</p>
<p className="text-xs leading-5 text-[var(--ink-muted)]">{item.detail}</p>
</li>
))}
</ol>
</Disclosure>
<InvestigationExpandCard title="任务时间线" disabled={timeline.length === 0} preview={
<p className="mt-2 text-sm leading-6 text-[var(--ink-muted)]">
{timeline[0] ? `${timeline[0].title} · ${dateLabel(timeline[0].occurredAt)},共 ${timeline.length} 条记录。` : "尚无调查记录。"}
</p>
}>
<InvestigationTimeline items={timeline} />
</InvestigationExpandCard>
{onLoadReports ? (
<Disclosure
label={
<span className="flex items-center gap-2">
<History size={14} aria-hidden />
报告版本
</span>
}
onToggle={(event) => {
if (event.currentTarget.open && history.length === 0 && !historyLoading)
void loadHistory(0);
}}
<InvestigationExpandCard title="报告版本"
preview={<p className="mt-2 text-sm leading-6 text-[var(--ink-muted)]">
{snapshot ? `${roundLabel(snapshot)} · 报告第 ${snapshot.revision} 版 · ${dateLabel(snapshot.published_at)}` : "尚未发布报告。"}
</p>}
onOpen={() => { if (history.length === 0 && !historyLoading) void loadHistory(0); }}
>
{(close) => <>
{history.map((item) => (
<button
key={item.revision}
type="button"
className="investigation-history-row"
aria-pressed={snapshot?.revision === item.revision}
onClick={() => selectReport(item)}
onClick={() => { selectReport(item); close(); }}
>
<span>
{roundLabel(item)} · 报告第 {item.revision} 版
@@ -593,16 +615,30 @@ export function InvestigationTaskReport({
{historyError ? "重新加载" : "更多报告"}
</Button>
) : null}
</Disclosure>
</>}
</InvestigationExpandCard>
) : null}
<p className="mt-3 text-xs text-[var(--ink-muted)]">
<p className="px-4 pb-4 pt-1 text-xs text-[var(--ink-muted)]">
任务更新于 {dateLabel(task.updated_at)}
</p>
</section>
</article>
);
}
function InvestigationTimeline({ items }: { items: InvestigationTimelineItem[] }) {
return <>
<p className="pb-3 text-xs leading-5 text-[var(--ink-muted)]">按时间倒序汇总当前任务记录;报告事件对应当前正在阅读的版本。</p>
<ol aria-label="任务时间线记录" className="investigation-timeline">
{items.map((item) => <li key={item.id} className="investigation-timeline-item">
<time dateTime={item.occurredAt} className="block text-[11px] text-[var(--ink-muted)]">{dateLabel(item.occurredAt)}</time>
<p className="text-xs font-medium leading-5 text-[var(--ink)]">{item.title}</p>
<p className="text-xs leading-5 text-[var(--ink-muted)]">{formatInvestigationText(item.detail)}</p>
</li>)}
</ol>
{items.length === 0 ? <p className="text-sm text-[var(--ink-muted)]">尚无调查记录。</p> : null}
</>;
}
function EvidenceReference({
id,
task,
@@ -626,7 +662,7 @@ function ReportItems({ title, items }: { title: string; items: string[] }) {
<p className="font-medium">{title}</p>
<ul className="mt-1 list-disc space-y-1 pl-4">
{items.map((item, index) => (
<li key={index}>{item}</li>
<li key={index}>{formatInvestigationText(item)}</li>
))}
</ul>
</div>