import { useCallback, 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"; import { useWorkbenchSessionResources } from "./workbench-session-resources"; 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, cacheScope = "project", recoveryVersion = 0, request, alerts, alertDataSource, conditions, runtimeDataSource, artifact, sessions }: ActivityInputs & { enabled: boolean; model: string; sessionId: string | null; cacheScope?: string; recoveryVersion?: number; request: AgentApiClient["generatePromptSuggestions"]; }) { const { cache } = useWorkbenchSessionResources(); const activities = useMemo(() => collectRecentAgentActivities({ alerts, alertDataSource, conditions, runtimeDataSource, artifact, sessions }), [alerts, alertDataSource, conditions, runtimeDataSource, artifact, sessions]); // Heartbeat timestamps change while the activity itself stays the same. const digest = JSON.stringify([...activities].sort((a, b) => a.id.localeCompare(b.id)).map((activity) => ({ id: activity.id, kind: activity.kind, title: activity.title, summary: activity.summary, status: activity.status, origin: activity.origin, }))); const activitySnapshot = useRef(activities); activitySnapshot.current = activities; const key = JSON.stringify(["suggestions", cacheScope, model, digest]); const [retryVersion, setRetryVersion] = useState(0); const [refreshVersion, setRefreshVersion] = useState(0); const attempts = useRef({ key, count: 0, recoveryVersion, retryVersion }); const retry = useCallback(() => setRetryVersion((version) => version + 1), []); const [state, setState] = useState<{ scope: string; suggestions: DisplaySuggestion[]; loading: boolean; unavailable: boolean }>({ scope: cacheScope, suggestions: [], loading: false, unavailable: false, }); useEffect(() => { if (!enabled || !model.trim()) return; let disposed = false; let timer: ReturnType | undefined; const previous = cache.peek(key); const recovered = attempts.current.recoveryVersion !== recoveryVersion || attempts.current.retryVersion !== retryVersion; if (attempts.current.key !== key || recovered) attempts.current = { key, count: 0, recoveryVersion, retryVersion }; const attempt = attempts.current; const context = activitySnapshot.current; const apply = (suggestions: DisplaySuggestion[]) => { if (!disposed) setState({ scope: cacheScope, suggestions, loading: false, unavailable: false }); }; if (previous?.value) apply(previous.value); else setState((current) => ({ scope: cacheScope, suggestions: current.scope === cacheScope ? current.suggestions : [], loading: true, unavailable: false })); void cache.load(key, async () => { attempt.count++; const suggestions = await request(context, model, AbortSignal.timeout(60_000)); const sourceById = new Map(context.map((item) => [item.id, item])); return 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))}` : "供水任务建议" }; }); }, 10 * 60_000, { errorTtl: 30_000, force: recovered && !!previous?.error }).then(apply).catch(() => { if (disposed) return; setState((current) => ({ scope: cacheScope, suggestions: current.scope === cacheScope ? current.suggestions : [], loading: false, unavailable: true })); // Cache expiry alone does not rerender the panel. Retry transient failures // here, with a bounded budget; reopening still reuses the shared cache. if (attempt.count < 3) { timer = setTimeout(() => setRefreshVersion((version) => version + 1), Math.max(1, (cache.peek(key)?.expiresAt ?? Date.now() + 30_000) - Date.now())); } }); return () => { disposed = true; clearTimeout(timer); }; }, [cache, cacheScope, digest, enabled, key, model, recoveryVersion, refreshVersion, request, retryVersion]); return { ...(state.scope === cacheScope ? state : { suggestions: [], loading: enabled, unavailable: false }), retry }; }