feat: add project-aware agent workspace
Generic Container CI/CD / test-build-publish (push) Successful in 2m9s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m39s

This commit is contained in:
2026-10-22 18:52:18 +08:00
parent c6a0e74e72
commit 2d8506fea0
191 changed files with 12373 additions and 3556 deletions
+171 -179
View File
@@ -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">