feat: stabilize page AI ACP runtimes

实现并稳定页面 AI 的 ACP Hermes / ACP Reasonix 运行路径。

主要内容:

- 分离 profile 与 acpRuntime,ACP Hermes 按所选 Hermes profile 启动并注入 provider key。

- 修复 Reasonix ACP wrapper 的 API key 读取、ToolRegistry 注册、LoopEvent role 映射和 reasoning/final 分流。

- 修复 ACP agent_thought_chunk 被 untagged enum 误解析为 message.delta 的问题,补充 thought 相关单测。

- 补充页面 AI 浏览器验证 skill 证据到 7-15 设计稿,并记录严格验收标准。

- 同步提交当前仓库中已存在的 rust-web / Hermes tools / SSE / bug 文档相关改动。

验证:

- node --check scripts/reasonix-acp-wrapper.mjs

- cargo test -p mnote-web acp -- --nocapture

- 页面 AI ACP 浏览器验证:tmp/page-ai-acp-browser-UAYwyM/
This commit is contained in:
lix-2026
2026-05-17 20:11:39 +08:00
parent 2ea559beaa
commit bb2f190f50
19 changed files with 1307 additions and 451 deletions
+121 -39
View File
@@ -27,16 +27,31 @@ import { homedir } from 'node:os';
import { join } from 'node:path';
const MNOTE_WEB_URL = process.env.MNOTE_WEB_URL || 'http://127.0.0.1:3000';
const DEBUG = process.env.MNOTE_REASONIX_ACP_DEBUG === '1';
// Load DeepSeek API key — same logic as Reasonix's loadApiKey():
// 1. DEEPSEEK_API_KEY env var
// 2. ~/.reasonix/config.yaml (yaml: api_key or apiKey)
function debugLog(message) {
if (DEBUG) process.stderr.write(`${message}\n`);
}
// 读取 DeepSeek API key:先环境变量,再 Reasonix 官方 JSON 配置,最后兼容旧 YAML。
function loadApiKey() {
if (process.env.DEEPSEEK_API_KEY) return process.env.DEEPSEEK_API_KEY;
const jsonConfigPath = join(homedir(), '.reasonix', 'config.json');
if (existsSync(jsonConfigPath)) {
try {
const raw = readFileSync(jsonConfigPath, 'utf-8');
const parsed = JSON.parse(raw);
if (typeof parsed?.apiKey === 'string' && parsed.apiKey.trim()) {
return parsed.apiKey.trim();
}
} catch {
// 配置损坏时继续尝试旧 YAML,不让启动阶段吞掉更明确的错误。
}
}
const configPath = join(homedir(), '.reasonix', 'config.yaml');
if (existsSync(configPath)) {
const raw = readFileSync(configPath, 'utf-8');
// Simple YAML key extraction (no yaml parser dependency needed)
// 简单提取 YAML key,避免为 wrapper 额外引入 yaml parser。
const match = raw.match(/^\s*(?:api_key|apiKey)\s*:\s*['"]?(.+?)['"]?\s*$/m);
if (match) return match[1].trim();
}
@@ -46,7 +61,7 @@ function loadApiKey() {
const DEEPSEEK_API_KEY = loadApiKey();
if (!DEEPSEEK_API_KEY) {
process.stderr.write('FATAL: DEEPSEEK_API_KEY is required\n');
process.stderr.write('Set env var DEEPSEEK_API_KEY or put api_key in ~/.reasonix/config.yaml\n');
process.stderr.write('Set env var DEEPSEEK_API_KEY, or put apiKey in ~/.reasonix/config.json\n');
process.exit(1);
}
@@ -207,6 +222,11 @@ const MNOTE_TOOL_NAMES = [
'mnote.page.*',
];
const REASONIX_TOOL_TO_MNOTE_TOOL = {
mnote_doc_fetch: 'mnote.doc.fetch',
mnote_doc_markdown_edit: 'mnote.doc.markdown_edit',
};
async function callMnoteTool(toolName, args) {
const url = `${MNOTE_WEB_URL}/api/hermes/tools/mnote/call`;
const response = await fetch(url, {
@@ -230,8 +250,8 @@ async function callMnoteTool(toolName, args) {
const tools = new ToolRegistry();
tools.register({
name: 'mnote.doc.fetch',
description: '读取当前文档的 markdown 内容。返回文档标题和正文。',
name: 'mnote_doc_fetch',
description: '读取当前 mnote 文档的 markdown 内容。返回文档标题和正文。',
parameters: {
type: 'object',
properties: {
@@ -239,12 +259,13 @@ tools.register({
format: { type: 'string', enum: ['markdown', 'blocks'], description: '返回格式' },
},
},
call: async (args) => callMnoteTool('mnote.doc.fetch', args),
readOnly: true,
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_doc_fetch, args),
parallelSafe: false,
});
tools.register({
name: 'mnote.doc.markdown_edit',
name: 'mnote_doc_markdown_edit',
description: '对当前文档进行 markdown 文本级搜索替换编辑。支持多次搜索替换操作。',
parameters: {
type: 'object',
@@ -265,7 +286,7 @@ tools.register({
},
required: ['documentId', 'operations'],
},
call: async (args) => callMnoteTool('mnote.doc.markdown_edit', args),
fn: async (args) => callMnoteTool(REASONIX_TOOL_TO_MNOTE_TOOL.mnote_doc_markdown_edit, args),
parallelSafe: false,
});
@@ -280,7 +301,7 @@ onRequest('initialize', (params) => {
protocolVersion: 1,
agentCapabilities: {
loadSession: false,
promptCapabilities: { image: false, audio: false, embeddedContext: false },
promptCapabilities: { image: false, audio: false, embeddedContext: true },
mcpCapabilities: { http: false, sse: false },
},
agentInfo: { name: 'reasonix-mnote', title: 'Reasonix MNote Agent', version: '0.1.0' },
@@ -297,9 +318,9 @@ onRequest('session/new', async (params) => {
const systemPrompt = [
'You are a helpful AI assistant for editing Markdown documents.',
'Use mnote.doc.fetch to read the current document.',
'Use mnote.doc.markdown_edit to apply precise search/replace edits.',
'Always use mnote.doc.fetch first to understand the document content before editing.',
'Use mnote_doc_fetch to read the current document.',
'Use mnote_doc_markdown_edit to apply precise search/replace edits.',
'Always use mnote_doc_fetch first to understand the document content before editing.',
].join('\n');
const loop = new CacheFirstLoop({
@@ -337,8 +358,32 @@ onRequest('session/prompt', async (params) => {
session.aborter = new AbortController();
let stopReason = 'end_turn';
let hasModelOutput = false;
let hasAssistantOutput = false;
let hasToolCall = false;
let nextToolCallSeq = 1;
const announcedToolKeys = new Set();
const preparingToolCallIds = [];
const inflightToolCallIds = [];
function nextToolCallId() {
return `tc_${nextToolCallSeq++}`;
}
function toolKeyFor(ev) {
if (ev.toolCallIndex !== undefined && ev.toolCallIndex !== null) {
return `${ev.turn ?? 0}:${ev.toolCallIndex}`;
}
return `${ev.turn ?? 0}:${ev.toolName || 'tool'}:${nextToolCallSeq}`;
}
function parseToolArgs(raw) {
if (!raw) return undefined;
try {
return JSON.parse(raw);
} catch {
return raw;
}
}
try {
for await (const ev of session.loop.step(text)) {
@@ -347,68 +392,105 @@ onRequest('session/prompt', async (params) => {
break;
}
switch (ev.type) {
case 'model_delta': {
hasModelOutput = true;
if (ev.text) emitTextDelta(session.id, ev.text);
debugLog(`[reasonix-acp] loop event role=${ev.role}`);
// Reasonix 的 LoopEvent 使用 role 字段;这里按官方 Eventizer 的核心语义映射到 ACP。
switch (ev.role) {
case 'assistant_delta': {
if (ev.content) {
hasAssistantOutput = true;
emitTextDelta(session.id, ev.content);
}
if (ev.reasoningDelta) {
emitThoughtDelta(session.id, ev.reasoningDelta);
}
break;
}
case 'model_thinking': {
hasModelOutput = true;
if (ev.text) emitThoughtDelta(session.id, ev.text);
case 'assistant_final': {
if (ev.content && !hasAssistantOutput) {
hasAssistantOutput = true;
emitTextDelta(session.id, ev.content);
} else if (ev.content) {
hasAssistantOutput = true;
}
break;
}
case 'done': {
if (ev.content && !hasAssistantOutput) {
hasAssistantOutput = true;
emitTextDelta(session.id, ev.content);
}
break;
}
case 'tool_call_delta': {
hasToolCall = true;
const call = ev.toolCall || {};
const key = toolKeyFor(ev);
if (announcedToolKeys.has(key)) break;
announcedToolKeys.add(key);
const toolCallId = nextToolCallId();
preparingToolCallIds.push(toolCallId);
emitToolCall(
session.id,
call.id || `tc_${Date.now()}`,
call.name || ev.name || 'tool',
toolCallId,
ev.toolName || 'tool',
'other',
'pending',
call.args || ev.args,
undefined,
);
break;
}
case 'tool_start': {
const call = ev.toolCall || {};
hasToolCall = true;
const toolCallId = preparingToolCallIds.shift() || ev.callId || nextToolCallId();
inflightToolCallIds.push(toolCallId);
emitToolCall(
session.id,
call.id || `tc_${Date.now()}`,
call.name || ev.name || 'tool',
toolCallId,
ev.toolName || 'tool',
'other',
'in_progress',
call.args || ev.args,
parseToolArgs(ev.toolArgs),
);
break;
}
case 'tool_result': {
const resultText = typeof ev.result === 'string'
? ev.result.slice(0, 8000)
: JSON.stringify(ev.result).slice(0, 8000);
case 'tool': {
hasToolCall = true;
const resultText = String(ev.content || '').slice(0, 8000);
emitToolResult(
session.id,
ev.toolCallId || `tc_${Date.now()}`,
ev.error ? 'failed' : 'completed',
inflightToolCallIds.shift() || ev.callId || nextToolCallId(),
resultText.includes('"error"') ? 'failed' : 'completed',
resultText,
);
break;
}
case 'usage': {
emitUsage(session.id, ev.inputTokens || 0, ev.cacheHitTokens || 0);
case 'error': {
const message = ev.error || ev.content || 'Reasonix loop error';
emitTextDelta(session.id, `\n\n[error] ${message}`);
stopReason = 'error';
break;
}
case 'warning':
case 'status': {
if (ev.content) emitThoughtDelta(session.id, ev.content);
break;
}
}
// Usage info from stats
if (ev.stats) {
emitUsage(session.id, ev.stats.inputTokens || 0, ev.stats.cacheHitTokens || 0);
}
}
} catch (err) {
const message = err.message || String(err);
const stack = err.stack || '';
process.stderr.write(`[reasonix-acp] prompt error: ${message}\n${stack}\n`);
emitTextDelta(session.id, `\n\n[error] ${message}`);
stopReason = 'error';
} finally {
// If no model output or tool calls were produced, it means the LLM backend failed.
// This can happen when the API key is invalid, base URL is wrong, or model is unavailable.
if (!hasModelOutput && !hasToolCall && stopReason !== 'error') {
if (!hasAssistantOutput && !hasToolCall && stopReason !== 'error') {
stopReason = 'error';
emitTextDelta(session.id, '\n\n[error] AI 模型未返回输出。可能原因:API Key 无效、模型不可用、或网络连接失败。请检查 DEEPSEEK_API_KEY 是否正确设置(以 sk- 开头)。');
}