From ad6dcc283f9980297fd33be095cb497757102a0f Mon Sep 17 00:00:00 2001 From: john Date: Thu, 27 Aug 2026 09:33:47 +0800 Subject: [PATCH] feat(cursor): wire executor through portal, admin, and wechat routes Register cursor in LLM executor bindings, route page/code tasks through cursor runtime selection, and bootstrap wechat cursor policy services. Co-authored-by: Cursor --- admin-bootstrap.mjs | 3 + admin-routes.mjs | 30 +++++ admin-server.mjs | 1 + agent-run-routes.mjs | 48 +++++-- chat-skills.mjs | 6 + chat-skills.test.mjs | 3 + db.mjs | 44 ++++++- llm-providers.mjs | 121 +++++++++++++++++- llm-providers.test.mjs | 27 +++- package.json | 1 + schema.sql | 24 +++- scripts/agent-run-worker.mjs | 22 ++++ scripts/memind-runtime-profile.mjs | 2 +- server.mjs | 5 + .../portal-integration-services-bootstrap.mjs | 4 + ...rtal-memory-session-services-bootstrap.mjs | 6 + wechat/handlers/page-generate.mjs | 12 ++ 17 files changed, 343 insertions(+), 16 deletions(-) diff --git a/admin-bootstrap.mjs b/admin-bootstrap.mjs index 5a78dca..95b76b3 100644 --- a/admin-bootstrap.mjs +++ b/admin-bootstrap.mjs @@ -34,6 +34,7 @@ import { createOrchestratorObservabilityService } from './services/orchestrator/ import { createSkillRuntimeAdminConfigService } from './skill-runtime-admin-config.mjs'; import { createSystemDisclosurePolicyService } from './system-disclosure-policy.mjs'; import { createAgentCodeRunAdminConfigService } from './agent-code-run-admin-config.mjs'; +import { createWechatCursorExecutorAdminConfigService } from './wechat-cursor-executor-admin-config.mjs'; import { createMindSearchConfigService } from './mindsearch-config.mjs'; import { createWechatScheduleLlmConfigService } from './wechat-schedule-llm-config.mjs'; import { createWechatIntentRouterConfigService } from './wechat-intent-router-config.mjs'; @@ -150,6 +151,7 @@ export async function createAdminServices(env = {}) { }); await systemDisclosurePolicyService.initialize(); const agentCodeRunPolicyService = createAgentCodeRunAdminConfigService(pool, { env: process.env }); + const wechatCursorExecutorPolicyService = createWechatCursorExecutorAdminConfigService(pool); const wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool); const wechatIntentRouterConfigService = createWechatIntentRouterConfigService(pool); const adminSystemTestService = createAdminSystemTestService({ @@ -198,6 +200,7 @@ export async function createAdminServices(env = {}) { skillRuntimeConfigService, systemDisclosurePolicyService, agentCodeRunPolicyService, + wechatCursorExecutorPolicyService, wechatScheduleLlmConfigService, wechatIntentRouterConfigService, adminSystemTestService, diff --git a/admin-routes.mjs b/admin-routes.mjs index 1d46534..7bca4b9 100644 --- a/admin-routes.mjs +++ b/admin-routes.mjs @@ -43,6 +43,7 @@ function plazaRouteError(res, req, error) { * @param {object|null} deps.plazaOps * @param {object|null} deps.wechatAdmin * @param {object|null} deps.wechatIntentRouterConfigService + * @param {object|null} deps.wechatCursorExecutorPolicyService */ export function createAdminApi({ jsonBody, @@ -66,6 +67,7 @@ export function createAdminApi({ plazaOps, wechatAdmin, wechatIntentRouterConfigService, + wechatCursorExecutorPolicyService, subscriptionService, templateCatalogService, }) { @@ -925,6 +927,34 @@ export function createAdminApi({ adminApi.put('/wechat/intent-router/config', requireAdmin, updateWechatIntentRouterConfig); adminApi.patch('/wechat/intent-router/config', requireAdmin, updateWechatIntentRouterConfig); + adminApi.get('/wechat/cursor-executor/config', requireAdmin, async (_req, res) => { + if (!wechatCursorExecutorPolicyService?.getAdminConfig) { + return res.status(503).json({ message: '微信 Cursor 体验通道未启用' }); + } + return res.json(await wechatCursorExecutorPolicyService.getAdminConfig()); + }); + + adminApi.get('/wechat/cursor-executor/runtime', requireAdmin, async (_req, res) => { + if (!wechatCursorExecutorPolicyService?.getRuntimeState) { + return res.status(503).json({ message: '微信 Cursor 体验通道未启用' }); + } + return res.json(await wechatCursorExecutorPolicyService.getRuntimeState()); + }); + + const updateWechatCursorExecutorConfig = async (req, res) => { + if (!wechatCursorExecutorPolicyService?.updateAdminConfig) { + return res.status(503).json({ message: '微信 Cursor 体验通道未启用' }); + } + const result = await wechatCursorExecutorPolicyService.updateAdminConfig( + req.body?.config ?? req.body ?? {}, + { updatedBy: req.currentUser.id }, + ); + return res.json(result); + }; + + adminApi.put('/wechat/cursor-executor/config', requireAdmin, updateWechatCursorExecutorConfig); + adminApi.patch('/wechat/cursor-executor/config', requireAdmin, updateWechatCursorExecutorConfig); + adminApi.get('/llm-providers/catalog', requireAdmin, (_req, res) => { if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' }); res.json({ catalog: llmProviderService.catalog }); diff --git a/admin-server.mjs b/admin-server.mjs index 3206a5a..dc8b9d9 100644 --- a/admin-server.mjs +++ b/admin-server.mjs @@ -87,6 +87,7 @@ const CONSOLES = { skillRuntimeConfigService: services.skillRuntimeConfigService, systemDisclosurePolicyService: services.systemDisclosurePolicyService, agentCodeRunPolicyService: services.agentCodeRunPolicyService, + wechatCursorExecutorPolicyService: services.wechatCursorExecutorPolicyService, adminSystemTestService: services.adminSystemTestService, plazaPosts: services.plazaPosts, plazaOps: services.plazaOps, diff --git a/agent-run-routes.mjs b/agent-run-routes.mjs index 1e6ab29..0a21705 100644 --- a/agent-run-routes.mjs +++ b/agent-run-routes.mjs @@ -23,6 +23,8 @@ import { } from './agent-run-stream.mjs'; import { wrapRunStreamPayload, writeSseErrorAndEnd } from './sse-event-taxonomy.mjs'; import { resolveGoalBindingForAgentRun } from './goal-run-resolve.mjs'; +import { enforcePageGenerationCursorRuntime } from './cursor-page-routing.mjs'; +import { resolvePreferredCodeExecutor } from './cursor-agent-launch.mjs'; function envFlag(value) { return ['1', 'true', 'yes', 'on'].includes(String(value ?? '').trim().toLowerCase()); @@ -160,12 +162,13 @@ export function enforceSelectedSkillRuntime(userMessage, { : {}; const taskText = extractAiderDevelopmentTask(userMessage); const requiresPageDataBuild = isPageDataIntent(taskText); + const codeExecutor = resolvePreferredCodeExecutor(); metadata.memindRun = { ...runMetadata, selectedChatSkill: AIDER_DEVELOPMENT_SKILL_NAME, ...(requiresPageDataBuild ? { reviewExecutor: 'aider', pageDataAiderWorkflow: true } - : { executor: 'aider' }), + : { executor: codeExecutor }), }; if (requiresPageDataBuild) delete metadata.memindRun.executor; return { @@ -174,7 +177,7 @@ export function enforceSelectedSkillRuntime(userMessage, { : { ...message, metadata }, rawToolMode: requiresPageDataBuild ? 'chat' : 'code', taskType: requiresPageDataBuild ? null : 'h5_chat_code_task', - requiredExecutor: requiresPageDataBuild ? null : 'aider', + requiredExecutor: requiresPageDataBuild ? null : codeExecutor, requiredReviewExecutor: requiresPageDataBuild ? 'aider' : null, }; } @@ -253,15 +256,35 @@ export function createPostAgentRunsHandler({ userMessage = selectedSkillRuntime.userMessage; rawToolMode = selectedSkillRuntime.rawToolMode; taskType = selectedSkillRuntime.taskType; + let requiredExecutor = selectedSkillRuntime.requiredExecutor ?? null; + let requiredReviewExecutor = selectedSkillRuntime.requiredReviewExecutor ?? null; + + if (!requiredExecutor && !requiredReviewExecutor) { + const pageCursorRuntime = enforcePageGenerationCursorRuntime(userMessage, { + rawToolMode, + taskType, + forceDeepReasoning, + env: process.env, + }); + userMessage = pageCursorRuntime.userMessage; + rawToolMode = pageCursorRuntime.rawToolMode; + taskType = pageCursorRuntime.taskType; + if (pageCursorRuntime.requiredExecutor) { + requiredExecutor = pageCursorRuntime.requiredExecutor; + } + } + if ( - (selectedSkillRuntime.requiredExecutor || selectedSkillRuntime.requiredReviewExecutor) && + (requiredExecutor || requiredReviewExecutor) && + selectedChatSkill(userMessage) === AIDER_DEVELOPMENT_SKILL_NAME && !extractAiderDevelopmentTask(userMessage) ) { response.status(400).json({ message: '请输入需要 Aider 执行的具体开发任务' }); return; } if ( - (selectedSkillRuntime.requiredExecutor || selectedSkillRuntime.requiredReviewExecutor) && + (requiredExecutor || requiredReviewExecutor) && + selectedChatSkill(userMessage) === AIDER_DEVELOPMENT_SKILL_NAME && userAuth?.getUserSkills ) { const skillState = await userAuth.getUserSkills(request.currentUser.id); @@ -279,9 +302,9 @@ export function createPostAgentRunsHandler({ }); return; } - if (toolMode === 'code' || selectedSkillRuntime.requiredReviewExecutor) { + if (toolMode === 'code' || requiredReviewExecutor) { const codeRunPolicy = await resolveCodeRunPolicy(request.currentUser.id); - const policyTaskType = selectedSkillRuntime.requiredReviewExecutor + const policyTaskType = requiredReviewExecutor ? 'h5_chat_code_task' : taskType; if (!codeRunPolicy.enabled) { @@ -305,7 +328,12 @@ export function createPostAgentRunsHandler({ response.status(403).json({ message: '当前代码任务类型未开启灰度' }); return; } - if (codeRunPolicy.requireValidation && !hasExpectedFileValidation(userMessage)) { + if ( + codeRunPolicy.requireValidation + && !hasExpectedFileValidation(userMessage) + && !userMessage?.metadata?.memindRun?.pageCursorDefault + && !userMessage?.metadata?.memindRun?.cursorAgentDefault + ) { response.status(400).json({ message: '代码任务必须声明产物校验规则' }); return; } @@ -372,10 +400,12 @@ export function createPostAgentRunsHandler({ response.status(202).json({ run }); } catch (err) { if (err?.code === 'SESSION_RUN_CONFLICT') { - response.status(409).json({ + const payload = { message: err.message, code: 'SESSION_RUN_CONFLICT', - }); + }; + if (err.existingRunId) payload.existingRunId = err.existingRunId; + response.status(409).json(payload); return; } if (err?.code === 'GOAL_RUN_NOT_FOUND') { diff --git a/chat-skills.mjs b/chat-skills.mjs index a3da0d3..29e4caa 100644 --- a/chat-skills.mjs +++ b/chat-skills.mjs @@ -48,11 +48,17 @@ const WEB_NEWS_INTENT_PATTERNS = [ const PAGE_GENERATION_INTENT_PATTERNS = [ /(?:生成|做|制作|创建|设计|写|出)(?:一个|一份|个)?(?:H5|h5|HTML|html|网页|页面|活动页|宣传页|落地页|分享页)/u, + /(?:生成|做|制作|创建|设计|写|出)(?:一个|一份|个)?.{0,40}(?:H5|h5|HTML|html|网页|页面|活动页|宣传页|落地页|分享页)/u, /(?:帮我|给我).*(?:H5|h5|HTML|html|网页|页面|活动页|宣传页|落地页|分享页)/u, /(?:publish|create|generate|make|build|design).*(?:html|web\s?page|landing\s?page|h5)/i, // 隐式页面需求:主题/攻略 + 页面,无显式动词(如「苏州攻略页面」) /(?:攻略|指南|游记|手册|简介|介绍|展示).{0,8}(?:页面|网页|H5|h5|html)/u, /[^\s,。!?]{2,20}(?:攻略|指南).{0,4}(?:页面|网页)/u, + // 页面跟进/修改:如「在页面再加几首唐诗」 + /(?:在|把|给|对|向).{0,12}(?:页面|网页|这个页|当前页|刚才的页).{0,32}(?:再|继续|追加|加上|添加|加|改|更新|补充|删|调整)/u, + /(?:页面|网页|HTML|html).{0,24}(?:再|继续|追加|加上|添加|加|改|更新|补充|删|调整)/u, + /public\/[\w-]+\.html/u, + /(?:把|更新|修改|改).{0,24}[\w-]+\.html/u, ]; const PRODUCT_CAMPAIGN_INTENT_PATTERNS = [ diff --git a/chat-skills.test.mjs b/chat-skills.test.mjs index 18cbdf9..3ced536 100644 --- a/chat-skills.test.mjs +++ b/chat-skills.test.mjs @@ -212,6 +212,9 @@ test('buildAutoChatSkillPrefix enables publish for page generation requests', () test('isPageGenerationIntent matches implicit travel guide page requests', () => { assert.equal(isPageGenerationIntent('苏州攻略页面'), true); assert.equal(isPageGenerationIntent('帮我看看苏州攻略,1日攻略,做一个页面'), true); + assert.equal(isPageGenerationIntent('做一个睡前故事页面'), true); + assert.equal(isPageGenerationIntent('在页面再加几首唐诗'), true); + assert.equal(isPageGenerationIntent('把 bedtime-story.html 再改一下'), true); assert.equal(isPageGenerationIntent('你好'), false); }); diff --git a/db.mjs b/db.mjs index 52806d1..a4835db 100644 --- a/db.mjs +++ b/db.mjs @@ -57,6 +57,17 @@ async function columnExists(pool, table, column) { return rows.length > 0; } +async function columnType(pool, table, column) { + const [rows] = await pool.query( + `SELECT COLUMN_TYPE + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? + LIMIT 1`, + [table, column], + ); + return rows[0]?.COLUMN_TYPE ?? null; +} + async function tableExists(pool, table) { const [rows] = await pool.query( `SELECT 1 FROM information_schema.TABLES @@ -285,7 +296,7 @@ export async function migrateSchema(pool) { await pool.query(` CREATE TABLE IF NOT EXISTS h5_llm_executor_bindings ( id CHAR(36) PRIMARY KEY, - executor ENUM('goose', 'aider', 'openhands') NOT NULL, + executor ENUM('goose', 'aider', 'openhands', 'cursor') NOT NULL, purpose VARCHAR(32) NOT NULL DEFAULT 'default', provider_key_id CHAR(36) NULL, model VARCHAR(128) NOT NULL, @@ -297,6 +308,13 @@ export async function migrateSchema(pool) { CONSTRAINT fk_h5_llm_executor_provider FOREIGN KEY (provider_key_id) REFERENCES h5_llm_provider_keys(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); + const executorColumnType = await columnType(pool, 'h5_llm_executor_bindings', 'executor'); + if (executorColumnType && !String(executorColumnType).includes("'cursor'")) { + await pool.query( + `ALTER TABLE h5_llm_executor_bindings + MODIFY COLUMN executor ENUM('goose', 'aider', 'openhands', 'cursor') NOT NULL`, + ); + } // Optional asset capability control plane. These rows configure no runtime // worker by themselves; the existing chat/page path remains independent. @@ -441,6 +459,30 @@ export async function migrateSchema(pool) { ); } + await pool.query(` + CREATE TABLE IF NOT EXISTS h5_help_escalations ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + channel VARCHAR(32) NOT NULL DEFAULT 'h5', + status ENUM('queued', 'running', 'succeeded', 'failed') NOT NULL DEFAULT 'queued', + user_text MEDIUMTEXT NOT NULL, + context_json JSON NULL, + agent_session_id VARCHAR(128) NULL, + result_text MEDIUMTEXT NULL, + agent_output MEDIUMTEXT NULL, + error_message TEXT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + started_at BIGINT NULL, + completed_at BIGINT NULL, + KEY idx_h5_help_escalation_status_created (status, created_at), + KEY idx_h5_help_escalation_user_created (user_id, created_at), + KEY idx_h5_help_escalation_session (agent_session_id, updated_at), + CONSTRAINT fk_h5_help_escalation_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE, + CONSTRAINT fk_h5_help_escalation_session FOREIGN KEY (agent_session_id) REFERENCES h5_user_sessions(agent_session_id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + await ensureGoalRunSchema(pool, { columnExists: (table, column) => columnExists(pool, table, column), indexExists: (table, index) => indexExists(pool, table, index), diff --git a/llm-providers.mjs b/llm-providers.mjs index f86b1b0..c72b589 100644 --- a/llm-providers.mjs +++ b/llm-providers.mjs @@ -11,6 +11,15 @@ import { resolveDeepseekNoThinkProxyBaseUrl, resolveMoonshotCompatProxyBaseUrl, } from './deepseek-no-think-proxy.mjs'; +import { + cursorChatBridgeEnabled, + MEMIND_CURSOR_CHAT_BRIDGE_PROVIDER_ID, + resolveCursorChatBridgeBaseUrl, +} from './cursor-openai-bridge.mjs'; +import { + buildCursorExecutorLaunchPlan, + cursorExecutorEnabled, +} from './cursor-agent-launch.mjs'; export const MEMIND_DEEPSEEK_NO_THINK_PROVIDER_ID = 'custom_memind_deepseek_no_think'; @@ -33,6 +42,14 @@ export const LLM_PROVIDER_CATALOG = [ defaultModel: 'deepseek-v4-pro', models: ['deepseek-v4-pro', 'deepseek-v4-flash'], }, + { + id: 'custom_cursor', + label: 'TKMind Chat Bridge (实验)', + kind: 'builtin', + apiKeyEnv: 'CURSOR_API_KEY', + defaultModel: 'composer-2.5', + models: ['composer-2.5', 'composer-2.5-fast'], + }, { id: 'openai', label: 'OpenAI', @@ -88,6 +105,12 @@ export const LLM_EXECUTOR_CATALOG = [ description: '复杂仓库任务、多文件改造和命令执行', purposes: ['default'], }, + { + id: 'cursor', + label: 'TKMind 智趣', + description: 'MindSpace 页面与代码任务,通过 TKMind 智趣执行器落盘', + purposes: ['default'], + }, ]; const catalogById = Object.fromEntries(LLM_PROVIDER_CATALOG.map((item) => [item.id, item])); @@ -509,8 +532,20 @@ function resolveExecutorCommand(executor) { ? [process.env.AIDER_BIN, process.env.GOOSE_AIDER_BIN, 'aider'] : executor === 'openhands' ? [process.env.OPENHANDS_BIN, process.env.GOOSE_OPENHANDS_BIN, 'openhands'] - : [executor]; - return candidates.map((item) => String(item ?? '').trim()).find(Boolean) ?? executor; + : executor === 'cursor' + ? [ + process.env.MEMIND_CURSOR_EXECUTOR_AGENT_BIN, + process.env.MEMIND_CURSOR_HELP_AGENT_BIN, + process.env.CURSOR_AGENT_BIN, + '~/.local/bin/agent', + 'agent', + ] + : [executor]; + const resolved = candidates.map((item) => String(item ?? '').trim()).find(Boolean) ?? executor; + if (resolved.startsWith('~/')) { + return path.join(os.homedir(), resolved.slice(2)); + } + return resolved; } function commandExists(command) { @@ -631,6 +666,7 @@ export function buildExecutorLaunchPlan(runtime, options = {}) { return { ok: true, executor: 'aider', + executorLabel: runtime.executorLabel ?? 'Aider', cwd, command, args: [ @@ -663,6 +699,7 @@ export function buildExecutorLaunchPlan(runtime, options = {}) { return { ok: true, executor: 'openhands', + executorLabel: runtime.executorLabel ?? 'OpenHands', cwd, command, args, @@ -675,6 +712,21 @@ export function buildExecutorLaunchPlan(runtime, options = {}) { }; } + if (runtime.executor === 'cursor') { + if (!cursorExecutorEnabled()) { + return { + ok: false, + executor: 'cursor', + message: 'TKMind 智趣执行器未启用(MEMIND_CURSOR_EXECUTOR_ENABLED)', + }; + } + return buildCursorExecutorLaunchPlan({ + cwd, + instruction, + env: process.env, + }); + } + return { ok: false, executor: runtime.executor, @@ -868,6 +920,41 @@ async function syncDeepseekNoThinkProfileToGoosed(apiTarget, apiSecret, profile, return goosedProviderId; } +async function syncCursorChatBridgeProfileToGoosed(apiTarget, apiSecret, profile, fetchImpl) { + if (!cursorChatBridgeEnabled()) { + throw new Error('TKMind Chat Bridge 未启用(MEMIND_CURSOR_CHAT_BRIDGE_ENABLED=1)'); + } + const catalogItem = catalogById.custom_cursor; + const models = [ + ...new Set([ + ...(Array.isArray(catalogItem?.models) ? catalogItem.models : []), + profile.defaultModel, + ...(Array.isArray(profile.models) ? profile.models : []), + ].filter(Boolean).map((item) => String(item).trim()).filter(Boolean)), + ]; + const goosedProviderId = await upsertCustomProviderOnGoosed( + apiTarget, + apiSecret, + { + name: 'memind_cursor_chat_bridge', + goosedProviderId: MEMIND_CURSOR_CHAT_BRIDGE_PROVIDER_ID, + apiUrl: resolveCursorChatBridgeBaseUrl(), + apiKey: profile.apiKey || 'cursor-bridge-local', + models, + defaultModel: profile.defaultModel || catalogItem?.defaultModel, + engine: 'openai', + preservesThinking: false, + }, + fetchImpl, + ); + await writeGoosedConfig(apiTarget, apiSecret, 'GOOSE_PROVIDER', goosedProviderId, false, fetchImpl); + await writeGoosedConfig(apiTarget, apiSecret, 'GOOSE_MODEL', profile.defaultModel, false, fetchImpl); + await writeGoosedConfig(apiTarget, apiSecret, 'TKMIND_PROVIDER', goosedProviderId, false, fetchImpl); + await writeGoosedConfig(apiTarget, apiSecret, 'TKMIND_MODEL', profile.defaultModel, false, fetchImpl); + await writeGoosedConfig(apiTarget, apiSecret, 'GOOSE_THINKING_EFFORT', 'off', false, fetchImpl); + return goosedProviderId; +} + async function removeCustomProviderOnGoosed(apiTarget, apiSecret, goosedProviderId, fetchImpl) { if (!goosedProviderId) return; const upstream = await goosedApiFetch( @@ -885,6 +972,9 @@ async function removeCustomProviderOnGoosed(apiTarget, apiSecret, goosedProvider } async function syncBuiltinProfileToGoosed(apiTarget, apiSecret, profile, fetchImpl) { + if (profile.providerId === 'custom_cursor') { + return syncCursorChatBridgeProfileToGoosed(apiTarget, apiSecret, profile, fetchImpl); + } if ( profile.providerId === 'custom_deepseek' && deepseekDisableThinkingEnabled() @@ -1195,6 +1285,23 @@ export function createLlmProviderService( ) { const normalizedExecutor = normalizeExecutor(executor); if (!normalizedExecutor) return { ok: false, message: '不支持的执行器' }; + if (normalizedExecutor === 'cursor') { + if (!cursorExecutorEnabled()) { + return { + ok: false, + executor: 'cursor', + purpose, + message: 'TKMind 智趣执行器未启用(MEMIND_CURSOR_EXECUTOR_ENABLED)', + }; + } + return { + ok: true, + executor: 'cursor', + executorLabel: executorById.cursor?.label ?? 'TKMind 智趣', + purpose, + env: {}, + }; + } const binding = await getExecutorBindingRow(normalizedExecutor, purpose); if (!binding?.enabled) { return { @@ -1431,7 +1538,7 @@ export function createLlmProviderService( const keys = await this.listKeys(); const keyMap = new Map(keys.map((key) => [key.id, key])); const [rows] = await pool.query( - 'SELECT * FROM h5_llm_executor_bindings ORDER BY FIELD(executor, "goose", "aider", "openhands"), purpose', + 'SELECT * FROM h5_llm_executor_bindings ORDER BY FIELD(executor, "goose", "aider", "openhands", "cursor"), purpose', ); const rowMap = new Map( rows.map((row) => [`${row.executor}:${row.purpose}`, row]), @@ -1656,6 +1763,14 @@ export function createLlmProviderService( }; } + if (normalizedExecutor === 'cursor' && !instruction) { + return { + ok: false, + executor: 'cursor', + message: 'TKMind 智趣启动需要 instruction', + }; + } + const runtime = await resolveExecutorRuntimeConfig(normalizedExecutor, { purpose, includeSecret: true, diff --git a/llm-providers.test.mjs b/llm-providers.test.mjs index c7597a3..5f72953 100644 --- a/llm-providers.test.mjs +++ b/llm-providers.test.mjs @@ -320,7 +320,7 @@ test('listExecutorBindings returns all executor placeholders', async () => { const bindings = await service.listExecutorBindings(); assert.deepEqual( bindings.map((binding) => binding.executor), - ['goose', 'aider', 'openhands'], + ['goose', 'aider', 'openhands', 'cursor'], ); assert.equal(bindings[0].enabled, false); }); @@ -649,6 +649,31 @@ test('buildExecutorLaunchPlan rejects openhands headless without instruction', ( assert.match(plan.message ?? '', /instruction/); }); +test('buildExecutorLaunchPlan creates cursor headless command when enabled', () => { + const previous = process.env.MEMIND_CURSOR_EXECUTOR_ENABLED; + process.env.MEMIND_CURSOR_EXECUTOR_ENABLED = '1'; + try { + const plan = buildExecutorLaunchPlan({ + ok: true, + executor: 'cursor', + executorLabel: 'TKMind 智趣', + purpose: 'default', + env: {}, + }, { + cwd: '/tmp/mindspace/user-1', + instruction: '生成 public/spring-poem.html', + }); + assert.equal(plan.ok, true); + assert.equal(plan.executor, 'cursor'); + assert.ok(plan.args.includes('--workspace')); + assert.ok(plan.args.includes('/tmp/mindspace/user-1')); + assert.match(plan.args.at(-1) ?? '', /spring-poem\.html/); + } finally { + if (previous === undefined) delete process.env.MEMIND_CURSOR_EXECUTOR_ENABLED; + else process.env.MEMIND_CURSOR_EXECUTOR_ENABLED = previous; + } +}); + test('launchExecutor rejects goose launch path', async () => { const pool = { async query() { diff --git a/package.json b/package.json index 1457140..b6f0644 100644 --- a/package.json +++ b/package.json @@ -113,6 +113,7 @@ "verify:page-data-delivery": "node scripts/repair-page-data-workspace-bindings.mjs --dry-run", "verify:template-catalog-portal": "node scripts/verify-template-catalog-portal.mjs", "verify:template-catalog-e2e": "node scripts/verify-template-catalog-e2e.mjs", + "verify:cursor-executor": "node --test cursor-agent-launch.test.mjs cursor-page-routing.test.mjs tool-gateway.test.mjs llm-providers.test.mjs help-escalation.test.mjs executor-display-label.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs wechat-cursor-executor-policy.test.mjs", "repair:page-data-bindings": "node scripts/repair-page-data-workspace-bindings.mjs", "repair:page-data:103": "node scripts/ensure-page-data-datasets.mjs && node scripts/repair-page-data-workspace-bindings.mjs", "verify:wechat-channel-isolation": "node scripts/check-wechat-channel-isolation.mjs", diff --git a/schema.sql b/schema.sql index 296a245..4b4dc21 100644 --- a/schema.sql +++ b/schema.sql @@ -622,7 +622,7 @@ CREATE TABLE IF NOT EXISTS h5_llm_provider_keys ( CREATE TABLE IF NOT EXISTS h5_llm_executor_bindings ( id CHAR(36) PRIMARY KEY, - executor ENUM('goose', 'aider', 'openhands') NOT NULL, + executor ENUM('goose', 'aider', 'openhands', 'cursor') NOT NULL, purpose VARCHAR(32) NOT NULL DEFAULT 'default', provider_key_id CHAR(36) NULL, model VARCHAR(128) NOT NULL, @@ -634,6 +634,28 @@ CREATE TABLE IF NOT EXISTS h5_llm_executor_bindings ( CONSTRAINT fk_h5_llm_executor_provider FOREIGN KEY (provider_key_id) REFERENCES h5_llm_provider_keys(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS h5_help_escalations ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + channel VARCHAR(32) NOT NULL DEFAULT 'h5', + status ENUM('queued', 'running', 'succeeded', 'failed') NOT NULL DEFAULT 'queued', + user_text MEDIUMTEXT NOT NULL, + context_json JSON NULL, + agent_session_id VARCHAR(128) NULL, + result_text MEDIUMTEXT NULL, + agent_output MEDIUMTEXT NULL, + error_message TEXT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + started_at BIGINT NULL, + completed_at BIGINT NULL, + KEY idx_h5_help_escalation_status_created (status, created_at), + KEY idx_h5_help_escalation_user_created (user_id, created_at), + KEY idx_h5_help_escalation_session (agent_session_id, updated_at), + CONSTRAINT fk_h5_help_escalation_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE, + CONSTRAINT fk_h5_help_escalation_session FOREIGN KEY (agent_session_id) REFERENCES h5_user_sessions(agent_session_id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS h5_asset_gateway_config ( config_key VARCHAR(32) PRIMARY KEY, enabled TINYINT(1) NOT NULL DEFAULT 0, diff --git a/scripts/agent-run-worker.mjs b/scripts/agent-run-worker.mjs index 5e306e3..fdafcc3 100644 --- a/scripts/agent-run-worker.mjs +++ b/scripts/agent-run-worker.mjs @@ -13,6 +13,9 @@ import { createManagedMemoryV2Runtime } from '../memory-v2-runtime.mjs'; import { createTkmindProxy } from '../tkmind-proxy.mjs'; import { createToolGateway } from '../tool-gateway.mjs'; import { createUserAuth } from '../user-auth.mjs'; +import { createSessionSnapshotService } from '../session-snapshot.mjs'; +import { createDirectChatService } from '../direct-chat-service.mjs'; +import { createSessionAccess } from '../session-broker.mjs'; import { createOrchestratorAdminConfigService } from '../services/orchestrator/admin-config.mjs'; import { createWorkflowShadowObserver } from '../services/orchestrator/shadow-observer.mjs'; @@ -158,6 +161,21 @@ async function bootstrapWorker() { err instanceof Error ? err.message : err, ); }); + const sessionSnapshotService = createSessionSnapshotService(pool, { + conversationMemoryService, + memoryV2, + episodicMemoryService, + }); + const sessionAccess = createSessionAccess({ userAuth, enabled: false }); + const directChatService = createDirectChatService({ + userAuth, + sessionAccess, + llmProviderService, + sessionSnapshotService, + memoryV2, + conversationMemoryService, + episodicMemoryService, + }); const chatIntentRouter = createManagedChatIntentRouter({ llmProviderService, memoryV2, @@ -176,8 +194,12 @@ async function bootstrapWorker() { const gateway = createAgentRunGateway({ pool, userAuth, + sessionAccess, tkmindProxy, toolGateway, + directChatService, + sessionSnapshotService, + conversationMemoryService, chatIntentRouter, conversationMemoryService, autoDispatch: false, diff --git a/scripts/memind-runtime-profile.mjs b/scripts/memind-runtime-profile.mjs index c58ef9e..d3c6e0c 100644 --- a/scripts/memind-runtime-profile.mjs +++ b/scripts/memind-runtime-profile.mjs @@ -8,7 +8,7 @@ export const MEMIND_RUNTIME_PROFILES = new Set(['local', 'split-service', 'produ */ export function loadMemindEnvFiles(rootDir, env = process.env) { const root = path.resolve(rootDir); - for (const relativePath of ['../../.env.local', '.env']) { + for (const relativePath of ['.env', '.env.local', '../../.env.local']) { loadEnvFile(path.join(root, relativePath), env); } } diff --git a/server.mjs b/server.mjs index 73dae2a..274d21c 100644 --- a/server.mjs +++ b/server.mjs @@ -283,6 +283,7 @@ let memoryV2ConfigService = null; let skillRuntimeConfigService = null; let systemDisclosurePolicyService = null; let agentCodeRunPolicyService = null; +let wechatCursorExecutorPolicyService = null; let wechatScheduleLlmConfigService = null; let wechatIntentRouter = null; let mindSpace = null; @@ -476,6 +477,8 @@ async function bootstrapUserAuth() { memorySessionServices.systemDisclosurePolicyService; agentCodeRunPolicyService = memorySessionServices.agentCodeRunPolicyService; + wechatCursorExecutorPolicyService = + memorySessionServices.wechatCursorExecutorPolicyService; wechatScheduleLlmConfigService = memorySessionServices.wechatScheduleLlmConfigService; wechatIntentRouter = @@ -543,6 +546,8 @@ async function bootstrapUserAuth() { sessionSnapshotService, wechatScheduleLlmConfigService, wechatIntentRouter, + wechatCursorExecutorPolicyService, + agentRunGateway, llmProviderService, chatIntentRouter, systemDisclosurePolicyService, diff --git a/server/portal-integration-services-bootstrap.mjs b/server/portal-integration-services-bootstrap.mjs index b663985..2bd60df 100644 --- a/server/portal-integration-services-bootstrap.mjs +++ b/server/portal-integration-services-bootstrap.mjs @@ -34,6 +34,8 @@ export async function bootstrapPortalIntegrationServices({ sessionSnapshotService = null, wechatScheduleLlmConfigService, wechatIntentRouter = null, + wechatCursorExecutorPolicyService = null, + agentRunGateway = null, llmProviderService, chatIntentRouter, systemDisclosurePolicyService, @@ -119,6 +121,8 @@ export async function bootstrapPortalIntegrationServices({ ? mindSpacePublicFinish : null, pageDataDeliveryReviewer, + wechatCursorExecutorPolicyService, + agentRunGateway, apiFetch: tkmindProxy.apiFetch, startAgentSession: ({ userId, diff --git a/server/portal-memory-session-services-bootstrap.mjs b/server/portal-memory-session-services-bootstrap.mjs index f039de7..a5e7c8e 100644 --- a/server/portal-memory-session-services-bootstrap.mjs +++ b/server/portal-memory-session-services-bootstrap.mjs @@ -13,6 +13,7 @@ import { createSystemDisclosurePolicyService } from '../system-disclosure-policy import { createWechatScheduleLlmConfigService } from '../wechat-schedule-llm-config.mjs'; import { createWechatIntentRouterConfigService } from '../wechat-intent-router-config.mjs'; import { createManagedWechatIntentRouter } from '../wechat-intent-router.mjs'; +import { createWechatCursorExecutorAdminConfigService } from '../wechat-cursor-executor-admin-config.mjs'; export async function bootstrapPortalMemorySessionServices({ pool, @@ -34,6 +35,8 @@ export async function bootstrapPortalMemorySessionServices({ createWechatScheduleLlmConfigService, createWechatIntentRouterConfigServiceFn = createWechatIntentRouterConfigService, + createWechatCursorExecutorAdminConfigServiceFn = + createWechatCursorExecutorAdminConfigService, createManagedWechatIntentRouterFn = createManagedWechatIntentRouter, createConversationMemoryServiceFn = @@ -79,6 +82,8 @@ export async function bootstrapPortalMemorySessionServices({ createWechatScheduleLlmConfigServiceFn(pool); const wechatIntentRouterConfigService = createWechatIntentRouterConfigServiceFn(pool, { env }); + const wechatCursorExecutorPolicyService = + createWechatCursorExecutorAdminConfigServiceFn(pool); const wechatIntentRouter = createManagedWechatIntentRouterFn({ llmProviderService, @@ -156,6 +161,7 @@ export async function bootstrapPortalMemorySessionServices({ agentCodeRunPolicyService, wechatScheduleLlmConfigService, wechatIntentRouterConfigService, + wechatCursorExecutorPolicyService, wechatIntentRouter, conversationMemoryService, memoryV2, diff --git a/wechat/handlers/page-generate.mjs b/wechat/handlers/page-generate.mjs index 339d3c2..3941b83 100644 --- a/wechat/handlers/page-generate.mjs +++ b/wechat/handlers/page-generate.mjs @@ -47,6 +47,7 @@ export function resolvePageGenerateOutcome({ replyHasPublicLinks = false, topic = '', repairContext = {}, + wechatCursorChannel = false, }) { const context = { topic: String(topic || repairContext.topic || '').trim(), @@ -64,6 +65,17 @@ export function resolvePageGenerateOutcome({ return { action: 'send', artifacts: sendable }; } + if (wechatCursorChannel) { + const cursorCandidates = selectSendableHtmlArtifacts({ + verifiedArtifacts, + confirmedArtifacts, + publishDir: context.publishDir, + }).filter((artifact) => verifyPageArtifactContent(artifact, previewOptions).ok); + if (cursorCandidates.length > 0) { + return { action: 'send', artifacts: cursorCandidates }; + } + } + if ( suspiciousPublishClaim || bareCompletionReply ||