879 lines
28 KiB
TypeScript
879 lines
28 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useChat } from "@ai-sdk/react";
|
|
import { DefaultChatTransport } from "ai";
|
|
import useSWR from "swr";
|
|
import useSWRImmutable from "swr/immutable";
|
|
import type { PersonaState } from "@/shared/ai-elements/persona";
|
|
import { showMapNotice } from "@/features/map/core/components/notice";
|
|
import type {
|
|
AgentChatMessage,
|
|
AgentModelOption,
|
|
AgentPermissionReply,
|
|
AgentPermissionRequest,
|
|
AgentQuestionRequest,
|
|
AgentStreamRenderState
|
|
} from "@/features/agent";
|
|
import {
|
|
createAgentApiClient,
|
|
type AgentSessionStreamEvent
|
|
} from "@/features/agent/api/client";
|
|
import {
|
|
agentBootstrapKey,
|
|
agentSessionsKey,
|
|
fetchAgentBootstrap,
|
|
fetchAgentSessions
|
|
} from "@/features/agent/api/swr";
|
|
import {
|
|
isUiEnvelopeAllowed,
|
|
parseUiEnvelopePayload,
|
|
type UIEnvelopePayload,
|
|
type UIRegistry
|
|
} from "@/features/agent/ui-envelope";
|
|
import type { FrontendActionRequest } from "@/features/agent/frontend-action";
|
|
import { FrontendActionExecutor } from "@/features/agent/frontend-action/executor";
|
|
import {
|
|
appendStreamRenderToken,
|
|
applySessionStreamEvent,
|
|
createCompletedStreamRenderState,
|
|
getDataString,
|
|
markLastAssistantStreamDone,
|
|
readBodyString,
|
|
toAgentChatMessages,
|
|
toAgentUiMessages,
|
|
toPermissionStatus,
|
|
type AgentDataPart,
|
|
type AgentUiMessage,
|
|
type PermissionOverride,
|
|
type QuestionOverride
|
|
} from "./agent-session-message-state";
|
|
|
|
const AGENT_PANEL_COLLAPSE_MS = 180;
|
|
|
|
type AgentRuntimeAvailability = "connecting" | "connected" | "unavailable" | "failed";
|
|
|
|
type UseWorkbenchAgentOptions = {
|
|
onUiEnvelope: (payload: UIEnvelopePayload, sessionId: string) => Promise<void> | void;
|
|
onFrontendAction: (request: FrontendActionRequest, signal: AbortSignal) => Promise<unknown>;
|
|
};
|
|
|
|
export function useWorkbenchAgent({ onUiEnvelope, onFrontendAction }: UseWorkbenchAgentOptions) {
|
|
const collapseTimerRef = useRef<number | null>(null);
|
|
const mobileCollapseTimerRef = useRef<number | null>(null);
|
|
const sessionStreamAbortRef = useRef<AbortController | null>(null);
|
|
const sessionStreamIdRef = useRef<string | null>(null);
|
|
const streamRenderChunkIdRef = useRef(0);
|
|
const clientRef = useRef(createAgentApiClient());
|
|
const sessionIdRef = useRef<string | null>(null);
|
|
const registryRef = useRef<UIRegistry | null>(null);
|
|
const frontendActionEnabledRef = useRef(false);
|
|
const onFrontendActionRef = useRef(onFrontendAction);
|
|
const processedEnvelopeIdsRef = useRef(new Set<string>());
|
|
const [panelOpen, setPanelOpen] = useState(true);
|
|
const [panelCollapsing, setPanelCollapsing] = useState(false);
|
|
const [mobileOpen, setMobileOpen] = useState(false);
|
|
const [mobilePanelCollapsing, setMobilePanelCollapsing] = useState(false);
|
|
const [sessionId, setSessionId] = useState<string | null>(null);
|
|
const [registry, setRegistry] = useState<UIRegistry | null>(null);
|
|
const [sessionTitle, setSessionTitle] = useState("新对话");
|
|
const [statusLabel, setStatusLabel] = useState("Agent 后端连接中");
|
|
const [runtimeAvailability, setRuntimeAvailability] = useState<AgentRuntimeAvailability>("connecting");
|
|
const [resumedStreaming, setResumedStreaming] = useState(false);
|
|
const [modelOptions, setModelOptions] = useState<AgentModelOption[]>([]);
|
|
const [selectedModel, setSelectedModel] = useState("");
|
|
const [streamRenderState, setStreamRenderState] = useState<AgentStreamRenderState>({});
|
|
const [permissionOverrides, setPermissionOverrides] = useState<Record<string, PermissionOverride>>({});
|
|
const [questionOverrides, setQuestionOverrides] = useState<Record<string, QuestionOverride>>({});
|
|
|
|
useEffect(() => {
|
|
sessionIdRef.current = sessionId;
|
|
}, [sessionId]);
|
|
|
|
useEffect(() => {
|
|
registryRef.current = registry;
|
|
}, [registry]);
|
|
|
|
useEffect(() => {
|
|
onFrontendActionRef.current = onFrontendAction;
|
|
}, [onFrontendAction]);
|
|
|
|
const applySessionId = useCallback((nextSessionId: string | undefined) => {
|
|
if (!nextSessionId || sessionIdRef.current === nextSessionId) {
|
|
return;
|
|
}
|
|
sessionIdRef.current = nextSessionId;
|
|
setSessionId(nextSessionId);
|
|
}, []);
|
|
|
|
const clearSessionId = useCallback(() => {
|
|
sessionIdRef.current = null;
|
|
setSessionId(null);
|
|
}, []);
|
|
|
|
const resolveRenderRef = useCallback((renderRef: string, nextSessionId: string) => {
|
|
return clientRef.current.resolveRenderRef(renderRef, nextSessionId);
|
|
}, []);
|
|
|
|
const frontendActionExecutor = useMemo(
|
|
() => new FrontendActionExecutor(
|
|
(request, signal) => onFrontendActionRef.current(request, signal),
|
|
(nextSessionId, actionId, result) => clientRef.current.submitFrontendActionResult(nextSessionId, actionId, result)
|
|
),
|
|
[]
|
|
);
|
|
const handleFrontendAction = useCallback(
|
|
(value: unknown) => frontendActionExecutor.handle(value, sessionIdRef.current),
|
|
[frontendActionExecutor]
|
|
);
|
|
|
|
const claimEnvelope = useCallback((payload: UIEnvelopePayload, activeSessionId: string) => {
|
|
if (payload.session_id !== activeSessionId) return false;
|
|
const key = `${activeSessionId}:${payload.envelope_id}`;
|
|
if (processedEnvelopeIdsRef.current.has(key)) return false;
|
|
processedEnvelopeIdsRef.current.add(key);
|
|
if (processedEnvelopeIdsRef.current.size > 256) {
|
|
const oldest = processedEnvelopeIdsRef.current.values().next().value;
|
|
if (oldest) processedEnvelopeIdsRef.current.delete(oldest);
|
|
}
|
|
return true;
|
|
}, []);
|
|
|
|
const handleDataPart = useCallback(
|
|
(part: AgentDataPart) => {
|
|
const eventSessionId = getDataString(part.data, "session_id");
|
|
if (eventSessionId && sessionIdRef.current && eventSessionId !== sessionIdRef.current) {
|
|
return;
|
|
}
|
|
applySessionId(eventSessionId);
|
|
|
|
if (part.type === "data-stream_token") {
|
|
appendStreamRenderToken(
|
|
part.data,
|
|
chatRef.current.messages,
|
|
setStreamRenderState,
|
|
streamRenderChunkIdRef
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (part.type === "data-progress") {
|
|
const title = getDataString(part.data, "title");
|
|
if (title) {
|
|
setStatusLabel(title);
|
|
}
|
|
return;
|
|
}
|
|
if (part.type === "data-frontend_action") { void handleFrontendAction(part.data); return; }
|
|
|
|
if (part.type === "data-session_title") {
|
|
const title = getDataString(part.data, "title");
|
|
if (title) {
|
|
setSessionTitle(title);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (part.type !== "data-ui_envelope") {
|
|
return;
|
|
}
|
|
|
|
const payload = parseUiEnvelopePayload(part.data);
|
|
const activeSessionId = getDataString(part.data, "session_id") ?? sessionIdRef.current;
|
|
if (!payload || !activeSessionId || !isUiEnvelopeAllowed(payload.envelope, registryRef.current)) {
|
|
showMapNotice({
|
|
tone: "warning",
|
|
title: "Agent 展示已降级",
|
|
message: "收到的 UIEnvelope 未通过前端白名单校验。"
|
|
});
|
|
return;
|
|
}
|
|
if (!claimEnvelope(payload, activeSessionId)) return;
|
|
|
|
void Promise.resolve(onUiEnvelope(payload, activeSessionId)).catch((error) => {
|
|
showMapNotice({
|
|
tone: "error",
|
|
title: "Agent UIEnvelope 处理失败",
|
|
message: error instanceof Error ? error.message : "工作台处理 Agent 展示结果失败。"
|
|
});
|
|
});
|
|
},
|
|
[applySessionId, claimEnvelope, handleFrontendAction, onUiEnvelope]
|
|
);
|
|
|
|
const transport = useMemo(
|
|
() =>
|
|
new DefaultChatTransport<AgentUiMessage>({
|
|
api: "/api/v1/agent/chat/stream",
|
|
prepareSendMessagesRequest({ id, messages, body, trigger, messageId }) {
|
|
return {
|
|
body: {
|
|
...body,
|
|
id,
|
|
messages,
|
|
trigger,
|
|
messageId,
|
|
session_id: readBodyString(body, "session_id") ?? sessionIdRef.current ?? undefined,
|
|
approval_mode: readBodyString(body, "approval_mode") ?? "request",
|
|
capabilities: frontendActionEnabledRef.current ? ["frontend-action@1"] : []
|
|
}
|
|
};
|
|
}
|
|
}),
|
|
[]
|
|
);
|
|
|
|
const chat = useChat<AgentUiMessage>({
|
|
transport,
|
|
onData: (part) => handleDataPart(part as AgentDataPart),
|
|
onError: (error) => {
|
|
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
|
|
setRuntimeAvailability("failed");
|
|
setStatusLabel("Agent 后端请求失败");
|
|
showMapNotice({
|
|
id: "agent-stream-status",
|
|
tone: "error",
|
|
title: "Agent 请求失败",
|
|
message: error.message || "请确认后端服务已启动。"
|
|
});
|
|
},
|
|
onFinish: () => {
|
|
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
|
|
setStatusLabel("Agent 分析完成");
|
|
}
|
|
});
|
|
const chatRef = useRef(chat);
|
|
|
|
useEffect(() => {
|
|
chatRef.current = chat;
|
|
}, [chat]);
|
|
|
|
const {
|
|
data: backendConnection,
|
|
error: backendConnectionError
|
|
} = useSWRImmutable(agentBootstrapKey, () => fetchAgentBootstrap(clientRef.current), {
|
|
shouldRetryOnError: false
|
|
});
|
|
const {
|
|
data: sessionHistory = [],
|
|
isLoading: sessionHistoryLoading,
|
|
isValidating: sessionHistoryValidating,
|
|
mutate: mutateSessionHistory
|
|
} = useSWR(agentSessionsKey, () => fetchAgentSessions(clientRef.current), {
|
|
revalidateOnFocus: false,
|
|
shouldRetryOnError: false
|
|
});
|
|
|
|
const clearCollapseTimer = useCallback(() => {
|
|
if (collapseTimerRef.current !== null) {
|
|
window.clearTimeout(collapseTimerRef.current);
|
|
collapseTimerRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const clearMobileCollapseTimer = useCallback(() => {
|
|
if (mobileCollapseTimerRef.current !== null) {
|
|
window.clearTimeout(mobileCollapseTimerRef.current);
|
|
mobileCollapseTimerRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const stopSessionStreamSubscription = useCallback(() => {
|
|
sessionStreamAbortRef.current?.abort();
|
|
sessionStreamAbortRef.current = null;
|
|
sessionStreamIdRef.current = null;
|
|
setResumedStreaming(false);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
clearCollapseTimer();
|
|
clearMobileCollapseTimer();
|
|
stopSessionStreamSubscription();
|
|
frontendActionExecutor.cancelAll();
|
|
chatRef.current.stop();
|
|
};
|
|
}, [clearCollapseTimer, clearMobileCollapseTimer, frontendActionExecutor, stopSessionStreamSubscription]);
|
|
|
|
useEffect(() => {
|
|
if (!backendConnection) {
|
|
return;
|
|
}
|
|
|
|
registryRef.current = backendConnection.registry;
|
|
frontendActionEnabledRef.current = Boolean(backendConnection.frontendActionRegistry);
|
|
setRegistry(backendConnection.registry);
|
|
setModelOptions(backendConnection.models);
|
|
setSelectedModel((current) => current || backendConnection.defaultModel || backendConnection.models[0]?.id || "");
|
|
if (chatRef.current.status === "ready") {
|
|
setRuntimeAvailability("connected");
|
|
setStatusLabel("Agent 后端已连接");
|
|
}
|
|
}, [backendConnection]);
|
|
|
|
useEffect(() => {
|
|
if (!backendConnectionError) {
|
|
return;
|
|
}
|
|
|
|
setRuntimeAvailability("unavailable");
|
|
setStatusLabel("Agent 后端不可用");
|
|
}, [backendConnectionError]);
|
|
|
|
const collapsePanel = useCallback(() => {
|
|
clearCollapseTimer();
|
|
|
|
if (prefersReducedMotion()) {
|
|
setPanelOpen(false);
|
|
setPanelCollapsing(false);
|
|
return;
|
|
}
|
|
|
|
setPanelCollapsing(true);
|
|
collapseTimerRef.current = window.setTimeout(() => {
|
|
setPanelOpen(false);
|
|
setPanelCollapsing(false);
|
|
collapseTimerRef.current = null;
|
|
}, AGENT_PANEL_COLLAPSE_MS);
|
|
}, [clearCollapseTimer]);
|
|
|
|
const expandPanel = useCallback(() => {
|
|
clearCollapseTimer();
|
|
setPanelCollapsing(false);
|
|
setPanelOpen(true);
|
|
}, [clearCollapseTimer]);
|
|
|
|
const openMobilePanel = useCallback(() => {
|
|
clearMobileCollapseTimer();
|
|
setMobilePanelCollapsing(false);
|
|
setMobileOpen(true);
|
|
}, [clearMobileCollapseTimer]);
|
|
|
|
const closeMobilePanel = useCallback(() => {
|
|
clearMobileCollapseTimer();
|
|
|
|
if (prefersReducedMotion()) {
|
|
setMobileOpen(false);
|
|
setMobilePanelCollapsing(false);
|
|
return;
|
|
}
|
|
|
|
setMobilePanelCollapsing(true);
|
|
mobileCollapseTimerRef.current = window.setTimeout(() => {
|
|
setMobileOpen(false);
|
|
setMobilePanelCollapsing(false);
|
|
mobileCollapseTimerRef.current = null;
|
|
}, AGENT_PANEL_COLLAPSE_MS);
|
|
}, [clearMobileCollapseTimer]);
|
|
|
|
const streaming = chat.status === "submitted" || chat.status === "streaming" || resumedStreaming;
|
|
const messages = useMemo(
|
|
() => toAgentChatMessages(chat.messages, permissionOverrides, questionOverrides),
|
|
[chat.messages, permissionOverrides, questionOverrides]
|
|
);
|
|
const personaState = useMemo(
|
|
() => deriveAgentPersonaState(messages, {
|
|
chatStatus: chat.status,
|
|
runtimeAvailability,
|
|
statusLabel
|
|
}),
|
|
[chat.status, messages, runtimeAvailability, statusLabel]
|
|
);
|
|
|
|
const refreshSessionHistory = useCallback(async () => {
|
|
try {
|
|
await mutateSessionHistory();
|
|
} catch (error) {
|
|
showMapNotice({
|
|
id: "agent-history-status",
|
|
tone: "error",
|
|
title: "Agent 历史记录加载失败",
|
|
message: error instanceof Error ? error.message : "无法获取 Agent 会话历史。"
|
|
});
|
|
}
|
|
}, [mutateSessionHistory]);
|
|
|
|
const handleSessionStreamEvent = useCallback(
|
|
(event: AgentSessionStreamEvent, expectedSessionId: string) => {
|
|
if (event.type === "state") {
|
|
if (sessionIdRef.current !== expectedSessionId && sessionIdRef.current !== event.sessionId) {
|
|
return;
|
|
}
|
|
const nextMessages = toAgentUiMessages(event.messages);
|
|
chatRef.current.setMessages(nextMessages);
|
|
setStreamRenderState(createCompletedStreamRenderState(nextMessages));
|
|
setResumedStreaming(event.isStreaming);
|
|
setStatusLabel(event.isStreaming ? "Agent 会话流已恢复" : "Agent 历史会话已打开");
|
|
return;
|
|
}
|
|
|
|
const eventSessionId = event.sessionId;
|
|
if (eventSessionId && eventSessionId !== sessionIdRef.current) {
|
|
return;
|
|
}
|
|
|
|
if (event.type === "session_title") {
|
|
const title = getDataString(event.data, "title");
|
|
if (title) {
|
|
setSessionTitle(title);
|
|
}
|
|
} else if (event.type === "token") {
|
|
appendStreamRenderToken(
|
|
event.data,
|
|
chatRef.current.messages,
|
|
setStreamRenderState,
|
|
streamRenderChunkIdRef
|
|
);
|
|
} else if (event.type === "ui_envelope") {
|
|
const payload = parseUiEnvelopePayload(event.data);
|
|
const activeSessionId = eventSessionId ?? sessionIdRef.current;
|
|
if (!payload || !activeSessionId || !isUiEnvelopeAllowed(payload.envelope, registryRef.current)) {
|
|
showMapNotice({
|
|
tone: "warning",
|
|
title: "Agent 展示已降级",
|
|
message: "收到的 UIEnvelope 未通过前端白名单校验。"
|
|
});
|
|
return;
|
|
}
|
|
if (!claimEnvelope(payload, activeSessionId)) return;
|
|
void Promise.resolve(onUiEnvelope(payload, activeSessionId)).catch((error) => {
|
|
showMapNotice({
|
|
tone: "error",
|
|
title: "Agent UIEnvelope 处理失败",
|
|
message: error instanceof Error ? error.message : "工作台处理 Agent 展示结果失败。"
|
|
});
|
|
});
|
|
} else if (event.type === "frontend_action") {
|
|
void handleFrontendAction(event.data);
|
|
} else if (event.type === "progress") {
|
|
const title = getDataString(event.data, "title");
|
|
if (title) {
|
|
setStatusLabel(title);
|
|
}
|
|
} else if (event.type === "done") {
|
|
setResumedStreaming(false);
|
|
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
|
|
setStatusLabel("Agent 分析完成");
|
|
void refreshSessionHistory();
|
|
} else if (event.type === "error") {
|
|
setResumedStreaming(false);
|
|
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
|
|
setStatusLabel("Agent 后端请求失败");
|
|
void refreshSessionHistory();
|
|
}
|
|
|
|
chatRef.current.setMessages((current) => applySessionStreamEvent(current, event));
|
|
},
|
|
[claimEnvelope, handleFrontendAction, onUiEnvelope, refreshSessionHistory]
|
|
);
|
|
|
|
const startSessionStreamSubscription = useCallback(
|
|
(nextSessionId: string) => {
|
|
stopSessionStreamSubscription();
|
|
|
|
const controller = new AbortController();
|
|
sessionStreamAbortRef.current = controller;
|
|
sessionStreamIdRef.current = nextSessionId;
|
|
setResumedStreaming(true);
|
|
|
|
void clientRef.current
|
|
.streamSession(nextSessionId, {
|
|
signal: controller.signal,
|
|
onEvent: (event) => handleSessionStreamEvent(event, nextSessionId)
|
|
})
|
|
.catch((error) => {
|
|
if (!controller.signal.aborted) {
|
|
setRuntimeAvailability("failed");
|
|
setStatusLabel("Agent 会话流恢复失败");
|
|
showMapNotice({
|
|
id: "agent-stream-status",
|
|
tone: "error",
|
|
title: "Agent 会话流恢复失败",
|
|
message: error instanceof Error ? error.message : "无法恢复这个运行中的会话。"
|
|
});
|
|
}
|
|
})
|
|
.finally(() => {
|
|
if (sessionStreamAbortRef.current === controller) {
|
|
sessionStreamAbortRef.current = null;
|
|
sessionStreamIdRef.current = null;
|
|
setResumedStreaming(false);
|
|
void refreshSessionHistory();
|
|
}
|
|
});
|
|
},
|
|
[handleSessionStreamEvent, refreshSessionHistory, stopSessionStreamSubscription]
|
|
);
|
|
|
|
const loadHistorySession = useCallback(
|
|
async (nextSessionId: string) => {
|
|
if (streaming) {
|
|
chat.stop();
|
|
stopSessionStreamSubscription();
|
|
}
|
|
|
|
try {
|
|
const loadedSession = await clientRef.current.loadSession(nextSessionId);
|
|
if (!loadedSession) {
|
|
showMapNotice({
|
|
id: "agent-history-status",
|
|
tone: "warning",
|
|
title: "Agent 历史记录不可用",
|
|
message: "这个历史会话不存在或已被删除。"
|
|
});
|
|
await refreshSessionHistory();
|
|
return;
|
|
}
|
|
|
|
applySessionId(loadedSession.sessionId);
|
|
setSessionTitle(loadedSession.title);
|
|
setStatusLabel("Agent 历史会话已打开");
|
|
setPermissionOverrides({});
|
|
setQuestionOverrides({});
|
|
const nextMessages = toAgentUiMessages(loadedSession.messages);
|
|
chat.setMessages(nextMessages);
|
|
setStreamRenderState(createCompletedStreamRenderState(nextMessages));
|
|
if (loadedSession.runStatus === "running" || loadedSession.isStreaming) {
|
|
startSessionStreamSubscription(loadedSession.sessionId);
|
|
}
|
|
await refreshSessionHistory();
|
|
} catch (error) {
|
|
showMapNotice({
|
|
id: "agent-history-status",
|
|
tone: "error",
|
|
title: "Agent 历史记录打开失败",
|
|
message: error instanceof Error ? error.message : "无法打开这个历史会话。"
|
|
});
|
|
}
|
|
},
|
|
[applySessionId, chat, refreshSessionHistory, startSessionStreamSubscription, stopSessionStreamSubscription, streaming]
|
|
);
|
|
|
|
const renameHistorySession = useCallback(
|
|
async (nextSessionId: string, title: string) => {
|
|
const normalizedTitle = title.trim();
|
|
if (!normalizedTitle) {
|
|
showMapNotice({ tone: "warning", message: "对话主题不能为空。" });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await clientRef.current.updateSessionTitle(nextSessionId, normalizedTitle, {
|
|
isTitleManuallyEdited: true
|
|
});
|
|
await mutateSessionHistory(
|
|
(current = []) =>
|
|
current.map((session) =>
|
|
session.id === nextSessionId
|
|
? {
|
|
...session,
|
|
title: normalizedTitle,
|
|
updatedAt: Date.now()
|
|
}
|
|
: session
|
|
),
|
|
{ revalidate: false }
|
|
);
|
|
if (sessionIdRef.current === nextSessionId) {
|
|
setSessionTitle(normalizedTitle);
|
|
}
|
|
await refreshSessionHistory();
|
|
} catch (error) {
|
|
showMapNotice({
|
|
id: "agent-history-status",
|
|
tone: "error",
|
|
title: "Agent 对话重命名失败",
|
|
message: error instanceof Error ? error.message : "无法更新这个对话主题。"
|
|
});
|
|
}
|
|
},
|
|
[mutateSessionHistory, refreshSessionHistory]
|
|
);
|
|
|
|
const resetDraftSession = useCallback(
|
|
(nextStatusLabel = "Agent 新对话已准备") => {
|
|
clearSessionId();
|
|
setSessionTitle("新对话");
|
|
setStatusLabel(nextStatusLabel);
|
|
setRuntimeAvailability("connected");
|
|
setPermissionOverrides({});
|
|
setQuestionOverrides({});
|
|
setStreamRenderState({});
|
|
chat.setMessages([]);
|
|
},
|
|
[chat, clearSessionId]
|
|
);
|
|
|
|
const deleteHistorySession = useCallback(
|
|
async (nextSessionId: string) => {
|
|
if (streaming) {
|
|
showMapNotice({ tone: "warning", message: "Agent 正在处理上一条指令,完成后再删除会话。" });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await clientRef.current.deleteSession(nextSessionId);
|
|
await mutateSessionHistory(
|
|
(current = []) => current.filter((session) => session.id !== nextSessionId),
|
|
{ revalidate: false }
|
|
);
|
|
|
|
if (sessionIdRef.current === nextSessionId) {
|
|
resetDraftSession();
|
|
}
|
|
|
|
await refreshSessionHistory();
|
|
} catch (error) {
|
|
showMapNotice({
|
|
id: "agent-history-status",
|
|
tone: "error",
|
|
title: "Agent 历史记录删除失败",
|
|
message: error instanceof Error ? error.message : "无法删除这个历史会话。"
|
|
});
|
|
}
|
|
},
|
|
[mutateSessionHistory, refreshSessionHistory, resetDraftSession, streaming]
|
|
);
|
|
|
|
const startNewSession = useCallback(async () => {
|
|
if (chat.status === "submitted" || chat.status === "streaming") {
|
|
chat.stop();
|
|
}
|
|
stopSessionStreamSubscription();
|
|
|
|
resetDraftSession();
|
|
}, [chat, resetDraftSession, stopSessionStreamSubscription]);
|
|
|
|
const submitPrompt = useCallback(
|
|
async (prompt: string) => {
|
|
const normalizedPrompt = prompt.trim();
|
|
if (!normalizedPrompt) {
|
|
return;
|
|
}
|
|
|
|
if (streaming) {
|
|
showMapNotice({ tone: "warning", message: "Agent 正在处理上一条指令。" });
|
|
return;
|
|
}
|
|
|
|
setStatusLabel("Agent 正在分析");
|
|
|
|
await chat.sendMessage(
|
|
{ text: normalizedPrompt },
|
|
{
|
|
body: {
|
|
session_id: sessionIdRef.current ?? undefined,
|
|
model: selectedModel || undefined,
|
|
approval_mode: "request"
|
|
}
|
|
}
|
|
);
|
|
},
|
|
[chat, selectedModel, streaming]
|
|
);
|
|
|
|
const stopPrompt = useCallback(() => {
|
|
chat.stop();
|
|
stopSessionStreamSubscription();
|
|
const activeSessionId = sessionIdRef.current;
|
|
if (activeSessionId) {
|
|
void clientRef.current.abort(activeSessionId).catch(() => undefined);
|
|
}
|
|
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
|
|
}, [chat, stopSessionStreamSubscription]);
|
|
|
|
const replyPermission = useCallback(async (permission: AgentPermissionRequest, reply: AgentPermissionReply) => {
|
|
setPermissionOverrides((current) => ({
|
|
...current,
|
|
[permission.requestId]: { status: "submitting" }
|
|
}));
|
|
|
|
try {
|
|
await clientRef.current.replyPermission(permission.requestId, {
|
|
sessionId: permission.sessionId,
|
|
reply
|
|
});
|
|
setPermissionOverrides((current) => ({
|
|
...current,
|
|
[permission.requestId]: {
|
|
status: toPermissionStatus(reply)
|
|
}
|
|
}));
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "权限请求提交失败。";
|
|
setPermissionOverrides((current) => ({
|
|
...current,
|
|
[permission.requestId]: {
|
|
status: "error",
|
|
error: message
|
|
}
|
|
}));
|
|
showMapNotice({
|
|
id: "agent-permission-status",
|
|
tone: "error",
|
|
title: "Agent 权限提交失败",
|
|
message
|
|
});
|
|
}
|
|
}, []);
|
|
|
|
const replyQuestion = useCallback(async (question: AgentQuestionRequest, answers: string[][]) => {
|
|
setQuestionOverrides((current) => ({
|
|
...current,
|
|
[question.requestId]: { status: "submitting" }
|
|
}));
|
|
|
|
try {
|
|
await clientRef.current.replyQuestion(question.requestId, {
|
|
sessionId: question.sessionId,
|
|
answers
|
|
});
|
|
setQuestionOverrides((current) => ({
|
|
...current,
|
|
[question.requestId]: {
|
|
status: "answered",
|
|
answers
|
|
}
|
|
}));
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "问题回复提交失败。";
|
|
setQuestionOverrides((current) => ({
|
|
...current,
|
|
[question.requestId]: {
|
|
status: "error",
|
|
error: message
|
|
}
|
|
}));
|
|
showMapNotice({
|
|
id: "agent-question-status",
|
|
tone: "error",
|
|
title: "Agent 问题提交失败",
|
|
message
|
|
});
|
|
}
|
|
}, []);
|
|
|
|
const rejectQuestion = useCallback(async (question: AgentQuestionRequest) => {
|
|
setQuestionOverrides((current) => ({
|
|
...current,
|
|
[question.requestId]: { status: "submitting" }
|
|
}));
|
|
|
|
try {
|
|
await clientRef.current.rejectQuestion(question.requestId, {
|
|
sessionId: question.sessionId
|
|
});
|
|
setQuestionOverrides((current) => ({
|
|
...current,
|
|
[question.requestId]: {
|
|
status: "rejected"
|
|
}
|
|
}));
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "问题跳过提交失败。";
|
|
setQuestionOverrides((current) => ({
|
|
...current,
|
|
[question.requestId]: {
|
|
status: "error",
|
|
error: message
|
|
}
|
|
}));
|
|
showMapNotice({
|
|
id: "agent-question-status",
|
|
tone: "error",
|
|
title: "Agent 问题提交失败",
|
|
message
|
|
});
|
|
}
|
|
}, []);
|
|
|
|
return {
|
|
collapsePanel,
|
|
expandPanel,
|
|
mobileOpen,
|
|
mobilePanelCollapsing,
|
|
messages,
|
|
modelOptions,
|
|
openMobilePanel,
|
|
closeMobilePanel,
|
|
panelCollapsing,
|
|
panelOpen,
|
|
personaState,
|
|
refreshSessionHistory,
|
|
startNewSession,
|
|
loadHistorySession,
|
|
renameHistorySession,
|
|
deleteHistorySession,
|
|
sessionHistory,
|
|
sessionHistoryLoading: sessionHistoryLoading || sessionHistoryValidating,
|
|
sessionId,
|
|
sessionTitle,
|
|
statusLabel,
|
|
streamRenderState,
|
|
selectedModel,
|
|
setSelectedModel,
|
|
stopPrompt,
|
|
streaming,
|
|
rejectQuestion,
|
|
replyPermission,
|
|
replyQuestion,
|
|
resolveRenderRef,
|
|
submitPrompt
|
|
};
|
|
}
|
|
|
|
function deriveAgentPersonaState(
|
|
messages: AgentChatMessage[],
|
|
{
|
|
chatStatus,
|
|
runtimeAvailability,
|
|
statusLabel
|
|
}: {
|
|
chatStatus: string;
|
|
runtimeAvailability: AgentRuntimeAvailability;
|
|
statusLabel: string;
|
|
}
|
|
): PersonaState {
|
|
if (
|
|
runtimeAvailability === "unavailable" ||
|
|
runtimeAvailability === "failed" ||
|
|
/失败|不可用|错误|降级/.test(statusLabel)
|
|
) {
|
|
return "asleep";
|
|
}
|
|
|
|
if (hasPendingOperatorInput(messages)) {
|
|
return "listening";
|
|
}
|
|
|
|
if (
|
|
chatStatus === "submitted" ||
|
|
chatStatus === "streaming" ||
|
|
hasRunningSessionWork(messages) ||
|
|
runtimeAvailability === "connecting"
|
|
) {
|
|
return "thinking";
|
|
}
|
|
|
|
return "idle";
|
|
}
|
|
|
|
function hasPendingOperatorInput(messages: AgentChatMessage[]) {
|
|
return messages.some(
|
|
(message) =>
|
|
message.permissions?.some((permission) => permission.status === "pending" || permission.status === "error") ||
|
|
message.questions?.some((question) => question.status === "pending" || question.status === "error")
|
|
);
|
|
}
|
|
|
|
function hasRunningSessionWork(messages: AgentChatMessage[]) {
|
|
const latestProgress = messages.flatMap((message) => message.progress ?? []).at(-1);
|
|
const latestTodo = messages.flatMap((message) => message.todos?.todos ?? []).at(-1);
|
|
|
|
return latestProgress?.status === "running" || latestTodo?.status === "in_progress";
|
|
}
|
|
|
|
function prefersReducedMotion() {
|
|
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
}
|