From e2baed948bcca7a5f7069b3d0345f4883e35c298 Mon Sep 17 00:00:00 2001 From: Innei Date: Mon, 1 Jun 2026 12:34:33 +0900 Subject: [PATCH] feat(admin): introduce agent-core turn loop + relocate persistence to client Refactor admin AI agent runtime: extract a self-contained agent-core feature module on the client (turn loop, approval gating, message normalizer, persistence queue, system prompt, general tools) and drop the server-side assistant-message persistence from core's chat service. The core now only streams SSE events; admin owns the loop, tool execution, and conversation persistence. Also wires the new admin workbench route under (intelligence)/ai/agent and updates the design spec to match the new architecture. --- apps/admin/package.json | 60 +- apps/admin/src/api/ai-agent.ts | 25 +- .../agent-core/AdminAgentWorkbenchRoute.tsx | 630 +++++++++++ .../features/agent-core/agent-transport.ts | 123 +++ .../src/features/agent-core/approval.test.ts | 75 ++ .../admin/src/features/agent-core/approval.ts | 40 + .../src/features/agent-core/contracts.ts | 50 + .../src/features/agent-core/general-tools.ts | 304 ++++++ .../agent-core/message-normalizer.test.ts | 116 ++ .../features/agent-core/message-normalizer.ts | 220 ++++ .../agent-core/persistence-queue.test.ts | 51 + .../features/agent-core/persistence-queue.ts | 60 ++ .../features/agent-core/system-prompt.test.ts | 40 + .../src/features/agent-core/system-prompt.ts | 52 + .../src/features/agent-core/turn-loop.test.ts | 234 +++++ .../src/features/agent-core/turn-loop.ts | 404 +++++++ .../write/components/agent/agent-transport.ts | 118 +-- .../write/components/agent/llm-provider.ts | 59 +- .../src/hooks/use-agent-session-manager.ts | 63 +- apps/admin/src/i18n/resources/en-US.ts | 23 + apps/admin/src/i18n/resources/zh-CN.ts | 21 + .../views/(intelligence)/ai/agent/page.tsx | 13 + apps/core/package.json | 2 +- .../ai/ai-agent/ai-agent-chat.service.ts | 175 +--- .../ai-agent/ai-agent-conversation.service.ts | 39 +- .../ai/ai-agent/ai-agent.controller.ts | 38 +- .../modules/ai/ai-agent/ai-agent.schema.ts | 12 +- .../src/modules/ai/ai-agent.faux.e2e.spec.ts | 57 +- .../2026-05-31-admin-agent-core-design.md | 128 ++- packages/ai/src/ai-agent-sse.ts | 5 + packages/cli/package.json | 6 +- pnpm-lock.yaml | 988 +++++++++--------- 32 files changed, 3259 insertions(+), 972 deletions(-) create mode 100644 apps/admin/src/features/agent-core/AdminAgentWorkbenchRoute.tsx create mode 100644 apps/admin/src/features/agent-core/agent-transport.ts create mode 100644 apps/admin/src/features/agent-core/approval.test.ts create mode 100644 apps/admin/src/features/agent-core/approval.ts create mode 100644 apps/admin/src/features/agent-core/contracts.ts create mode 100644 apps/admin/src/features/agent-core/general-tools.ts create mode 100644 apps/admin/src/features/agent-core/message-normalizer.test.ts create mode 100644 apps/admin/src/features/agent-core/message-normalizer.ts create mode 100644 apps/admin/src/features/agent-core/persistence-queue.test.ts create mode 100644 apps/admin/src/features/agent-core/persistence-queue.ts create mode 100644 apps/admin/src/features/agent-core/system-prompt.test.ts create mode 100644 apps/admin/src/features/agent-core/system-prompt.ts create mode 100644 apps/admin/src/features/agent-core/turn-loop.test.ts create mode 100644 apps/admin/src/features/agent-core/turn-loop.ts create mode 100644 apps/admin/src/views/(intelligence)/ai/agent/page.tsx diff --git a/apps/admin/package.json b/apps/admin/package.json index d16e0e8513d..193cd82d008 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -25,36 +25,36 @@ "@codemirror/view": "^6.43.0", "@ddietr/codemirror-themes": "1.5.2", "@excalidraw/excalidraw": "^0.18.0", - "@haklex/rich-agent-core": "0.16.1", - "@haklex/rich-compose": "0.16.1", - "@haklex/rich-diff": "0.16.1", - "@haklex/rich-editor": "0.16.1", - "@haklex/rich-editor-ui": "0.16.1", - "@haklex/rich-ext-ai-agent": "0.16.1", - "@haklex/rich-ext-chat": "0.16.1", - "@haklex/rich-ext-code-snippet": "0.16.1", - "@haklex/rich-ext-embed": "0.16.1", - "@haklex/rich-ext-excalidraw": "0.16.1", - "@haklex/rich-ext-gallery": "0.16.1", - "@haklex/rich-ext-nested-doc": "0.16.1", - "@haklex/rich-plugin-block-handle": "0.16.1", - "@haklex/rich-plugin-floating-toolbar": "0.16.1", - "@haklex/rich-plugin-link-edit": "0.16.1", - "@haklex/rich-plugin-litexml-paste": "0.16.1", - "@haklex/rich-plugin-mention": "0.16.1", - "@haklex/rich-plugin-slash-menu": "0.16.1", - "@haklex/rich-plugin-table": "0.16.1", - "@haklex/rich-plugin-toolbar": "0.16.1", - "@haklex/rich-renderer-alert": "0.16.1", - "@haklex/rich-renderer-banner": "0.16.1", - "@haklex/rich-renderer-codeblock": "0.16.1", - "@haklex/rich-renderer-image": "0.16.1", - "@haklex/rich-renderer-katex": "0.16.1", - "@haklex/rich-renderer-linkcard": "0.16.1", - "@haklex/rich-renderer-mention": "0.16.1", - "@haklex/rich-renderer-mermaid": "0.16.1", - "@haklex/rich-renderer-ruby": "0.16.1", - "@haklex/rich-renderer-video": "0.16.1", + "@haklex/rich-agent-core": "0.18.0", + "@haklex/rich-compose": "0.18.0", + "@haklex/rich-diff": "0.18.0", + "@haklex/rich-editor": "0.18.0", + "@haklex/rich-editor-ui": "0.18.0", + "@haklex/rich-ext-ai-agent": "0.18.0", + "@haklex/rich-ext-chat": "0.18.0", + "@haklex/rich-ext-code-snippet": "0.18.0", + "@haklex/rich-ext-embed": "0.18.0", + "@haklex/rich-ext-excalidraw": "0.18.0", + "@haklex/rich-ext-gallery": "0.18.0", + "@haklex/rich-ext-nested-doc": "0.18.0", + "@haklex/rich-plugin-block-handle": "0.18.0", + "@haklex/rich-plugin-floating-toolbar": "0.18.0", + "@haklex/rich-plugin-link-edit": "0.18.0", + "@haklex/rich-plugin-litexml-paste": "0.18.0", + "@haklex/rich-plugin-mention": "0.18.0", + "@haklex/rich-plugin-slash-menu": "0.18.0", + "@haklex/rich-plugin-table": "0.18.0", + "@haklex/rich-plugin-toolbar": "0.18.0", + "@haklex/rich-renderer-alert": "0.18.0", + "@haklex/rich-renderer-banner": "0.18.0", + "@haklex/rich-renderer-codeblock": "0.18.0", + "@haklex/rich-renderer-image": "0.18.0", + "@haklex/rich-renderer-katex": "0.18.0", + "@haklex/rich-renderer-linkcard": "0.18.0", + "@haklex/rich-renderer-mention": "0.18.0", + "@haklex/rich-renderer-mermaid": "0.18.0", + "@haklex/rich-renderer-ruby": "0.18.0", + "@haklex/rich-renderer-video": "0.18.0", "@lexical/markdown": "^0.44.0", "@lexical/react": "^0.44.0", "@lezer/highlight": "1.2.3", diff --git a/apps/admin/src/api/ai-agent.ts b/apps/admin/src/api/ai-agent.ts index 1287a64f353..ddb444cd793 100644 --- a/apps/admin/src/api/ai-agent.ts +++ b/apps/admin/src/api/ai-agent.ts @@ -11,6 +11,12 @@ export interface AgentConversation { updatedAt: string } +export interface AgentTitleProjectionData { + messages?: Record[] + model?: string | null + providerId?: string | null +} + export function createAgentConversation(data: { messages?: Record[] model?: string | null @@ -33,16 +39,6 @@ export function getAgentConversation(id: string) { return getJson(`/ai/agent/conversations/${id}`) } -export function appendAgentConversationMessages( - id: string, - messages: Record[], -) { - return patchJson[] }>( - `/ai/agent/conversations/${id}/messages`, - { messages }, - ) -} - export function replaceAgentConversationMessages( id: string, messages: Record[], @@ -71,9 +67,12 @@ export function deleteAgentConversation(id: string) { return deleteJson(`/ai/agent/conversations/${id}`) } -export function generateAgentConversationTitle(id: string) { - return postJson>( +export function generateAgentConversationTitle( + id: string, + data: AgentTitleProjectionData = {}, +) { + return postJson( `/ai/agent/conversations/${id}/title`, - {}, + data, ) } diff --git a/apps/admin/src/features/agent-core/AdminAgentWorkbenchRoute.tsx b/apps/admin/src/features/agent-core/AdminAgentWorkbenchRoute.tsx new file mode 100644 index 00000000000..5a3d093e96c --- /dev/null +++ b/apps/admin/src/features/agent-core/AdminAgentWorkbenchRoute.tsx @@ -0,0 +1,630 @@ +import { useQuery } from '@tanstack/react-query' +import { Bot, Check, Loader2, Plus, Send, Square, Trash2 } from 'lucide-react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { toast } from 'sonner' +import { Streamdown } from 'streamdown' + +import { getModels } from '~/api/ai' +import type { AgentConversation } from '~/api/ai-agent' +import { + createAgentConversation, + deleteAgentConversation, + generateAgentConversationTitle, + getAgentConversation, + getAgentConversations, + replaceAgentConversationMessages, +} from '~/api/ai-agent' +import type { SelectedAgentModel } from '~/features/write/components/agent/types' +import { useLocalStorageState } from '~/hooks/use-local-storage-state' +import { useI18n } from '~/i18n' +import { adminQueryKeys } from '~/query/keys' +import { AppPage, PageHeader } from '~/ui/layout/page-layout' +import { Button } from '~/ui/primitives/button' +import { Scroll } from '~/ui/primitives/scroll' +import { cn } from '~/utils/cn' + +import { adminAgentTransport } from './agent-transport' +import { validateDryRunApproval } from './approval' +import { createGeneralScene } from './contracts' +import { createGeneralAgentTools } from './general-tools' +import type { AgentPersistedMessage } from './message-normalizer' +import { buildTitleProjection } from './message-normalizer' +import type { PersistenceQueue } from './persistence-queue' +import { createPersistenceQueue } from './persistence-queue' +import { buildAgentSystemPrompt } from './system-prompt' +import { runAgentTurn } from './turn-loop' + +const GENERAL_SESSION_ID = 'admin-agent:general' +const MODEL_VALUE_SEPARATOR = '::' + +function getModelOptionValue(providerId: string, modelId: string) { + return `${providerId}${MODEL_VALUE_SEPARATOR}${modelId}` +} + +function isSelectedAgentModelAvailable( + model: SelectedAgentModel | null, + providerGroups: Awaited>, +) { + if (!model) return false + const provider = providerGroups.find( + (group) => group.providerId === model.providerId, + ) + return Boolean(provider?.models.some((item) => item.id === model.modelId)) +} + +function sessionLabel(conversation: AgentConversation) { + return ( + conversation.title ?? + deriveTitle(conversation.messages) ?? + conversation.id.slice(0, 8) + ) +} + +function deriveTitle(messages: AgentConversation['messages']) { + const firstUser = messages?.find( + (message) => message.role === 'user' || message.type === 'user', + ) + const content = + typeof firstUser?.content === 'string' ? firstUser.content.trim() : '' + if (!content) return null + return content.length > 36 ? `${content.slice(0, 36)}...` : content +} + +export function AdminAgentWorkbenchRoute() { + const { t } = useI18n() + const [selectedModel, selectModel] = + useLocalStorageState( + 'agent-chat:selected-model', + null, + ) + const [sessions, setSessions] = useState([]) + const [activeId, setActiveId] = useState(null) + const [messages, setMessages] = useState([]) + const [input, setInput] = useState('') + const [isLoadingSessions, setIsLoadingSessions] = useState(false) + const [isRunning, setIsRunning] = useState(false) + const abortRef = useRef(null) + const queuesRef = useRef( + new Map< + string, + PersistenceQueue + >(), + ) + + const scene = useMemo(() => createGeneralScene(createGeneralAgentTools()), []) + const tools = scene.tools + const systemPrompt = useMemo(() => buildAgentSystemPrompt(scene), [scene]) + + const modelsQuery = useQuery({ + queryFn: getModels, + queryKey: adminQueryKeys.ai.models('admin-agent-workbench'), + }) + const providerGroups = modelsQuery.data ?? [] + + useEffect(() => { + if (!providerGroups.length) return + if (isSelectedAgentModelAvailable(selectedModel, providerGroups)) return + + const firstProvider = providerGroups.find( + (group) => group.models.length > 0, + ) + const firstModel = firstProvider?.models[0] + if (!firstProvider || !firstModel) { + selectModel(null) + return + } + selectModel({ + modelId: firstModel.id, + providerId: firstProvider.providerId, + providerType: firstProvider.providerType, + }) + }, [providerGroups, selectedModel, selectModel]) + + const getQueue = useCallback((conversationId: string) => { + const existing = queuesRef.current.get(conversationId) + if (existing) return existing + const queue = createPersistenceQueue< + AgentPersistedMessage[], + AgentConversation + >({ + save: (nextMessages) => + replaceAgentConversationMessages(conversationId, nextMessages), + }) + queuesRef.current.set(conversationId, queue) + return queue + }, []) + + const persistMessages = useCallback( + async (conversationId: string, nextMessages: AgentPersistedMessage[]) => { + const updated = await getQueue(conversationId).enqueue(nextMessages) + if (updated) { + setSessions((current) => + current.map((session) => + session.id === conversationId ? updated : session, + ), + ) + } + return updated + }, + [getQueue], + ) + + const refreshSessions = useCallback(async () => { + setIsLoadingSessions(true) + try { + const list = await getAgentConversations(GENERAL_SESSION_ID) + setSessions(list) + if (!activeId && list[0]) { + setActiveId(list[0].id) + const detail = await getAgentConversation(list[0].id) + setMessages(detail.messages ?? []) + } + } finally { + setIsLoadingSessions(false) + } + }, [activeId]) + + useEffect(() => { + void refreshSessions() + }, [refreshSessions]) + + const ensureConversation = useCallback( + async (initialMessages: AgentPersistedMessage[]) => { + if (activeId) return activeId + if (!selectedModel) throw new Error(t('ai.agent.toast.selectModel')) + + const conversation = await createAgentConversation({ + messages: initialMessages, + model: selectedModel.modelId, + providerId: selectedModel.providerId, + sessionId: GENERAL_SESSION_ID, + }) + setSessions((current) => [conversation, ...current]) + setActiveId(conversation.id) + return conversation.id + }, + [activeId, selectedModel, t], + ) + + const runTurn = useCallback( + async (conversationId: string, baseMessages: AgentPersistedMessage[]) => { + if (!selectedModel) throw new Error(t('ai.agent.toast.selectModel')) + + const abortController = new AbortController() + abortRef.current = abortController + setIsRunning(true) + try { + const result = await runAgentTurn({ + messages: baseMessages, + onMessages: setMessages, + systemPrompt, + tools, + transport: (request) => + adminAgentTransport({ + messages: request.messages, + model: selectedModel.modelId, + providerId: selectedModel.providerId, + signal: abortController.signal, + tools: request.tools, + }), + }) + setMessages(result.messages) + await persistMessages(conversationId, result.messages) + await generateAgentConversationTitle(conversationId, { + messages: buildTitleProjection(result.messages), + model: selectedModel.modelId, + providerId: selectedModel.providerId, + }).catch(() => null) + } finally { + if (abortRef.current === abortController) abortRef.current = null + setIsRunning(false) + } + }, + [persistMessages, selectedModel, systemPrompt, t, tools], + ) + + const sendMessage = useCallback(async () => { + const content = input.trim() + if (!content || isRunning) return + if (!selectedModel) { + toast.error(t('ai.agent.toast.selectModel')) + return + } + + const baseMessages = [...messages, { type: 'user', content }] + setInput('') + setMessages(baseMessages) + + try { + const conversationId = await ensureConversation(baseMessages) + await persistMessages(conversationId, baseMessages) + await runTurn(conversationId, baseMessages) + } catch (error) { + toast.error( + error instanceof Error ? error.message : t('ai.agent.toast.turnFailed'), + ) + } + }, [ + ensureConversation, + input, + isRunning, + messages, + persistMessages, + runTurn, + selectedModel, + ]) + + const switchSession = useCallback(async (conversationId: string) => { + abortRef.current?.abort() + setActiveId(conversationId) + const detail = await getAgentConversation(conversationId) + setMessages(detail.messages ?? []) + }, []) + + const createSession = useCallback(() => { + abortRef.current?.abort() + setActiveId(null) + setMessages([]) + setInput('') + }, []) + + const removeSession = useCallback(async () => { + if (!activeId) return + abortRef.current?.abort() + await deleteAgentConversation(activeId) + queuesRef.current.delete(activeId) + setSessions((current) => + current.filter((session) => session.id !== activeId), + ) + setActiveId(null) + setMessages([]) + }, [activeId]) + + const approveDryRun = useCallback( + async (message: AgentPersistedMessage) => { + if (!activeId || !selectedModel || isRunning) return + const toolName = + typeof message.toolName === 'string' ? message.toolName : '' + const tool = tools.find((item) => item.manifest.name === toolName) + if (!tool?.execute) { + toast.error(t('ai.agent.toast.noExecuteHandler')) + return + } + + setIsRunning(true) + try { + const validation = await validateDryRunApproval(message, tool) + if (!validation.ok) { + toast.error(validation.reason) + return + } + const args = validation.args + const result = await tool.execute(args, { + arguments: args, + id: String(message.toolCallId ?? ''), + name: toolName, + }) + const nextMessages = [ + ...messages, + { + decision: 'approved', + dryRunHash: message.dryRunHash, + type: 'approval', + }, + { + content: result.content, + isError: Boolean(result.isError), + toolCallId: message.toolCallId, + toolName, + type: 'execute-result', + }, + ] + setMessages(nextMessages) + await persistMessages(activeId, nextMessages) + await runTurn(activeId, nextMessages) + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : t('ai.agent.toast.executeFailed'), + ) + } finally { + setIsRunning(false) + } + }, + [ + activeId, + isRunning, + messages, + persistMessages, + runTurn, + selectedModel, + tools, + ], + ) + + const abort = useCallback(() => { + abortRef.current?.abort() + }, []) + + return ( + + + ), + }, + { + kind: 'button', + icon: Plus, + label: t('ai.agent.action.new'), + onClick: createSession, + }, + { + kind: 'button', + disabled: !activeId, + icon: Trash2, + iconOnly: true, + label: t('ai.agent.action.delete'), + onClick: () => void removeSession(), + }, + ]} + description={t('routes.aiAgent.description')} + icon={