-
{title}
-
{description}
+
+
+
+
+
+
{title}
+
{description}
+
{fields.map((field) =>
field.kind === "datetime" ? (
@@ -347,6 +338,7 @@ function FollowUpForm({
/>
) : (
- {submitting ? "正在发送…" : "发送补充资料"}
+ {submitting ? submitMode === "draft" ? "正在处理…" : "正在发送…" : submitMode === "draft" ? "带入对话" : "发送补充资料"}
{feedback ? (
diff --git a/src/features/agent/answer-ui/contract.ts b/src/features/agent/answer-ui/contract.ts
index c898979..a56b485 100644
--- a/src/features/agent/answer-ui/contract.ts
+++ b/src/features/agent/answer-ui/contract.ts
@@ -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[number];
+
export type AnswerUiBlock = {
version: typeof ANSWER_UI_VERSION;
block_id: string;
diff --git a/src/features/agent/answer-ui/interactive-chart.tsx b/src/features/agent/answer-ui/interactive-chart.tsx
new file mode 100644
index 0000000..8f7d611
--- /dev/null
+++ b/src/features/agent/answer-ui/interactive-chart.tsx
@@ -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 ;
+}
+export function InteractiveChart(props: AnswerChartProps) {
+ const [view, setView] = useState(props.chart_type);
+ const [hidden, setHidden] = useState([]);
+ 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
+
+ {props.title}
+ {labels.length} / {props.x_data.length} 个数据点 · {visible.length} 组序列
+
+
+
+
+ 折线
+ 柱状
+ 数据
+
+
+ {props.series.map((series, index) => )}
+
+
+
+
+
+
+
+ {fullRange ? "全部数据" : `${props.x_data[range[0]]} 至 ${props.x_data[range[1]]}`}
+
+
+
+
+
setRange([start, Math.max(start, range[1])])} />
+ 至
+ setRange([Math.min(range[0], end), end])} />
+
+
+
+
+ {(["line", "bar"] as const).map((type) =>
+
+ {
+ if (event.componentType === "series" && typeof event.dataIndex === "number")
+ setPoint(range[0] + event.dataIndex);
+ } }} />
+
+ )}
+
+ series.name)]}
+ rows={labels.map((label, index) => [label, ...visible.map((series) => String(series.data[range[0] + index]))])}
+ source={props.source} />
+
+
+
+
查看数据点
+
setPoint(range[0] + index)} />
+
+
+ {visible.map((series, index) =>
+
- {series.name}
+ - {series.data[pointIndex]}
+ )}
+
+
`${series.name}:${series.data[pointIndex]}`).join("\n")}\n来源:${props.source}`} />
+
+
+ 来源:{props.source}。区间操作仅查看已加载数据。
+
+
+ ;
+}
diff --git a/src/features/agent/answer-ui/interactive-data-table.tsx b/src/features/agent/answer-ui/interactive-data-table.tsx
new file mode 100644
index 0000000..80f4092
--- /dev/null
+++ b/src/features/agent/answer-ui/interactive-data-table.tsx
@@ -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([]);
+ 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
+
+ { setQuery(event.currentTarget.value); setSelection([]); }} />
+ {filtered.length} / {rows.length} 行
+
+
+
+ {title}
+
+ 选择行
+ {columns.map((column, index) =>
+
+ )}
+
+
+ {filtered.map(({ cells, id }) =>
+ setSelection((current) => event.target.checked ? [...current, id] : current.filter((value) => value !== id))} />
+ {cells.map((cell, index) => {cell})}
+ )}
+ {!filtered.length ?
+ {rows.length ? "当前筛选没有匹配的数据。" : "没有已加载的数据。"}
+ : null}
+
+
+
+ {selected.length ?
+
已选 {selected.length} 行
+
+
:
筛选与排序仅作用于已加载数据;勾选行可继续提问。
}
+
;
+}
diff --git a/src/features/agent/api/artifact-download.test.ts b/src/features/agent/api/artifact-download.test.ts
new file mode 100644
index 0000000..bfb5f8b
--- /dev/null
+++ b/src/features/agent/api/artifact-download.test.ts
@@ -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();
+ });
+});
diff --git a/src/features/agent/api/client.test.ts b/src/features/agent/api/client.test.ts
index b64d784..2ce0cff 100644
--- a/src/features/agent/api/client.test.ts
+++ b/src/features/agent/api/client.test.ts
@@ -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
}
]);
});
diff --git a/src/features/agent/api/client.ts b/src/features/agent/api/client.ts
index 0c6ecfd..14737ce 100644
--- a/src/features/agent/api/client.ts
+++ b/src/features/agent/api/client.ts
@@ -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;
submitResponseInput: (sessionId: string, text: string, delivery: AgentInputDelivery, id: string) => Promise;
listResponseInputs: (sessionId: string) => Promise;
updateResponseInput: (sessionId: string, id: string, delivery: AgentInputDelivery) => Promise;
+ cancelResponseInput: (sessionId: string, id: string) => Promise;
listAgentMessages: (sessionId: string, options?: { cursor?: string; signal?: AbortSignal }) => Promise;
streamAgentMessages: (sessionId: string, options: { signal?: AbortSignal; onMessage: () => void }) => Promise;
generatePromptSuggestions: (activities: AgentRecentActivity[], model: string, signal?: AbortSignal) => Promise;
- createSession: (options?: { triggerContext?: AgentEventTriggerContext }) => Promise;
+ createSession: (options?: { triggerContext?: AgentEventTriggerContext; topicId?: string }) => Promise;
listSessions: (options?: { purpose?: AgentSessionPurpose; taskId?: string }) => Promise;
+ listSessionPage: (options?: { q?: string; cursor?: string; limit?: number; purpose?: AgentSessionPurpose; topicId?: string; includeCollaboration?: boolean; signal?: AbortSignal }) => Promise;
+ listTopics: () => Promise;
+ createTopic: (name: string) => Promise;
+ renameTopic: (id: string, name: string) => Promise;
+ deleteTopic: (id: string) => Promise;
+ moveSessionToTopic: (sessionId: string, topicId: string | null) => Promise;
+ loadOlderMessages: (sessionId: string, before: string, signal?: AbortSignal) => Promise;
createTaskCollaborationSession: (taskId: string) => Promise;
- loadSession: (sessionId: string) => Promise;
+ loadSession: (sessionId: string, options?: { messageLimit?: number; signal?: AbortSignal }) => Promise;
streamSession: (
sessionId: string,
options: {
signal?: AbortSignal;
+ messageLimit?: number;
onEvent: (event: AgentSessionStreamEvent) => void;
}
) => Promise;
@@ -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("/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(`/session/${encodeURIComponent(sessionId)}/inputs/${encodeURIComponent(id)}`, {
+ method: "DELETE"
+ });
+ },
async generatePromptSuggestions(activities, model, signal) {
const result = await requestJson("/suggestions", {
method: "POST",
@@ -260,7 +301,7 @@ export function createAgentApiClient(
return requestJson("/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("/topics", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }) }); },
+ async renameTopic(id, name) { return requestJson(`/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(
`/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 };
+}
diff --git a/src/features/agent/api/responses-transport.test.ts b/src/features/agent/api/responses-transport.test.ts
index 9d9f6da..3a3676d 100644
--- a/src/features/agent/api/responses-transport.test.ts
+++ b/src/features/agent/api/responses-transport.test.ts
@@ -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 () => {
diff --git a/src/features/agent/api/responses-transport.ts b/src/features/agent/api/responses-transport.ts
index 06af575..ce17610 100644
--- a/src/features/agent/api/responses-transport.ts
+++ b/src/features/agent/api/responses-transport.ts
@@ -4,7 +4,7 @@ const record = (value: unknown): value is Record => 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): 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 流缺少创建事件");
diff --git a/src/features/agent/components/agent-chat-message-chrome.tsx b/src/features/agent/components/agent-chat-message-chrome.tsx
new file mode 100644
index 0000000..d2b8c88
--- /dev/null
+++ b/src/features/agent/components/agent-chat-message-chrome.tsx
@@ -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 (
+
+ {running ? (
+
+
+ 正在回复
+
+ ) : interactive ? (
+
+ 交互回答
+
+ ) : null}
+
+ );
+}
+
+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 (
+
+ {
+ try {
+ await navigator.clipboard.writeText(text);
+ setCopied(true);
+ setError(false);
+ } catch {
+ setCopied(false);
+ setError(true);
+ }
+ }}
+ >
+ {copied ? : }
+ {copied ? "已复制" : "复制回复"}
+
+
+ {error ? "复制失败,请选择文字复制" : copied ? "已复制回复文字与交互内容摘要" : ""}
+
+
+ );
+}
diff --git a/src/features/agent/components/agent-collapsed-rail.tsx b/src/features/agent/components/agent-collapsed-rail.tsx
index d58e14a..6165337 100644
--- a/src/features/agent/components/agent-collapsed-rail.tsx
+++ b/src/features/agent/components/agent-collapsed-rail.tsx
@@ -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 (
-
+
+
+
);
}
diff --git a/src/features/agent/components/agent-command-panel.tsx b/src/features/agent/components/agent-command-panel.tsx
index 793d3dc..d658d93 100644
--- a/src/features/agent/components/agent-command-panel.tsx
+++ b/src/features/agent/components/agent-command-panel.tsx
@@ -1,16 +1,14 @@
import type { AnswerUiMapTarget } from "../answer-ui/answer-ui-view";
import { AnswerPartsView } from "../answer-ui/answer-parts-view";
+import type { AnswerUiSelection } from "../answer-ui/answer-ui-interaction-context";
+import { ExpandableReading } from "@/shared/ui/reading-workspace";
import { getAnswerPresentation } from "../answer-ui/answer-presentation";
import { Disclosure } from "@/shared/ui/disclosure";
import {
- Activity,
- ArrowUpRight,
CheckCircle2,
ChevronDown,
ChevronsUpDown,
ChevronLeft,
- Folder,
- FolderPlus,
History,
LoaderCircle,
Mic,
@@ -22,7 +20,8 @@ import {
Shield,
ShieldCheck,
Square,
- UserRound,
+ Sparkles,
+ Zap,
Volume2,
X
} from "lucide-react";
@@ -30,7 +29,6 @@ import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "motion/
import {
useCallback,
useEffect,
- useId,
useLayoutEffect,
useMemo,
useRef,
@@ -46,6 +44,7 @@ import { Agent, AgentContent } from "@/shared/ai-elements/agent";
import {
Conversation,
ConversationContent,
+ ConversationEmptyState,
ConversationScrollButton
} from "@/shared/ai-elements/conversation";
import type { PersonaState } from "@/shared/ai-elements/persona";
@@ -69,17 +68,23 @@ import {
DropdownMenuTrigger
} from "@/shared/ui/dropdown-menu";
import { Button } from "@/shared/ui/button";
+import { Separator } from "@/shared/ui/separator";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/shared/ui/tooltip";
import { cn } from "@/shared/ui/cn";
-import { StatusBadge, StatusDot, type StatusTone } from "@/shared/ui/status";
+import { StatusDot, type StatusTone } from "@/shared/ui/status";
import type { AgentChatSessionSummary, AgentInputDelivery, AgentResponseInput } from "../api/client";
-import { AgentPersona } from "./agent-persona";
+import { AgentChatMessageActions, AgentChatMessageHeader } from "./agent-chat-message-chrome";
import {
+ agentLayoutTransition,
agentPanelItemVariants,
agentPanelListVariants,
agentPanelSectionVariants
} from "./agent-motion";
+import { AgentPersona } from "./agent-persona";
+import { AgentTopicControls } from "./agent-topic-controls";
import { AgentHistoryPanel } from "./agent-history-panel";
import { AgentMessageInbox } from "./agent-message-inbox";
+import { AgentPendingInputs } from "./agent-pending-inputs";
import type { ComponentProps } from "react";
import { AgentMessageDetails, AgentMessageProgress } from "./agent-message-details";
import { AgentUiEnvelopeRenderer } from "./agent-ui-envelope-renderer";
@@ -111,10 +116,6 @@ export type AgentWorkspaceContextItem = {
tone?: "neutral" | "info" | "warning";
};
-export type AgentConversationProjectOption = {
- id: string;
- name: string;
-};
type AgentCommandPanelProps = {
presentation?: AgentCommandPanelPresentation;
@@ -123,6 +124,21 @@ type AgentCommandPanelProps = {
sessionTitle?: string;
sessionHistory?: AgentChatSessionSummary[];
sessionHistoryLoading?: boolean;
+ historyQuery?: string;
+ historyHasMore?: boolean;
+ historyLoadingMore?: boolean;
+ historyError?: string;
+ historyPendingUpdate?: boolean;
+ onHistoryQueryChange?: (value: string) => void;
+ onHistoryOpen?: () => Promise | void;
+ onHistoryClose?: () => void;
+ onLoadMoreHistory?: () => Promise | void;
+ hasOlderMessages?: boolean;
+ loadingOlderMessages?: boolean;
+ onLoadOlderMessages?: () => Promise | void;
+ backgroundStatus?: string;
+ readingPosition?: { messageId?: string; offset: number; atBottom: boolean };
+ onReadingPositionChange?: (position: { messageId?: string; offset: number; atBottom: boolean }) => void;
promptSuggestions?: Array<{ question: string; sourceLabel?: string }>;
promptSuggestionsLoading?: boolean;
promptSuggestionsUnavailable?: boolean;
@@ -135,17 +151,18 @@ type AgentCommandPanelProps = {
pendingInputs?: AgentResponseInput[];
onInputDeliveryChange?: (delivery: AgentInputDelivery) => void;
onPendingInputDeliveryChange?: (id: string, delivery: AgentInputDelivery) => Promise | void;
+ onPendingInputEdit?: (id: string) => Promise | void;
+ onPendingInputCancel?: (id: string) => Promise | void;
+ onDownloadFile?: (path: string) => Promise | void;
messages?: AgentChatMessage[];
streamRenderState?: AgentStreamRenderState;
modelOptions?: AgentModelOption[];
selectedModel?: string;
- conversationScope?: "personal" | "project";
- onConversationScopeChange?: (scope: "personal" | "project") => Promise | void;
- conversationProjects?: AgentConversationProjectOption[];
- selectedConversationProjectId?: string | null;
- onConversationProjectChange?: (projectId: string) => Promise | void;
currentProjectName?: string | null;
- onOpenProjectCreator?: () => void;
+ topicControls?: ComponentProps;
+ historyTopicId?: string;
+ onHistoryTopicChange?: (id: string | undefined) => void;
+ onMoveSessionToTopic?: (sessionId: string, topicId: string | null) => Promise;
approvalMode?: AgentApprovalMode;
uiResults?: AgentUiResult[];
reviewPanel?: ReactNode;
@@ -154,6 +171,8 @@ type AgentCommandPanelProps = {
prompt: string;
promptFocusRequest?: number;
promptReference?: { title: string; detail?: string; placeholder?: string };
+ contentReference?: AnswerUiSelection;
+ onContentReferenceChange?: (selection: AnswerUiSelection | undefined) => void;
onCollapse: () => void;
onPromptChange: Dispatch>;
onRefreshHistory?: () => Promise | void;
@@ -181,6 +200,10 @@ export function AgentCommandPanel({
sessionTitle = "新对话",
sessionHistory = [],
sessionHistoryLoading = false,
+ historyQuery, historyHasMore, historyLoadingMore, historyError, historyPendingUpdate,
+ onHistoryQueryChange, onHistoryOpen, onHistoryClose, onLoadMoreHistory,
+ hasOlderMessages, loadingOlderMessages, onLoadOlderMessages, backgroundStatus,
+ readingPosition, onReadingPositionChange,
promptSuggestions = [],
promptSuggestionsLoading = false,
promptSuggestionsUnavailable = false,
@@ -192,17 +215,15 @@ export function AgentCommandPanel({
pendingInputs = [],
onInputDeliveryChange,
onPendingInputDeliveryChange,
+ onPendingInputEdit,
+ onPendingInputCancel,
+ onDownloadFile,
messages = [],
streamRenderState = {},
modelOptions = [],
selectedModel = "",
- conversationScope = "project",
- onConversationScopeChange,
- conversationProjects = [],
- selectedConversationProjectId,
- onConversationProjectChange,
currentProjectName,
- onOpenProjectCreator,
+ topicControls, historyTopicId, onHistoryTopicChange, onMoveSessionToTopic,
approvalMode = "request",
uiResults = [],
reviewPanel,
@@ -210,6 +231,8 @@ export function AgentCommandPanel({
prompt,
promptFocusRequest = 0,
promptReference,
+ contentReference,
+ onContentReferenceChange,
onCollapse,
onPromptChange,
onRefreshHistory,
@@ -227,23 +250,21 @@ export function AgentCommandPanel({
onRejectQuestion
}: AgentCommandPanelProps) {
const [historyOpen, setHistoryOpen] = useState(false);
+ const [historySelectionError, setHistorySelectionError] = useState();
const [loadingSessionId, setLoadingSessionId] = useState(null);
const [scrollRequestId, setScrollRequestId] = useState(0);
- const historyPanelId = useId();
+ const prefersReducedMotion = useReducedMotion();
const historyTriggerRef = useRef(null);
- const historyPanelRef = useRef(null);
const promptRestoreGenerationRef = useRef(0);
- const composerRef = useRef(null);
const promptTextareaRef = useRef(null);
- const composerStartRectRef = useRef(null);
- const composerTransitionRef = useRef(null);
const mountedRef = useRef(true);
const desktopFloating = presentation === "desktop-floating";
- const floatingPresentation = desktopFloating || presentation === "mobile-sheet";
+ const floatingPresentation = desktopFloating;
const hasConversation = messages.length > 0 || streaming || Boolean(messageInbox?.messages.length);
- const centeredComposer = !hasConversation && (presentation === "desktop-dock" || Boolean(promptReference));
+ // Existing task state and loading/errors stay visible even before a first message.
+ const centeredComposer = !hasConversation && !loadingSessionId && !reviewPanel
+ && uiResults.length === 0 && !messageInbox?.loading && !messageInbox?.error;
const showStartSuggestions = centeredComposer && !qaOnly && !promptReference;
- const prefersReducedMotion = useReducedMotion();
const trimmedPrompt = prompt.trim();
useEffect(() => {
if (!promptFocusRequest) return;
@@ -254,10 +275,7 @@ export function AgentCommandPanel({
});
return () => cancelAnimationFrame(frame);
}, [promptFocusRequest]);
- const selectedConversationProject = conversationProjects.find(
- (project) => project.id === selectedConversationProjectId
- );
- const selectedProjectName = selectedConversationProject?.name ?? currentProjectName ?? "当前项目";
+
const {
speechState,
speakingMessageId,
@@ -287,12 +305,6 @@ export function AgentCommandPanel({
isSupported: isRecognitionSupported
} = useAgentSpeechRecognition(appendRecognitionResult);
- useEffect(() => {
- if (historyOpen) {
- void Promise.resolve(onRefreshHistory?.());
- }
- }, [historyOpen, onRefreshHistory]);
-
useEffect(() => {
mountedRef.current = true;
return () => {
@@ -301,46 +313,6 @@ export function AgentCommandPanel({
};
}, []);
- useEffect(() => {
- if (!historyOpen) {
- return;
- }
-
- const closeHistoryOnOutsidePointer = (event: PointerEvent) => {
- const target = event.target;
- if (!(target instanceof Node)) {
- return;
- }
-
- if (
- historyPanelRef.current?.contains(target) ||
- historyTriggerRef.current?.contains(target)
- ) {
- return;
- }
-
- setHistoryOpen(false);
- };
- const closeHistoryOnEscape = (event: KeyboardEvent) => {
- if (event.key !== "Escape") {
- return;
- }
-
- event.preventDefault();
- event.stopPropagation();
- setHistoryOpen(false);
- window.requestAnimationFrame(() => historyTriggerRef.current?.focus());
- };
-
- document.addEventListener("pointerdown", closeHistoryOnOutsidePointer, true);
- document.addEventListener("keydown", closeHistoryOnEscape);
-
- return () => {
- document.removeEventListener("pointerdown", closeHistoryOnOutsidePointer, true);
- document.removeEventListener("keydown", closeHistoryOnEscape);
- };
- }, [historyOpen]);
-
useEffect(() => {
stopSpeech();
}, [activeSessionId, stopSpeech]);
@@ -349,53 +321,50 @@ export function AgentCommandPanel({
if (streaming) stopListening();
}, [stopListening, streaming]);
- useLayoutEffect(() => {
- if (centeredComposer || !composerStartRectRef.current || !composerRef.current) return;
- const start = composerStartRectRef.current;
- composerStartRectRef.current = null;
- const end = composerRef.current.getBoundingClientRect();
- if (prefersReducedMotion || (start.x === end.x && start.y === end.y)) return;
- composerTransitionRef.current?.cancel();
- composerTransitionRef.current = composerRef.current.animate(
- [
- { transform: `translate(${start.x - end.x}px, ${start.y - end.y}px)` },
- { transform: "translate(0, 0)" }
- ],
- { duration: 280, easing: "cubic-bezier(0.22, 1, 0.36, 1)" }
- );
- }, [centeredComposer, prefersReducedMotion]);
-
const submitPrompt = (nextPrompt: string) => {
if (!nextPrompt) {
return;
}
const restoreGeneration = promptRestoreGenerationRef.current + 1;
promptRestoreGenerationRef.current = restoreGeneration;
- if (centeredComposer && !prefersReducedMotion && composerRef.current) {
- composerStartRectRef.current = composerRef.current.getBoundingClientRect();
- }
onPromptChange("");
stopListening();
setScrollRequestId((requestId) => requestId + 1);
- void Promise.resolve(onSubmitPrompt(nextPrompt)).catch(() => {
+ const submittedReference = contentReference;
+ const message = submittedReference
+ ? `${nextPrompt}\n\n引用内容:${submittedReference.title}\n${submittedReference.text}`
+ : nextPrompt;
+ onContentReferenceChange?.(undefined);
+ void Promise.resolve(onSubmitPrompt(message)).catch(() => {
if (!mountedRef.current || promptRestoreGenerationRef.current !== restoreGeneration) {
return;
}
onPromptChange((current) => current || nextPrompt);
+ if (submittedReference) onContentReferenceChange?.(submittedReference);
});
};
const historyPanel = (
{ setHistoryOpen(open); if (!open) onHistoryClose?.(); }}
+ triggerRef={historyTriggerRef}
+ scopeLabel={currentProjectName ?? "当前项目"}
+ query={historyQuery} onQueryChange={onHistoryQueryChange}
+ hasMore={historyHasMore} loadingMore={historyLoadingMore} error={historySelectionError ?? historyError}
+ pendingUpdate={historyPendingUpdate} onLoadMore={onLoadMoreHistory}
activeSessionId={activeSessionId}
loadingSessionId={loadingSessionId}
loading={sessionHistoryLoading}
sessions={sessionHistory}
+ topics={topicControls?.topics} topicId={historyTopicId} onTopicChange={onHistoryTopicChange} onMoveSessionToTopic={onMoveSessionToTopic}
onRefresh={onRefreshHistory}
onSelectSession={(nextSessionId) => {
promptRestoreGenerationRef.current += 1;
setLoadingSessionId(nextSessionId);
+ setHistorySelectionError(undefined);
void Promise.resolve(onLoadHistorySession?.(nextSessionId))
- .then(() => setHistoryOpen(false))
+ .then(() => { setHistoryOpen(false); onHistoryClose?.(); })
+ .catch((error: unknown) => setHistorySelectionError(error instanceof Error ? error.message : "无法打开这个历史会话"))
.finally(() => setLoadingSessionId(null));
}}
onRenameSession={onRenameHistorySession}
@@ -404,10 +373,24 @@ export function AgentCommandPanel({
);
return (
-