fix(router): route casual chat to DeepSeek and reserve Cursor for page tasks

Default unmatched conversation to direct_chat, stop blanket Agent session continuation and generic Agent→Cursor promotion; fix WeChat page retry userPublishDir scope and simplify ChatPanel controls.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-26 21:09:44 +08:00
parent 1ec971515b
commit 9ac9f96167
9 changed files with 853 additions and 215 deletions
+46 -2
View File
@@ -59,7 +59,8 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173
# MEMIND_AGENT_RUN_WORKER_POLL_MS=1000
# MEMIND_AGENT_RUN_WORKER_BATCH_SIZE=1
# MEMIND_AGENT_RUN_HEARTBEAT_MS=30000
# MEMIND_AGENT_RUN_HEARTBEAT_STALE_MS=90000
# 页面/代码任务建议 5 分钟,避免长任务被误判 stale
# MEMIND_AGENT_RUN_HEARTBEAT_STALE_MS=300000
# MEMIND_AGENT_RUN_RECOVERY_INTERVAL_MS=30000
# MEMIND_RUNTIME_BUILD_ID=<release-or-build-id>
# MEMIND_AGENT_RUN_WORKER_ID=<optional-instance-id>
@@ -147,7 +148,19 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173
# MEMIND_CHAT_ROUTER_MIN_CONFIDENCE=0.65
# MEMIND_CHAT_ROUTER_TIMEOUT_MS=1200
# MEMIND_CHAT_ROUTER_MEMORY_ENABLED=1
# MEMIND_CHAT_ROUTER_FALLBACK_ROUTE=agent_orchestration
# MEMIND_CHAT_ROUTER_FALLBACK_ROUTE=direct_chat
# ----- Token 压缩(本地开发可选,降低 DeepSeek 上下文)-----
# Agent 会话历史压缩:超过阈值后清空 Goose 会话并保留摘要(默认 180 条 / 12 万字符,偏松)
# MEMIND_AGENT_SESSION_COMPACT_MESSAGE_COUNT=40
# MEMIND_AGENT_SESSION_COMPACT_CHAR_COUNT=30000
# 路由时不再附加 Memory 上下文(省 input token)
# MEMIND_CHAT_ROUTER_MEMORY_ENABLED=0
# Memory 召回预算(也可在 memind_adm Memory V2 页调整 retriever.tokenBudget / limit)
# MEMORY_RETRIEVER_TOKEN_BUDGET=900
# MEMORY_RETRIEVER_LIMIT=4
# MEMORY_AGENT_RESOLVE_LIMIT=2
# 对比脚本:node scripts/benchmark-token-optimization.mjs --scenario token-benchmark-chat
# ----- 本地 LLM Router(pnpm dev,全员 Active,无 Shadow)-----
# Router 模型建议(分类 JSON,短 prompt,要快不要大):
@@ -501,3 +514,34 @@ MEMIND_RUNTIME_PROFILE=local
# MINDSPACE_REMOTE_BASE_URL=http://127.0.0.1:8082
# MINDSPACE_REMOTE_AUTH_TOKEN=<与 cn.tkmind.mindspace-service 一致>
# MINDSPACE_STORAGE_ROOT=/Users/john/MindSpace/data/mindspace
# ---------------------------------------------------------------------------
# Cursor 双轨(help 工程师 + 实验性 Goose 聊天桥)
# ---------------------------------------------------------------------------
# Track B: 用户 help → Cursor agent → 自动 Goose 原会话补发链接
# MEMIND_CURSOR_HELP_ENABLED=1
# MEMIND_CURSOR_HELP_WORKER_ENABLED=1
# MEMIND_CURSOR_HELP_WORKSPACE=/Users/john/Project/Memind
# MEMIND_CURSOR_HELP_AGENT_BIN=/Users/john/.local/bin/agent
# MEMIND_CURSOR_HELP_AUTO_GOOSE_REMEDIATE=1
# Track C: Goose 编排 + Cursor 作为 code executor(MindSpace 落盘,推荐 PoC)
# MEMIND_CURSOR_EXECUTOR_ENABLED=1
# MEMIND_AIDER_SKILL_USE_CURSOR=1
# 默认将页面生成、问卷(Page Data)、Excel 分析路由到 Cursor 执行
# MEMIND_CURSOR_PAGE_TASKS_DEFAULT=1
# 已废弃:不再把普通 Agent 编排全量切到 Cursor;简单对话走 DeepSeek direct_chat
# MEMIND_CURSOR_AGENT_TASKS_DEFAULT=0
# MEMIND_CURSOR_DEEPSEEK_FALLBACK=1
# MEMIND_TOOL_GATEWAY_DEFAULT_EXECUTOR=cursor
# MEMIND_TOOL_GATEWAY_ENABLED=1
# MEMIND_TOOL_GATEWAY_DRY_RUN=0
# MEMIND_TOOL_GATEWAY_CURSOR_TASK_TYPES=h5_chat_code_task,mindspace_page,mindspace_html_page
# MEMIND_CURSOR_EXECUTOR_AGENT_BIN=/Users/john/.local/bin/agent
# Track A: goosed 经 OpenAI 兼容桥接调 Cursor(PoC,不支持 tools;仅灰度)
# MEMIND_CURSOR_CHAT_BRIDGE_ENABLED=1
# MEMIND_CURSOR_CHAT_BRIDGE_PORT=18040
# MEMIND_CURSOR_CHAT_BRIDGE_ENTRYPOINT=1
# CURSOR_API_KEY=your-cursor-api-key
# memindadm 选择「Cursor Chat Bridge (实验)」后 sync 到 goosed
+36 -39
View File
@@ -399,11 +399,9 @@ export function coerceRealtimeWebSkill(classification, text, { grantedSkills = [
}
function resolveRouterFallbackRoute(env, overrides = {}) {
const raw = overrides.fallbackRoute
return overrides.fallbackRoute
?? normalizeRoute(env?.MEMIND_CHAT_ROUTER_FALLBACK_ROUTE)
?? CHAT_INTENT_ROUTE.AGENT;
// Timeout/parse failures must fail open to agent so realtime/tool tasks are not dropped.
return raw === CHAT_INTENT_ROUTE.DIRECT_CHAT ? CHAT_INTENT_ROUTE.AGENT : raw;
?? CHAT_INTENT_ROUTE.DIRECT_CHAT;
}
const DEFAULT_ROUTER_TIMEOUT_MS = 1200;
@@ -1071,12 +1069,7 @@ export function classifyWithRules({
reason: '消息包含非文本内容',
}, { source: 'rule' }), decisionContext);
}
if (
includeIntentPatterns &&
normalized &&
isMemoryRecallQuestion(normalized) &&
!hasPriorAgentConversation(sessionId, sessionMessageCount)
) {
if (includeIntentPatterns && normalized && isMemoryRecallQuestion(normalized)) {
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.DIRECT_CHAT,
confidence: 0.98,
@@ -1091,16 +1084,6 @@ export function classifyWithRules({
reason: faqMatch?.reason ?? '用户明确要求纯文字、不生成页面',
}, { source: 'rule' }), decisionContext);
}
if (hasPriorAgentConversation(sessionId, sessionMessageCount)) {
const reason = isAgentSessionContinueText(normalized)
? 'Agent 会话确认/续聊'
: '延续已有 Agent 会话';
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.AGENT,
confidence: 1,
reason,
}, { source: 'rule' }), decisionContext);
}
if (includeIntentPatterns && normalized) {
const faqMatch = matchDirectChatFaqRule(normalized);
if (faqMatch) {
@@ -1111,6 +1094,35 @@ export function classifyWithRules({
}, { source: 'rule' }), decisionContext);
}
}
if (
includeIntentPatterns &&
normalized &&
OBVIOUS_DIRECT_PATTERNS.some((pattern) => pattern.test(normalized))
) {
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.DIRECT_CHAT,
confidence: 0.95,
reason: '简单寒暄或连通性测试',
}, { source: 'rule' }), decisionContext);
}
if (
includeIntentPatterns &&
normalized &&
OBVIOUS_DIRECT_CHAT_PATTERNS.some((pattern) => pattern.test(normalized))
) {
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.DIRECT_CHAT,
confidence: 0.95,
reason: '纯文字创作或闲聊,无需执行任务',
}, { source: 'rule' }), decisionContext);
}
if (hasPriorAgentConversation(sessionId, sessionMessageCount) && isAgentSessionContinueText(normalized)) {
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.AGENT,
confidence: 1,
reason: 'Agent 会话确认/续聊',
}, { source: 'rule' }), decisionContext);
}
if (includeIntentPatterns && normalized && isPageDataDevIntent(normalized)) {
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.AGENT,
@@ -1160,26 +1172,11 @@ export function classifyWithRules({
if (includeIntentPatterns && normalized && isRealtimeInfoQuestion(normalized)) {
return finalizeRouterClassification(buildRealtimeInfoClassification(), decisionContext);
}
if (
includeIntentPatterns &&
normalized &&
OBVIOUS_DIRECT_PATTERNS.some((pattern) => pattern.test(normalized))
) {
if (includeIntentPatterns && normalized) {
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.DIRECT_CHAT,
confidence: 0.95,
reason: '简单寒暄或连通性测试',
}, { source: 'rule' }), decisionContext);
}
if (
includeIntentPatterns &&
normalized &&
OBVIOUS_DIRECT_CHAT_PATTERNS.some((pattern) => pattern.test(normalized))
) {
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.DIRECT_CHAT,
confidence: 0.95,
reason: '纯文字创作或闲聊,无需执行任务',
confidence: 0.72,
reason: '普通对话默认走轻量聊天',
}, { source: 'rule' }), decisionContext);
}
return null;
@@ -1220,7 +1217,7 @@ export function createChatIntentRouter(options = {}) {
});
}
function buildFallbackClassification(reason = '规则未命中,走 skill 默认 Agent 通道') {
function buildFallbackClassification(reason = '规则未命中,默认轻量聊天') {
return normalizeClassification({
route: policy.fallbackRoute,
confidence: 0.5,
+62 -42
View File
@@ -443,14 +443,22 @@ test('classifyWithRules keeps agent session continuation even when llm router fl
assert.match(continued.reason, /Agent 会话确认|延续已有 Agent 会话/);
});
test('classifyWithRules keeps empty pre-created sessions eligible for llm routing', () => {
test('classifyWithRules keeps agent session confirmation on agent path', () => {
const continued = classifyWithRules({
text: '继续聊聊',
sessionId: '20260704_2',
sessionMessageCount: 2,
});
assert.equal(continued.route, CHAT_INTENT_ROUTE.AGENT);
assert.equal(continued.reason, '延续已有 Agent 会话');
assert.equal(continued.route, CHAT_INTENT_ROUTE.DIRECT_CHAT);
assert.match(continued.reason, /轻量聊天/);
const confirmed = classifyWithRules({
text: '可以',
sessionId: '20260704_2',
sessionMessageCount: 2,
});
assert.equal(confirmed.route, CHAT_INTENT_ROUTE.AGENT);
assert.match(confirmed.reason, /Agent 会话确认/);
const fresh = classifyWithRules({
text: '我是 John,你对我的记忆知道多少?',
@@ -461,7 +469,7 @@ test('classifyWithRules keeps empty pre-created sessions eligible for llm routin
assert.match(fresh.reason, /记忆/);
});
test('classifyWithRules keeps memory recall on agent when session already active', () => {
test('classifyWithRules keeps memory recall on direct chat even when session already active', () => {
const result = classifyWithRules({
text: '你记得我说想去哪儿吗',
sessionId: '20260704_9',
@@ -472,8 +480,23 @@ test('classifyWithRules keeps memory recall on agent when session already active
metadata: { displayText: '你记得我说想去哪儿吗' },
},
});
assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT);
assert.match(result.reason, /延续已有 Agent 会话/);
assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT);
assert.match(result.reason, /记忆|历史对话/);
});
test('classifyWithRules keeps casual FAQ chat on direct chat even when session already active', () => {
const result = classifyWithRules({
text: '你觉得人都是为了什么而活',
sessionId: '20260826_39',
sessionMessageCount: 4,
userMessage: {
role: 'user',
content: [{ type: 'text', text: '你觉得人都是为了什么而活' }],
metadata: { displayText: '你觉得人都是为了什么而活' },
},
});
assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT);
assert.match(result.reason, /闲聊|FAQ/);
});
test('classifyWithRules bypasses llm router when user selected summarize skill', async () => {
@@ -559,7 +582,7 @@ test('classifyWithRules bypasses llm router for explicit web skill prompt', asyn
assert.equal(llmCalls.length, 0);
});
test('createChatIntentRouter keeps continued sessions on agent without llm router', async () => {
test('createChatIntentRouter keeps continued sessions on direct chat for general conversation', async () => {
const llmCalls = [];
const router = createChatIntentRouter({
enabled: true,
@@ -590,12 +613,12 @@ test('createChatIntentRouter keeps continued sessions on agent without llm route
});
assert.equal(llmCalls.length, 0);
assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT);
assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT);
assert.equal(result.source, 'rule');
assert.match(result.reason, /延续已有 Agent 会话/);
assert.match(result.reason, /轻量聊天/);
});
test('createChatIntentRouter uses agent fallback for general conversational analysis when llm router disabled', async () => {
test('createChatIntentRouter uses direct chat fallback for general conversational analysis when llm router disabled', async () => {
const llmCalls = [];
const resolveCalls = [];
const router = createChatIntentRouter({
@@ -630,8 +653,8 @@ test('createChatIntentRouter uses agent fallback for general conversational anal
grantedSkills: ['web'],
});
assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT);
assert.equal(result.source, 'fallback');
assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT);
assert.equal(result.source, 'rule');
assert.equal(resolveCalls.length, 0);
assert.equal(llmCalls.length, 0);
});
@@ -775,11 +798,11 @@ test('createManagedChatIntentRouter hot-loads admin config and activates llm rou
},
});
assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT);
assert.equal(result.source, 'llm');
assert.equal(llmCalls.length, 1);
assert.equal(result.source, 'rule');
assert.equal(llmCalls.length, 0);
});
test('createChatIntentRouter rejects low-confidence llm direct_chat and falls back to agent', async () => {
test('createChatIntentRouter rejects low-confidence llm direct_chat and keeps rule default', async () => {
const llmCalls = [];
const router = createChatIntentRouter({
enabled: true,
@@ -809,12 +832,12 @@ test('createChatIntentRouter rejects low-confidence llm direct_chat and falls ba
},
});
assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT);
assert.equal(result.source, 'fallback');
assert.equal(llmCalls.length, 1);
assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT);
assert.equal(result.source, 'rule');
assert.equal(llmCalls.length, 0);
});
test('createChatIntentRouter falls back to agent when llm router fails', async () => {
test('createChatIntentRouter keeps direct chat when llm router fails', async () => {
const llmCalls = [];
const router = createChatIntentRouter({
enabled: true,
@@ -835,12 +858,12 @@ test('createChatIntentRouter falls back to agent when llm router fails', async (
grantedSkills: ['web'],
});
assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT);
assert.equal(result.source, 'fallback');
assert.equal(llmCalls.length, 1);
assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT);
assert.equal(result.source, 'rule');
assert.equal(llmCalls.length, 0);
});
test('createChatIntentRouter uses agent fallback when llm router disabled', async () => {
test('createChatIntentRouter uses direct chat when llm router disabled', async () => {
const llmCalls = [];
const router = createChatIntentRouter({
enabled: false,
@@ -860,8 +883,8 @@ test('createChatIntentRouter uses agent fallback when llm router disabled', asyn
},
});
assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT);
assert.equal(result.source, 'fallback');
assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT);
assert.equal(result.source, 'rule');
assert.equal(llmCalls.length, 0);
});
@@ -1176,13 +1199,13 @@ test('createChatIntentRouter fast-paths world cup without calling router LLM', a
assert.equal(result.source, 'rule');
});
test('resolveChatIntentRouterPolicy coerces direct_chat fallback to agent', () => {
test('resolveChatIntentRouterPolicy defaults fallback route to direct chat', () => {
const policy = resolveChatIntentRouterPolicy({
env: {
MEMIND_CHAT_ROUTER_FALLBACK_ROUTE: 'direct_chat',
},
});
assert.equal(policy.fallbackRoute, CHAT_INTENT_ROUTE.AGENT);
assert.equal(policy.fallbackRoute, CHAT_INTENT_ROUTE.DIRECT_CHAT);
});
test('coerceRealtimeWebSkill replaces search with web for world cup query', () => {
@@ -1259,7 +1282,7 @@ test('createChatIntentRouter coerces LLM search route to web for realtime query'
assert.match(result.agentBrief, /web_search/);
});
test('createChatIntentRouter timeout fallback prefers agent even when policy requests direct_chat', async () => {
test('createChatIntentRouter timeout fallback keeps direct chat default', async () => {
const router = createChatIntentRouter({
enabled: true,
timeoutMs: 40,
@@ -1280,8 +1303,8 @@ test('createChatIntentRouter timeout fallback prefers agent even when policy req
},
});
assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT);
assert.equal(result.source, 'fallback');
assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT);
assert.equal(result.source, 'rule');
});
test('agent memory shadow resolve is independently gated and never marked for injection', async () => {
@@ -1592,14 +1615,14 @@ test('createChatIntentRouter limits llm routing to canary users', async () => {
},
});
assert.equal(control.route, CHAT_INTENT_ROUTE.AGENT);
assert.equal(control.source, 'fallback');
assert.equal(control.route, CHAT_INTENT_ROUTE.DIRECT_CHAT);
assert.equal(control.source, 'rule');
assert.equal(canary.route, CHAT_INTENT_ROUTE.DIRECT_CHAT);
assert.equal(canary.source, 'llm');
assert.equal(llmCalls.length, 1);
assert.equal(canary.source, 'rule');
assert.equal(llmCalls.length, 0);
});
test('createChatIntentRouter shadow mode logs llm suggestion without changing route', async () => {
test('createChatIntentRouter shadow mode skips llm shadow when rule default matches', async () => {
const lines = [];
const logger = { warn(line) { lines.push(line); }, info(line) { lines.push(line); } };
const router = createChatIntentRouter({
@@ -1631,13 +1654,10 @@ test('createChatIntentRouter shadow mode logs llm suggestion without changing ro
},
});
assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT);
assert.equal(result.source, 'fallback');
assert.equal(result.llmShadow?.route, CHAT_INTENT_ROUTE.DIRECT_CHAT);
assert.equal(result.llmShadow?.wouldChangeRoute, true);
assert.equal(lines.length, 1);
assert.match(lines[0], /\[chat-llm-router-shadow\]/);
assert.match(lines[0], /wouldChangeRoute":true/);
assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT);
assert.equal(result.source, 'rule');
assert.equal(result.llmShadow, undefined);
assert.equal(lines.length, 0);
});
test('logChatLlmRouterShadow emits structured payload', () => {
+109
View File
@@ -0,0 +1,109 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
function envFlag(value, fallback = false) {
const raw = String(value ?? '').trim().toLowerCase();
if (!raw) return fallback;
return ['1', 'true', 'yes', 'on'].includes(raw);
}
export function expandHome(value) {
const text = String(value ?? '').trim();
if (!text) return text;
if (text.startsWith('~/')) return path.join(os.homedir(), text.slice(2));
if (text === '~') return os.homedir();
return text;
}
export function cursorExecutorEnabled(env = process.env) {
return envFlag(env.MEMIND_CURSOR_EXECUTOR_ENABLED, false);
}
export function resolvePreferredCodeExecutor(env = process.env) {
if (!cursorExecutorEnabled(env)) return 'aider';
if (envFlag(env.MEMIND_AIDER_SKILL_USE_CURSOR, true)) return 'cursor';
return 'aider';
}
export function cursorPageTasksDefaultEnabled(env = process.env) {
if (!cursorExecutorEnabled(env)) return false;
return envFlag(env.MEMIND_CURSOR_PAGE_TASKS_DEFAULT, true);
}
/** @deprecated 普通 Agent 编排不再默认走 Cursor;仅页面/问卷/Excel 等代码任务使用 enforcePageGenerationCursorRuntime */
export function cursorAgentTasksDefaultEnabled(env = process.env) {
if (!cursorExecutorEnabled(env)) return false;
return envFlag(env.MEMIND_CURSOR_AGENT_TASKS_DEFAULT, false);
}
/** 智趣执行失败时回退 DeepSeek / Goose 会话 */
export function cursorDeepseekFallbackEnabled(env = process.env) {
return envFlag(env.MEMIND_CURSOR_DEEPSEEK_FALLBACK, false);
}
export function resolveCursorAgentBin(env = process.env) {
const configured = expandHome(
env.MEMIND_CURSOR_EXECUTOR_AGENT_BIN
?? env.MEMIND_CURSOR_HELP_AGENT_BIN
?? '~/.local/bin/agent',
);
if (configured && fs.existsSync(configured)) return configured;
return 'agent';
}
export function buildCursorExecutorInstruction(instruction) {
const base = String(instruction ?? '').trim();
if (!base) return base;
return [
base,
'',
'执行要求:',
'- 在当前 MindSpace 工作区(cwd)内完成改动',
'- 页面产物写入 public/ 目录下的 .html 文件',
'- 直接完成可交付产物,不要等待用户确认',
'- 不要执行 git commit、git push、git checkout、git reset、生产发布或远端同步命令',
'- 不要修改当前工作区之外的文件',
'- 完成后在最终输出中简述改动文件与验证结果',
].join('\n');
}
export function buildCursorExecutorLaunchPlan({
cwd,
instruction,
env = process.env,
agentBin = resolveCursorAgentBin(env),
} = {}) {
const workspace = path.resolve(String(cwd ?? env.MEMIND_CURSOR_EXECUTOR_WORKSPACE ?? process.cwd()));
const prompt = buildCursorExecutorInstruction(instruction);
if (!prompt) {
return {
ok: false,
executor: 'cursor',
message: 'TKMind 智趣执行器需要 instruction',
};
}
return {
ok: true,
executor: 'cursor',
executorLabel: 'TKMind 智趣',
cwd: workspace,
command: agentBin,
args: [
'--print',
'--trust',
'--force',
'--approve-mcps',
'--output-format',
'text',
'--workspace',
workspace,
prompt,
],
env: {},
notes: [
'TKMind 智趣执行器在 MindSpace 工作区 headless 执行。',
'Goose 仍负责对话编排,智趣只负责代码/页面落盘。',
],
};
}
+56
View File
@@ -0,0 +1,56 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildCursorExecutorLaunchPlan,
cursorExecutorEnabled,
resolvePreferredCodeExecutor,
} from './cursor-agent-launch.mjs';
test('cursor executor is disabled unless explicitly enabled', () => {
assert.equal(cursorExecutorEnabled({}), false);
assert.equal(cursorExecutorEnabled({ MEMIND_CURSOR_EXECUTOR_ENABLED: '1' }), true);
});
test('resolvePreferredCodeExecutor prefers cursor when enabled', () => {
assert.equal(resolvePreferredCodeExecutor({}), 'aider');
assert.equal(
resolvePreferredCodeExecutor({ MEMIND_CURSOR_EXECUTOR_ENABLED: '1' }),
'cursor',
);
assert.equal(
resolvePreferredCodeExecutor({
MEMIND_CURSOR_EXECUTOR_ENABLED: '1',
MEMIND_AIDER_SKILL_USE_CURSOR: '0',
}),
'aider',
);
});
test('buildCursorExecutorLaunchPlan includes workspace and mindspace hints', async () => {
const fs = await import('node:fs/promises');
const os = await import('node:os');
const path = await import('node:path');
const agentStub = path.join(await fs.mkdtemp(path.join(os.tmpdir(), 'cursor-agent-')), 'agent');
await fs.writeFile(agentStub, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
const plan = buildCursorExecutorLaunchPlan({
cwd: '/tmp/mindspace/user-1',
instruction: '写一首春日诗并生成 public/spring-poem.html',
env: { MEMIND_CURSOR_EXECUTOR_AGENT_BIN: agentStub },
});
assert.equal(plan.ok, true);
assert.equal(plan.executor, 'cursor');
assert.equal(plan.cwd, '/tmp/mindspace/user-1');
assert.equal(plan.command, agentStub);
assert.ok(plan.args.includes('--workspace'));
assert.ok(plan.args.includes('/tmp/mindspace/user-1'));
assert.match(plan.args.at(-1) ?? '', /public\/.*\.html/);
assert.match(plan.args.at(-1) ?? '', /不要执行 git commit/);
assert.match(plan.args.at(-1) ?? '', /不要修改当前工作区之外的文件/);
});
test('buildCursorExecutorLaunchPlan rejects empty instruction', () => {
const plan = buildCursorExecutorLaunchPlan({ cwd: '/tmp/work', instruction: ' ' });
assert.equal(plan.ok, false);
assert.match(plan.message ?? '', /instruction/i);
});
+252
View File
@@ -0,0 +1,252 @@
import {
AIDER_DEVELOPMENT_SKILL_NAME,
EXCEL_ANALYST_SKILL_NAME,
PAGE_DATA_COLLECT_SKILL_NAME,
extractAiderDevelopmentTask,
isExcelAnalysisIntent,
isGenericPageGenerationRequest,
isPageDataDevIntent,
isPageDataIntent,
isPageGenerationIntent,
} from './chat-skills.mjs';
import {
cursorAgentTasksDefaultEnabled,
cursorPageTasksDefaultEnabled,
resolvePreferredCodeExecutor,
} from './cursor-agent-launch.mjs';
const PUBLISH_SKILL_NAME = 'static-page-publish';
const PAGE_CURSOR_TASK_TYPE = 'h5_chat_code_task';
const CURSOR_TASK_KIND = {
PAGE_GENERATION: 'page_generation',
PAGE_DATA: 'page_data',
EXCEL_ANALYSIS: 'excel_analysis',
};
function selectedChatSkill(userMessage) {
const metadata = userMessage?.metadata;
const runMetadata = metadata?.memindRun ?? metadata?.agentRun ?? {};
return String(runMetadata.selectedChatSkill ?? '').trim();
}
export function resolveCursorTaskKind(taskText, skill = '') {
const normalized = String(taskText ?? '').trim();
if (!normalized || isPageDataDevIntent(normalized)) return null;
const selectedSkill = String(skill ?? '').trim();
if (
selectedSkill === PAGE_DATA_COLLECT_SKILL_NAME
|| isPageDataIntent(normalized)
) {
return CURSOR_TASK_KIND.PAGE_DATA;
}
if (
selectedSkill === EXCEL_ANALYST_SKILL_NAME
|| isExcelAnalysisIntent(normalized)
) {
return CURSOR_TASK_KIND.EXCEL_ANALYSIS;
}
if (
selectedSkill === PUBLISH_SKILL_NAME
|| isPageGenerationIntent(normalized)
) {
if (isGenericPageGenerationRequest(normalized)) return null;
return CURSOR_TASK_KIND.PAGE_GENERATION;
}
return null;
}
export function isPageCursorDefaultCandidate(userMessage, {
rawToolMode = 'chat',
env = process.env,
} = {}) {
if (!cursorPageTasksDefaultEnabled(env)) return false;
if (String(rawToolMode ?? '').trim().toLowerCase() === 'code') return false;
const skill = selectedChatSkill(userMessage);
if (skill === AIDER_DEVELOPMENT_SKILL_NAME) return false;
const runMetadata = userMessage?.metadata?.memindRun ?? userMessage?.metadata?.agentRun ?? {};
if (runMetadata.pageTemplateSkill || runMetadata.pageTemplateStrict) return false;
const taskText = extractAiderDevelopmentTask(userMessage);
if (!taskText) return false;
return resolveCursorTaskKind(taskText, skill) !== null;
}
function buildCursorPageInstruction(taskText) {
return [
taskText,
'',
'[Memind page task via TKMind 智趣 executor]',
'在当前用户 MindSpace 工作区生成或更新 public/*.html 页面。',
'完成后确保页面可通过 /MindSpace/<userId>/public/<file>.html 访问。',
'不要只回复文字,必须实际落盘 HTML 文件。',
].join('\n');
}
function buildCursorPageDataInstruction(taskText) {
return [
taskText,
'',
'[Memind Page Data survey task via TKMind 智趣 executor]',
'在当前用户 MindSpace 工作区完成可提交、可持久化的问卷/数据收集与结果分析页面。',
'必须先阅读 docs/page-data-api-usage.md 与 docs/examples/page-data-survey-test.md。',
'需要:1) 创建/更新 public/*.html 问卷页(含 MindSpacePageData.createClient);',
'2) 按需创建后台分析页;3) 注册 dataset 与 page policy(可参考 scripts/setup-page-data-survey-demo.mjs);',
'4) 数据必须持久化到 PG(通过 Page Data API),不要旁路自建 API。',
'完成后确保页面可通过 /MindSpace/<userId>/public/<file>.html 访问。',
'不要只回复文字,必须实际落盘 HTML 与相关配置文件。',
].join('\n');
}
function buildCursorExcelInstruction(taskText) {
return [
taskText,
'',
'[Memind Excel analysis task via TKMind 智趣 executor]',
'在当前用户 MindSpace 工作区分析用户上传的 Excel/CSV 等表格文件。',
'先在工作区 attachments/、uploads/ 或用户消息提及的路径定位附件文件,读取并分析数据。',
'输出分析结论,并生成可访问的结果页面 public/*.html 或分析报告文件。',
'不要只回复文字,必须实际读取文件并产出可交付结果。',
].join('\n');
}
function buildCursorTaskInstruction(taskText, taskKind) {
switch (taskKind) {
case CURSOR_TASK_KIND.PAGE_DATA:
return buildCursorPageDataInstruction(taskText);
case CURSOR_TASK_KIND.EXCEL_ANALYSIS:
return buildCursorExcelInstruction(taskText);
case CURSOR_TASK_KIND.PAGE_GENERATION:
default:
return buildCursorPageInstruction(taskText);
}
}
function resolveCursorTaskSkill(taskKind, runMetadataSkill = '') {
const existing = String(runMetadataSkill ?? '').trim();
if (existing && existing !== AIDER_DEVELOPMENT_SKILL_NAME) return existing;
switch (taskKind) {
case CURSOR_TASK_KIND.PAGE_DATA:
return PAGE_DATA_COLLECT_SKILL_NAME;
case CURSOR_TASK_KIND.EXCEL_ANALYSIS:
return EXCEL_ANALYST_SKILL_NAME;
case CURSOR_TASK_KIND.PAGE_GENERATION:
default:
return PUBLISH_SKILL_NAME;
}
}
function resolveCursorSuggestedDelivery(taskKind) {
switch (taskKind) {
case CURSOR_TASK_KIND.PAGE_DATA:
return 'mindspace_page_data';
case CURSOR_TASK_KIND.EXCEL_ANALYSIS:
return 'mindspace_analysis_html';
case CURSOR_TASK_KIND.PAGE_GENERATION:
default:
return 'mindspace_public_html';
}
}
function rewriteUserMessageForCursorTask(userMessage, taskText, taskKind) {
const message = userMessage && typeof userMessage === 'object' && !Array.isArray(userMessage)
? { ...userMessage }
: { value: userMessage };
const metadata = message.metadata && typeof message.metadata === 'object' && !Array.isArray(message.metadata)
? { ...message.metadata }
: {};
const instruction = buildCursorTaskInstruction(taskText, taskKind);
metadata.displayText = taskText;
metadata.userVisible = true;
const content = Array.isArray(message.content)
? message.content.map((item) => (
item?.type === 'text'
? { ...item, text: instruction }
: item
))
: [{ type: 'text', text: instruction }];
return { ...message, metadata, content };
}
export function enforcePageGenerationCursorRuntime(userMessage, {
rawToolMode = 'chat',
taskType = null,
forceDeepReasoning = false,
env = process.env,
} = {}) {
if (!isPageCursorDefaultCandidate(userMessage, { rawToolMode, env })) {
return {
userMessage,
rawToolMode,
taskType,
forceDeepReasoning,
requiredExecutor: null,
};
}
const taskText = extractAiderDevelopmentTask(userMessage);
const skill = selectedChatSkill(userMessage);
const taskKind = resolveCursorTaskKind(taskText, skill);
if (!taskKind) {
return {
userMessage,
rawToolMode,
taskType,
forceDeepReasoning,
requiredExecutor: null,
};
}
const codeExecutor = resolvePreferredCodeExecutor(env);
const message = rewriteUserMessageForCursorTask(userMessage, taskText, taskKind);
const metadata = message.metadata && typeof message.metadata === 'object' && !Array.isArray(message.metadata)
? { ...message.metadata }
: {};
const runMetadata = metadata.memindRun && typeof metadata.memindRun === 'object' && !Array.isArray(metadata.memindRun)
? { ...metadata.memindRun }
: {};
metadata.memindRun = {
...runMetadata,
executor: codeExecutor,
pageCursorDefault: true,
cursorTaskKind: taskKind,
suggestedDelivery: resolveCursorSuggestedDelivery(taskKind),
selectedChatSkill: resolveCursorTaskSkill(taskKind, runMetadata.selectedChatSkill),
};
return {
userMessage: { ...message, metadata },
rawToolMode: 'code',
taskType: PAGE_CURSOR_TASK_TYPE,
forceDeepReasoning: true,
requiredExecutor: codeExecutor,
};
}
export function applyCursorFirstAgentExecution(userMessage, runOptions = {}, {
routingDecision = null,
env = process.env,
} = {}) {
// Cursor 仅服务于显式页面/代码任务(enforcePageGenerationCursorRuntime)。
// 普通 Agent 编排走 Goose/DeepSeek,不因 MEMIND_CURSOR_AGENT_TASKS_DEFAULT 全量切 Cursor。
void userMessage;
void runOptions;
void routingDecision;
void env;
return { userMessage, runOptions, cursorFirst: false };
}
export function __cursorPageRoutingTestExports() {
return {
PAGE_CURSOR_TASK_TYPE,
CURSOR_TASK_KIND,
cursorPageTasksDefaultEnabled,
cursorAgentTasksDefaultEnabled,
};
}
+155
View File
@@ -0,0 +1,155 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
enforcePageGenerationCursorRuntime,
isPageCursorDefaultCandidate,
resolveCursorTaskKind,
applyCursorFirstAgentExecution,
} from './cursor-page-routing.mjs';
const enabledEnv = {
MEMIND_CURSOR_EXECUTOR_ENABLED: '1',
MEMIND_CURSOR_PAGE_TASKS_DEFAULT: '1',
};
test('page cursor default matches poem page requests', () => {
const userMessage = {
role: 'user',
content: [{ type: 'text', text: '帮我写首诗,并做成页面' }],
metadata: { displayText: '帮我写首诗,并做成页面', userVisible: true },
};
assert.equal(isPageCursorDefaultCandidate(userMessage, { env: enabledEnv }), true);
assert.equal(resolveCursorTaskKind('帮我写首诗,并做成页面'), 'page_generation');
});
test('page cursor default matches survey page-data requests', () => {
const userMessage = {
role: 'user',
content: [{ type: 'text', text: '帮我做一个调查问卷,结果存 PG' }],
metadata: { displayText: '帮我做一个调查问卷,结果存 PG' },
};
assert.equal(isPageCursorDefaultCandidate(userMessage, { env: enabledEnv }), true);
assert.equal(resolveCursorTaskKind('帮我做一个调查问卷,结果存 PG'), 'page_data');
});
test('page cursor default matches excel analysis requests', () => {
const userMessage = {
role: 'user',
content: [{ type: 'text', text: '帮我分析这个 Excel 表格的数据趋势' }],
metadata: { displayText: '帮我分析这个 Excel 表格的数据趋势' },
};
assert.equal(isPageCursorDefaultCandidate(userMessage, { env: enabledEnv }), true);
assert.equal(resolveCursorTaskKind('帮我分析这个 Excel 表格的数据趋势'), 'excel_analysis');
});
test('page cursor default skips generic empty page requests', () => {
const userMessage = {
role: 'user',
content: [{ type: 'text', text: '帮我做一个页面吧' }],
metadata: { displayText: '帮我做一个页面吧' },
};
assert.equal(isPageCursorDefaultCandidate(userMessage, { env: enabledEnv }), false);
});
test('page cursor default skips page-data dev repair requests', () => {
const userMessage = {
role: 'user',
content: [{ type: 'text', text: '问卷页面提交失败,帮我排查 Page Data 绑定' }],
metadata: { displayText: '问卷页面提交失败,帮我排查 Page Data 绑定' },
};
assert.equal(isPageCursorDefaultCandidate(userMessage, { env: enabledEnv }), false);
});
test('enforcePageGenerationCursorRuntime switches page generation to code cursor executor', () => {
const userMessage = {
role: 'user',
content: [{ type: 'text', text: '帮我写首诗,并做成页面' }],
metadata: { displayText: '帮我写首诗,并做成页面' },
};
const enforced = enforcePageGenerationCursorRuntime(userMessage, { env: enabledEnv });
assert.equal(enforced.rawToolMode, 'code');
assert.equal(enforced.taskType, 'h5_chat_code_task');
assert.equal(enforced.requiredExecutor, 'cursor');
assert.equal(enforced.userMessage.metadata.memindRun.executor, 'cursor');
assert.equal(enforced.userMessage.metadata.memindRun.pageCursorDefault, true);
assert.equal(enforced.userMessage.metadata.memindRun.cursorTaskKind, 'page_generation');
assert.equal(enforced.userMessage.metadata.memindRun.selectedChatSkill, 'static-page-publish');
assert.match(
enforced.userMessage.content[0].text,
/MindSpace 工作区生成或更新 public\/\*\.html/,
);
});
test('enforcePageGenerationCursorRuntime switches survey tasks to cursor page-data flow', () => {
const userMessage = {
role: 'user',
content: [{ type: 'text', text: '帮我做一个调查问卷,结果存 PG' }],
metadata: { displayText: '帮我做一个调查问卷,结果存 PG' },
};
const enforced = enforcePageGenerationCursorRuntime(userMessage, { env: enabledEnv });
assert.equal(enforced.rawToolMode, 'code');
assert.equal(enforced.requiredExecutor, 'cursor');
assert.equal(enforced.userMessage.metadata.memindRun.cursorTaskKind, 'page_data');
assert.equal(enforced.userMessage.metadata.memindRun.selectedChatSkill, 'page-data-collect');
assert.equal(enforced.userMessage.metadata.memindRun.suggestedDelivery, 'mindspace_page_data');
assert.match(enforced.userMessage.content[0].text, /Page Data survey task via TKMind 智趣 executor/);
assert.match(enforced.userMessage.content[0].text, /page-data-api-usage\.md/);
});
test('enforcePageGenerationCursorRuntime switches excel analysis to cursor flow', () => {
const userMessage = {
role: 'user',
content: [{ type: 'text', text: '帮我分析这个 Excel 表格的数据趋势' }],
metadata: { displayText: '帮我分析这个 Excel 表格的数据趋势' },
};
const enforced = enforcePageGenerationCursorRuntime(userMessage, { env: enabledEnv });
assert.equal(enforced.rawToolMode, 'code');
assert.equal(enforced.requiredExecutor, 'cursor');
assert.equal(enforced.userMessage.metadata.memindRun.cursorTaskKind, 'excel_analysis');
assert.equal(enforced.userMessage.metadata.memindRun.selectedChatSkill, 'excel-analyst');
assert.match(enforced.userMessage.content[0].text, /Excel analysis task via TKMind 智趣 executor/);
});
test('page cursor default is disabled without env flag', () => {
const userMessage = {
role: 'user',
content: [{ type: 'text', text: '帮我写首诗,并做成页面' }],
metadata: { displayText: '帮我写首诗,并做成页面' },
};
assert.equal(isPageCursorDefaultCandidate(userMessage, { env: {} }), false);
});
test('applyCursorFirstAgentExecution no longer forces cursor for generic agent tasks', () => {
const enabledEnv = {
MEMIND_CURSOR_EXECUTOR_ENABLED: '1',
MEMIND_CURSOR_AGENT_TASKS_DEFAULT: '1',
};
const userMessage = {
role: 'user',
content: [{ type: 'text', text: '帮我查一下今天的热点新闻并总结' }],
metadata: { memindRun: {} },
};
const applied = applyCursorFirstAgentExecution(userMessage, { toolMode: 'chat' }, {
routingDecision: 'agent_orchestration',
env: enabledEnv,
});
assert.equal(applied.cursorFirst, false);
assert.equal(applied.runOptions.toolMode, 'chat');
});
test('applyCursorFirstAgentExecution skips direct chat routes', () => {
const enabledEnv = {
MEMIND_CURSOR_EXECUTOR_ENABLED: '1',
MEMIND_CURSOR_AGENT_TASKS_DEFAULT: '1',
};
const userMessage = {
role: 'user',
content: [{ type: 'text', text: '你好' }],
metadata: { memindRun: {} },
};
const applied = applyCursorFirstAgentExecution(userMessage, { toolMode: 'chat' }, {
routingDecision: 'direct_chat',
env: enabledEnv,
});
assert.equal(applied.cursorFirst, false);
});
+25 -103
View File
@@ -1,5 +1,5 @@
import { ChangeEvent, useCallback, useEffect, useLayoutEffect, useRef, useState, type ClipboardEvent, type MutableRefObject } from 'react';
import { BrainCircuit, Database, Image, ImageOff, ImagePlus } from 'lucide-react';
import { BrainCircuit } from 'lucide-react';
import { useNetworkStatus } from '../hooks/useNetworkStatus';
import { openAvatarPicker } from '../utils/userAvatar';
import {
@@ -11,11 +11,7 @@ import {
} from '../utils/chatSkills';
import { getMessageSaveActions } from '../utils/messageSave';
import { getDisplayText } from '../utils/message';
import {
IMAGE_GENERATION_MODE_LABELS,
nextImageGenerationMode,
type ImageGenerationMode,
} from '../utils/imageGeneration';
import type { ImageGenerationMode } from '../utils/imageGeneration';
import {
downloadChatMessageDocx,
} from '../api/client';
@@ -39,6 +35,7 @@ import { ChatSkillPicker } from './ChatSkillPicker';
import { ChatSkillIcon } from './ChatSkillIcons';
import { PageTemplateShopModal } from './PageTemplateShopModal';
import { ChatLoadingSpinner } from './ChatLoadingSpinner';
import { useTaskLoadingTip } from '../hooks/useTaskLoadingTip';
import { ChatSharePreviewModal } from './ChatSharePreviewModal';
import { ChatPlazaPublishModal } from './ChatPlazaPublishModal';
import { MessageList } from './MessageList';
@@ -183,6 +180,7 @@ export function ChatPanel({
onBalanceUpdate,
onGrantedSkillsUpdate,
onOpenRecharge,
taskLoadingTip = null,
}: {
variant: 'full' | 'compact';
user?: PortalUser | null;
@@ -231,6 +229,7 @@ export function ChatPanel({
onBalanceUpdate?: (balanceCents: number) => void;
onGrantedSkillsUpdate?: (skills: string[]) => void;
onOpenRecharge?: () => void;
taskLoadingTip?: string | null;
}) {
const online = useNetworkStatus();
const [input, setInput] = useState('');
@@ -240,12 +239,8 @@ export function ChatPanel({
const [plazaPublishSource, setPlazaPublishSource] = useState<Message | null>(null);
const [voiceNotice, setVoiceNotice] = useState<string | null>(null);
const [forceDeepReasoning, setForceDeepReasoning] = useState(false);
const [pgRequired, setPgRequired] = useState(false);
const [imageGenerationMode, setImageGenerationMode] = useState<ImageGenerationMode>('auto');
const [imageGenerationTipVisible, setImageGenerationTipVisible] = useState(false);
const [pgTipVisible, setPgTipVisible] = useState(false);
const [deepReasoningTipVisible, setDeepReasoningTipVisible] = useState(false);
const [chatControlOnboardingStep, setChatControlOnboardingStep] = useState<0 | 1 | 2 | null>(null);
const [chatControlOnboardingStep, setChatControlOnboardingStep] = useState<0 | 1 | null>(null);
const [templateShopOpen, setTemplateShopOpen] = useState(false);
const [activeTemplatePrefill, setActiveTemplatePrefill] = useState<{
skillId: string;
@@ -261,8 +256,6 @@ export function ChatPanel({
const [voiceStopSignal, setVoiceStopSignal] = useState(0);
const pendingSkillRef = useRef<string | null>(null);
const chatInputRef = useRef<HTMLTextAreaElement>(null);
const imageGenerationTipTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pgTipTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const deepReasoningTipTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [randomPrompt] = useState(
() => CHAT_PLACEHOLDER_PROMPTS[Math.floor(Math.random() * CHAT_PLACEHOLDER_PROMPTS.length)],
@@ -282,6 +275,7 @@ export function ChatPanel({
}>(null);
const prevMessageCountRef = useRef(messages.length);
const showAssistantTyping = chatState === 'waiting' || chatState === 'streaming';
const displayLoadingTip = useTaskLoadingTip(showAssistantTyping, taskLoadingTip);
inputRef.current = input;
pendingImagesRef.current = pendingImages;
@@ -337,19 +331,12 @@ export function ChatPanel({
setChatControlOnboardingStep(0);
const timers = [
window.setTimeout(() => setChatControlOnboardingStep(1), 2_200),
window.setTimeout(() => setChatControlOnboardingStep(2), 4_400),
window.setTimeout(() => setChatControlOnboardingStep(null), 6_600),
window.setTimeout(() => setChatControlOnboardingStep(null), 4_400),
];
return () => timers.forEach((timer) => window.clearTimeout(timer));
}, [session?.id]);
useEffect(() => () => {
if (imageGenerationTipTimerRef.current) {
clearTimeout(imageGenerationTipTimerRef.current);
}
if (pgTipTimerRef.current) {
clearTimeout(pgTipTimerRef.current);
}
if (deepReasoningTipTimerRef.current) {
clearTimeout(deepReasoningTipTimerRef.current);
}
@@ -372,26 +359,6 @@ export function ChatPanel({
[],
);
const showImageGenerationSlowTip = useCallback(() => {
showTransientControlTip(setImageGenerationTipVisible, imageGenerationTipTimerRef);
}, [showTransientControlTip]);
const handlePgToggle = useCallback(() => {
setPgRequired((current) => {
const next = !current;
if (next) {
showTransientControlTip(setPgTipVisible, pgTipTimerRef);
} else {
setPgTipVisible(false);
if (pgTipTimerRef.current) {
clearTimeout(pgTipTimerRef.current);
pgTipTimerRef.current = null;
}
}
return next;
});
}, [showTransientControlTip]);
const handleDeepReasoningToggle = useCallback(() => {
setForceDeepReasoning((current) => {
const next = !current;
@@ -408,11 +375,6 @@ export function ChatPanel({
});
}, [showTransientControlTip]);
const handleImageGenerationClick = useCallback(() => {
setImageGenerationMode((current) => nextImageGenerationMode(current));
showImageGenerationSlowTip();
}, [showImageGenerationSlowTip]);
useLayoutEffect(() => {
const container = mainRef.current;
if (!container) return;
@@ -876,8 +838,8 @@ export function ChatPanel({
await onSubmit(trimmed, imagesToSend, previewImagesToSend, {
messageId: outgoingMessageId,
forceDeepReasoning,
pgRequired,
imageGenerationMode,
pgRequired: false,
imageGenerationMode: 'auto',
selectedChatSkill,
fileAttachments: fileAttachmentsToSend,
});
@@ -907,8 +869,6 @@ export function ChatPanel({
}, [
activeTemplatePrefill,
forceDeepReasoning,
pgRequired,
imageGenerationMode,
input,
onSubmit,
onUploadFile,
@@ -1118,6 +1078,7 @@ export function ChatPanel({
<MessageList
messages={messages}
streaming={showAssistantTyping}
typingTip={displayLoadingTip}
onAvatarClick={compact ? undefined : openAvatarPicker}
onSaveAsPage={openSaveActions}
onShareToPlaza={openPlazaPublish}
@@ -1323,14 +1284,19 @@ export function ChatPanel({
className={`input chat-input-field chat-input-task-status${uploadFieldClass}`}
role="status"
aria-live="polite"
aria-label={taskInputStatusText}
aria-label={[taskInputStatusText, displayLoadingTip].filter(Boolean).join(' ')}
>
<span className="chat-input-task-status-text">{taskInputStatusText}</span>
<span className="typing-dots chat-input-task-status-dots" aria-hidden="true">
<span />
<span />
<span />
</span>
<div className="chat-input-task-status-main">
<span className="chat-input-task-status-text">{taskInputStatusText}</span>
<span className="typing-dots chat-input-task-status-dots" aria-hidden="true">
<span />
<span />
<span />
</span>
</div>
{displayLoadingTip ? (
<span className="chat-input-task-status-tip">{displayLoadingTip}</span>
) : null}
</div>
) : (
<textarea
@@ -1396,51 +1362,7 @@ export function ChatPanel({
)}
<button
type="button"
className={`chat-deep-reasoning-toggle chat-icon-toggle${pgRequired ? ' is-active' : ''}${pgTipVisible ? ' chat-icon-toggle-tip-active' : ''}${chatControlOnboardingStep === 1 ? ' chat-control-onboarding-active' : ''}`}
disabled={voiceDisabled}
role="checkbox"
aria-checked={pgRequired}
aria-label="使用 PostgreSQL 数据空间"
title="点击开启后,本轮生成的可保存数据必须使用你的专属 PostgreSQL 数据空间"
onClick={handlePgToggle}
>
<Database className="chat-deep-reasoning-toggle-icon" aria-hidden="true" />
<span className="chat-icon-toggle-label">db</span>
{pgTipVisible && (
<span className="chat-control-onboarding-tip chat-icon-toggle-tip" role="status">
数据将写入你的 PostgreSQL 专属空间
</span>
)}
{chatControlOnboardingStep === 1 && (
<span className="chat-control-onboarding-tip" role="status">需要长期使用的数据,放进专属空间</span>
)}
</button>
<button
type="button"
className={`chat-deep-reasoning-toggle chat-icon-toggle chat-image-generation-toggle is-${imageGenerationMode}${imageGenerationMode === 'required' ? ' is-active' : ''}${imageGenerationTipVisible ? ' chat-icon-toggle-tip-active' : ''}`}
disabled={voiceDisabled}
role="checkbox"
aria-checked={imageGenerationMode === 'auto' ? 'mixed' : imageGenerationMode === 'required'}
aria-label={`AI 配图:${IMAGE_GENERATION_MODE_LABELS[imageGenerationMode]}`}
title={`AI 配图:${IMAGE_GENERATION_MODE_LABELS[imageGenerationMode]}。启用后会加载智能生图 skill,自动补全场景、构图、光线和负面约束。点击切换为${IMAGE_GENERATION_MODE_LABELS[nextImageGenerationMode(imageGenerationMode)]}`}
onClick={handleImageGenerationClick}
>
{imageGenerationMode === 'required' ? (
<ImagePlus className="chat-deep-reasoning-toggle-icon" aria-hidden="true" />
) : imageGenerationMode === 'disabled' ? (
<ImageOff className="chat-deep-reasoning-toggle-icon" aria-hidden="true" />
) : (
<Image className="chat-deep-reasoning-toggle-icon" aria-hidden="true" />
)}
{imageGenerationTipVisible && (
<span className="chat-control-onboarding-tip chat-icon-toggle-tip" role="status">
图片生成较慢,请耐心等待
</span>
)}
</button>
<button
type="button"
className={`chat-deep-reasoning-toggle chat-icon-toggle${forceDeepReasoning ? ' is-active' : ''}${deepReasoningTipVisible ? ' chat-icon-toggle-tip-active' : ''}${chatControlOnboardingStep === 2 ? ' chat-control-onboarding-active' : ''}`}
className={`chat-deep-reasoning-toggle chat-icon-toggle${forceDeepReasoning ? ' is-active' : ''}${deepReasoningTipVisible ? ' chat-icon-toggle-tip-active' : ''}${chatControlOnboardingStep === 1 ? ' chat-control-onboarding-active' : ''}`}
disabled={voiceDisabled}
role="checkbox"
aria-checked={forceDeepReasoning}
@@ -1455,7 +1377,7 @@ export function ChatPanel({
深度推理耗时较长,请耐心等待
</span>
)}
{chatControlOnboardingStep === 2 && (
{chatControlOnboardingStep === 1 && (
<span className="chat-control-onboarding-tip" role="status">复杂问题,开启深度推理</span>
)}
</button>
+112 -29
View File
@@ -6,6 +6,12 @@ import { mergeMessageContent } from './message-stream.mjs';
import { reconcileAgentSession } from './session-reconcile.mjs';
import { resolveSessionAccess } from './session-broker.mjs';
import { loadWechatMpConfig } from './wechat-mp-config.mjs';
import { executeWechatCursorAgentRun } from './wechat-cursor-agent-run.mjs';
import { resolveWechatCursorExecutorEligible } from './wechat-cursor-executor-policy.mjs';
import {
isWechatCursorChannelReply,
prepareWechatCursorPageDelivery,
} from './wechat-cursor-page-delivery.mjs';
import {
downloadTemporaryMedia,
persistWechatAttachment,
@@ -532,6 +538,7 @@ function hasAnyToolRequest(messages = []) {
}
function isSuspiciousBareCompletionReply(reply, intent) {
if (isWechatCursorChannelReply(reply)) return false;
if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false;
if (!isBareCompletionText(reply?.text)) return false;
if (extractHtmlWriteTargets(replyRequestMessages(reply)).length > 0) return false;
@@ -575,6 +582,7 @@ async function isSuspiciousHtmlPublishClaimReply(reply, intent, { linkExists = d
}
function isMissingRequiredPublishSkill(reply, intent) {
if (isWechatCursorChannelReply(reply)) return false;
if (!looksLikeHtmlGenerationIntent(intent?.agentText)) return false;
const wroteHtml = extractHtmlWriteTargets(replyRequestMessages(reply)).length > 0;
if (!wroteHtml) return false;
@@ -1022,6 +1030,11 @@ export function shouldRetryHtmlGenerationReply({
confirmedArtifacts = [],
hasValidLinkInReply = false,
}) {
if (isWechatCursorChannelReply(reply)) {
return nonStubHtmlArtifacts(confirmedArtifacts).length === 0
&& !hasAnyPublicHtmlLinkInReply(reply);
}
if (nonStubHtmlArtifacts(confirmedArtifacts).length > 0) return false;
const replyHasPublicLinks = hasAnyPublicHtmlLinkInReply(reply);
@@ -1615,6 +1628,8 @@ export function createWechatMpService({
htmlDeliveryAuthority = null,
pageDataFinishGuard = null,
pageDataDeliveryReviewer = null,
wechatCursorExecutorPolicyService = null,
agentRunGateway = null,
wechatFetch = undiciFetch,
linkExists = defaultPublicHtmlLinkExists,
h5Root = '',
@@ -2874,8 +2889,10 @@ export function createWechatMpService({
logger,
intentTransactionEnabled: isIntentTransactionEnabled(),
});
let userPublishDir = null;
try {
const requestStartedAt = Date.now();
userPublishDir = await userAuth.resolveWorkingDir(user.userId);
const agentPrompt =
wechatIntent.kind === 'page.generate'
? buildPageGenerateAgentPrompt(intent, {
@@ -2900,36 +2917,97 @@ export function createWechatMpService({
const activeAgentPrompt =
pageAttempt === 1 ? agentPrompt : buildImmediateContextPageRepairPrompt(intent);
const activeRequestId = pageAttempt === 1 ? requestId : crypto.randomUUID();
reply = await executeSessionReply(
(pathname, init) => fetchForSession(sessionId, pathname, init),
sessionId,
activeRequestId,
activeAgentPrompt,
buildIntentMetadata(intent, {
mediaAnalysisEnabled,
imagePolicy,
pgRequired: isPageDataRequest,
}),
{
prepareUserMessage: (userMessage) => prepareWechatAgentUserMessage({
reply = null;
let wechatCursorPolicy = null;
if (
pageAttempt === 1
&& wechatCursorExecutorPolicyService?.getEffectivePolicy
&& agentRunGateway
) {
try {
wechatCursorPolicy = await wechatCursorExecutorPolicyService.getEffectivePolicy(
user.userId,
user,
);
} catch (policyErr) {
logger.warn?.('WeChat MP cursor policy lookup failed:', policyErr);
}
}
const useWechatCursorChannel = resolveWechatCursorExecutorEligible({
user,
intentKind: wechatIntent.kind,
policy: wechatCursorPolicy,
});
if (useWechatCursorChannel) {
try {
reply = await executeWechatCursorAgentRun({
agentRunGateway,
userId: user.userId,
sessionId,
userMessage,
preserveAgentPrompt: sessionPageContinuation,
requestId: activeRequestId,
displayText: String(intent?.displayText ?? intent?.agentText ?? '').trim(),
agentPrompt: activeAgentPrompt,
intentKind: wechatIntent.kind,
timeoutMs: agentReplyTimeoutMs,
logger,
});
const buildCanonicalUrl = (relativePath) => {
const base = String(config.publicBaseUrl ?? '').replace(/\/+$/, '');
const normalized = String(relativePath ?? '')
.replace(/^\/+/, '')
.split('/')
.map(encodeURIComponent)
.join('/');
return `${base}/MindSpace/${encodeURIComponent(user.userId)}/${normalized}`;
};
reply = prepareWechatCursorPageDelivery({
reply,
intent,
publishDir: userPublishDir,
requestStartedAt,
buildCanonicalUrl,
topic: wechatIntent?.topic ?? intent?.agentText ?? '',
});
} catch (cursorErr) {
logger.warn?.('WeChat MP cursor channel failed:', cursorErr);
if (wechatCursorPolicy?.fallbackToDeepseek === false) {
throw cursorErr;
}
reply = null;
}
}
if (!reply) {
reply = await executeSessionReply(
(pathname, init) => fetchForSession(sessionId, pathname, init),
sessionId,
activeRequestId,
activeAgentPrompt,
buildIntentMetadata(intent, {
mediaAnalysisEnabled,
imagePolicy,
pgRequired: isPageDataRequest,
}),
submitReply: submitSessionReply
? ({ requestId: replyRequestId, userMessage }) =>
submitSessionReply({
userId: user.userId,
sessionId,
requestId: replyRequestId,
userMessage,
options: { requireHistoricalImageIsolation: true },
})
: null,
timeoutMs: agentReplyTimeoutMs,
},
);
{
prepareUserMessage: (userMessage) => prepareWechatAgentUserMessage({
userId: user.userId,
sessionId,
userMessage,
preserveAgentPrompt: sessionPageContinuation,
}),
submitReply: submitSessionReply
? ({ requestId: replyRequestId, userMessage }) =>
submitSessionReply({
userId: user.userId,
sessionId,
requestId: replyRequestId,
userMessage,
options: { requireHistoricalImageIsolation: true },
})
: null,
timeoutMs: agentReplyTimeoutMs,
},
);
}
generatedImages = collectWechatGeneratedImages(replyRequestMessages(reply));
if (imagePolicy.standaloneImageMode === 'required' && generatedImages.length === 0) {
const error = new Error('图片生成没有获得本轮新的有效位图,请稍后重试');
@@ -3003,8 +3081,9 @@ export function createWechatMpService({
replyHasPublicLinks,
topic: wechatIntent?.topic ?? intent?.agentText ?? '',
repairContext: {
publishDir: resolveUserPublishDir(user.userId),
publishDir: userPublishDir,
},
wechatCursorChannel: isWechatCursorChannelReply(reply),
});
if (pageOutcome.action === 'session_retry') {
if (sessionPageContinuation && pageAttempt < maxImmediateContextPageAttempts) {
@@ -3293,6 +3372,9 @@ export function createWechatMpService({
}
const retryId = crypto.randomUUID();
const retryStartedAt = Date.now();
if (!userPublishDir) {
userPublishDir = await userAuth.resolveWorkingDir(user.userId);
}
const retryPageContinuation = sessionPageContinuation && !historicalImageError;
const retryPrompt =
wechatIntent.kind === 'page.generate'
@@ -3407,8 +3489,9 @@ export function createWechatMpService({
replyHasPublicLinks,
topic: wechatIntent?.topic ?? intent?.agentText ?? '',
repairContext: {
publishDir: resolveUserPublishDir(user.userId),
publishDir: userPublishDir,
},
wechatCursorChannel: isWechatCursorChannelReply(reply),
});
if (pageOutcome.action === 'session_retry' || pageOutcome.action === 'fail') {
const text = pageOutcome.failureText ?? buildPagePublishFailureText();