feat(agent): support Responses steering and project-aware tools

Use the dedicated TJWATER_AGENT_URL and Responses stream protocol. Keep running
turns active while users steer or queue follow-ups and edit pending delivery.
Refresh the model catalog before generation and preserve approval mode selection.

Integrate scoped investigation messaging/presentation, geographic place selection,
current Agent composer styles and supporting workbench contracts.

Validation: 77 focused Vitest checks, 21 browser cases, lint, typecheck and
production build. Preserve direct Server/Agent routing and project authentication.
This commit is contained in:
2026-10-19 18:46:58 +08:00
parent 2c5bf15cce
commit ec23d44b02
131 changed files with 6663 additions and 1204 deletions
@@ -0,0 +1,81 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { AgentApiClient, AgentChatSessionSummary, AgentPromptSuggestion, AgentRecentActivity } from "@/features/agent";
import type { WorkbenchAlert, ScheduledConditionItem, WorkbenchAlertDataSource, WorkbenchRuntimeDataSource } from "../types";
import type { AnalysisArtifact } from "../workspace/workspace-model";
type ActivityInputs = {
alerts: WorkbenchAlert[];
alertDataSource: WorkbenchAlertDataSource;
conditions: ScheduledConditionItem[];
runtimeDataSource: WorkbenchRuntimeDataSource;
artifact: AnalysisArtifact | null;
sessions: AgentChatSessionSummary[];
};
const activityLabels = { alert: "告警", task: "任务", analysis: "分析成果", session: "近期会话" } as const;
const timeFormatter = new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false });
type DisplaySuggestion = AgentPromptSuggestion & { sourceLabel: string };
export function collectRecentAgentActivities(input: ActivityInputs, now = Date.now()): AgentRecentActivity[] {
const origin = (source: string) => source === "mock" || source === "demo" ? "demo" as const : "backend" as const;
const validDate = (time: string | number) => Number.isFinite(new Date(time).getTime());
const alerts: AgentRecentActivity[] = input.alerts.filter((item) => item.occurredAt && validDate(item.occurredAt))
.sort((a, b) => Date.parse(b.occurredAt!) - Date.parse(a.occurredAt!)).slice(0, 4).map((item) => ({
id: `alert:${item.id}`, kind: "alert", title: item.title.slice(0, 160), occurred_at: new Date(item.occurredAt!).toISOString(),
summary: item.description.slice(0, 600), status: item.severity,
origin: item.id.startsWith("operational-task-notification:") ? "backend" : origin(input.alertDataSource),
}));
const tasks: AgentRecentActivity[] = input.conditions.filter((item) => validDate(item.updatedAt))
.sort((a, b) => b.updatedAt - a.updatedAt).slice(0, 3).map((item) => ({
id: `task:${item.id}`, kind: "task", title: item.title.slice(0, 160), occurred_at: new Date(item.updatedAt).toISOString(),
summary: item.summary.slice(0, 600), status: item.status,
origin: origin(item.kind === "condition" ? item.dataSource ?? input.runtimeDataSource : input.runtimeDataSource),
}));
const artifact = input.artifact;
const analyses: AgentRecentActivity[] = artifact && validDate(artifact.generatedAt) ? [{
id: `analysis:${artifact.id}`, kind: "analysis", title: artifact.title.slice(0, 160), occurred_at: new Date(artifact.generatedAt).toISOString(),
summary: artifact.summary.slice(0, 600), origin: artifact.origin ?? "backend",
}] : [];
const sessions: AgentRecentActivity[] = input.sessions.filter((item) => item.purpose === "ordinary_chat" && item.title.trim() !== "新对话" &&
validDate(item.updatedAt) && item.updatedAt >= now - 7 * 24 * 60 * 60_000 && item.updatedAt <= now)
.sort((a, b) => b.updatedAt - a.updatedAt).slice(0, 4).map((item) => ({
id: `session:${item.id}`, kind: "session", title: item.title.slice(0, 160), occurred_at: new Date(item.updatedAt).toISOString(), origin: "backend",
}));
return [...new Map([...alerts, ...tasks, ...analyses, ...sessions].map((item) => [item.id, item])).values()];
}
export function useAgentPromptSuggestions({ enabled, model, sessionId, request, alerts, alertDataSource, conditions, runtimeDataSource, artifact, sessions }: ActivityInputs & {
enabled: boolean;
model: string;
sessionId: string | null;
request: AgentApiClient["generatePromptSuggestions"];
}) {
const activities = useMemo(() => collectRecentAgentActivities({ alerts, alertDataSource, conditions, runtimeDataSource, artifact, sessions }),
[alerts, alertDataSource, conditions, runtimeDataSource, artifact, sessions]);
const snapshot = useRef(activities);
useEffect(() => { snapshot.current = activities; }, [activities]);
const key = JSON.stringify([enabled, model, sessionId]);
const [state, setState] = useState<{ key: string; suggestions: DisplaySuggestion[]; loading: boolean; unavailable: boolean }>({
key: "", suggestions: [], loading: false, unavailable: false,
});
useEffect(() => {
if (!enabled) return;
const controller = new AbortController();
const context = snapshot.current;
setState({ key, suggestions: [], loading: true, unavailable: false });
void request(context, model, controller.signal).then((suggestions) => {
if (controller.signal.aborted) return;
const sourceById = new Map(context.map((item) => [item.id, item]));
setState({ key, loading: false, unavailable: false, suggestions: suggestions.map((item) => {
const source = item.activityId ? sourceById.get(item.activityId) : undefined;
return { ...item, sourceLabel: source
? `${source.origin === "demo" ? "演示" : ""}${activityLabels[source.kind]} · ${timeFormatter.format(new Date(source.occurred_at))}`
: "供水任务建议" };
}) });
}).catch(() => {
if (!controller.signal.aborted) setState({ key, suggestions: [], loading: false, unavailable: true });
});
return () => controller.abort();
}, [enabled, key, model, request]);
return state.key === key ? state : { suggestions: [], loading: enabled, unavailable: false };
}