feat: add project-aware agent workspace
This commit is contained in:
@@ -1,26 +1,32 @@
|
||||
import { AskAboutContent } from "./answer-ui-interactions";
|
||||
import { JSONUIProvider, Renderer, defineRegistry } from "@json-render/react";
|
||||
import { createContext, useContext, useId, useState } from "react";
|
||||
import { useContext, useId, useState } from "react";
|
||||
import {
|
||||
ArrowUpRight,
|
||||
ChevronDown,
|
||||
ClipboardList,
|
||||
Database,
|
||||
FileCheck2,
|
||||
FlaskConical,
|
||||
Lightbulb,
|
||||
MapPin,
|
||||
MessageSquare
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Disclosure } from "@/shared/ui/disclosure";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Card, CardContent } from "@/shared/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/ui/collapsible";
|
||||
import { DateTimeField } from "@/shared/ui/date-time-field";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { AgentChartView } from "../components/agent-ui-envelope-renderer";
|
||||
import { InteractiveChart } from "./interactive-chart";
|
||||
import { InteractiveDataTable } from "./interactive-data-table";
|
||||
import { AnswerInteractionContext as InteractionContext, type AnswerUiInteractions, type AnswerUiMapTarget } from "./answer-ui-interaction-context";
|
||||
export type { AnswerUiMapTarget, AnswerUiSelection } from "./answer-ui-interaction-context";
|
||||
import { answerUiCatalog } from "./catalog";
|
||||
import type { AnswerUiBlock } from "./contract";
|
||||
|
||||
export type AnswerUiMapTarget = {
|
||||
layer: "junctions" | "pipes" | "valves" | "pumps" | "tanks" | "reservoirs" | "scada";
|
||||
feature_ids: string[];
|
||||
};
|
||||
type Interactions = {
|
||||
disabled: boolean;
|
||||
onLocate?: (target: AnswerUiMapTarget) => Promise<unknown> | void;
|
||||
onSubmit?: (text: string) => Promise<void> | void;
|
||||
};
|
||||
const InteractionContext = createContext<Interactions>({ disabled: true });
|
||||
|
||||
const { registry } = defineRegistry(answerUiCatalog, {
|
||||
actions: {},
|
||||
components: {
|
||||
@@ -37,9 +43,12 @@ const { registry } = defineRegistry(answerUiCatalog, {
|
||||
),
|
||||
Section: ({ props, children }) => (
|
||||
<section className="answer-ui-section">
|
||||
<h3 className="text-sm font-semibold text-[var(--ink)]">{props.title}</h3>
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<ClipboardList size={15} aria-hidden="true" />
|
||||
{props.title}
|
||||
</h3>
|
||||
{props.description ? (
|
||||
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">{props.description}</p>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{props.description}</p>
|
||||
) : null}
|
||||
<div className="mt-3 space-y-3">{children}</div>
|
||||
</section>
|
||||
@@ -59,43 +68,27 @@ const { registry } = defineRegistry(answerUiCatalog, {
|
||||
</p>
|
||||
),
|
||||
Metric: ({ props }) => (
|
||||
<div className="min-w-0 py-2">
|
||||
<p className="text-xs text-[var(--ink-muted)]">{props.label}</p>
|
||||
<p className="mt-1 break-words text-xl font-semibold tabular-nums text-[var(--ink)]">
|
||||
{props.value}
|
||||
{props.unit ? <span className="ml-1 text-xs font-normal">{props.unit}</span> : null}
|
||||
</p>
|
||||
{props.source ? (
|
||||
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">来源:{props.source}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Card className="answer-ui-metric">
|
||||
<CardContent className="answer-ui-metric-content p-3">
|
||||
<p className="answer-ui-metric-label text-xs leading-5 text-muted-foreground">{props.label}</p>
|
||||
<p className="answer-ui-metric-value break-words text-2xl font-semibold tabular-nums text-foreground">
|
||||
{props.value}
|
||||
{props.unit ? (
|
||||
<span className="ml-1.5 text-xs font-normal text-muted-foreground">{props.unit}</span>
|
||||
) : null}
|
||||
</p>
|
||||
{props.source ? (
|
||||
<p className="answer-ui-metric-source text-[11px] leading-5 text-muted-foreground">来源:{props.source}</p>
|
||||
) : null}
|
||||
<AskAboutContent compact title={props.label} text={`${props.label}:${props.value}${props.unit ?? ""}${props.source ? `\n来源:${props.source}` : ""}`} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
Table: ({ props }) => (
|
||||
<DataTable title={props.title} columns={props.columns} rows={props.rows} />
|
||||
),
|
||||
Chart: ({ props }) => <AnswerChart {...props} />,
|
||||
Evidence: ({ props }) => (
|
||||
<Disclosure label={props.title} className="answer-ui-evidence">
|
||||
<div className="space-y-2 py-2 text-xs leading-5">
|
||||
<p className="text-[var(--ink-muted)]">
|
||||
{
|
||||
(
|
||||
{
|
||||
observed: "查询记录",
|
||||
human_input: "用户提供",
|
||||
inference: "分析推断",
|
||||
demo: "演练数据"
|
||||
} as const
|
||||
)[props.kind]
|
||||
}{" "}
|
||||
· {props.source}
|
||||
</p>
|
||||
<p className="whitespace-pre-wrap">{props.summary}</p>
|
||||
{props.observed_at ? <p>记录时间:{props.observed_at}</p> : null}
|
||||
{props.evidence_id ? <p>资料编号:{props.evidence_id}</p> : null}
|
||||
</div>
|
||||
</Disclosure>
|
||||
<InteractiveDataTable key={JSON.stringify([props.columns, props.rows])} title={props.title} columns={props.columns} rows={props.rows} />
|
||||
),
|
||||
Chart: ({ props }) => <InteractiveChart key={JSON.stringify(props)} {...props} />,
|
||||
Evidence: ({ props }) => <EvidenceCard {...props} />,
|
||||
MapLocation: ({ props }) => <LocateButton {...props} />,
|
||||
FollowUpForm: ({ props }) => <FollowUpForm {...props} />
|
||||
}
|
||||
@@ -105,11 +98,21 @@ export function AnswerUiView({
|
||||
block,
|
||||
disabled = false,
|
||||
onLocate,
|
||||
onSubmit
|
||||
}: { block: AnswerUiBlock } & Partial<Interactions>) {
|
||||
onSubmit,
|
||||
onAsk,
|
||||
submitMode = "send"
|
||||
}: { block: AnswerUiBlock } & Partial<AnswerUiInteractions>) {
|
||||
const [filter, setFilter] = useState("all");
|
||||
const kinds = [...new Set(Object.values(block.spec.elements).filter((node) => node.type === "Evidence").map((node) => String(node.props.kind)))];
|
||||
return (
|
||||
<InteractionContext.Provider value={{ disabled, onLocate, onSubmit }}>
|
||||
<InteractionContext.Provider value={{ disabled, onLocate, onSubmit, submitMode, evidenceFilter: filter,
|
||||
onAsk: onAsk ? (selection) => onAsk({ ...selection, text: `${selection.text}\n引用内容块:${block.block_id}` }) : undefined }}>
|
||||
<div className="answer-ui-root" data-answer-ui={block.block_id}>
|
||||
{kinds.length > 1 ? <div className="mb-3 flex flex-wrap gap-1.5" aria-label="筛选证据类别">
|
||||
{["all", ...kinds].map((kind) => <Button key={kind} variant="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}
|
||||
<JSONUIProvider registry={registry}>
|
||||
<Renderer spec={block.spec} registry={registry} />
|
||||
</JSONUIProvider>
|
||||
@@ -118,97 +121,74 @@ export function AnswerUiView({
|
||||
);
|
||||
}
|
||||
|
||||
function DataTable({
|
||||
title,
|
||||
columns,
|
||||
rows
|
||||
}: {
|
||||
title: string;
|
||||
columns: string[];
|
||||
rows: string[][];
|
||||
}) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
role="region"
|
||||
aria-label={title}
|
||||
tabIndex={0}
|
||||
className="overflow-x-auto rounded-lg border border-[var(--surface-divider)]"
|
||||
>
|
||||
<table className="w-full border-collapse text-left text-xs">
|
||||
<caption className="px-3 py-2 text-left font-medium text-[var(--ink)]">{title}</caption>
|
||||
<thead className="bg-[var(--surface-subtle)]">
|
||||
<tr>
|
||||
{columns.map((column, index) => (
|
||||
<th key={index} className="whitespace-nowrap px-3 py-2 font-medium">
|
||||
{column}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => (
|
||||
<tr key={index} className="border-t border-[var(--surface-divider)]">
|
||||
{row.map((cell, cellIndex) => (
|
||||
<td key={cellIndex} className="min-w-24 px-3 py-2 align-top">
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ChartProps = {
|
||||
title: string;
|
||||
chart_type: "line" | "bar";
|
||||
x_data: string[];
|
||||
series: Array<{ name: string; data: number[] }>;
|
||||
source: string;
|
||||
const evidenceKinds = {
|
||||
observed: { label: "查询记录", icon: Database },
|
||||
human_input: { label: "用户提供", icon: MessageSquare },
|
||||
inference: { label: "分析推断", icon: Lightbulb },
|
||||
demo: { label: "演练数据", icon: FlaskConical }
|
||||
};
|
||||
function AnswerChart(props: ChartProps) {
|
||||
const [mode, setMode] = useState<"line" | "bar" | "table">(props.chart_type);
|
||||
|
||||
function EvidenceCard(props: {
|
||||
title: string;
|
||||
summary: string;
|
||||
source: string;
|
||||
kind: keyof typeof evidenceKinds;
|
||||
observed_at: string | null;
|
||||
evidence_id: string | null;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const meta = evidenceKinds[props.kind];
|
||||
const Icon = meta.icon;
|
||||
const { evidenceFilter } = useContext(InteractionContext);
|
||||
if (evidenceFilter && evidenceFilter !== "all" && evidenceFilter !== props.kind) return null;
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<div role="group" aria-label={`${props.title}显示方式`} className="mb-2 flex flex-wrap gap-1">
|
||||
{(
|
||||
[
|
||||
["line", "折线"],
|
||||
["bar", "柱状"],
|
||||
["table", "数据"]
|
||||
] as const
|
||||
).map(([value, label]) => (
|
||||
<Button
|
||||
key={value}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-pressed={mode === value}
|
||||
onClick={() => setMode(value)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{mode === "table" ? (
|
||||
<DataTable
|
||||
title={props.title}
|
||||
columns={["时间或类别", ...props.series.map((series) => series.name)]}
|
||||
rows={props.x_data.map((label, index) => [
|
||||
label,
|
||||
...props.series.map((series) => String(series.data[index]))
|
||||
])}
|
||||
/>
|
||||
) : (
|
||||
<AgentChartView chart={{ ...props, chart_type: mode }} />
|
||||
)}
|
||||
<p className="mt-2 whitespace-pre-wrap text-xs leading-5 text-[var(--ink-muted)]">
|
||||
来源:{props.source}
|
||||
</p>
|
||||
</div>
|
||||
<Collapsible open={open} onOpenChange={setOpen} className="answer-ui-evidence">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
className="h-auto min-h-12 w-full justify-start gap-2.5 whitespace-normal rounded-xl px-3 py-3 text-left shadow-none"
|
||||
>
|
||||
<span className="answer-ui-evidence-icon">
|
||||
<Icon size={16} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-xs font-medium">{props.title}</span>
|
||||
<span className="mt-0.5 block break-words text-[11px] font-normal text-muted-foreground">
|
||||
{props.source}
|
||||
</span>
|
||||
</span>
|
||||
<Badge variant="outline" className="shrink-0 px-1.5 text-[10px] font-normal">
|
||||
{meta.label}
|
||||
</Badge>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={cn("shrink-0", open && "rotate-180")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="px-3 pb-3 text-xs leading-6">
|
||||
<p className="whitespace-pre-wrap border-t border-border pt-3">{props.summary}</p>
|
||||
{props.observed_at || props.evidence_id ? (
|
||||
<dl className="mt-3 grid gap-1 text-[11px] text-muted-foreground">
|
||||
{props.observed_at ? (
|
||||
<div>
|
||||
<dt className="inline">记录时间:</dt>
|
||||
<dd className="inline tabular-nums">{props.observed_at}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{props.evidence_id ? (
|
||||
<div>
|
||||
<dt className="inline">资料编号:</dt>
|
||||
<dd className="inline break-all font-mono">{props.evidence_id}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
) : null}
|
||||
<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}` : ""}`} />
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -221,37 +201,43 @@ function LocateButton({
|
||||
const [running, setRunning] = useState(false);
|
||||
const [feedback, setFeedback] = useState<string | null>(null);
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={disabled || running || !onLocate}
|
||||
onClick={async () => {
|
||||
setRunning(true);
|
||||
setFeedback(null);
|
||||
try {
|
||||
const result = await onLocate?.(target);
|
||||
const output = result as { located_ids?: string[]; missing_ids?: string[] } | undefined;
|
||||
setFeedback(
|
||||
output?.missing_ids?.length
|
||||
? `已定位 ${output.located_ids?.length ?? 0} 个,另有 ${output.missing_ids.length} 个未找到`
|
||||
: "已定位到地图"
|
||||
);
|
||||
} catch (error) {
|
||||
setFeedback(error instanceof Error ? error.message : "地图定位失败");
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{running ? "正在定位…" : label}
|
||||
</Button>
|
||||
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">依据:{source}</p>
|
||||
{feedback ? (
|
||||
<p role="status" className="mt-1 text-xs leading-5">
|
||||
{feedback}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="answer-ui-location">
|
||||
<MapPin size={17} className="mt-1 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={disabled || running || !onLocate}
|
||||
onClick={async () => {
|
||||
setRunning(true);
|
||||
setFeedback(null);
|
||||
try {
|
||||
const result = await onLocate?.(target);
|
||||
if (result === false) throw new Error("关联要素暂不可定位,请检查地图提示。");
|
||||
const output = result as
|
||||
{ located_ids?: string[]; missing_ids?: string[] } | undefined;
|
||||
setFeedback(
|
||||
output?.missing_ids?.length
|
||||
? `已定位 ${output.located_ids?.length ?? 0} 个,另有 ${output.missing_ids.length} 个未找到`
|
||||
: "已定位到地图"
|
||||
);
|
||||
} catch (error) {
|
||||
setFeedback(error instanceof Error ? error.message : "地图定位失败");
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{running ? "正在定位…" : label}
|
||||
<ArrowUpRight size={14} aria-hidden="true" />
|
||||
</Button>
|
||||
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">依据:{source}</p>
|
||||
{feedback ? (
|
||||
<p role="status" className="mt-1 text-xs leading-5">
|
||||
{feedback}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -271,7 +257,7 @@ function FollowUpForm({
|
||||
description: string;
|
||||
fields: FormField[];
|
||||
}) {
|
||||
const { disabled, onSubmit } = useContext(InteractionContext);
|
||||
const { disabled, onSubmit, submitMode } = useContext(InteractionContext);
|
||||
const prefix = useId();
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -307,7 +293,7 @@ function FollowUpForm({
|
||||
await onSubmit(
|
||||
`我补充以下资料,请按用户提供的信息处理,说明保存或调查的实际结果:\n${entries.join("\n")}`
|
||||
);
|
||||
setFeedback("资料已发送,请查看 Agent 回复。");
|
||||
setFeedback(submitMode === "draft" ? "资料已带入对话,请确认后发送。" : "资料已发送,请查看 Agent 回复。");
|
||||
} catch (error) {
|
||||
setFeedback(error instanceof Error ? error.message : "资料发送失败,请重试。");
|
||||
} finally {
|
||||
@@ -315,9 +301,14 @@ function FollowUpForm({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold">{title}</h4>
|
||||
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">{description}</p>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span className="answer-ui-evidence-icon">
|
||||
<FileCheck2 size={16} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h4 className="text-sm font-semibold">{title}</h4>
|
||||
<p className="mt-1 text-xs leading-5 text-[var(--ink-muted)]">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
{fields.map((field) =>
|
||||
field.kind === "datetime" ? (
|
||||
@@ -347,6 +338,7 @@ function FollowUpForm({
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
className="h-10"
|
||||
id={`${prefix}-${field.name}`}
|
||||
type="number"
|
||||
value={values[field.name] ?? ""}
|
||||
@@ -367,7 +359,7 @@ function FollowUpForm({
|
||||
variant="outline"
|
||||
disabled={disabled || submitting || !onSubmit}
|
||||
>
|
||||
{submitting ? "正在发送…" : "发送补充资料"}
|
||||
{submitting ? submitMode === "draft" ? "正在处理…" : "正在发送…" : submitMode === "draft" ? "带入对话" : "发送补充资料"}
|
||||
</Button>
|
||||
{feedback ? (
|
||||
<p role="status" className="text-xs leading-5">
|
||||
|
||||
Reference in New Issue
Block a user