feat: add project-aware agent workspace
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
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,7 +10,8 @@ export function AnswerPartsView({
|
||||
streaming,
|
||||
streamDone,
|
||||
onLocate,
|
||||
onSubmit
|
||||
onSubmit,
|
||||
onAsk
|
||||
}: {
|
||||
parts: AnswerUiPart[];
|
||||
messageId: string;
|
||||
@@ -18,6 +20,7 @@ export function AnswerPartsView({
|
||||
streamDone: boolean;
|
||||
onLocate?: (target: AnswerUiMapTarget) => Promise<unknown> | void;
|
||||
onSubmit?: (text: string) => Promise<void> | void;
|
||||
onAsk?: (selection: AnswerUiSelection) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -29,6 +32,7 @@ export function AnswerPartsView({
|
||||
disabled={disabled}
|
||||
onLocate={onLocate}
|
||||
onSubmit={onSubmit}
|
||||
onAsk={onAsk}
|
||||
/>
|
||||
) : (
|
||||
<StreamingTokenResponse
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
export type AnswerUiSelection = { title: string; text: string };
|
||||
export type AnswerUiMapTarget = {
|
||||
layer: "junctions" | "pipes" | "valves" | "pumps" | "tanks" | "reservoirs" | "scada";
|
||||
feature_ids: string[];
|
||||
};
|
||||
export type AnswerUiInteractions = {
|
||||
disabled: boolean;
|
||||
onLocate?: (target: AnswerUiMapTarget) => Promise<unknown> | void;
|
||||
onSubmit?: (text: string) => Promise<void> | void;
|
||||
onAsk?: (selection: AnswerUiSelection) => Promise<void> | void;
|
||||
evidenceFilter?: string;
|
||||
submitMode?: "send" | "draft";
|
||||
};
|
||||
export const AnswerInteractionContext = createContext<AnswerUiInteractions>({ disabled: true });
|
||||
@@ -0,0 +1,24 @@
|
||||
import { AnswerInteractionContext, type AnswerUiSelection } from "./answer-ui-interaction-context";
|
||||
import { useContext, useState } from "react";
|
||||
import { MessageSquarePlus } from "lucide-react";
|
||||
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>;
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
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";
|
||||
import { parseAnswerUiBlock } from "./contract";
|
||||
vi.mock("echarts-for-react", () => ({ default: () => <div aria-label="测试图形" /> }));
|
||||
|
||||
function block(type: string, props: Record<string, unknown>) {
|
||||
const parsed = parseAnswerUiBlock({
|
||||
@@ -14,7 +16,19 @@ function block(type: string, props: Record<string, unknown>) {
|
||||
return parsed;
|
||||
}
|
||||
describe("local interactive answer renderer", () => {
|
||||
it("renders through json-render and lets readers inspect exact chart data", () => {
|
||||
it("offers a named metric quote action and retains the exact value, unit and source", async () => {
|
||||
const user = userEvent.setup();
|
||||
const ask = vi.fn();
|
||||
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",
|
||||
});
|
||||
});
|
||||
it("renders through json-render and lets readers inspect exact chart data with keyboard tabs", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AnswerUiView
|
||||
block={block("Chart", {
|
||||
@@ -26,10 +40,14 @@ describe("local interactive answer renderer", () => {
|
||||
})}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /^数据$/ }));
|
||||
await user.click(screen.getByRole("tab", { name: /^数据$/ }));
|
||||
expect(screen.getByRole("table")).toHaveTextContent("0.3");
|
||||
expect(screen.getByRole("table")).toHaveTextContent("09:00");
|
||||
expect(screen.getByRole("button", { name: /^数据$/ })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByRole("tab", { name: /^数据$/ })).toHaveAttribute("aria-selected", "true");
|
||||
await user.keyboard("{ArrowLeft}");
|
||||
expect(screen.getByRole("tab", { name: /^柱状$/ })).toHaveFocus();
|
||||
expect(screen.getByRole("tab", { name: /^柱状$/ })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.queryByRole("table")).not.toBeInTheDocument();
|
||||
});
|
||||
it("runs map location only after a user click and reports missing IDs", async () => {
|
||||
const locate = vi
|
||||
@@ -54,6 +72,35 @@ 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 () => {
|
||||
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} />);
|
||||
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(submit).not.toHaveBeenCalled();
|
||||
});
|
||||
it("keeps form values after a failed submission", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AnswerUiView block={block("FollowUpForm", {
|
||||
title: "现场事实", description: "用户填写",
|
||||
fields: [{ name: "detail", label: "记录", kind: "text", required: true }]
|
||||
})} onSubmit={async () => { throw new Error("暂未发送"); }} />);
|
||||
await user.type(screen.getByLabelText("记录(必填)"), "北站现场记录");
|
||||
await user.click(screen.getByRole("button", { name: "发送补充资料" }));
|
||||
expect(await screen.findByRole("status")).toHaveTextContent("暂未发送");
|
||||
expect(screen.getByLabelText("记录(必填)")).toHaveValue("北站现场记录");
|
||||
});
|
||||
it("starts blank, requires input and sends one user message without claiming evidence is saved", async () => {
|
||||
let complete!: () => void;
|
||||
const submit = vi.fn(
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -16,6 +16,19 @@ const payloadSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
// Stored reports remain readable if one presentation component is no longer supported.
|
||||
export const storedAnswerUiBlockSchema = z.object({
|
||||
version: z.string(),
|
||||
block_id: key,
|
||||
fallback_text: z.string().min(1).max(4000),
|
||||
spec: z.unknown()
|
||||
});
|
||||
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);
|
||||
export type StoredReportUiBlock = z.infer<typeof reportUiBlocksSchema>[number];
|
||||
|
||||
export type AnswerUiBlock = {
|
||||
version: typeof ANSWER_UI_VERSION;
|
||||
block_id: string;
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useState } from "react";
|
||||
import ReactECharts from "echarts-for-react";
|
||||
import { BarChart3, ChevronDown, Database, SlidersHorizontal, Table2, TrendingUp } from "lucide-react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/ui/collapsible";
|
||||
import { InteractiveDataTable } from "./interactive-data-table";
|
||||
import { AskAboutContent } from "./answer-ui-interactions";
|
||||
|
||||
export type AnswerChartProps = {
|
||||
title: string;
|
||||
chart_type: "line" | "bar";
|
||||
x_data: string[];
|
||||
series: Array<{ name: string; data: number[] }>;
|
||||
source: string;
|
||||
};
|
||||
const colors = ["#2563eb", "#059669", "#d97706", "#7c3aed", "#0891b2", "#e11d48", "#475569", "#a16207"];
|
||||
function PointSelect({ label, value, labels, onChange }: {
|
||||
label: string; value: number; labels: string[]; onChange: (value: number) => void;
|
||||
}) {
|
||||
return <Select value={String(value)} onValueChange={(next) => onChange(Number(next))}>
|
||||
<SelectTrigger aria-label={label} className="h-9 min-w-0 flex-1 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{labels.map((text, index) => <SelectItem key={index} value={String(index)}>{text}</SelectItem>)}</SelectContent>
|
||||
</Select>;
|
||||
}
|
||||
export function InteractiveChart(props: AnswerChartProps) {
|
||||
const [view, setView] = useState<string>(props.chart_type);
|
||||
const [hidden, setHidden] = useState<number[]>([]);
|
||||
const [range, setRange] = useState<[number, number]>([0, props.x_data.length - 1]);
|
||||
const [point, setPoint] = useState(0);
|
||||
const visible = props.series.filter((_, index) => !hidden.includes(index));
|
||||
const labels = props.x_data.slice(range[0], range[1] + 1);
|
||||
const pointIndex = Math.min(range[1], Math.max(range[0], point));
|
||||
const fullRange = range[0] === 0 && range[1] === props.x_data.length - 1;
|
||||
const option = (type: "line" | "bar") => ({
|
||||
animation: false,
|
||||
color: colors.filter((_, index) => !hidden.includes(index)),
|
||||
aria: { enabled: true },
|
||||
tooltip: { trigger: "axis", renderMode: "richText" },
|
||||
grid: { top: 16, right: 12, bottom: 38, left: 12, containLabel: true },
|
||||
xAxis: { type: "category", data: labels, axisLabel: { fontSize: 11, color: "#64748b", hideOverlap: true } },
|
||||
yAxis: { type: "value", axisLabel: { fontSize: 11, color: "#64748b" }, splitLine: { lineStyle: { color: "#e2e8f0" } } },
|
||||
series: visible.map((series) => ({ name: series.name, type, data: series.data.slice(range[0], range[1] + 1),
|
||||
showSymbol: true, symbolSize: 7, emphasis: { focus: "series" } }))
|
||||
});
|
||||
return <Card className="answer-ui-chart overflow-hidden">
|
||||
<CardHeader className="space-y-1 px-3.5 pb-3 pt-3.5">
|
||||
<CardTitle className="flex items-center gap-2 text-xs leading-5 tracking-normal"><TrendingUp size={15} />{props.title}</CardTitle>
|
||||
<CardDescription className="text-[11px]">{labels.length} / {props.x_data.length} 个数据点 · {visible.length} 组序列</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 px-3.5 pb-3.5 pt-0">
|
||||
<Tabs value={view} onValueChange={setView}>
|
||||
<TabsList aria-label={`${props.title}显示方式`} className="mb-3 grid w-full grid-cols-3">
|
||||
<TabsTrigger value="line"><TrendingUp size={13} />折线</TabsTrigger>
|
||||
<TabsTrigger value="bar"><BarChart3 size={13} />柱状</TabsTrigger>
|
||||
<TabsTrigger value="table"><Table2 size={13} />数据</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="answer-ui-chart-series mb-2 flex flex-wrap gap-2" aria-label="显示的数据序列">
|
||||
{props.series.map((series, index) => <Button key={index} size="sm" variant="outline"
|
||||
className="h-8 gap-1.5 text-[11px]" aria-pressed={!hidden.includes(index)}
|
||||
disabled={visible.length === 1 && !hidden.includes(index)}
|
||||
onClick={() => setHidden((current) => current.includes(index) ? current.filter((item) => item !== index) : [...current, index])}>
|
||||
<span className="size-2 rounded-full" style={{ background: hidden.includes(index) ? "#cbd5e1" : colors[index] }} />{series.name}
|
||||
</Button>)}
|
||||
</div>
|
||||
<Collapsible className="mb-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-2 gap-y-1">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="sm" aria-label="筛选数据范围"
|
||||
className="group h-8 gap-1.5 px-1.5 font-normal text-muted-foreground">
|
||||
<SlidersHorizontal size={13} aria-hidden />数据范围
|
||||
<ChevronDown size={13} aria-hidden className="group-data-[state=open]:rotate-180" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{fullRange ? "全部数据" : `${props.x_data[range[0]]} 至 ${props.x_data[range[1]]}`}
|
||||
</span>
|
||||
</div>
|
||||
<CollapsibleContent className="space-y-2 rounded-lg border bg-muted/30 p-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<PointSelect label="区间起点" value={range[0]} labels={props.x_data}
|
||||
onChange={(start) => setRange([start, Math.max(start, range[1])])} />
|
||||
<span className="text-xs text-muted-foreground">至</span>
|
||||
<PointSelect label="区间终点" value={range[1]} labels={props.x_data}
|
||||
onChange={(end) => setRange([Math.min(range[0], end), end])} />
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="sm" disabled={fullRange}
|
||||
onClick={() => setRange([0, props.x_data.length - 1])}>重置范围</Button>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
{(["line", "bar"] as const).map((type) => <TabsContent key={type} value={type}>
|
||||
<div role="img" aria-label={props.title}>
|
||||
<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")
|
||||
setPoint(range[0] + event.dataIndex);
|
||||
} }} />
|
||||
</div>
|
||||
</TabsContent>)}
|
||||
<TabsContent value="table" forceMount style={view === "table" ? undefined : { display: "none" }}>
|
||||
<InteractiveDataTable key={JSON.stringify([hidden, range])} title={props.title} columns={["时间或类别", ...visible.map((series) => series.name)]}
|
||||
rows={labels.map((label, index) => [label, ...visible.map((series) => String(series.data[range[0] + index]))])}
|
||||
source={props.source} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<div className="rounded-lg bg-muted/40 p-2.5">
|
||||
<div className="flex items-center gap-2"><span className="text-xs text-muted-foreground">查看数据点</span>
|
||||
<PointSelect label="选择数据点" value={pointIndex - range[0]} labels={labels} onChange={(index) => setPoint(range[0] + index)} />
|
||||
</div>
|
||||
<dl className="my-2 space-y-1.5 text-xs">
|
||||
{visible.map((series, index) => <div key={index} className="flex items-baseline justify-between gap-3">
|
||||
<dt className="min-w-0 text-muted-foreground">{series.name}</dt>
|
||||
<dd className="shrink-0 font-medium tabular-nums">{series.data[pointIndex]}</dd>
|
||||
</div>)}
|
||||
</dl>
|
||||
<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>
|
||||
<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>来源:{props.source}。区间操作仅查看已加载数据。</span>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useMemo, useState } 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 { AskAboutContent } from "./answer-ui-interactions";
|
||||
|
||||
export function InteractiveDataTable({ title, columns, rows, source }: {
|
||||
title: string; columns: string[]; rows: string[][]; source?: string;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [sort, setSort] = useState<{ column: number; descending: boolean } | null>(null);
|
||||
const [selection, setSelection] = useState<number[]>([]);
|
||||
const filtered = useMemo(() => {
|
||||
const items = rows.map((cells, id) => ({ cells, id }))
|
||||
.filter(({ cells }) => cells.join(" ").toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()));
|
||||
if (sort) items.sort((a, b) => {
|
||||
const aValue = a.cells[sort.column], bValue = b.cells[sort.column];
|
||||
const compared = /^[-+]?\d+(\.\d+)?%?$/.test(aValue) && /^[-+]?\d+(\.\d+)?%?$/.test(bValue)
|
||||
? Number.parseFloat(aValue) - Number.parseFloat(bValue)
|
||||
: aValue.localeCompare(bValue, "zh-CN", { numeric: true });
|
||||
return sort.descending ? -compared : compared;
|
||||
});
|
||||
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) => { setQuery(event.currentTarget.value); setSelection([]); }} />
|
||||
<span className="text-[11px] tabular-nums text-muted-foreground">{filtered.length} / {rows.length} 行</span>
|
||||
</div>
|
||||
<div role="region" aria-label={title} tabIndex={0}
|
||||
className="answer-ui-table overflow-x-auto rounded-lg border border-border focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring">
|
||||
<Table>
|
||||
<TableCaption className="caption-top font-medium">{title}</TableCaption>
|
||||
<TableHeader className="bg-muted/50"><TableRow>
|
||||
<TableHead scope="col" className="w-10"><span className="sr-only">选择行</span></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}排序`}
|
||||
onClick={() => setSort((current) => current?.column === index
|
||||
? current.descending ? null : { column: index, descending: true }
|
||||
: { column: index, descending: false })}>
|
||||
{column}{sort?.column === index ? sort.descending ? <ArrowDown size={12} /> : <ArrowUp size={12} /> : <ArrowUpDown size={12} />}
|
||||
</Button>
|
||||
</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)}
|
||||
onChange={(event) => setSelection((current) => event.target.checked ? [...current, id] : current.filter((value) => value !== id))} /></TableCell>
|
||||
{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">
|
||||
{rows.length ? "当前筛选没有匹配的数据。" : "没有已加载的数据。"}
|
||||
</TableCell></TableRow> : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{selected.length ? <div className="flex flex-wrap items-center justify-between gap-1">
|
||||
<span className="text-xs text-muted-foreground">已选 {selected.length} 行</span>
|
||||
<AskAboutContent title={title} text={`${quote}${source ? `\n来源:${source}` : ""}`} />
|
||||
</div> : <p className="text-[11px] text-muted-foreground">筛选与排序仅作用于已加载数据;勾选行可继续提问。</p>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createAgentApiClient } from "./client";
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
|
||||
|
||||
describe("authenticated session exports", () => {
|
||||
it("fetches only Agent session files with identity and project headers", async () => {
|
||||
const fetchMock = vi.fn(async () => new Response("ZIP", { headers: { "Content-Type": "application/zip" } }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.stubGlobal("URL", class extends URL {
|
||||
static createObjectURL = vi.fn(() => "blob:fixture");
|
||||
static revokeObjectURL = vi.fn();
|
||||
});
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
const client = createAgentApiClient("https://agent.test", { getAccessToken: async () => "FIXTURE", getProjectId: async () => "project-a" });
|
||||
const path = "/api/v1/session/session-a/files/mcp-11111111-1111-1111-1111-111111111111.zip";
|
||||
await client.downloadFile(path);
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(url).toBe(`https://agent.test${path}`);
|
||||
expect(new Headers(init.headers).get("Authorization")).toBe("Bearer FIXTURE");
|
||||
expect(new Headers(init.headers).get("X-Project-Id")).toBe("project-a");
|
||||
expect(init.redirect).toBe("error");
|
||||
expect(click).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects arbitrary URLs and paths before requesting any credential", async () => {
|
||||
const getAccessToken = vi.fn();
|
||||
const client = createAgentApiClient("https://agent.test", { getAccessToken });
|
||||
for (const path of ["https://foreign.test/file.zip", "/api/v1/admin/files/a.zip", "/api/v1/session/s/files/secret.env", "/api/v1/session/s/files/../owner.json"]) {
|
||||
await expect(client.downloadFile(path)).rejects.toThrow("无效");
|
||||
}
|
||||
expect(getAccessToken).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2,16 +2,45 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createAgentApiClient } from "./client";
|
||||
|
||||
describe("Agent prompt suggestions", () => {
|
||||
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()
|
||||
.mockResolvedValueOnce(Response.json({ topics: [topic] }))
|
||||
.mockResolvedValueOnce(Response.json({ sessions: [{ id: "session-b", title: "另一段对话", topic_id: "topic-b", topic_name: "检修事件", search_snippet: "正文中的南桥阀门", purpose: "task_collaboration" }], has_more: false }))
|
||||
.mockResolvedValueOnce(Response.json({ session_id: "session-b", topic_id: "topic-a" }))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 204 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const client = createAgentApiClient("http://agent.local", { getAccessToken: async () => "token", getProjectId: async () => "project-a" });
|
||||
expect(await client.listTopics()).toEqual([topic]);
|
||||
const result = await client.listSessionPage({ q: "南桥阀门", includeCollaboration: true });
|
||||
expect(result.sessions[0]).toMatchObject({ title: "另一段对话", topicId: "topic-b", topicName: "检修事件", searchSnippet: "正文中的南桥阀门", purpose: "task_collaboration" });
|
||||
const query = new URL(String(fetchMock.mock.calls[1]?.[0])).searchParams;
|
||||
expect(query.get("q")).toBe("南桥阀门");
|
||||
expect(query.has("topic_id")).toBe(false);
|
||||
expect(query.get("include_collaboration")).toBe("true");
|
||||
await client.moveSessionToTopic("session-b", "topic-a");
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[2]?.[1]?.body))).toEqual({ topic_id: "topic-a" });
|
||||
await client.deleteTopic("topic-a");
|
||||
expect(String(fetchMock.mock.calls[3]?.[0])).toContain("/topics/topic-a");
|
||||
for (const [, init] of fetchMock.mock.calls) {
|
||||
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer token");
|
||||
expect(new Headers(init?.headers).get("X-Project-Id")).toBe("project-a");
|
||||
}
|
||||
});
|
||||
|
||||
it("submits and edits pending input through the scoped Responses and session endpoints", async () => {
|
||||
const input = { id: `msg_${"1".repeat(32)}`, text: "补充压力测点", delivery: "steer", status: "pending" };
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(Response.json(input)).mockResolvedValueOnce(Response.json({ items: [input] }))
|
||||
.mockResolvedValueOnce(Response.json({ ...input, delivery: "queue" }));
|
||||
.mockResolvedValueOnce(Response.json({ ...input, delivery: "queue" }))
|
||||
.mockResolvedValueOnce(Response.json({ ...input, status: "cancelled" }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const client = createAgentApiClient("http://agent.local", { getAccessToken: async () => "token", getProjectId: async () => "project" });
|
||||
await expect(client.submitResponseInput("session-1", input.text, "steer", input.id)).resolves.toEqual(input);
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ input: input.text, conversation: "session-1", stream: false, tjwater: { delivery: "steer", input_id: input.id } });
|
||||
await expect(client.listResponseInputs("session-1")).resolves.toEqual([input]);
|
||||
await expect(client.updateResponseInput("session-1", input.id, "queue")).resolves.toEqual({ ...input, delivery: "queue" });
|
||||
await expect(client.cancelResponseInput("session-1", input.id)).resolves.toEqual({ ...input, status: "cancelled" });
|
||||
expect(fetchMock.mock.calls[3]?.[1]?.method).toBe("DELETE");
|
||||
expect(String(fetchMock.mock.calls[0]?.[0])).toBe("http://agent.local/api/v1/responses");
|
||||
expect(String(fetchMock.mock.calls[2]?.[0])).toContain(`/api/v1/session/session-1/inputs/${input.id}`);
|
||||
for (const [, request] of fetchMock.mock.calls) {
|
||||
@@ -83,7 +112,8 @@ describe("Agent API client sessions", () => {
|
||||
runStatus: "running",
|
||||
purpose: "ordinary_chat",
|
||||
taskId: undefined,
|
||||
trigger: undefined
|
||||
trigger: undefined,
|
||||
topicId: null, topicName: undefined, searchSnippet: undefined
|
||||
},
|
||||
{
|
||||
id: "old",
|
||||
@@ -96,7 +126,8 @@ describe("Agent API client sessions", () => {
|
||||
runStatus: undefined,
|
||||
purpose: "ordinary_chat",
|
||||
taskId: undefined,
|
||||
trigger: undefined
|
||||
trigger: undefined,
|
||||
topicId: null, topicName: undefined, searchSnippet: undefined
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -39,12 +39,15 @@ export type AgentModelsResponse = {
|
||||
};
|
||||
|
||||
export type AgentInputDelivery = "steer" | "queue";
|
||||
export type AgentResponseInput = { id: string; text: string; delivery: AgentInputDelivery; status: "pending" | "delivered" };
|
||||
export type AgentResponseInput = { id: string; text: string; delivery: AgentInputDelivery; status: "pending" | "delivered" | "cancelled" };
|
||||
|
||||
export type AgentTopic = { id: string; name: string; createdAt: string; updatedAt: string };
|
||||
|
||||
export type AgentChatSession = {
|
||||
id?: string;
|
||||
session_id: string;
|
||||
title?: string;
|
||||
topic_id?: string | null;
|
||||
status?: string;
|
||||
run_status?: AgentRunStatus;
|
||||
is_streaming?: boolean;
|
||||
@@ -64,12 +67,16 @@ export type AgentChatSessionSummary = {
|
||||
trigger?: { subjectId: string; workflow: "water_quality_complaint"; occurredAt: string };
|
||||
id: string;
|
||||
title: string;
|
||||
topicId?: string | null;
|
||||
topicName?: string;
|
||||
searchSnippet?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
status?: string;
|
||||
parentSessionId?: string;
|
||||
isStreaming?: boolean;
|
||||
runStatus?: AgentRunStatus;
|
||||
needsInput?: boolean;
|
||||
purpose: AgentSessionPurpose;
|
||||
taskId?: string;
|
||||
};
|
||||
@@ -89,10 +96,15 @@ export type AgentPromptSuggestion = {
|
||||
activityId: string | null;
|
||||
};
|
||||
|
||||
export type AgentMessagePagination = { hasMore: boolean; beforeCursor: string | null };
|
||||
export type AgentSessionPage = { sessions: AgentChatSessionSummary[]; hasMore: boolean; nextCursor: string | null };
|
||||
export type AgentHistoryMessagePage = { messages: unknown[]; messagePage: AgentMessagePagination };
|
||||
|
||||
export type AgentLoadedChatSession = AgentChatSessionSummary & {
|
||||
sessionId: string;
|
||||
isTitleManuallyEdited: boolean;
|
||||
messages: unknown[];
|
||||
messagePage?: AgentMessagePagination;
|
||||
};
|
||||
|
||||
export type AgentSessionStreamDataEventType =
|
||||
@@ -124,6 +136,7 @@ export type AgentSessionStreamEvent =
|
||||
| {
|
||||
type: "state";
|
||||
executionPlans?: unknown[];
|
||||
messagePage?: AgentMessagePagination;
|
||||
sessionId: string;
|
||||
messages: unknown[];
|
||||
isStreaming: boolean;
|
||||
@@ -136,20 +149,30 @@ export type AgentSessionStreamEvent =
|
||||
};
|
||||
|
||||
export type AgentApiClient = {
|
||||
downloadFile: (path: string) => Promise<void>;
|
||||
submitResponseInput: (sessionId: string, text: string, delivery: AgentInputDelivery, id: string) => Promise<AgentResponseInput>;
|
||||
listResponseInputs: (sessionId: string) => Promise<AgentResponseInput[]>;
|
||||
updateResponseInput: (sessionId: string, id: string, delivery: AgentInputDelivery) => Promise<AgentResponseInput>;
|
||||
cancelResponseInput: (sessionId: string, id: string) => Promise<AgentResponseInput>;
|
||||
listAgentMessages: (sessionId: string, options?: { cursor?: string; signal?: AbortSignal }) => Promise<AgentMessagePage>;
|
||||
streamAgentMessages: (sessionId: string, options: { signal?: AbortSignal; onMessage: () => void }) => Promise<void>;
|
||||
generatePromptSuggestions: (activities: AgentRecentActivity[], model: string, signal?: AbortSignal) => Promise<AgentPromptSuggestion[]>;
|
||||
createSession: (options?: { triggerContext?: AgentEventTriggerContext }) => Promise<AgentChatSession>;
|
||||
createSession: (options?: { triggerContext?: AgentEventTriggerContext; topicId?: string }) => Promise<AgentChatSession>;
|
||||
listSessions: (options?: { purpose?: AgentSessionPurpose; taskId?: string }) => Promise<AgentChatSessionSummary[]>;
|
||||
listSessionPage: (options?: { q?: string; cursor?: string; limit?: number; purpose?: AgentSessionPurpose; topicId?: string; includeCollaboration?: boolean; signal?: AbortSignal }) => Promise<AgentSessionPage>;
|
||||
listTopics: () => Promise<AgentTopic[]>;
|
||||
createTopic: (name: string) => Promise<AgentTopic>;
|
||||
renameTopic: (id: string, name: string) => Promise<AgentTopic>;
|
||||
deleteTopic: (id: string) => Promise<void>;
|
||||
moveSessionToTopic: (sessionId: string, topicId: string | null) => Promise<void>;
|
||||
loadOlderMessages: (sessionId: string, before: string, signal?: AbortSignal) => Promise<AgentHistoryMessagePage>;
|
||||
createTaskCollaborationSession: (taskId: string) => Promise<AgentChatSession>;
|
||||
loadSession: (sessionId: string) => Promise<AgentLoadedChatSession | null>;
|
||||
loadSession: (sessionId: string, options?: { messageLimit?: number; signal?: AbortSignal }) => Promise<AgentLoadedChatSession | null>;
|
||||
streamSession: (
|
||||
sessionId: string,
|
||||
options: {
|
||||
signal?: AbortSignal;
|
||||
messageLimit?: number;
|
||||
onEvent: (event: AgentSessionStreamEvent) => void;
|
||||
}
|
||||
) => Promise<void>;
|
||||
@@ -218,6 +241,19 @@ export function createAgentApiClient(
|
||||
};
|
||||
|
||||
return {
|
||||
async downloadFile(path) {
|
||||
const match = /^\/api\/v1(\/session\/[^/?#]+\/files\/mcp-[a-f0-9-]{36}\.(?:zip|xlsx))$/.exec(path);
|
||||
if (!match) throw new Error("无效的会话文件地址");
|
||||
const response = await request(match[1], { redirect: "error" });
|
||||
if (!response.ok) throw new Error("文件不可用或当前账号无权下载");
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = path.split("/").at(-1) ?? "export.zip";
|
||||
anchor.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
},
|
||||
async submitResponseInput(sessionId, text, delivery, id) {
|
||||
return requestJson<AgentResponseInput>("/responses", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
@@ -233,6 +269,11 @@ export function createAgentApiClient(
|
||||
method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ delivery })
|
||||
});
|
||||
},
|
||||
async cancelResponseInput(sessionId, id) {
|
||||
return requestJson<AgentResponseInput>(`/session/${encodeURIComponent(sessionId)}/inputs/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
},
|
||||
async generatePromptSuggestions(activities, model, signal) {
|
||||
const result = await requestJson<unknown>("/suggestions", {
|
||||
method: "POST",
|
||||
@@ -260,7 +301,7 @@ export function createAgentApiClient(
|
||||
return requestJson<AgentChatSession>("/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ trigger_context: options?.triggerContext })
|
||||
body: JSON.stringify({ trigger_context: options?.triggerContext, topic_id: options?.topicId })
|
||||
});
|
||||
},
|
||||
|
||||
@@ -274,6 +315,31 @@ export function createAgentApiClient(
|
||||
return (payload.sessions ?? []).map(toSessionSummary).filter(isPresent).sort(compareSessionSummaries);
|
||||
},
|
||||
|
||||
async listSessionPage(options) {
|
||||
const params = new URLSearchParams({ purpose: options?.purpose ?? "ordinary_chat", limit: String(options?.limit ?? 30) });
|
||||
if (options?.q) params.set("q", options.q);
|
||||
if (options?.topicId) params.set("topic_id", options.topicId);
|
||||
if (options?.includeCollaboration) params.set("include_collaboration", "true");
|
||||
if (options?.cursor) params.set("cursor", options.cursor);
|
||||
const payload = await requestJson<{ sessions?: unknown[]; has_more?: boolean; next_cursor?: string | null }>(
|
||||
`/sessions?${params}`, { signal: options?.signal });
|
||||
return { sessions: (payload.sessions ?? []).map(toSessionSummary).filter(isPresent),
|
||||
hasMore: payload.has_more === true, nextCursor: typeof payload.next_cursor === "string" ? payload.next_cursor : null };
|
||||
},
|
||||
async listTopics() { return (await requestJson<{ topics: AgentTopic[] }>("/topics")).topics; },
|
||||
async createTopic(name) { return requestJson<AgentTopic>("/topics", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }) }); },
|
||||
async renameTopic(id, name) { return requestJson<AgentTopic>(`/topics/${encodeURIComponent(id)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }) }); },
|
||||
async deleteTopic(id) { await requestJson(`/topics/${encodeURIComponent(id)}`, { method: "DELETE" }); },
|
||||
async moveSessionToTopic(sessionId, topicId) { await requestJson(`/session/${encodeURIComponent(sessionId)}/topic`, {
|
||||
method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ topic_id: topicId }),
|
||||
}); },
|
||||
async loadOlderMessages(sessionId, before, signal) {
|
||||
const query = new URLSearchParams({ limit: "40", before });
|
||||
const payload = await requestJson<{ messages?: unknown[]; message_page?: unknown }>(
|
||||
`/session/${encodeURIComponent(sessionId)}/messages?${query}`, { signal });
|
||||
return { messages: payload.messages ?? [], messagePage: readMessagePagination(payload.message_page) ?? { hasMore: false, beforeCursor: null } };
|
||||
},
|
||||
|
||||
async createTaskCollaborationSession(taskId) {
|
||||
return requestJson<AgentChatSession>(
|
||||
`/task/${encodeURIComponent(taskId)}/collaboration/session`,
|
||||
@@ -293,8 +359,9 @@ export function createAgentApiClient(
|
||||
});
|
||||
},
|
||||
|
||||
async loadSession(sessionId) {
|
||||
const response = await request(`/session/${encodeURIComponent(sessionId)}`);
|
||||
async loadSession(sessionId, options) {
|
||||
const query = options?.messageLimit ? `?message_limit=${options.messageLimit}` : "";
|
||||
const response = await request(`/session/${encodeURIComponent(sessionId)}${query}`, { signal: options?.signal });
|
||||
const text = await response.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
|
||||
@@ -310,7 +377,7 @@ export function createAgentApiClient(
|
||||
|
||||
async streamSession(sessionId, options) {
|
||||
const response = await request(
|
||||
`/session/${encodeURIComponent(sessionId)}/stream`,
|
||||
`/session/${encodeURIComponent(sessionId)}/stream${options.messageLimit ? `?message_limit=${options.messageLimit}` : ""}`,
|
||||
{ signal: options.signal }
|
||||
);
|
||||
|
||||
@@ -564,6 +631,9 @@ function toSessionSummary(value: unknown): AgentChatSessionSummary | null {
|
||||
return {
|
||||
id,
|
||||
title: normalizeTitle(value.title),
|
||||
topicId: typeof value.topic_id === "string" ? value.topic_id : null,
|
||||
topicName: typeof value.topic_name === "string" ? value.topic_name : undefined,
|
||||
searchSnippet: typeof value.search_snippet === "string" ? value.search_snippet : undefined,
|
||||
createdAt: toMillis(value.created_at),
|
||||
updatedAt: toMillis(value.updated_at),
|
||||
status: typeof value.status === "string" ? value.status : undefined,
|
||||
@@ -591,7 +661,8 @@ function toLoadedSession(value: unknown): AgentLoadedChatSession {
|
||||
sessionId: typeof value.session_id === "string" ? value.session_id : summary.id,
|
||||
isTitleManuallyEdited:
|
||||
typeof value.is_title_manually_edited === "boolean" ? value.is_title_manually_edited : false,
|
||||
messages: Array.isArray(value.messages) ? value.messages : []
|
||||
messages: Array.isArray(value.messages) ? value.messages : [],
|
||||
messagePage: readMessagePagination(value.message_page)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -657,6 +728,7 @@ function emitSessionSseEvent(
|
||||
onEvent({
|
||||
type: "state",
|
||||
executionPlans: Array.isArray(data.execution_plans) ? data.execution_plans : undefined,
|
||||
messagePage: readMessagePagination(data.message_page),
|
||||
sessionId,
|
||||
messages: Array.isArray(data.messages) ? data.messages : [],
|
||||
isStreaming: data.is_streaming === true,
|
||||
@@ -717,3 +789,8 @@ function compareSessionSummaries(left: AgentChatSessionSummary, right: AgentChat
|
||||
|
||||
return right.id.localeCompare(left.id);
|
||||
}
|
||||
|
||||
function readMessagePagination(value: unknown): AgentMessagePagination | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
return { hasMore: value.has_more === true, beforeCursor: typeof value.before_cursor === "string" ? value.before_cursor : null };
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("Responses transport", () => {
|
||||
const messages = [{ id: "old", role: "assistant", parts: [{ type: "text", text: "旧回答" }] }, { id: "new", role: "user", parts: [{ type: "text", text: "继续核查" }] }] as UIMessage[];
|
||||
expect(createResponseRequest(messages, { conversation: "owned", approvalMode: "always" })).toEqual({
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "继续核查" }] }], stream: true, conversation: "owned",
|
||||
tjwater: { approval_mode: "always", capabilities: [] },
|
||||
tjwater: { client_message_id: "new", approval_mode: "always", capabilities: [] },
|
||||
});
|
||||
});
|
||||
it("decodes split UTF-8 and CRLF while retaining approval events", async () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ const record = (value: unknown): value is Record<string, unknown> => typeof valu
|
||||
|
||||
export function createResponseRequest(messages: UIMessage[], options: {
|
||||
conversation?: string; model?: string; approvalMode?: string; capabilities?: string[];
|
||||
triggerContext?: unknown; taskContext?: unknown;
|
||||
triggerContext?: unknown; taskContext?: unknown; topicId?: string;
|
||||
}) {
|
||||
const latest = [...messages].reverse().find((message) => message.role === "user");
|
||||
const text = latest?.parts.filter((part) => part.type === "text").map((part) => part.text).join("\n").trim();
|
||||
@@ -13,7 +13,7 @@ export function createResponseRequest(messages: UIMessage[], options: {
|
||||
input: [{ role: "user", content: [{ type: "input_text", text }] }], stream: true,
|
||||
...(options.model ? { model: options.model } : {}),
|
||||
...(options.conversation ? { conversation: options.conversation } : {}),
|
||||
tjwater: { approval_mode: options.approvalMode ?? "request", capabilities: options.capabilities ?? [],
|
||||
tjwater: { ...(options.topicId ? { topic_id: options.topicId } : {}), ...(latest?.id ? { client_message_id: latest.id } : {}), approval_mode: options.approvalMode ?? "request", capabilities: options.capabilities ?? [],
|
||||
...(options.triggerContext ? { trigger_context: options.triggerContext } : {}),
|
||||
...(options.taskContext ? { task_context: options.taskContext } : {}),
|
||||
},
|
||||
@@ -44,6 +44,9 @@ export function responsesToUiStream(stream: ReadableStream<Uint8Array>): Readabl
|
||||
if (value.type === "response.created") {
|
||||
if (responseId || !record(value.response) || typeof value.response.id !== "string") throw new Error("Responses 创建事件不正确");
|
||||
responseId = value.response.id;
|
||||
if (record(value.response.conversation) && typeof value.response.conversation.id === "string") {
|
||||
controller.enqueue({ type: "data-session_created", data: { session_id: value.response.conversation.id }, transient: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!responseId) throw new Error("Responses 流缺少创建事件");
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useState } from "react";
|
||||
import { Check, Copy, LoaderCircle } from "lucide-react";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { MessageAction, MessageActions } from "@/shared/ai-elements/message";
|
||||
import type { AnswerUiPart } from "../answer-ui/contract";
|
||||
|
||||
export function AgentChatMessageHeader({
|
||||
running,
|
||||
interactive
|
||||
}: {
|
||||
running: boolean;
|
||||
interactive: boolean;
|
||||
}) {
|
||||
if (!running && !interactive) return null;
|
||||
|
||||
return (
|
||||
<div className="agent-chat-message-header">
|
||||
{running ? (
|
||||
<span
|
||||
role="status"
|
||||
className="ml-auto inline-flex items-center gap-1.5 text-[11px] text-muted-foreground"
|
||||
>
|
||||
<LoaderCircle size={12} className="motion-safe:animate-spin" aria-hidden="true" />
|
||||
正在回复
|
||||
</span>
|
||||
) : interactive ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="ml-auto border-0 px-2 py-0.5 text-[10px] font-medium shadow-none"
|
||||
>
|
||||
交互回答
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentChatMessageActions({ parts }: { parts: AnswerUiPart[] }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const text = parts
|
||||
.map((part) => (part.type === "text" ? part.text : part.block.fallback_text))
|
||||
.join("\n\n");
|
||||
if (!text.trim()) return null;
|
||||
|
||||
return (
|
||||
<MessageActions className="agent-chat-message-actions">
|
||||
<MessageAction
|
||||
tooltip={copied ? "回复已复制" : "复制回复"}
|
||||
label={copied ? "回复已复制" : "复制回复"}
|
||||
size="sm"
|
||||
className="h-10 gap-1.5 px-2 text-[11px] font-normal shadow-none"
|
||||
aria-label={copied ? "回复已复制" : "复制回复"}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setError(false);
|
||||
} catch {
|
||||
setCopied(false);
|
||||
setError(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{copied ? <Check size={13} aria-hidden="true" /> : <Copy size={13} aria-hidden="true" />}
|
||||
{copied ? "已复制" : "复制回复"}
|
||||
</MessageAction>
|
||||
<span role="status" className="text-[11px] text-muted-foreground">
|
||||
{error ? "复制失败,请选择文字复制" : copied ? "已复制回复文字与交互内容摘要" : ""}
|
||||
</span>
|
||||
</MessageActions>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import type { PersonaState } from "@/shared/ai-elements/persona";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { AgentPersona } from "./agent-persona";
|
||||
import { agentLayoutTransition } from "./agent-motion";
|
||||
|
||||
type AgentCollapsedRailProps = {
|
||||
personaState?: PersonaState;
|
||||
@@ -15,26 +16,27 @@ export function AgentCollapsedRail({
|
||||
onExpand
|
||||
}: AgentCollapsedRailProps) {
|
||||
const expandLabel = `展开 Agent 助手面板,当前状态:${statusLabel}`;
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<aside
|
||||
<motion.aside
|
||||
aria-label="Agent 折叠栏"
|
||||
className={cn(
|
||||
"agent-rail-enter acrylic-control pointer-events-auto w-[72px] rounded-2xl border p-1.5"
|
||||
)}
|
||||
initial={prefersReducedMotion ? false : { opacity: 0, x: -5, scale: 0.94 }}
|
||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||
transition={prefersReducedMotion ? { duration: 0 } : agentLayoutTransition}
|
||||
style={{ transformOrigin: "left top" }}
|
||||
className="agent-collapsed-rail pointer-events-auto size-[var(--workbench-agent-rail-width,64px)] rounded-full"
|
||||
>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
aria-label={expandLabel}
|
||||
title={expandLabel}
|
||||
onClick={onExpand}
|
||||
className="agent-rail-item group relative flex h-14 w-full items-center justify-center rounded-xl border border-transparent bg-transparent hover:bg-[var(--action-selection-soft)]"
|
||||
className="flex h-full w-full items-center justify-center rounded-full border-0 bg-transparent p-0"
|
||||
>
|
||||
<AgentPersona className="h-11 w-11" state={personaState} />
|
||||
<span className="agent-panel-primary-icon absolute bottom-0.5 right-0.5 grid h-5 w-5 place-items-center rounded-md border-0! shadow-xs transition-transform group-hover:translate-x-0.5">
|
||||
<ChevronRight size={13} strokeWidth={2.5} aria-hidden="true" />
|
||||
</span>
|
||||
</button>
|
||||
</aside>
|
||||
<AgentPersona className="h-full w-full" state={personaState} />
|
||||
</Button>
|
||||
</motion.aside>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,333 +1,196 @@
|
||||
import { Check, Loader2, Pencil, RefreshCw, Trash2, X } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
import { Check, ChevronDown, FolderInput, MoreHorizontal, Pencil, RefreshCw, Search, Trash2, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState, type CSSProperties, type RefObject } from "react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/shared/ui/dialog";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger } from "@/shared/ui/dropdown-menu";
|
||||
import { Separator } from "@/shared/ui/separator";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { StatusBadge, StatusDot } from "@/shared/ui/status";
|
||||
import type { AgentChatSessionSummary } from "../api/client";
|
||||
import {
|
||||
agentLayoutTransition,
|
||||
agentPanelItemVariants,
|
||||
agentPanelListVariants
|
||||
} from "./agent-motion";
|
||||
import { StatusBadge } from "@/shared/ui/status";
|
||||
import type { AgentChatSessionSummary, AgentTopic } from "../api/client";
|
||||
|
||||
type AgentHistoryPanelProps = {
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
triggerRef: RefObject<HTMLButtonElement | null>;
|
||||
scopeLabel: string;
|
||||
activeSessionId?: string | null;
|
||||
loading: boolean;
|
||||
loadingSessionId: string | null;
|
||||
sessions: AgentChatSessionSummary[];
|
||||
topics?: AgentTopic[];
|
||||
topicId?: string;
|
||||
onTopicChange?: (id: string | undefined) => void;
|
||||
onMoveSessionToTopic?: (sessionId: string, topicId: string | null) => Promise<void>;
|
||||
query?: string;
|
||||
onQueryChange?: (value: string) => void;
|
||||
hasMore?: boolean;
|
||||
loadingMore?: boolean;
|
||||
error?: string;
|
||||
pendingUpdate?: boolean;
|
||||
onLoadMore?: () => Promise<unknown> | void;
|
||||
onRefresh?: () => Promise<void> | void;
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
onRenameSession?: (sessionId: string, title: string) => Promise<void> | void;
|
||||
onDeleteSession?: (sessionId: string) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export function AgentHistoryPanel({
|
||||
activeSessionId,
|
||||
loading,
|
||||
loadingSessionId,
|
||||
sessions,
|
||||
onRefresh,
|
||||
onSelectSession,
|
||||
onRenameSession,
|
||||
onDeleteSession
|
||||
}: AgentHistoryPanelProps) {
|
||||
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
|
||||
const [draftTitle, setDraftTitle] = useState("");
|
||||
const [savingSessionId, setSavingSessionId] = useState<string | null>(null);
|
||||
const [deletingSessionId, setDeletingSessionId] = useState<string | null>(null);
|
||||
const [confirmingDeleteSessionId, setConfirmingDeleteSessionId] = useState<string | null>(null);
|
||||
|
||||
const beginRename = (session: AgentChatSessionSummary) => {
|
||||
setEditingSessionId(session.id);
|
||||
setDraftTitle(session.title);
|
||||
export function AgentHistoryPanel({ open, onOpenChange, triggerRef, scopeLabel, activeSessionId, loading, loadingSessionId,
|
||||
sessions, query = "", onQueryChange, hasMore, loadingMore, error, pendingUpdate, onLoadMore, onRefresh,
|
||||
topics = [], topicId, onTopicChange, onMoveSessionToTopic, onSelectSession, onRenameSession, onDeleteSession }: Props) {
|
||||
const [search, setSearch] = useState(query);
|
||||
const [movingId, setMovingId] = useState<string | null>(null);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const [viewport, setViewport] = useState<{ height: number; top: number } | null>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const visual = window.visualViewport;
|
||||
const update = () => {
|
||||
const next = { height: visual?.height ?? window.innerHeight, top: visual?.offsetTop ?? 0 };
|
||||
setViewport((current) => current?.height === next.height && current.top === next.top ? current : next);
|
||||
};
|
||||
update();
|
||||
visual?.addEventListener("resize", update);
|
||||
visual?.addEventListener("scroll", update);
|
||||
window.addEventListener("resize", update);
|
||||
return () => {
|
||||
visual?.removeEventListener("resize", update);
|
||||
visual?.removeEventListener("scroll", update);
|
||||
window.removeEventListener("resize", update);
|
||||
};
|
||||
}, [open]);
|
||||
useEffect(() => {
|
||||
if (!open || search === query) return;
|
||||
const timer = setTimeout(() => onQueryChange?.(search.trim()), 250);
|
||||
return () => clearTimeout(timer);
|
||||
}, [onQueryChange, open, query, search]);
|
||||
const perform = async (id: string, action: () => Promise<void> | void) => {
|
||||
setBusy(id); setActionError(null);
|
||||
try { await action(); setEditingId(null); setDeletingId(null); }
|
||||
catch (reason) { setActionError(reason instanceof Error ? reason.message : "操作失败,请重试"); }
|
||||
finally { setBusy(null); }
|
||||
};
|
||||
|
||||
const cancelRename = () => {
|
||||
setEditingSessionId(null);
|
||||
setDraftTitle("");
|
||||
};
|
||||
|
||||
const saveRename = (session: AgentChatSessionSummary) => {
|
||||
const normalizedTitle = draftTitle.trim();
|
||||
if (!normalizedTitle || normalizedTitle === session.title) {
|
||||
cancelRename();
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingSessionId(session.id);
|
||||
void Promise.resolve(onRenameSession?.(session.id, normalizedTitle))
|
||||
.then(cancelRename)
|
||||
.finally(() => setSavingSessionId(null));
|
||||
};
|
||||
|
||||
const confirmDelete = (sessionId: string) => {
|
||||
setDeletingSessionId(sessionId);
|
||||
void Promise.resolve(onDeleteSession?.(sessionId))
|
||||
.then(() => setConfirmingDeleteSessionId(null))
|
||||
.finally(() => setDeletingSessionId(null));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-3 pb-3">
|
||||
<div className="agent-panel-control rounded-2xl p-2.5 shadow-xs">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-slate-950">历史记录</p>
|
||||
<p className="mt-0.5 text-xs text-[var(--ink-muted)]">
|
||||
{sessions.length ? `${sessions.length} 个会话` : "暂无历史会话"}
|
||||
</p>
|
||||
let previousGroup = "";
|
||||
return <Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent 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}
|
||||
onOpenAutoFocus={(event) => { event.preventDefault(); searchRef.current?.focus(); }}
|
||||
onCloseAutoFocus={(event) => { event.preventDefault(); triggerRef.current?.focus(); }}>
|
||||
<div data-agent-history-header className="shrink-0 space-y-4 p-4 sm:p-6 sm:pb-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<DialogHeader className="min-w-0 text-left">
|
||||
<DialogTitle>历史对话</DialogTitle>
|
||||
<DialogDescription className="break-words">{scopeLabel}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button variant="ghost" size="icon-sm" aria-label="刷新 Agent 历史记录" loading={loadingMore}
|
||||
onClick={() => void perform("refresh", () => onRefresh?.())}><RefreshCw aria-hidden="true" /></Button>
|
||||
<Button variant="ghost" size="icon-sm" aria-label="关闭历史对话" onClick={() => onOpenChange(false)}><X aria-hidden="true" /></Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="刷新 Agent 历史记录"
|
||||
title="刷新 Agent 历史记录"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={loading && sessions.length === 0}
|
||||
loading={loading && sessions.length > 0}
|
||||
loadingLabel="正在刷新 Agent 历史记录"
|
||||
onClick={() => void Promise.resolve(onRefresh?.())}
|
||||
>
|
||||
<RefreshCw size={15} aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="max-h-56 space-y-1 overflow-y-auto pr-1"
|
||||
variants={agentPanelListVariants}
|
||||
animate="animate"
|
||||
>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{loading && sessions.length === 0 ? (
|
||||
<motion.div
|
||||
key="history-loading"
|
||||
className="agent-history-state flex items-center gap-2 rounded-xl px-3 py-3 text-xs font-semibold text-[var(--ink-muted)]"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
>
|
||||
<Loader2 size={14} className="animate-spin" aria-hidden="true" />
|
||||
正在加载
|
||||
</motion.div>
|
||||
) : sessions.length > 0 ? (
|
||||
sessions.map((session) => {
|
||||
const active = session.id === activeSessionId;
|
||||
const itemLoading = loadingSessionId === session.id;
|
||||
const editing = editingSessionId === session.id;
|
||||
const saving = savingSessionId === session.id;
|
||||
const deleting = deletingSessionId === session.id;
|
||||
const confirmingDelete = confirmingDeleteSessionId === session.id;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={session.id}
|
||||
layout
|
||||
className={cn(
|
||||
"agent-history-item grid w-full grid-cols-[1fr_auto] gap-2 rounded-xl px-3 py-2 text-left transition-colors",
|
||||
active && !editing
|
||||
? "agent-history-item-active border border-[var(--surface-border)]"
|
||||
: "border border-transparent"
|
||||
)}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
transition={agentLayoutTransition}
|
||||
>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{confirmingDelete ? (
|
||||
<motion.div
|
||||
key="confirm-delete"
|
||||
className="min-w-0"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
>
|
||||
<span
|
||||
className="status-tone-danger block truncate text-xs font-semibold text-[var(--status-foreground)]"
|
||||
title={session.title}
|
||||
>
|
||||
删除“{session.title}”?
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-xs text-[var(--ink-muted)]">
|
||||
删除后无法从历史记录中恢复
|
||||
</span>
|
||||
</motion.div>
|
||||
) : editing ? (
|
||||
<motion.form
|
||||
key="rename"
|
||||
className="min-w-0"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
saveRename(session);
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
className="agent-history-rename-input h-8 rounded-lg bg-[var(--surface-reading)] text-xs"
|
||||
value={draftTitle}
|
||||
disabled={saving}
|
||||
onChange={(event) => setDraftTitle(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") {
|
||||
cancelRename();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</motion.form>
|
||||
) : (
|
||||
<motion.button
|
||||
key="session-summary"
|
||||
type="button"
|
||||
className="agent-history-session-trigger min-w-0 text-left"
|
||||
disabled={itemLoading || deleting}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
onClick={() => onSelectSession(session.id)}
|
||||
>
|
||||
<span
|
||||
className="block truncate text-xs font-semibold text-slate-900"
|
||||
title={session.title}
|
||||
>
|
||||
{session.title}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-xs text-[var(--ink-muted)]">
|
||||
{formatSessionUpdatedAt(session.updatedAt)}
|
||||
</span>
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<span className="flex items-center gap-2">
|
||||
{session.runStatus === "running" || session.isStreaming ? (
|
||||
<StatusDot tone="info" activity="live" />
|
||||
) : null}
|
||||
{itemLoading || saving || deleting ? (
|
||||
<Loader2
|
||||
size={13}
|
||||
className="animate-spin text-[var(--action-blue-hover)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
{active && !editing && !confirmingDelete ? (
|
||||
<StatusBadge tone="info">当前</StatusBadge>
|
||||
) : null}
|
||||
{confirmingDelete ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
loading={deleting}
|
||||
loadingLabel="正在删除对话"
|
||||
onClick={() => confirmDelete(session.id)}
|
||||
>
|
||||
确认
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="取消删除"
|
||||
title="取消删除"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
disabled={deleting}
|
||||
onClick={() => setConfirmingDeleteSessionId(null)}
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</Button>
|
||||
</>
|
||||
) : editing ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="保存对话主题"
|
||||
title="保存对话主题"
|
||||
size="icon-sm"
|
||||
loading={saving}
|
||||
loadingLabel="正在保存对话主题"
|
||||
onClick={() => saveRename(session)}
|
||||
>
|
||||
<Check size={14} aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="取消重命名"
|
||||
title="取消重命名"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
disabled={saving}
|
||||
onClick={cancelRename}
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="重命名对话"
|
||||
title="重命名对话"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
disabled={itemLoading || deleting || !onRenameSession}
|
||||
onClick={() => beginRename(session)}
|
||||
>
|
||||
<Pencil size={14} aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
aria-label="删除对话"
|
||||
title="删除对话"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="text-[var(--ink-muted)] hover:bg-red-50 hover:text-red-600"
|
||||
disabled={itemLoading || deleting || !onDeleteSession}
|
||||
onClick={() => {
|
||||
cancelRename();
|
||||
setConfirmingDeleteSessionId(session.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} aria-hidden="true" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</motion.div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<motion.div
|
||||
key="history-empty"
|
||||
className="agent-history-state rounded-xl px-3 py-3 text-xs text-[var(--ink-muted)]"
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
variants={agentPanelItemVariants}
|
||||
>
|
||||
暂无历史记录
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
<div className="relative">
|
||||
<Search aria-hidden="true" className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input ref={searchRef} type="search" maxLength={200} aria-label="搜索标题和对话内容" placeholder="搜索标题或对话内容"
|
||||
value={search} onChange={(event) => setSearch(event.target.value)} className="pl-9" />
|
||||
</div>
|
||||
{onTopicChange || pendingUpdate ? <div className="flex flex-wrap items-center justify-between gap-2">
|
||||
{onTopicChange ? <DropdownMenu><DropdownMenuTrigger asChild><Button variant="outline" size="sm" aria-label="筛选历史主题" className="max-w-full">
|
||||
<FolderInput aria-hidden="true" /><span className="truncate">{topicId === "unclassified" ? "未分类" : topics.find((topic) => topic.id === topicId)?.name ?? "全部主题"}</span><ChevronDown aria-hidden="true" />
|
||||
</Button></DropdownMenuTrigger><DropdownMenuContent data-agent-theme="chat" align="start" className="z-[112] max-h-64 overflow-y-auto">
|
||||
<DropdownMenuRadioGroup value={topicId ?? "all"} onValueChange={(id) => onTopicChange(id === "all" ? undefined : id)}>
|
||||
<DropdownMenuRadioItem value="all">全部主题</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="unclassified">未分类</DropdownMenuRadioItem>
|
||||
{topics.map((topic) => <DropdownMenuRadioItem key={topic.id} value={topic.id}>{topic.name}</DropdownMenuRadioItem>)}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent></DropdownMenu> : null}
|
||||
{pendingUpdate ? <Button variant="link" className="text-xs" onClick={() => void perform("refresh", () => onRefresh?.())}>
|
||||
历史记录有更新,点击刷新
|
||||
</Button> : null}
|
||||
</div> : null}
|
||||
{error || actionError ? <p role="alert" className="text-sm text-destructive">{actionError ?? error}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<Separator />
|
||||
<div role="region" aria-label="Agent 历史记录" aria-busy={loading || undefined}
|
||||
className="agent-history-list min-h-0 flex-1 overflow-y-auto overscroll-contain px-2 pb-4 sm:px-4">
|
||||
{loading && !sessions.length ? <p role="status" className="px-3 py-8 text-center text-sm text-muted-foreground">正在加载历史对话…</p>
|
||||
: !sessions.length ? <p className="px-3 py-8 text-center text-sm text-muted-foreground">{error ? "历史记录暂不可用,请刷新重试" : query ? "没有找到匹配的会话" : "暂无历史记录"}</p> : null}
|
||||
{sessions.map((session) => {
|
||||
const group = historyGroup(session.updatedAt);
|
||||
const showGroup = group !== previousGroup;
|
||||
previousGroup = group;
|
||||
const active = session.id === activeSessionId;
|
||||
const editing = session.id === editingId;
|
||||
const deleting = session.id === deletingId;
|
||||
const itemBusy = busy === session.id || loadingSessionId === session.id;
|
||||
return <div key={session.id}>
|
||||
{showGroup ? <h3 className="px-3 pb-2 pt-4 text-xs font-medium text-muted-foreground">{group}</h3> : null}
|
||||
<div className={cn("agent-history-item flex items-center gap-3 rounded-md p-3", active ? "agent-history-item-active bg-accent" : "hover:bg-accent/50")}>
|
||||
{editing ? <form className="flex min-w-0 flex-1 flex-wrap items-center gap-2" onSubmit={(event) => {
|
||||
event.preventDefault(); if (title.trim()) void perform(session.id, () => onRenameSession?.(session.id, title.trim()));
|
||||
}} onKeyDown={(event) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); setEditingId(null); } }}>
|
||||
<Input autoFocus aria-label="会话标题" value={title} disabled={itemBusy} onChange={(event) => setTitle(event.target.value)} className="min-w-0 flex-1" />
|
||||
<Button type="submit" size="icon" aria-label="保存对话标题" loading={itemBusy} disabled={!title.trim()}><Check aria-hidden="true" /></Button>
|
||||
<Button type="button" size="icon" variant="ghost" aria-label="取消重命名" onClick={() => setEditingId(null)}><X aria-hidden="true" /></Button>
|
||||
</form> : movingId === session.id ? <div className="min-w-0 flex-1 space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">移入主题</p>
|
||||
<div className="flex max-h-48 flex-col gap-1 overflow-y-auto">
|
||||
{[{ id: "", name: "未分类" }, ...topics].map((topic) => <Button key={topic.id} variant="ghost" disabled={Boolean(busy)} className="h-auto min-h-9 justify-start whitespace-normal break-words text-left" onClick={() => void perform(session.id, async () => {
|
||||
await onMoveSessionToTopic?.(session.id, topic.id || null); setMovingId(null);
|
||||
})}>{topic.name}</Button>)}
|
||||
</div><Button size="sm" variant="ghost" onClick={() => setMovingId(null)}>取消移动</Button>
|
||||
</div> : deleting ? <div className="flex min-w-0 flex-1 flex-wrap items-center justify-between gap-2">
|
||||
<div className="min-w-0 flex-1"><p className="break-words text-sm">删除“{session.title}”?</p><p className="mt-1 text-xs text-muted-foreground">删除后无法从历史记录中恢复</p></div>
|
||||
<Button variant="destructive" loading={itemBusy} onClick={() => void perform(session.id, () => onDeleteSession?.(session.id))}>确认</Button>
|
||||
<Button variant="ghost" size="icon" aria-label="取消删除" onClick={() => setDeletingId(null)}><X aria-hidden="true" /></Button>
|
||||
</div> : <>
|
||||
<button type="button" className="agent-history-session-trigger min-w-0 flex-1 rounded-sm text-left outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
aria-current={active ? "true" : undefined}
|
||||
disabled={itemBusy || Boolean(loadingSessionId)} onClick={() => onSelectSession(session.id)}>
|
||||
<span className="line-clamp-2 break-words text-sm font-medium leading-6 text-foreground" title={session.title}>{session.title}</span>
|
||||
<span className="mt-1 block truncate text-xs text-muted-foreground">{topics.find((topic) => topic.id === session.topicId)?.name ?? session.topicName ?? "未分类"}</span>
|
||||
{session.searchSnippet ? <span className="mt-1 line-clamp-2 break-words text-xs leading-5 text-muted-foreground">{session.searchSnippet}</span> : null}
|
||||
<span className="mt-1 block text-xs tabular-nums text-muted-foreground">{formatUpdatedAt(session.updatedAt)}{itemBusy ? " · 正在打开" : ""}</span>
|
||||
</button>
|
||||
<span className="flex shrink-0 flex-col items-end gap-1">
|
||||
{session.purpose === "task_collaboration" ? <StatusBadge tone="neutral">任务协作</StatusBadge> : null}
|
||||
{active ? <StatusBadge tone="info">当前</StatusBadge> : null}
|
||||
{session.needsInput ? <StatusBadge tone="warning">需要确认</StatusBadge> : null}
|
||||
{session.isStreaming || session.runStatus === "running" ? <StatusBadge tone="info">处理中</StatusBadge> : null}
|
||||
{session.runStatus === "error" ? <StatusBadge tone="danger">处理失败</StatusBadge> : null}
|
||||
</span>
|
||||
<DropdownMenu><DropdownMenuTrigger asChild><Button size="icon" variant="ghost" disabled={Boolean(loadingSessionId)} aria-label={`会话操作:${session.title}`}><MoreHorizontal aria-hidden="true" /></Button></DropdownMenuTrigger>
|
||||
<DropdownMenuContent data-agent-theme="chat" align="end" className="z-[112]">
|
||||
<DropdownMenuItem disabled={!onRenameSession} onSelect={() => { setEditingId(session.id); setDeletingId(null); setTitle(session.title); }}><Pencil aria-hidden="true" />重命名对话</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!onMoveSessionToTopic} onSelect={() => { setMovingId(session.id); setEditingId(null); setDeletingId(null); }}><FolderInput aria-hidden="true" />移入主题</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!onDeleteSession} onSelect={() => { setDeletingId(session.id); setEditingId(null); }}><Trash2 aria-hidden="true" />删除对话</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>}
|
||||
</div>
|
||||
</div>;
|
||||
})}
|
||||
{hasMore ? <div className="px-3 pt-4"><Button className="w-full" variant="outline" loading={loadingMore} onClick={() => void onLoadMore?.()}>加载更多</Button></div> : null}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
|
||||
function formatSessionUpdatedAt(value: number) {
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(value);
|
||||
function historyGroup(value: number) {
|
||||
const now = new Date();
|
||||
const day = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
||||
const date = new Date(value);
|
||||
const start = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
||||
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1).getTime();
|
||||
const week = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7).getTime();
|
||||
return start >= day ? "今天" : start === yesterday ? "昨天" : start >= week ? "近 7 天" : "更早";
|
||||
}
|
||||
function formatUpdatedAt(value: number) {
|
||||
return new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }).format(value);
|
||||
}
|
||||
|
||||
@@ -27,8 +27,10 @@ export function AgentMessageInbox({ messages, loading, error, hasMore, loadingMo
|
||||
<span>{messageStatusLabels[message.status]}</span>
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap break-words text-sm leading-6 text-[var(--ink)]">{message.text}</p>
|
||||
<p className="break-all text-[var(--ink-muted)]">task_id: <span className="select-text font-mono">{message.task_id}</span></p>
|
||||
<p className="break-all text-[var(--ink-muted)]">run_id: <span className="select-text font-mono">{message.run_id}</span></p>
|
||||
<details className="text-[var(--ink-muted)]"><summary className="cursor-pointer">消息详情</summary>
|
||||
<p className="break-all">任务编号:<span className="select-text font-mono">{message.task_id}</span></p>
|
||||
<p className="break-all">运行编号:<span className="select-text font-mono">{message.run_id}</span></p>
|
||||
</details>
|
||||
{message.failure_detail ? <p className="break-words text-[var(--status-danger-foreground)]">{message.failure_detail}</p> : null}
|
||||
</article>)}
|
||||
{hasMore ? <Button variant="ghost" size="sm" disabled={loadingMore} onClick={() => void onLoadMore?.().catch(() => undefined)}>{loadingMore ? "正在加载…" : "加载更多消息"}</Button> : null}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { LoaderCircle, MoreHorizontal, Pencil, X } from "lucide-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import type { AgentInputDelivery, AgentResponseInput } from "../api/client";
|
||||
import type { AgentCommandPanelPresentation } from "./agent-command-panel";
|
||||
|
||||
type AgentPendingInputsProps = {
|
||||
presentation: AgentCommandPanelPresentation;
|
||||
inputs: AgentResponseInput[];
|
||||
onDeliveryChange?: (id: string, delivery: AgentInputDelivery) => Promise<void> | void;
|
||||
onEdit?: (id: string) => Promise<void> | void;
|
||||
onCancel?: (id: string) => Promise<void> | void;
|
||||
};
|
||||
|
||||
const menuItemClassName = "min-h-10 rounded-lg border border-transparent px-2.5 py-2 text-xs 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)]";
|
||||
|
||||
export function AgentPendingInputs({ presentation, inputs, onDeliveryChange, onEdit, onCancel }: AgentPendingInputsProps) {
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const listRef = useRef<HTMLUListElement | null>(null);
|
||||
const latestId = inputs.at(-1)?.id;
|
||||
|
||||
useEffect(() => {
|
||||
const list = listRef.current;
|
||||
if (list) list.scrollTop = list.scrollHeight;
|
||||
}, [latestId]);
|
||||
|
||||
const performAction = (id: string, action: () => Promise<void> | void) => {
|
||||
setBusyId(id);
|
||||
void Promise.resolve().then(action).catch(() => {}).finally(() => setBusyId(null));
|
||||
};
|
||||
|
||||
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)]">
|
||||
<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>
|
||||
<ul ref={listRef} aria-label="待处理消息" className="max-h-[min(12rem,28dvh)] divide-y divide-[var(--surface-divider)] overflow-y-auto overscroll-contain px-2 pb-1">
|
||||
{inputs.map((input) => (
|
||||
<li key={input.id} aria-busy={busyId === input.id} className="flex min-w-0 items-start gap-1 py-1">
|
||||
<PendingInputText text={input.text} />
|
||||
<span className="shrink-0 py-2 text-[11px] leading-6 text-muted-foreground" title={input.delivery === "queue" ? "当前任务结束后处理" : "在下一步补充当前任务"}>
|
||||
{input.delivery === "queue" ? "排队" : "引导"}
|
||||
</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="icon" aria-label={`消息操作:${input.text}`}
|
||||
disabled={Boolean(busyId)} className="agent-panel-icon-button size-10 shrink-0 active:scale-95 motion-reduce:transform-none">
|
||||
{busyId === input.id
|
||||
? <LoaderCircle size={16} className="motion-safe:animate-spin" aria-hidden />
|
||||
: <MoreHorizontal size={16} aria-hidden />}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent data-agent-presentation={presentation} side="top" align="end" sideOffset={6} collisionPadding={12}
|
||||
className="agent-panel-overlay w-48 rounded-xl p-1.5">
|
||||
<DropdownMenuItem disabled={!onEdit} className={menuItemClassName}
|
||||
onSelect={() => performAction(input.id, () => onEdit?.(input.id))}>
|
||||
<Pencil size={14} aria-hidden />编辑消息
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="px-2 py-1 text-[11px] font-normal text-muted-foreground">处理方式</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup aria-label="消息处理方式" value={input.delivery} onValueChange={(delivery) => {
|
||||
if (onDeliveryChange && (delivery === "steer" || delivery === "queue")) {
|
||||
void Promise.resolve().then(() => onDeliveryChange(input.id, delivery)).catch(() => {});
|
||||
}
|
||||
}}>
|
||||
<DropdownMenuRadioItem value="steer" disabled={!onDeliveryChange} className={`${menuItemClassName} pl-8`}>引导当前任务</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="queue" disabled={!onDeliveryChange} className={`${menuItemClassName} pl-8`}>排队处理</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem disabled={!onCancel} className={menuItemClassName}
|
||||
onSelect={() => performAction(input.id, () => onCancel?.(input.id))}>
|
||||
<X size={14} aria-hidden />关闭排队
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PendingInputText({ text }: { text: string }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [overflows, setOverflows] = useState(false);
|
||||
const textRef = useRef<HTMLParagraphElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const node = textRef.current;
|
||||
if (!node) return;
|
||||
const measure = () => {
|
||||
setOverflows(node.scrollHeight > Number.parseFloat(getComputedStyle(node).lineHeight) * 2 + 1);
|
||||
};
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(node);
|
||||
measure();
|
||||
return () => observer.disconnect();
|
||||
}, [text]);
|
||||
|
||||
useEffect(() => {
|
||||
if (expanded) textRef.current?.parentElement?.scrollIntoView({ block: "nearest" });
|
||||
}, [expanded]);
|
||||
|
||||
return (
|
||||
<div className="min-w-0 flex-1 px-1 py-2">
|
||||
<p ref={textRef} className={cn("text-sm leading-6 text-foreground [overflow-wrap:anywhere]", expanded ? "whitespace-pre-wrap" : "whitespace-normal line-clamp-2")}>
|
||||
{text}
|
||||
</p>
|
||||
{overflows ? <button type="button" aria-expanded={expanded} onClick={() => setExpanded((current) => !current)}
|
||||
className="relative mt-1 rounded-sm text-[11px] leading-5 text-muted-foreground underline underline-offset-4 after:absolute after:-inset-y-2.5 after:inset-x-0 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring active:opacity-70">
|
||||
{expanded ? "收起全文" : "展开全文"}
|
||||
</button> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,37 @@
|
||||
import { Bot } from "lucide-react";
|
||||
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { PersonaState } from "@/shared/ai-elements/persona";
|
||||
import asset from "@/shared/ai-elements/persona-resource-manifest.json";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
|
||||
const LazyPersona = lazy(async () => {
|
||||
const module = await import("@/shared/ai-elements/persona");
|
||||
return { default: module.Persona };
|
||||
});
|
||||
const Persona = lazy(() =>
|
||||
import("@/shared/ai-elements/persona").then((module) => ({ default: module.Persona }))
|
||||
);
|
||||
const stillSources = {
|
||||
idle: "/agent/obsidian-water-idle.png",
|
||||
asleep: "/agent/obsidian-water-offline.png"
|
||||
};
|
||||
|
||||
type AgentPersonaProps = {
|
||||
className?: string;
|
||||
state?: PersonaState;
|
||||
};
|
||||
|
||||
function usePrefersReducedMotion() {
|
||||
const [reducedMotion, setReducedMotion] = useState(
|
||||
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const update = () => setReducedMotion(query.matches);
|
||||
update();
|
||||
query.addEventListener("change", update);
|
||||
return () => query.removeEventListener("change", update);
|
||||
}, []);
|
||||
|
||||
return reducedMotion;
|
||||
}
|
||||
|
||||
function useElementVisibility() {
|
||||
const containerRef = useRef<HTMLSpanElement>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
@@ -20,60 +39,92 @@ function useElementVisibility() {
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
let intersectsViewport = false;
|
||||
const updateVisibility = () => {
|
||||
const updateVisibility = () =>
|
||||
setVisible(intersectsViewport && document.visibilityState === "visible");
|
||||
};
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
intersectsViewport = Boolean(entry?.isIntersecting);
|
||||
updateVisibility();
|
||||
});
|
||||
|
||||
observer.observe(container);
|
||||
document.addEventListener("visibilitychange", updateVisibility);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
document.removeEventListener("visibilitychange", updateVisibility);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { containerRef, visible };
|
||||
}
|
||||
|
||||
function PersonaFallback() {
|
||||
function PersonaStill({ state }: { state: PersonaState }) {
|
||||
const source = stillSources[state === "asleep" ? "asleep" : "idle"];
|
||||
return <img className="agent-orb-still" src={`${source}?v=${asset.sha256}`} alt="" draggable={false} />;
|
||||
}
|
||||
|
||||
function LivePersona({ state }: { state: PersonaState }) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const onLoad = useCallback(() => setLoaded(true), []);
|
||||
const onLoadError = useCallback(() => setFailed(true), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaded) return;
|
||||
// Keep the matching still visible while Rive completes its entrance animation.
|
||||
const timer = window.setTimeout(() => setReady(true), 600);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [loaded]);
|
||||
|
||||
return (
|
||||
<span className="grid h-full w-full place-items-center rounded-full border border-[var(--surface-border)]/70 bg-[var(--surface-control)] text-[var(--ink-muted)]">
|
||||
<Bot className="h-2/5 w-2/5" aria-hidden="true" />
|
||||
<span className="agent-orb-live" data-ready={ready && !failed} data-failed={failed}>
|
||||
<PersonaStill state={state} />
|
||||
{!failed ? (
|
||||
<Suspense fallback={null}>
|
||||
<Persona
|
||||
className="agent-orb-canvas"
|
||||
state={state}
|
||||
onLoad={onLoad}
|
||||
onLoadError={onLoadError}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentPersona({ className, state = "idle" }: AgentPersonaProps) {
|
||||
const { containerRef, visible } = useElementVisibility();
|
||||
const [failed, setFailed] = useState(false);
|
||||
const handleLoadError = useCallback(() => setFailed(true), []);
|
||||
const reducedMotion = usePrefersReducedMotion();
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const live = visible && !reducedMotion;
|
||||
|
||||
const shouldRenderAnimation = !failed && visible;
|
||||
useEffect(() => {
|
||||
if (!live) setHovered(false);
|
||||
}, [live]);
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={containerRef}
|
||||
aria-label="Agent 状态"
|
||||
className={cn("relative block shrink-0", className)}
|
||||
className={cn("relative block h-16 w-16 shrink-0", className)}
|
||||
>
|
||||
{shouldRenderAnimation ? (
|
||||
<Suspense fallback={<PersonaFallback />}>
|
||||
<LazyPersona
|
||||
className="h-full w-full scale-110"
|
||||
onLoadError={handleLoadError}
|
||||
state={state}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<PersonaFallback />
|
||||
)}
|
||||
<span
|
||||
className="agent-orb"
|
||||
data-agent-orb={state}
|
||||
data-animated={live}
|
||||
data-hovered={hovered && live}
|
||||
aria-hidden="true"
|
||||
onPointerEnter={(event) => {
|
||||
if (event.isPrimary && event.pointerType !== "touch") setHovered(true);
|
||||
}}
|
||||
onPointerLeave={() => setHovered(false)}
|
||||
onPointerCancel={() => setHovered(false)}
|
||||
>
|
||||
<span className="agent-orb-response">
|
||||
{live ? <LivePersona state={state} /> : <PersonaStill state={state} />}
|
||||
<span className="agent-orb-contrast" />
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ChevronsUpDown, Folder, FolderPlus, Settings2 } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import type { AgentTopic } from "../api/client";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/shared/ui/dropdown-menu";
|
||||
|
||||
import { AgentTopicDialog } from "./agent-topic-dialog";
|
||||
|
||||
export type AgentTopicControlsProps = {
|
||||
topics: AgentTopic[];
|
||||
topicId?: string | null;
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
onOpen: () => Promise<unknown>;
|
||||
onSelect: (id: string | null) => void;
|
||||
onCreate: (name: string) => Promise<unknown>;
|
||||
onRename: (id: string, name: string) => Promise<unknown>;
|
||||
onDelete: (id: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
export function AgentTopicControls({ topics, topicId, loading, error, onOpen, onSelect, onCreate, onRename, onDelete }: AgentTopicControlsProps) {
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mode, setMode] = useState<"create" | "manage">("create");
|
||||
const [dialogKey, setDialogKey] = useState(0);
|
||||
const current = topics.find((topic) => topic.id === topicId);
|
||||
const begin = (next: "create" | "manage") => {
|
||||
setMode(next);
|
||||
setDialogKey((key) => key + 1);
|
||||
setOpen(true);
|
||||
};
|
||||
return <>
|
||||
<DropdownMenu onOpenChange={(next) => { if (next) void onOpen().catch(() => {}); }}>
|
||||
<DropdownMenuTrigger asChild><Button ref={triggerRef} variant="outline" size="sm" aria-label="选择主题" aria-description={current?.name ?? "未分类"} title={`主题:${current?.name ?? "未分类"}`} className="min-w-0 max-w-full justify-start gap-1.5 px-1.5 text-xs font-medium text-foreground">
|
||||
<Folder aria-hidden="true" className="text-muted-foreground" /><span className="truncate">{current?.name ?? "未分类"}</span><ChevronsUpDown aria-hidden="true" className="ml-auto text-muted-foreground" />
|
||||
</Button></DropdownMenuTrigger>
|
||||
<DropdownMenuContent data-agent-theme="chat" align="start" side="bottom" className="w-[var(--radix-dropdown-menu-trigger-width)] min-w-56 max-w-[min(20rem,calc(100vw-1rem))]">
|
||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">切换主题</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup value={current?.id ?? "unclassified"} onValueChange={(id) => onSelect(id === "unclassified" ? null : id)} className="max-h-[min(45dvh,15rem)] overflow-y-auto">
|
||||
<DropdownMenuRadioItem value="unclassified">未分类</DropdownMenuRadioItem>
|
||||
{topics.map((topic) => <DropdownMenuRadioItem key={topic.id} value={topic.id}>
|
||||
<span className="min-w-0 line-clamp-2 break-words" title={topic.name}>{topic.name}</span>
|
||||
</DropdownMenuRadioItem>)}
|
||||
</DropdownMenuRadioGroup>
|
||||
{loading ? <p role="status" className="px-2 py-2 text-xs text-muted-foreground">正在加载主题…</p> : null}
|
||||
{error ? <p role="alert" className="px-2 py-2 text-xs text-destructive">{error}</p> : null}
|
||||
<DropdownMenuSeparator className="bg-border/50" />
|
||||
<DropdownMenuItem onSelect={() => begin("create")}><FolderPlus size={15} />新建主题</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => begin("manage")}><Settings2 size={15} />管理主题</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<AgentTopicDialog key={dialogKey} open={open} initialMode={mode} onOpenChange={setOpen} triggerRef={triggerRef}
|
||||
topics={topics} onCreate={onCreate} onRename={onRename} onDelete={onDelete} />
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Folder, FolderPlus, Pencil, Trash2, X } from "lucide-react";
|
||||
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 type { AgentTopic } from "../api/client";
|
||||
|
||||
type TopicView = { kind: "list" | "create" } | { kind: "rename" | "delete"; topic: AgentTopic };
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
initialMode: "create" | "manage";
|
||||
onOpenChange: (open: boolean) => void;
|
||||
triggerRef: RefObject<HTMLButtonElement | null>;
|
||||
topics: AgentTopic[];
|
||||
onCreate: (name: string) => Promise<unknown>;
|
||||
onRename: (id: string, name: string) => Promise<unknown>;
|
||||
onDelete: (id: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
export function AgentTopicDialog({ open, initialMode, onOpenChange, triggerRef, topics, onCreate, onRename, onDelete }: Props) {
|
||||
const [view, setView] = useState<TopicView>({ kind: initialMode === "create" ? "create" : "list" });
|
||||
const [name, setName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const fieldId = useId();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const newTopicRef = useRef<HTMLButtonElement>(null);
|
||||
const isForm = view.kind === "create" || view.kind === "rename";
|
||||
const title = view.kind === "create" ? "新建主题" : view.kind === "rename" ? "重命名主题" : view.kind === "delete" ? "删除主题" : "管理主题";
|
||||
const description = view.kind === "create" ? "将相关事件、问题和对话整理到同一主题。"
|
||||
: view.kind === "rename" ? "修改名称不会影响主题中的现有会话。"
|
||||
: view.kind === "delete" ? "仅删除分类,其中的会话会保留在未分类中。"
|
||||
: `当前项目共有 ${topics.length} 个主题,用于整理相关事件与问题。`;
|
||||
const changeView = (next: TopicView) => {
|
||||
setError(undefined);
|
||||
setName(next.kind === "rename" ? next.topic.name : "");
|
||||
setView(next);
|
||||
};
|
||||
const perform = async (action: () => Promise<unknown>, done: () => void) => {
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
try { await action(); done(); }
|
||||
catch (reason) { setError(reason instanceof Error ? reason.message : "主题操作失败,请重试"); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
const cancel = () => initialMode === "create" ? onOpenChange(false) : changeView({ kind: "list" });
|
||||
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}
|
||||
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(); }}>
|
||||
<DialogHeader className="p-6 pb-4 pr-14 text-left">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Button variant="ghost" size="icon-sm" className="absolute right-4 top-4" aria-label="关闭主题窗口" disabled={busy} onClick={() => onOpenChange(false)}>
|
||||
<X aria-hidden="true" />
|
||||
</Button>
|
||||
{isForm ? <form className="flex min-h-0 flex-col" onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!name.trim() || busy) return;
|
||||
void perform(() => view.kind === "rename" ? onRename(view.topic.id, name.trim()) : onCreate(name.trim()),
|
||||
() => view.kind === "create" ? onOpenChange(false) : changeView({ kind: "list" }));
|
||||
}}>
|
||||
<div className="min-h-0 space-y-2 overflow-y-auto px-6 pb-6">
|
||||
<label htmlFor={fieldId} className="text-sm font-medium">主题名称</label>
|
||||
<Input ref={inputRef} id={fieldId} autoFocus maxLength={80} value={name} disabled={busy}
|
||||
aria-describedby={`${fieldId}-hint`} onChange={(event) => setName(event.target.value)} placeholder="例如:城南压力异常排查" />
|
||||
<p id={`${fieldId}-hint`} className="text-xs text-muted-foreground">名称最多 80 个字符。</p>
|
||||
{error ? <p role="alert" className="text-sm text-destructive">{error}</p> : null}
|
||||
</div>
|
||||
<DialogFooter className={footerClass}>
|
||||
<Button type="button" variant="outline" disabled={busy} onClick={cancel}>取消</Button>
|
||||
<Button type="submit" loading={busy} disabled={!name.trim()}>{view.kind === "rename" ? "保存主题" : "创建主题"}</Button>
|
||||
</DialogFooter>
|
||||
</form> : view.kind === "delete" ? <>
|
||||
<div className="min-h-0 space-y-3 overflow-y-auto px-6 pb-6">
|
||||
<p className="break-words text-sm">确认删除“{view.topic.name}”?</p>
|
||||
{error ? <p role="alert" className="text-sm text-destructive">{error}</p> : null}
|
||||
</div>
|
||||
<DialogFooter className={footerClass}>
|
||||
<Button type="button" autoFocus variant="outline" disabled={busy} onClick={() => changeView({ kind: "list" })}>取消</Button>
|
||||
<Button variant="destructive" loading={busy} onClick={() => void perform(() => onDelete(view.topic.id), () => changeView({ kind: "list" }))}>确认删除主题</Button>
|
||||
</DialogFooter>
|
||||
</> : <>
|
||||
<div role="list" aria-label="主题列表" className="min-h-0 overflow-y-auto px-6 pb-4">
|
||||
{topics.length === 0 ? <p className="py-6 text-center text-sm text-muted-foreground">还没有主题,可以新建一个来整理对话。</p> : topics.map((topic) => (
|
||||
<div role="listitem" key={topic.id} className="flex items-center gap-3 border-b border-border/40 py-3 last:border-0">
|
||||
<Folder aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 break-words text-sm font-medium">{topic.name}</span>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button variant="ghost" size="icon-sm" aria-label={`重命名主题:${topic.name}`} onClick={() => changeView({ kind: "rename", topic })}><Pencil aria-hidden="true" /></Button>
|
||||
<Button variant="ghost" size="icon-sm" aria-label={`删除主题:${topic.name}`} onClick={() => changeView({ kind: "delete", topic })}><Trash2 aria-hidden="true" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter className={footerClass}>
|
||||
<Button ref={newTopicRef} autoFocus variant="outline" onClick={() => changeView({ kind: "create" })}><FolderPlus aria-hidden="true" />新建主题</Button>
|
||||
<Button onClick={() => onOpenChange(false)}>完成</Button>
|
||||
</DialogFooter>
|
||||
</>}
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
@@ -71,11 +71,11 @@ function AgentUiResultCard({ result }: { result: AgentUiResult }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentChartView({ chart }: { chart: NonNullable<ReturnType<typeof parseFrontendChart>> }) {
|
||||
return <SafeChartEnvelope envelope={{ kind: "chart", schemaVersion: "agent-ui@1", grammar: "echarts-safe-subset", surface: "side_panel", spec: { title: chart.title, chart_type: chart.chart_type }, data: { x_data: chart.x_data, series: chart.series } }} />;
|
||||
export function AgentChartView({ chart, embedded = false }: { chart: NonNullable<ReturnType<typeof parseFrontendChart>>; embedded?: boolean }) {
|
||||
return <SafeChartEnvelope embedded={embedded} envelope={{ kind: "chart", schemaVersion: "agent-ui@1", grammar: "echarts-safe-subset", surface: "side_panel", spec: { title: chart.title, chart_type: chart.chart_type }, data: { x_data: chart.x_data, series: chart.series } }} />;
|
||||
}
|
||||
|
||||
function SafeChartEnvelope({ envelope }: { envelope: Extract<UIEnvelope, { kind: "chart" }> }) {
|
||||
function SafeChartEnvelope({ envelope, embedded = false }: { envelope: Extract<UIEnvelope, { kind: "chart" }>; embedded?: boolean }) {
|
||||
const spec = parseChartSpec(envelope.spec);
|
||||
const data = normalizeSafeChartData(envelope.data, 100);
|
||||
|
||||
@@ -84,9 +84,9 @@ function SafeChartEnvelope({ envelope }: { envelope: Extract<UIEnvelope, { kind:
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="agent-panel-nested rounded-xl p-3">
|
||||
<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="min-w-0">
|
||||
{!embedded ? <div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-slate-950" title={spec.title}>
|
||||
{spec.title ?? "Agent 图表"}
|
||||
</p>
|
||||
@@ -94,7 +94,7 @@ function SafeChartEnvelope({ envelope }: { envelope: Extract<UIEnvelope, { kind:
|
||||
{spec.chartType === "bar" ? "柱状图" : "折线图"}
|
||||
{spec.yAxisName ? ` · ${spec.yAxisName}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div> : null}
|
||||
<div className="flex shrink-0 flex-wrap justify-end gap-1.5">
|
||||
{data.series.slice(0, 3).map((series, index) => (
|
||||
<span key={series.name} className="flex items-center gap-1 text-xs text-slate-600">
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createElement, type ComponentType, type ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { expect, it } from "vitest";
|
||||
import { artifactLinkComponents, artifactLinksPlugin } from "./artifact-markdown";
|
||||
|
||||
it("keeps external link safety and rejects forged custom export links", () => {
|
||||
const Renderer = artifactLinkComponents["agent-export-link"] as ComponentType<{ href: string; children: ReactNode }>;
|
||||
const path = "/api/v1/session/s/files/mcp-11111111-1111-1111-1111-111111111111.zip";
|
||||
const render = (href: string) => renderToStaticMarkup(createElement(Renderer, { href, children: "下载" }));
|
||||
expect(render(path)).toContain(`<a href="${path}"`);
|
||||
for (const href of ["https://foreign.test/x", "javascript:alert(1)", "/api/v1/admin/secrets"]) {
|
||||
expect(render(href)).toBe("<span>下载</span>");
|
||||
}
|
||||
const root = { type: "root", children: [
|
||||
{ type: "element", tagName: "a", properties: { href: path } },
|
||||
{ type: "element", tagName: "a", properties: { href: "https://example.com" } },
|
||||
] };
|
||||
artifactLinksPlugin()(root);
|
||||
expect(root.children.map((child) => child.tagName)).toEqual(["agent-export-link", "a"]);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Components } from "streamdown";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type MarkdownNode = {
|
||||
type: string;
|
||||
tagName?: string;
|
||||
properties?: Record<string, unknown>;
|
||||
children?: MarkdownNode[];
|
||||
};
|
||||
|
||||
const artifactPath = /^\/api\/v1\/session\/[^/?#]+\/files\/mcp-[a-f0-9-]{36}\.(?:zip|xlsx)$/;
|
||||
|
||||
// Only verified-format first-party download paths use the authenticated click
|
||||
// handler. All other anchors retain Streamdown's built-in link safety dialog.
|
||||
export function artifactLinksPlugin() {
|
||||
return (tree: MarkdownNode) => {
|
||||
const visit = (node: MarkdownNode) => {
|
||||
if (node.tagName === "a" && typeof node.properties?.href === "string" && artifactPath.test(node.properties.href)) {
|
||||
node.tagName = "agent-export-link";
|
||||
}
|
||||
node.children?.forEach(visit);
|
||||
};
|
||||
visit(tree);
|
||||
};
|
||||
}
|
||||
|
||||
export const artifactLinkComponents: Components = {
|
||||
"agent-export-link": ({ href, children }) => {
|
||||
if (typeof href !== "string" || !artifactPath.test(href)) return <span>{children as ReactNode}</span>;
|
||||
return <a href={href} className="wrap-anywhere font-medium text-primary underline">{children as ReactNode}</a>;
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,9 @@
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { MessageResponse } from "@/shared/ai-elements/message";
|
||||
import { defaultRehypePlugins } from "streamdown";
|
||||
import { artifactLinkComponents, artifactLinksPlugin } from "./artifact-markdown";
|
||||
|
||||
const responseRehypePlugins = [...Object.values(defaultRehypePlugins), artifactLinksPlugin];
|
||||
|
||||
type StreamingTokenResponseProps = {
|
||||
content: string;
|
||||
@@ -27,6 +31,8 @@ export function StreamingTokenResponse({
|
||||
isAnimating={isAnimating}
|
||||
mode="streaming"
|
||||
parseIncompleteMarkdown={true}
|
||||
rehypePlugins={responseRehypePlugins}
|
||||
components={artifactLinkComponents}
|
||||
>
|
||||
{content}
|
||||
</MessageResponse>
|
||||
|
||||
@@ -7,7 +7,6 @@ export { AgentChartView } from "./components/agent-ui-envelope-renderer";
|
||||
export { countPendingAgentInputs, hasPendingAgentInput } from "./message-state";
|
||||
export type {
|
||||
AgentCommandPanelPresentation,
|
||||
AgentConversationProjectOption,
|
||||
AgentWorkspaceContextItem
|
||||
} from "./components/agent-command-panel";
|
||||
export { AgentPersona } from "./components/agent-persona";
|
||||
@@ -20,6 +19,7 @@ export type {
|
||||
AgentInputDelivery,
|
||||
AgentResponseInput,
|
||||
AgentChatSessionSummary,
|
||||
AgentTopic,
|
||||
AgentSessionPurpose,
|
||||
AgentEventTriggerContext,
|
||||
AgentLoadedChatSession,
|
||||
@@ -94,8 +94,13 @@ export type { SafeChartData, SafeChartSeries, SafeChartSpec } from "./chart-data
|
||||
|
||||
export { parseFrontendChart } from "./frontend-action/chart";
|
||||
|
||||
export { parseAnswerUiBlock } from "./answer-ui/contract";
|
||||
export type { AnswerUiBlock, AnswerUiPart } from "./answer-ui/contract";
|
||||
export type { AnswerUiMapTarget } from "./answer-ui/answer-ui-view";
|
||||
|
||||
export { ResponsesChatTransport, createResponseRequest } from "./api/responses-transport";
|
||||
export { AnswerUiView } from "./answer-ui/answer-ui-view";
|
||||
export { parseAnswerUiBlock, reportUiBlocksSchema } from "./answer-ui/contract";
|
||||
export type { AnswerUiBlock, AnswerUiPart, StoredReportUiBlock } from "./answer-ui/contract";
|
||||
export type { AnswerUiSelection } from "./answer-ui/answer-ui-interaction-context";
|
||||
|
||||
export type { AgentSessionPage, AgentHistoryMessagePage, AgentMessagePagination } from "./api/client";
|
||||
export type { AgentRunStatus } from "./api/client";
|
||||
|
||||
Reference in New Issue
Block a user