diff --git a/agent-run-gateway.mjs b/agent-run-gateway.mjs index 11f48b0..6029249 100644 --- a/agent-run-gateway.mjs +++ b/agent-run-gateway.mjs @@ -155,7 +155,7 @@ async function validateToolGatewayResult({ result, validation, cwd }) { return { expectedFiles: checks }; } -function withRunMetadata(userMessage, { toolMode = 'chat', taskType = null } = {}) { +function withRunMetadata(userMessage, { toolMode = 'chat', taskType = null, forceGoose = false } = {}) { const message = (userMessage && typeof userMessage === 'object' && !Array.isArray(userMessage)) ? { ...userMessage } : { value: userMessage }; @@ -168,6 +168,7 @@ function withRunMetadata(userMessage, { toolMode = 'chat', taskType = null } = { : {}), toolMode: normalizeAgentRunToolMode(toolMode), ...(taskType ? { taskType } : {}), + ...(forceGoose ? { forceGoose: true } : {}), }; return { ...message, metadata }; } @@ -184,6 +185,7 @@ function getRunOptionsFromMessage(userMessage) { return { toolMode, taskType: normalizeTaskType(runMetadata?.taskType ?? metadata?.taskType), + forceGoose: runMetadata?.forceGoose === true || metadata?.forceGoose === true, validation: normalizeToolGatewayValidation(runMetadata?.validation ?? metadata?.toolGatewayValidation), }; } @@ -287,6 +289,7 @@ export function createAgentRunGateway({ userMessage, toolMode = 'chat', taskType = null, + forceGoose = false, }) { const normalizedRequestId = String(requestId ?? '').trim(); if (!normalizedRequestId) { @@ -305,6 +308,7 @@ export function createAgentRunGateway({ const runMessage = withRunMetadata(userMessage, { toolMode: normalizedToolMode, taskType: normalizedTaskType, + forceGoose, }); await pool.query( `INSERT INTO h5_agent_runs @@ -325,6 +329,7 @@ export function createAgentRunGateway({ sessionId: sessionId || null, toolMode: normalizedToolMode, taskType: normalizedTaskType, + forceGoose: Boolean(forceGoose), }); if (autoDispatch) dispatchRun(runId); return projectRun(await getRunById(runId)); @@ -386,7 +391,7 @@ export function createAgentRunGateway({ const userMessage = safeJsonParse(row.user_message_json, {}); const runOptions = getRunOptionsFromMessage(userMessage); const toolGatewayStatus = toolGateway?.getStatus ? toolGateway.getStatus() : null; - if (directChatService?.canHandle?.({ + if (!runOptions.forceGoose && directChatService?.canHandle?.({ sessionId: row.agent_session_id ?? null, toolMode: runOptions.toolMode, userMessage, @@ -461,6 +466,12 @@ export function createAgentRunGateway({ } let sessionId = row.agent_session_id ?? null; + if (isDirectChatSessionId(sessionId)) { + await appendEvent(runId, 'direct_session_escalated_to_goose', { + previousSessionId: sessionId, + }); + sessionId = null; + } if (!sessionId) { const sessionOptions = {}; if (runOptions.toolMode === 'code' && userAuth?.getCodeAgentSessionPolicy) { diff --git a/agent-run-gateway.test.mjs b/agent-run-gateway.test.mjs index 1d5864b..23fc675 100644 --- a/agent-run-gateway.test.mjs +++ b/agent-run-gateway.test.mjs @@ -301,6 +301,36 @@ test('agent run uses direct chat service for eligible chat messages', async () = assert.ok(pool.events.some((event) => event.eventType === 'direct_chat_completed')); }); +test('agent run escalates direct sessions to a new goose session when forced', async () => { + const pool = createFakePool(); + const submitted = []; + const gateway = createAgentRunGateway({ + pool, + tkmindProxy: { + async startSessionForUser(userId) { + assert.equal(userId, 'user-1'); + return { id: 'goose-session-1' }; + }, + async submitSessionReplyForUser(userId, sessionId, requestId, userMessage, options = {}) { + submitted.push({ userId, sessionId, requestId, userMessage, options }); + }, + }, + retryDelaysMs: [], + }); + + const run = await gateway.createRun('user-1', { + sessionId: 'h5direct_existing', + requestId: 'req-force-goose', + userMessage: { role: 'user', content: [{ type: 'text', text: '帮我生成页面 public/a.html' }] }, + forceGoose: true, + }); + + await waitFor(() => pool.runs.get(run.id)?.status === 'succeeded'); + assert.equal(pool.runs.get(run.id).agent_session_id, 'goose-session-1'); + assert.equal(submitted[0].sessionId, 'goose-session-1'); + assert.ok(pool.events.some((event) => event.eventType === 'direct_session_escalated_to_goose')); +}); + test('agent run with code tool mode starts and submits with code policy', async () => { const pool = createFakePool(); const submitted = []; diff --git a/agent-run-routes.mjs b/agent-run-routes.mjs index f985334..0234d39 100644 --- a/agent-run-routes.mjs +++ b/agent-run-routes.mjs @@ -56,6 +56,7 @@ export function createPostAgentRunsHandler({ const userMessage = request.body?.user_message ?? null; const rawToolMode = request.body?.tool_mode ?? request.body?.toolMode ?? 'chat'; const taskType = String(request.body?.task_type ?? request.body?.taskType ?? '').trim() || null; + const forceGoose = request.body?.force_goose === true || request.body?.forceGoose === true; if (!requestId) { response.status(400).json({ message: '缺少 request_id' }); return; @@ -110,6 +111,7 @@ export function createPostAgentRunsHandler({ userMessage, toolMode, taskType, + ...(forceGoose ? { forceGoose: true } : {}), }); response.status(202).json({ run }); } catch (err) { diff --git a/agent-run-routes.test.mjs b/agent-run-routes.test.mjs index d1a0daf..d3cc039 100644 --- a/agent-run-routes.test.mjs +++ b/agent-run-routes.test.mjs @@ -100,6 +100,39 @@ test('POST /agent/runs creates a run and returns 202', async () => { ]); }); +test('POST /agent/runs forwards force_goose to the run gateway', async () => { + const created = []; + const handler = createPostAgentRunsHandler({ + userAuth: { + async ownsSession() { + return true; + }, + }, + agentRunGateway: { + async createRun(userId, payload) { + created.push({ userId, payload }); + return { id: 'run-goose', status: 'queued' }; + }, + }, + }); + const res = createResponseRecorder(); + + await handler( + { + currentUser: { id: 'user-1' }, + body: { + request_id: 'req-goose', + user_message: { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + force_goose: true, + }, + }, + res, + ); + + assert.equal(res.statusCode, 202); + assert.equal(created[0].payload.forceGoose, true); +}); + test('POST /agent/runs accepts explicit code tool mode and task type', async () => { const created = []; const handler = createPostAgentRunsHandler({ diff --git a/direct-chat-service.mjs b/direct-chat-service.mjs index 7a2c679..0686568 100644 --- a/direct-chat-service.mjs +++ b/direct-chat-service.mjs @@ -12,6 +12,16 @@ export function isDirectChatSessionId(sessionId) { return String(sessionId ?? '').startsWith(DIRECT_CHAT_SESSION_PREFIX); } +const GOOSE_REQUIRED_PATTERNS = [ + /\b(public\/[^\s"'<>]+\.html)\b/i, + /\b(html|h5|web\s?page|landing\s?page|microsite|docx|word)\b/i, + /\b(write|create|generate|publish|download|export|save|edit)\b.{0,40}\b(file|page|html|docx|word|asset)\b/i, + /\b(file|page|html|docx|word|asset)\b.{0,40}\b(write|create|generate|publish|download|export|save|edit)\b/i, + /(?:生成|创建|制作|做|写|设计|发布|导出|下载|保存|修改|编辑).{0,24}(?:页面|网页|HTML|html|H5|h5|文件|文档|Word|word|docx|下载页|公开页|分享页|落地页|活动页)/u, + /(?:页面|网页|HTML|html|H5|h5|文件|文档|Word|word|docx|下载页|公开页|分享页|落地页|活动页).{0,24}(?:生成|创建|制作|做|写|设计|发布|导出|下载|保存|修改|编辑)/u, + /MindSpace\/[^/\s]+\/public\/[^\s"'<>]+\.html/i, +]; + export function sendDirectChatSessionEvents(req, res, snapshot) { let closed = false; let keepalive = null; @@ -89,6 +99,11 @@ function isTextOnlyUserMessage(message) { }); } +function requiresGooseExecution(message) { + const text = `${messageText(message)}\n${assistantFacingText(message)}`.trim(); + return GOOSE_REQUIRED_PATTERNS.some((pattern) => pattern.test(text)); +} + function renderMemoryLines(memories) { const items = Array.isArray(memories) ? memories : []; if (items.length === 0) return ''; @@ -166,6 +181,7 @@ export function createDirectChatService({ if (toolMode !== 'chat') return false; if (sessionId && !isDirectChatSessionId(sessionId)) return false; if (!isTextOnlyUserMessage(userMessage)) return false; + if (requiresGooseExecution(userMessage)) return false; return Boolean(userAuth && llmProviderService && sessionSnapshotService); } diff --git a/direct-chat-service.test.mjs b/direct-chat-service.test.mjs index fb9fa41..eb3db40 100644 --- a/direct-chat-service.test.mjs +++ b/direct-chat-service.test.mjs @@ -184,3 +184,20 @@ test('direct chat rejects non-text messages', () => { }, }), false); }); + +test('direct chat rejects page generation tasks so goose can execute them', () => { + const service = createDirectChatService({ + enabled: true, + userAuth: {}, + llmProviderService: {}, + sessionSnapshotService: {}, + }); + + assert.equal(service.canHandle({ + toolMode: 'chat', + userMessage: { + role: 'user', + content: [{ type: 'text', text: '帮我生成一个秋夜诗页面 public/autumn-night-poem.html' }], + }, + }), false); +}); diff --git a/src/api/client.ts b/src/api/client.ts index d415ce2..7e43f80 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -114,8 +114,26 @@ function withAgentRunValidationMetadata( function prepareAgentRunUserMessage(message: Message, options: AgentRunCreateOptions): Message { const normalized = normalizeUserMessageForApi(message); + const withValidation = withAgentRunValidationMetadata(normalized, options.validation); + const withRunMetadata = options.forceGoose + ? { + ...withValidation, + metadata: { + ...withValidation.metadata, + memindRun: { + ...( + withValidation.metadata?.memindRun && + typeof withValidation.metadata.memindRun === 'object' + ? withValidation.metadata.memindRun + : {} + ), + forceGoose: true, + }, + }, + } + : withValidation; return appendAgentRunValidationInstruction( - withAgentRunValidationMetadata(normalized, options.validation), + withRunMetadata, options.toolMode === 'code' ? options.validationInstruction : null, ); } @@ -2440,6 +2458,7 @@ export async function createAgentRun( user_message: userMessagePayload, ...(options.toolMode ? { tool_mode: options.toolMode } : {}), ...(options.taskType ? { task_type: options.taskType } : {}), + ...(options.forceGoose ? { force_goose: true } : {}), }), }, { timeoutMs: AGENT_CONNECT_TIMEOUT_MS }, diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index c2a24a6..a6798c4 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -144,7 +144,7 @@ export function ChatPanel({ text: string, imageUrls?: string[], previewImageUrls?: string[], - options?: { messageId?: string }, + options?: { messageId?: string; forceGoose?: boolean }, ) => void | Promise; onUploadImage?: ( file: File, @@ -167,6 +167,7 @@ export function ChatPanel({ const [pageSource, setPageSource] = useState(null); const [sharePreviewSource, setSharePreviewSource] = useState(null); const [voiceNotice, setVoiceNotice] = useState(null); + const [forceGoose, setForceGoose] = useState(false); const [pendingImages, setPendingImages] = useState([]); const [uploadingImage, setUploadingImage] = useState(false); const [imageError, setImageError] = useState(null); @@ -514,7 +515,10 @@ export function ChatPanel({ })); const imagesToSend = uploadedUrls.filter(Boolean); const previewImagesToSend = imagesToSend; - await onSubmit(trimmed, imagesToSend, previewImagesToSend, { messageId: outgoingMessageId }); + await onSubmit(trimmed, imagesToSend, previewImagesToSend, { + messageId: outgoingMessageId, + forceGoose, + }); uploadedImages.forEach(revokePendingImage); setPendingImages([]); } catch (err) { @@ -530,7 +534,7 @@ export function ChatPanel({ } setUploadingImage(false); suppressVoiceUpdateRef.current = false; - }, [input, onSubmit, onUploadImage, pendingImages, revokePendingImage, voiceDisabled, voiceRecording]); + }, [forceGoose, input, onSubmit, onUploadImage, pendingImages, revokePendingImage, voiceDisabled, voiceRecording]); const handleSubmit = useCallback(async () => { await submitText(); @@ -822,6 +826,20 @@ export function ChatPanel({ onPrefill={setInput} /> )} + {!showHomeWelcome && ( + + )} {chatState === 'streaming' ? (