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

This commit is contained in:
2026-10-30 17:30:19 +08:00
parent ddd03dbff6
commit c0e4231b83
223 changed files with 13155 additions and 5304 deletions
+11 -45
View File
@@ -1,5 +1,4 @@
import { LocateButton, FollowUpForm } from "./answer-action-components";
import { AskAboutContent, SelectForAnalysis } from "./answer-ui-interactions";
import { Renderer, defineRegistry } from "@json-render/react";
import { useContext, useRef, useState, type ReactNode } from "react";
import {
@@ -15,22 +14,20 @@ import {
import { Button } from "@/shared/ui/button";
import { Badge } from "@/shared/ui/badge";
import { Alert, AlertDescription, AlertTitle } from "@/shared/ui/alert";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/ui/card";
import { Card, CardContent } from "@/shared/ui/card";
import { AnswerDisclosure, AnswerDisclosureChevron, AnswerMotionScope } from "./answer-motion";
import { cn } from "@/shared/ui/cn";
import { InteractiveChart } from "./interactive-chart";
import { InteractiveDataTable } from "./interactive-data-table";
import { AnswerResult } from "./answer-result";
import { AnswerInteractionContext as InteractionContext, type AnswerUiInteractions, type AnswerUiMapTarget, type AnswerUiSelection } from "./answer-ui-interaction-context";
import { AnswerInteractionContext as InteractionContext, type AnswerUiInteractions } from "./answer-ui-interaction-context";
export type { AnswerUiMapTarget, AnswerUiSelection } from "./answer-ui-interaction-context";
import { answerUiCatalog } from "./catalog";
import type { AnswerUiBlock } from "./contract";
import { AnswerContentNav } from "./answer-content-nav";
import { getAnswerTaskLayout } from "./answer-task-layout";
import { AnswerSelectionTray } from "./answer-selection-tray";
import { AnswerViewStateProvider, useAnswerBlockState, useAnswerNodeState, withAnswerNodeIdentity, withAnswerNodeIds } from "./answer-view-state-provider";
import { type AnswerEvidenceState, type AnswerViewState, type AnswerViewStateChange } from "./answer-view-state";
import { ANSWER_SELECTION_LIMIT, getSelectionText } from "./answer-selection";
const { registry: baseRegistry } = defineRegistry(answerUiCatalog, {
actions: {},
@@ -86,10 +83,6 @@ const { registry: baseRegistry } = defineRegistry(answerUiCatalog, {
{props.source ? (
<p className="answer-ui-metric-source text-[11px] leading-5 text-muted-foreground">来源:{props.source}</p>
) : null}
<div className="answer-ui-metric-actions flex items-center">
<SelectForAnalysis compact title={props.label} text={`${props.label}:${props.value}${props.unit ?? ""}${props.source ? `\n来源:${props.source}` : ""}`} />
<AskAboutContent compact title={props.label} text={`${props.label}:${props.value}${props.unit ?? ""}${props.source ? `\n来源:${props.source}` : ""}`} />
</div>
</CardContent>
</Card>
),
@@ -119,43 +112,25 @@ function AnswerUiBody({
disabled = false,
onLocate,
onSubmit,
onAsk,
submitMode = "send"
}: { block: AnswerUiBlock } & Partial<AnswerUiInteractions>) {
const [{ evidenceFilter: filter }, setFilter] = useAnswerBlockState();
const [selections, setSelections] = useState<AnswerUiSelection[]>([]);
const [selectionError, setSelectionError] = useState<string | null>(null);
const contentRef = useRef<HTMLDivElement>(null);
const taskLayout = getAnswerTaskLayout(block.spec);
const root = block.spec.elements[block.spec.root];
const density = root.type === "Stack" ? root.props.density : undefined;
const intent = root.type === "Stack" ? root.props.intent : undefined;
const hasTaskLayout = intent !== "response" && Boolean(taskLayout?.analysis.length && (taskLayout.evidence.length || taskLayout.followUp.length));
const ask = onAsk ? (selection: AnswerUiSelection) => onAsk({ ...selection, text: `${selection.text}\n引用内容块:${block.block_id}` }) : undefined;
const kinds = [...new Set(Object.values(block.spec.elements).filter((node) => node.type === "Evidence").map((node) => String(node.props.kind)))];
const evidenceFilters = kinds.length > 1 ? <div className="mb-3 flex flex-wrap gap-1.5" aria-label="筛选证据类别">
{["all", ...kinds].map((kind) => <Button key={kind} variant={filter === kind ? "secondary" : "outline"} size="sm" className="h-8 text-xs" aria-pressed={filter === kind} onClick={() => setFilter(kind)}>
{kind === "all" ? "全部依据" : evidenceKinds[kind as keyof typeof evidenceKinds].label}
</Button>)}
</div> : null;
const isGroup = (ids: string[]) => hasTaskLayout && ids.length > 1 && !ids.every((id) => block.spec.elements[id].type === "Section");
const renderNodes = (ids: string[]) => ids.map((id) => <Renderer key={id} spec={{ ...block.spec, root: id }} registry={registry} />);
return (
<InteractionContext.Provider value={{ disabled, onLocate, onSubmit, submitMode, evidenceFilter: filter,
onAsk: ask, selections,
onToggleSelection: onAsk ? (selection) => {
setSelectionError(null);
const alreadySelected = selections.some((item) => item.title === selection.title && item.text === selection.text);
if (alreadySelected) {
setSelections((current) => current.filter((item) => item.title !== selection.title || item.text !== selection.text));
return;
}
const next = [...selections, selection];
if (next.length > 8 || getSelectionText(next).length > ANSWER_SELECTION_LIMIT) {
setSelectionError("一次最多选择 8 项、8000 字内容。请减少所选数据行,或移除已有内容后重试。");
return;
}
setSelections(next);
} : undefined }}>
<InteractionContext.Provider value={{ disabled, onLocate, onSubmit, submitMode, evidenceFilter: filter }}>
<AnswerMotionScope className="answer-ui-root" data-answer-ui={block.block_id} data-answer-phase={block.phase} data-answer-intent={intent} data-answer-density={density}>
<AnswerContentNav block={block} contentRef={contentRef} />
<div ref={contentRef}>
@@ -163,35 +138,30 @@ function AnswerUiBody({
{taskLayout ? <div className={hasTaskLayout ? "answer-task-layout" : "answer-task-layout-simple"} data-answer-task-layout={hasTaskLayout || undefined}>
{taskLayout.overview.length ? <div className="answer-task-overview grid gap-3" aria-label="回答概览">{renderNodes(taskLayout.overview)}</div> : null}
{taskLayout.analysis.length ? <section className="answer-task-analysis grid min-w-0 gap-3" aria-label="分析内容">
{hasTaskLayout ? <div className="answer-task-heading"><span>分析内容</span><span>直接探索已加载数据</span></div> : null}
{renderNodes(taskLayout.analysis)}
</section> : null}
{taskLayout.evidence.length || taskLayout.followUp.length ? <div className="answer-task-support grid min-w-0 gap-4">
{taskLayout.evidence.length ? <TaskSupport enhanced={hasTaskLayout} title="核查依据" description="区分记录、推断与用户资料" icon={<Database size={15} aria-hidden />}>
{taskLayout.evidence.length ? <TaskSupport grouped={isGroup(taskLayout.evidence)} title="核查依据" icon={<Database size={15} aria-hidden />}>
{evidenceFilters}{renderNodes(taskLayout.evidence)}
</TaskSupport> : null}
{taskLayout.followUp.length ? <TaskSupport enhanced={hasTaskLayout} title="补充资料" description="由你填写,主动提交" icon={<FileCheck2 size={15} aria-hidden />}>
{taskLayout.followUp.length ? <TaskSupport grouped={isGroup(taskLayout.followUp)} title="补充资料" icon={<FileCheck2 size={15} aria-hidden />}>
{renderNodes(taskLayout.followUp)}
</TaskSupport> : null}
</div> : null}
</div> : <>{evidenceFilters}<Renderer spec={block.spec} registry={registry} /></>}
</>
</div>
{selectionError ? <p role="status" className="mt-3 text-xs leading-5 text-destructive">{selectionError}</p> : null}
{ask ? <AnswerSelectionTray selections={selections} disabled={disabled}
onRemove={(selection) => { setSelectionError(null); setSelections((current) => current.filter((item) => item !== selection)); }}
onClear={() => { setSelections([]); setSelectionError(null); }} onAsk={ask} /> : null}
</AnswerMotionScope>
</InteractionContext.Provider>
);
}
function TaskSupport({ title, description, icon, children, enhanced }: { title: string; description: string; icon: ReactNode; children: ReactNode; enhanced: boolean }) {
function TaskSupport({ title, icon, children, grouped }: { title: string; icon: ReactNode; children: ReactNode; grouped: boolean }) {
const [open, setOpen] = useState(true);
return <AnswerDisclosure open={open} onOpenChange={setOpen} className="answer-task-support-section"
contentClassName="answer-task-support-content space-y-2.5 pt-2"
trigger={<Button type="button" variant="ghost" className={cn("h-auto min-h-11 w-full justify-start gap-2 px-0 text-left", !enhanced && "hidden")} aria-label={`${open ? "收起" : "展开"}${title}`}>
{icon}<span className="min-w-0 flex-1 text-xs font-medium">{title}<span className="mt-0.5 block text-[11px] font-normal text-muted-foreground">{description}</span></span>
return <AnswerDisclosure open={grouped ? open : true} onOpenChange={setOpen} className="answer-task-support-section" data-grouped={grouped || undefined}
contentClassName={cn("answer-task-support-content space-y-2.5", grouped && "pt-2")}
trigger={<Button type="button" variant="ghost" hidden={!grouped} className={cn("h-auto min-h-11 w-full justify-start gap-2 px-0 text-left", !grouped && "hidden")} aria-label={`${open ? "收起" : "展开"}${title}`}>
{icon}<span className="min-w-0 flex-1 text-xs font-medium">{title}</span>
<AnswerDisclosureChevron open={open} />
</Button>}>{children}</AnswerDisclosure>;
}
@@ -256,10 +226,6 @@ function EvidenceCard(props: {
) : null}
</dl>
) : null}
<div className="mt-2 flex flex-wrap items-center gap-1">
<SelectForAnalysis title={props.title} text={`${props.summary}\n来源:${props.source}\n类别:${meta.label}${props.evidence_id ? `\n资料编号:${props.evidence_id}` : ""}${props.observed_at ? `\n记录时间:${props.observed_at}` : ""}`} />
<AskAboutContent title={props.title} text={`${props.summary}\n来源:${props.source}\n类别:${meta.label}${props.evidence_id ? `\n资料编号:${props.evidence_id}` : ""}${props.observed_at ? `\n记录时间:${props.observed_at}` : ""}`} />
</div>
</AnswerDisclosure>
);
}