feat: Page AI Reasonix desktop session alignment, settings IA cleanup, knowledge RAG hardening

- ACP client/session manager: Reasonix desktop live session context
- Hermes tools: knowledge_rag tool manifest and skill updates
- Browser runtime: sidebar page AI permission/profile/render/session/tree modules
- Routes: hermes_client, hermes_tools, knowledge_rag, web_shell
- Scripts: reasonix ACP wrapper, LightRAG MCP, smoke tasks 159/558/559/561/562
- Skills: mnote-knowledge-rag and mnote-lightrag-bridge SKILL.md updates
This commit is contained in:
lix-2026
2026-06-13 22:20:01 +08:00
parent 2236a053c0
commit 4a7efd4a30
30 changed files with 5321 additions and 291 deletions
+82
View File
@@ -0,0 +1,82 @@
# Reasonix configuration.
# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.
# Secrets come from the environment via api_key_env; never put keys here.
config_version = 2 # schema marker for diagnostics; old versions may ignore it
default_model = "deepseek-flash"
# language = "zh" # ui/model language; empty = auto-detect from $LANG / $REASONIX_LANG
[agent]
# system_prompt = """...""" # omit to use the built-in prompt for this version
# system_prompt_file = "prompts/system.md" # overrides system_prompt when set
max_steps = 0 # executor tool-call rounds; 0 = no limit
planner_max_steps = 12 # planner read-only tool-call rounds; 0 = no limit
temperature = 0.0
auto_plan = "off" # off|on; off keeps plan mode manual
# auto_plan_classifier = "deepseek-flash" # optional; only used for borderline tasks
soft_compact_ratio = 0.5 # notice only; keeps cache-first prefix intact
compact_ratio = 0.8 # try compacting when prompt reaches this fraction
compact_force_ratio = 0.9 # force compacting at this high-water mark
# planner_model = "mimo" # optional: enable two-model collaboration
# subagent_model = "deepseek-pro" # optional default for runAs=subagent skills
# subagent_models = { review = "deepseek-pro", security_review = "deepseek-pro" } # per-skill overrides
# subagent_effort = "high" # optional default effort for subagents
# subagent_efforts = { review = "max", task = "high" } # per-tool/skill effort overrides
# output_style = "explanatory" # explanatory | learning | concise | custom; empty = default
[tools]
enabled = [] # empty = all built-in tools
bash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap
[codegraph]
enabled = true # built-in MCP server; off by default for first-run sessions
auto_install = true # fetch the runtime when CodeGraph is enabled but missing
# path = "" # empty = cache, then PATH, then a bundle beside reasonix
[lsp]
enabled = true # language server tools; servers launch lazily when used
# [lsp.servers.go]
# command = "gopls"
# args = []
# extensions = [".go"]
[skills]
# paths = ["~/my-skills", "../shared/skills"] # extra custom skill roots
# excluded_paths = ["~/.agents/skills"] # hide convention roots without deleting folders
# max_depth = 3 # nested scan depth; set 1 for legacy root-only discovery
# disabled_skills = ["review"] # hide noisy or unwanted skills
[permissions]
# Per-call gating. mode = writer fallback when no rule matches: ask|allow|deny.
# Readers always default to allow. Precedence: deny > ask > allow > fallback.
# Rules are "Tool" or "Tool(specifier)"; e.g. Bash(go test:*), Edit(src/**).
mode = "ask"
# deny = ["Bash(rm -rf*)", "Bash(git push*)"] # hard-blocked in every mode
allow = ["mcp__lightrag_native__get_health"]
# ask = ["Edit(src/**)"] # force a prompt even if otherwise allowed
[sandbox]
# Confine tool blast radius. File-writers (write_file/edit_file/multi_edit)
# may only write under workspace_root (empty = current dir) + allow_write.
# bash = "enforce" (default) jails each command in an OS sandbox (macOS now;
# graceful fallback elsewhere); "off" disables it. network allows egress.
# workspace_root = "" # default: current working directory
# allow_write = ["/tmp"] # extra dirs writers may also modify
bash = "enforce"
network = true
[statusline]
# A custom status line: a command whose first stdout line replaces the built-in
# data row. It receives {"model","contextUsed","contextWindow","cwd"} as JSON on stdin.
# command = "my-statusline.sh"
# External MCP servers. type: "stdio" (default, a subprocess) | "http" | "sse".
# ${VAR} / ${VAR:-default} are expanded from the environment in command/args/env/url/headers.
# [[plugins]]
# name = "example"
# command = "reasonix-plugin-example"
# [[plugins]] # a remote server over Streamable HTTP
# name = "stripe"
# type = "http"
# url = "https://mcp.stripe.com"
# headers = { Authorization = "Bearer ${STRIPE_KEY}" }
@@ -356,11 +356,12 @@ function openEditorUploadFilePicker(detail, deps) {
async function fetchWithTimeout(input, init, timeoutMs, label) {
var controller = typeof AbortController === 'function' ? new AbortController() : null;
var timer = 0;
var timeout = Math.max(1000, Number(timeoutMs) || 15000);
try {
if (controller) {
timer = window.setTimeout(function() {
controller.abort();
}, Math.max(1000, Number(timeoutMs) || 15000));
}, timeout);
}
var nextInit = Object.assign({}, init || {});
if (controller) nextInit.signal = controller.signal;
@@ -375,6 +376,14 @@ async function fetchWithTimeout(input, init, timeoutMs, label) {
}
}
function uploadTimeoutMsForFile(file, requestedMs) {
var requested = Number(requestedMs) || 0;
var size = Number(file && file.size || 0);
if (!Number.isFinite(size) || size <= 0) return Math.max(requested, 15000);
var sizeBased = 15000 + Math.ceil(size / (1024 * 1024)) * 12000;
return Math.min(10 * 60 * 1000, Math.max(requested, 60000, sizeBased));
}
async function uploadLocalFolderAsset(file, plan, context) {
var rootUri = String(context && context.rootUri || '').trim() || currentRootUri();
var documentId = String(
@@ -401,7 +410,7 @@ async function uploadLocalFolderAsset(file, plan, context) {
method: 'POST',
credentials: 'include',
body: localForm
}, Number(context && context.timeoutMs) || 15000, '本地上传');
}, uploadTimeoutMsForFile(file, context && context.timeoutMs), '本地上传');
var localPayload = await localResponse.json().catch(function() { return null; });
if (!localResponse.ok || !localPayload || !localPayload.asset) {
throw new Error(localPayload && localPayload.error ? localPayload.error : '上传失败');
@@ -552,7 +561,7 @@ async function uploadFileToMediaAsset(file, plan, options, deps) {
var localResult = await uploadLocalFolderAsset(file, plan, {
rootUri: rootUri,
documentId: documentId,
timeoutMs: 15000
timeoutMs: uploadTimeoutMsForFile(file, 15000)
});
var localAsset = localResult && localResult.asset ? localResult.asset : null;
if (!localAsset) {
@@ -753,6 +762,7 @@ window.__mnoteLocalUploadRuntime = {
editorRootFromUploadOptions: editorRootFromUploadOptions,
openEditorUploadFilePicker: openEditorUploadFilePicker,
fetchWithTimeout: fetchWithTimeout,
uploadTimeoutMsForFile: uploadTimeoutMsForFile,
uploadLocalFolderAsset: uploadLocalFolderAsset,
uploadMediaAsset: uploadMediaAsset,
insertUploadedAssetIntoEditor: insertUploadedAssetIntoEditor,
@@ -7,26 +7,133 @@ export function createSidebarPageAiPermissionRuntime(context) {
} = context;
const doc = documentRef || document;
function pageAiPermissionStorageKey() {
var sessionId = String(pageUiState.pageAiActiveSessionId || 'no-session').trim() || 'no-session';
var path = doc && doc.location ? String(doc.location.pathname || '') : '';
return 'mnote.page_ai.permission_queue.v1:' + path + ':' + sessionId;
}
function pageAiPersistPermissionRequests() {
try {
var pending = pageUiState.pageAiPermissionRequests.filter(function(item) {
return item && item.kind === 'permission' && !item.resolved;
}).slice(-20);
if (window.localStorage) window.localStorage.setItem(pageAiPermissionStorageKey(), JSON.stringify(pending));
} catch (_) {}
}
function pageAiRestorePermissionRequests() {
try {
var raw = window.localStorage ? window.localStorage.getItem(pageAiPermissionStorageKey()) : '';
var items = raw ? JSON.parse(raw) : [];
if (!Array.isArray(items)) items = [];
pageUiState.pageAiPermissionRequests = items.filter(function(item) {
return item && item.kind === 'permission' && !item.resolved;
}).slice(-20);
pageUiState.pageAiPermissionRequests.forEach(function(item) {
var exists = pageUiState.pageAiMessages.some(function(message) {
return message.kind === 'permission' && message.permissionId === item.permissionId;
});
if (!exists) pageUiState.pageAiMessages.push(item);
});
var pending = pageUiState.pageAiPermissionRequests.find(function(item) { return !item.resolved; });
if (pending) {
if (!pageAiApplyPermissionMode(pending)) pageAiShowPermissionDialog(pending);
}
return pageUiState.pageAiPermissionRequests;
} catch (_) {
return [];
}
}
function pageAiPermissionMessage(payload, eventType) {
payload = payload && typeof payload === 'object' ? payload : {};
var permissionId = String(payload.permissionId || payload.permission_id || payload.id || ('perm_' + Date.now())).trim();
var toolName = String(payload.toolName || payload.tool || payload.name || payload.method || 'session/request_permission').trim();
var detail = pageAiPermissionDetail(payload, permissionId);
var toolName = detail.toolName || String(payload.toolName || payload.tool || payload.name || payload.method || 'session/request_permission').trim();
var args = payload.arguments || payload.args || payload.input || payload.params || payload;
var decision = String(payload.decision || payload.result || '').trim();
if (!decision && eventType === 'permission.denied') decision = 'denied';
if (!decision && eventType === 'permission.allowed') decision = 'allowed';
var options = pageAiPermissionOptions(payload);
return {
role: 'tool',
kind: 'permission',
permissionId: permissionId,
runId: String(payload.runId || payload.run_id || pageUiState.pageAiCurrentRunId || '').trim(),
toolName: toolName,
argsSummary: pageAiPreviewValue(args),
argsSummary: detail.summary || pageAiPreviewValue(args),
content: decision === 'denied' ? '已自动拒绝权限请求' : (decision === 'allowed' ? '已自动允许权限请求' : '等待权限确认'),
resolved: decision === 'denied' || decision === 'allowed',
decision: decision
decision: decision,
options: options,
permissionDetails: detail
};
}
function pageAiPermissionDetail(payload, permissionId) {
var params = payload && payload.params && typeof payload.params === 'object' ? payload.params : {};
var toolCall = params.toolCall && typeof params.toolCall === 'object' ? params.toolCall : {};
var rawInput = toolCall.rawInput && typeof toolCall.rawInput === 'object' ? toolCall.rawInput : {};
var title = String(toolCall.title || payload.toolName || payload.tool || payload.name || '').trim();
var kind = String(toolCall.kind || params.kind || '').trim();
var command = String(rawInput.command || rawInput.cmd || '').trim();
var path = String(rawInput.path || rawInput.file || rawInput.target || '').trim();
var toolName = title || String(payload.toolName || payload.tool || payload.name || 'session/request_permission').trim();
var primary = command || path || String(rawInput.pattern || rawInput.query || '').trim() || title || '权限请求';
var rows = [];
rows.push({ label: '操作', value: toolName });
if (kind) rows.push({ label: '类型', value: pageAiPermissionKindLabel(kind) });
if (command) rows.push({ label: '命令', value: command });
return {
toolName: toolName,
kind: kind,
kindLabel: pageAiPermissionKindLabel(kind),
command: command,
path: path,
summary: primary,
rows: rows
};
}
function pageAiPermissionKindLabel(kind) {
var normalized = String(kind || '').trim().toLowerCase();
if (normalized === 'execute') return '执行命令';
if (normalized === 'edit') return '写入文件';
if (normalized === 'read') return '读取';
if (normalized === 'other') return '其他';
return kind || '';
}
function pageAiPermissionOptions(payload) {
var rawOptions = payload && Array.isArray(payload.options)
? payload.options
: (payload && payload.params && Array.isArray(payload.params.options) ? payload.params.options : []);
return rawOptions.map(function(option) {
option = option && typeof option === 'object' ? option : {};
var optionId = String(option.optionId || option.option_id || option.id || '').trim();
if (!optionId) return null;
return {
optionId: optionId,
name: pageAiPermissionOptionLabel(optionId, option),
kind: String(option.kind || '').trim()
};
}).filter(Boolean).slice(0, 8);
}
function pageAiPermissionOptionLabel(optionId, option) {
var id = String(optionId || '').trim().toLowerCase();
var raw = String((option && (option.name || option.title)) || '').trim();
if (id === 'allow_once') return raw && !/^allow$/i.test(raw) ? raw.replace(/^Allow\b/i, '允许') : '允许一次';
if (id === 'allow_always') return '本会话允许';
if (id === 'allow_persistent') return '始终允许';
if (id === 'reject_once' || id === 'reject') return '拒绝';
if (id === 'cancel') return '取消';
if (id === 'refine' || id === 'revise') return '要求修改';
if (id === 'accept') return '接受';
return raw || optionId || '选择';
}
function pageAiApplyPermissionEvent(eventName, payloadText) {
var payload = null;
try {
@@ -46,6 +153,10 @@ export function createSidebarPageAiPermissionRuntime(context) {
pageUiState.pageAiPermissionRequests = pageUiState.pageAiPermissionRequests.filter(function(item) {
return item.permissionId !== message.permissionId;
}).concat([message]).slice(-20);
pageAiPersistPermissionRequests();
if (!message.resolved && pageAiApplyPermissionMode(message)) {
return;
}
if (!message.resolved) {
pageAiShowPermissionDialog(message);
} else {
@@ -53,15 +164,76 @@ export function createSidebarPageAiPermissionRuntime(context) {
}
}
function pageAiResolvePermission(permissionId, decision) {
function pageAiReasonixApprovalMode() {
var preferences = pageUiState.pageAiSkillPreferences && typeof pageUiState.pageAiSkillPreferences === 'object'
? pageUiState.pageAiSkillPreferences
: {};
var mode = String(preferences['ai.agent.reasonix.approval_mode'] || 'ask').trim() || 'ask';
return mode === 'allow' || mode === 'deny' ? mode : 'ask';
}
function pageAiApplyPermissionMode(message) {
if (!message || message.resolved) return false;
var mode = pageAiReasonixApprovalMode();
if (mode === 'ask') return false;
var optionId = pageAiPermissionPreferredOptionId(message, mode);
window.setTimeout(function() {
pageAiResolvePermission(message.permissionId, mode, optionId);
}, 0);
return true;
}
function pageAiPermissionPreferredOptionId(message, decision) {
var options = Array.isArray(message && message.options) ? message.options : [];
if (!options.length) return '';
var allowKeywords = ['allow_once', 'allow', 'approve', 'yes', 'allow_always', 'allow_persistent'];
var denyKeywords = ['deny_once', 'reject_once', 'deny', 'reject', 'no', 'cancel', 'stop'];
var keywords = decision === 'allow' ? allowKeywords : denyKeywords;
for (var i = 0; i < keywords.length; i += 1) {
var keyword = keywords[i];
var found = options.find(function(option) {
var text = [option && option.optionId, option && option.kind, option && option.name].map(function(value) {
return String(value || '').toLowerCase();
}).join(' ');
return text.indexOf(keyword) >= 0;
});
if (found && found.optionId) return String(found.optionId || '');
}
if (decision === 'allow') {
var allowFallback = options.find(function(option) { return !pageAiPermissionOptionRejectLike(option); });
return allowFallback && allowFallback.optionId ? String(allowFallback.optionId || '') : '';
}
return '';
}
function pageAiResolvePermission(permissionId, decision, optionId) {
permissionId = String(permissionId || '').trim();
decision = String(decision || '').trim() || 'deny';
optionId = String(optionId || '').trim();
if (!permissionId) return;
var runId = pageUiState.pageAiCurrentRunId;
var runId = String(pageUiState.pageAiCurrentRunId || '').trim();
if (!runId && pageUiState.pageAiActiveSessionId && Array.isArray(pageUiState.pageAiSessions)) {
var activeSession = pageUiState.pageAiSessions.find(function(session) {
return session && session.id === pageUiState.pageAiActiveSessionId;
});
runId = String(activeSession && (activeSession.runId || activeSession.hostRunId) || '').trim();
}
if (!runId && Array.isArray(pageUiState.pageAiPermissionRequests)) {
var pending = pageUiState.pageAiPermissionRequests.find(function(item) {
return item && item.permissionId === permissionId;
});
runId = String(pending && pending.runId || '').trim();
}
if (!runId && doc && doc.documentElement) {
runId = String(doc.documentElement.getAttribute('data-mnote-page-ai-run-id') || doc.documentElement.getAttribute('data-mnote-page-ai-active-host-run-id') || '').trim();
}
if (runId) {
var body = { permissionId: permissionId, decision: decision };
if (optionId) body.optionId = optionId;
fetch('/api/hermes/client/runs/' + encodeURIComponent(runId) + '/resolve-permission', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ permissionId: permissionId, decision: decision })
body: JSON.stringify(body)
}).then(function(response) {
if (!response.ok) console.warn('resolve-permission 后端返回异常', response.status);
}).catch(function(err) {
@@ -79,6 +251,7 @@ export function createSidebarPageAiPermissionRuntime(context) {
});
var dialog = doc.querySelector('[data-page-ai-permission-dialog]');
if (dialog instanceof HTMLElement) dialog.hidden = true;
pageAiPersistPermissionRequests();
renderPageAiConversation();
}
@@ -101,31 +274,70 @@ export function createSidebarPageAiPermissionRuntime(context) {
dialog.innerHTML = '' +
'<div class="wolai-page-ai-permission-panel" role="dialog" aria-modal="false">' +
'<div class="wolai-page-ai-memory-title" data-page-ai-permission-tool></div>' +
'<div class="wolai-page-ai-tool-meta" data-page-ai-permission-args></div>' +
'<div class="wolai-page-ai-message-actions">' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-permission-action="allow" data-page-ai-permission-dialog-action>允许</button>' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-permission-action="deny" data-page-ai-permission-dialog-action>拒绝</button>' +
'</div>' +
'<div class="wolai-page-ai-permission-summary" data-page-ai-permission-args></div>' +
'<div class="wolai-page-ai-message-actions" data-page-ai-permission-actions></div>' +
'</div>';
doc.body.appendChild(dialog);
}
var tool = dialog.querySelector('[data-page-ai-permission-tool]');
if (tool instanceof HTMLElement) tool.textContent = message.toolName || 'session/request_permission';
if (tool instanceof HTMLElement) tool.textContent = "权限 - " + (message.toolName || "session/request_permission");
var args = dialog.querySelector('[data-page-ai-permission-args]');
if (args instanceof HTMLElement) args.textContent = message.argsSummary || message.content || '';
dialog.querySelectorAll('[data-page-ai-permission-dialog-action]').forEach(function(button) {
if (button instanceof HTMLButtonElement) {
button.setAttribute('data-page-ai-permission-id', message.permissionId || '');
button.disabled = Boolean(message.resolved);
}
});
if (args instanceof HTMLElement) args.innerHTML = pageAiPermissionDetailsHtml(message);
var actions = dialog.querySelector('[data-page-ai-permission-actions]');
if (actions instanceof HTMLElement) actions.innerHTML = pageAiPermissionActionsHtml(message);
dialog.hidden = false;
}
function pageAiPermissionDetailsHtml(message) {
var details = message && message.permissionDetails && typeof message.permissionDetails === 'object'
? message.permissionDetails
: {};
var rows = Array.isArray(details.rows) ? details.rows : [];
if (!rows.length) {
return '<div class="wolai-page-ai-permission-row"><span>请求</span><strong>' + escapeHtml(message.argsSummary || message.content || '') + '</strong></div>';
}
return rows.slice(0, 6).map(function(row) {
return '<div class="wolai-page-ai-permission-row"><span>' + escapeHtml(row.label || '') + '</span><strong>' + escapeHtml(row.value || '') + '</strong></div>';
}).join('');
}
function pageAiPermissionActionsHtml(message) {
var options = Array.isArray(message.options) ? message.options : [];
if (!options.length) {
return '' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-permission-action="allow" data-page-ai-permission-id="' + escapeAttr(message.permissionId || '') + '" data-page-ai-permission-dialog-action>允许</button>' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-permission-action="deny" data-page-ai-permission-id="' + escapeAttr(message.permissionId || '') + '" data-page-ai-permission-dialog-action>拒绝</button>';
}
return options.map(function(option) {
var denyLike = pageAiPermissionOptionRejectLike(option);
return '<button type="button" class="wolai-page-ai-ghost' + (denyLike ? ' wolai-page-ai-ghost--danger' : '') + '" data-page-ai-permission-action="' + (denyLike ? 'deny' : 'allow') + '" data-page-ai-permission-option-id="' + escapeAttr(option.optionId || '') + '" data-page-ai-permission-id="' + escapeAttr(message.permissionId || '') + '" data-page-ai-permission-dialog-action>' + escapeHtml(option.name || option.optionId || '选择') + '</button>';
}).join('');
}
function pageAiPermissionOptionRejectLike(option) {
var text = String((option && option.optionId) || '') + ' ' + String((option && option.kind) || '') + ' ' + String((option && option.name) || '');
text = text.toLowerCase();
return /reject|deny|cancel|stop|no/.test(text);
}
function escapeAttr(value) {
return escapeHtml(String(value || ''));
}
function escapeHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
return {
pageAiPermissionMessage,
pageAiApplyPermissionEvent,
pageAiResolvePermission,
pageAiRestorePermissionRequests,
pageAiHidePermissionDialog,
pageAiShowPermissionDialog
};
@@ -163,6 +163,50 @@ export function createSidebarPageAiProfileRuntime(context) {
});
}
function pageAiSessionStatusFilterValue(session) {
var status = String(session && session.status || '').trim();
var mode = String(session && (session.runtimeMode || session.mode) || '').trim();
if (pageUiState.pageAiActiveSessionId && session && session.id === pageUiState.pageAiActiveSessionId) return 'active';
if (session && session.replaySeen) return 'replay_seen';
if (mode === 'native_live' || mode === 'cold_resumed' || mode === 'replay_seen') return mode;
if (['running', 'tool_calling', 'queued', 'pending', 'acp_pending'].indexOf(status) >= 0) return 'active';
if (['failed', 'aborted', 'cancelled', 'canceled'].indexOf(status) >= 0) return 'failed';
if (status === 'completed') return 'completed';
return status || 'unknown';
}
function pageAiSessionStatusLabel(value) {
return {
all: '全部状态',
active: 'Active',
completed: 'Completed',
failed: 'Failed',
native_live: 'Reasonix native-live',
cold_resumed: 'Cold resumed',
replay_seen: 'Replay seen',
unknown: 'Unknown'
}[value] || value;
}
function pageAiSessionStatusFilterOptions(rows) {
var byValue = {
all: pageAiSessionStatusLabel('all'),
active: pageAiSessionStatusLabel('active'),
completed: pageAiSessionStatusLabel('completed'),
failed: pageAiSessionStatusLabel('failed'),
native_live: pageAiSessionStatusLabel('native_live'),
cold_resumed: pageAiSessionStatusLabel('cold_resumed'),
replay_seen: pageAiSessionStatusLabel('replay_seen')
};
pageAiNormalizeArray(rows).filter(pageAiVisibleHistorySession).forEach(function(session) {
var value = pageAiSessionStatusFilterValue(session);
byValue[value] = pageAiSessionStatusLabel(value);
});
return Object.keys(byValue).map(function(value) {
return { value: value, label: byValue[value] };
});
}
function pageAiVisibleHistorySession(session) {
var source = String(session && session.source || '').trim();
var status = String(session && session.status || '').trim();
@@ -172,10 +216,11 @@ export function createSidebarPageAiProfileRuntime(context) {
function pageAiFilteredHistoryRows(rows) {
var filterValue = String(pageUiState.pageAiSessionAgentFilter || 'all').trim() || 'all';
var statusFilter = String(pageUiState.pageAiSessionStatusFilter || 'all').trim() || 'all';
var normalized = pageAiNormalizeArray(rows).filter(pageAiVisibleHistorySession);
if (filterValue === 'all') return normalized;
return normalized.filter(function(session) {
return pageAiSessionAgentFilterValue(session) === filterValue;
return (filterValue === 'all' || pageAiSessionAgentFilterValue(session) === filterValue)
&& (statusFilter === 'all' || pageAiSessionStatusFilterValue(session) === statusFilter);
});
}
@@ -220,6 +265,9 @@ export function createSidebarPageAiProfileRuntime(context) {
pageAiSessionAgentLabel,
pageAiSessionPreviewText,
pageAiSessionAgentFilterOptions,
pageAiSessionStatusFilterValue,
pageAiSessionStatusLabel,
pageAiSessionStatusFilterOptions,
pageAiFilteredHistoryRows,
pageAiTimestamp,
pageAiUsageSummary
@@ -39,6 +39,9 @@ export function createSidebarPageAiRenderRuntime(context) {
pageAiSessionAgentFilterOptions,
pageAiSessionAgentLabel,
pageAiSessionAgentFilterValue,
pageAiSessionStatusFilterOptions,
pageAiSessionStatusFilterValue,
pageAiSessionStatusLabel,
pageAiSessionPreviewText,
pageAiSessionStorageLabel,
pageAiSessionSelected,
@@ -192,6 +195,50 @@ export function createSidebarPageAiRenderRuntime(context) {
'</label>';
}
function pageAiReasonixQuickControlLabel(key, fallback) {
if (key === 'ai.agent.reasonix.model_id') return '模型';
if (key === 'ai.agent.reasonix.approval_mode') return '审批';
if (key === 'ai.agent.reasonix.plan_mode') return 'Plan';
return fallback || key;
}
function pageAiRenderReasonixQuickControl(field) {
var key = String(field && field.key || '').trim();
if (!key || field.secret === true) return '';
var value = pageAiDescriptorFieldValue(field);
var options = pageAiDescriptorFieldOptions(field);
if (!options.length) return '';
return '' +
'<label class="wolai-page-ai-reasonix-control" data-page-ai-reasonix-control-row="' + escapeHtml(key) + '">' +
'<span>' + escapeHtml(pageAiReasonixQuickControlLabel(key, field.label)) + '</span>' +
'<select data-page-ai-descriptor-field="' + escapeHtml(key) + '" data-page-ai-descriptor-kind="select" data-page-ai-reasonix-quick-control="' + escapeHtml(key) + '">' +
options.map(function(option) {
return '<option value="' + escapeHtml(option.value) + '"' + (String(value) === String(option.value) ? ' selected' : '') + '>' + escapeHtml(option.label) + '</option>';
}).join('') +
'</select>' +
'</label>';
}
function pageAiReasonixQuickControlsHtml() {
if (pageAiCurrentAgentId() !== 'reasonix') return '';
var descriptor = pageAiAgentDescriptor('reasonix') || pageAiAgentRecord('reasonix');
var wanted = {
'ai.agent.reasonix.model_id': true,
'ai.agent.reasonix.approval_mode': true,
'ai.agent.reasonix.plan_mode': true
};
var fields = pageAiNormalizeArray(descriptor && descriptor.fields)
.filter(function(field) { return wanted[String(field && field.key || '').trim()] === true; })
.map(pageAiRenderReasonixQuickControl)
.filter(Boolean)
.join('');
if (!fields) return '';
return '' +
'<div class="wolai-page-ai-reasonix-controls" data-page-ai-reasonix-quick-controls>' +
fields +
'</div>';
}
function pageAiRenderAgentDescriptorCard(agentId, fallbackTitle) {
var descriptor = pageAiAgentDescriptor(agentId) || pageAiAgentRecord(agentId);
var title = String(descriptor && (descriptor.displayName || descriptor.label) || fallbackTitle || agentId).trim();
@@ -210,11 +257,27 @@ export function createSidebarPageAiRenderRuntime(context) {
function pageAiCurrentModelLabel() {
var profile = pageAiCurrentProfileRecord();
var prefs = pageUiState.pageAiSkillPreferences || {};
var toolModel = pageAiMnoteToolModel();
var reasonixModel = String(prefs['ai.agent.reasonix.model_id'] || '').trim();
var approvalMode = String(prefs['ai.agent.reasonix.approval_mode'] || 'ask').trim() || 'ask';
var planMode = String(prefs['ai.agent.reasonix.plan_mode'] || 'manual').trim() || 'manual';
var approvalLabel = {
ask: '审批:询问',
allow: '审批:自动允许',
deny: '审批:自动拒绝'
}[approvalMode] || ('审批:' + approvalMode);
var planLabel = {
manual: '计划:手动',
auto: '计划:自动'
}[planMode] || ('计划:' + planMode);
if (!profile) return 'tool: ' + toolModel;
var model = String(profile.model || '').trim();
var gateway = String(profile.gateway || '').trim();
var profileLabel = [model, gateway].filter(Boolean).join(' · ') || '由 Hermes 决定';
if (pageAiCurrentAgentId() === 'reasonix') {
return ['Reasonix', reasonixModel || toolModel, approvalLabel, planLabel].filter(Boolean).join(' · ');
}
return 'tool: ' + toolModel + ' · profile: ' + profileLabel;
}
@@ -283,6 +346,88 @@ export function createSidebarPageAiRenderRuntime(context) {
});
}
function pageAiRuntimeStatusValue(path, fallback) {
var node = pageUiState.pageAiRuntimeStatus || {};
String(path || '').split('.').forEach(function(part) {
if (!part || node == null || typeof node !== 'object') return;
node = node[part];
});
if (node == null || node === '') return fallback || '';
return String(node);
}
function pageAiRuntimeStatusLabel() {
var status = pageUiState.pageAiRuntimeStatus || {};
var runtime = String(status.runtime || pageUiState.pageAiAcpRuntime || 'reasonix');
var mode = String(status.mode || 'new_session');
if (runtime === 'reasonix') return pageAiCurrentModelLabel() + ' · ' + mode.replace(/_/g, '-');
return runtime + ' · ' + mode.replace(/_/g, '-');
}
function pageAiRuntimeDetailHtml() {
var status = pageUiState.pageAiRuntimeStatus || {};
var acp = status.acp && typeof status.acp === 'object' ? status.acp : {};
var roots = status.roots && typeof status.roots === 'object' ? status.roots : {};
var tools = status.tools && typeof status.tools === 'object' ? status.tools : {};
var mcp = status.mcp && typeof status.mcp === 'object' ? status.mcp : {};
var queue = status.session && status.session.queue && typeof status.session.queue === 'object' ? status.session.queue : {};
var logs = status.logs && Array.isArray(status.logs.items) ? status.logs.items : [];
var rows = [
['runtime', pageAiRuntimeStatusLabel()],
['session', String(status.mnoteSessionId || pageUiState.pageAiActiveSessionId || '无')],
['acpSessionId', String(acp.acpSessionId || '无')],
['load/replay', String(acp.loadStatus || 'not_applicable') + ' / replay=' + String(acp.replaySeen === true)],
['live binding', acp.liveBinding && typeof acp.liveBinding === 'object' ? String(acp.liveBinding.status || 'unknown') : '无'],
['roots', 'write=' + String(roots.write || 0) + ' readOnly=' + String(roots.readOnly || 0)],
['MCP/tools', String(mcp.status || 'unknown') + ' · enabled=' + String(tools.enabled || 0) + ' disabled=' + String(tools.disabled || 0)],
['queue', String(queue.queuedCount || pageUiState.pageAiQueueLength || 0)],
['jobs', pageAiRuntimeStatusValue('jobs.running', '0')]
];
var html = rows.map(function(row) {
return '<div class="wolai-page-ai-tool-row"><span class="wolai-page-ai-tool-name">' + escapeHtml(row[0]) + '</span><span class="wolai-page-ai-tool-meta">' + escapeHtml(row[1]) + '</span></div>';
}).join('');
if (pageUiState.pageAiRuntimeStatusError) {
html += '<div class="wolai-page-ai-inline-error">' + escapeHtml(pageUiState.pageAiRuntimeStatusError) + '</div>';
}
if (logs.length) {
html += logs.slice(-4).map(function(item) {
return '<div class="wolai-page-ai-tool-row"><span class="wolai-page-ai-tool-name">' + escapeHtml(String(item.kind || 'log')) + '</span><span class="wolai-page-ai-tool-meta">' + escapeHtml(String(item.seq || '')) + '</span></div>';
}).join('');
}
return html || '<div class="wolai-page-ai-empty">Runtime status 尚未加载。</div>';
}
function pageAiUsageDetailHtml() {
var status = pageUiState.pageAiRuntimeStatus || {};
var usage = status.usage && typeof status.usage === 'object' ? status.usage : {};
var rows = [
['last run', usage.lastRunTokens || usage.lastRunCost || 'unknown'],
['session total', usage.sessionTokens || usage.sessionCost || 'unknown'],
['cache', usage.cacheTokens || usage.cacheHit || 'unknown']
];
return rows.map(function(row) {
return '<div class="wolai-page-ai-tool-row"><span class="wolai-page-ai-tool-name">' + escapeHtml(row[0]) + '</span><span class="wolai-page-ai-tool-meta">' + escapeHtml(String(row[1])) + '</span></div>';
}).join('');
}
function pageAiSettingsTabsHtml(activeTab) {
return [
['agent', 'MNote'],
['reasonix-settings', 'Reasonix'],
['hermes-settings', 'Hermes']
].map(function(tab) {
var active = tab[0] === activeTab;
return '<button type="button" class="wolai-page-ai-tab' + (active ? ' is-active' : '') + '" data-page-ai-tab="' + escapeHtml(tab[0]) + '" role="tab" aria-selected="' + (active ? 'true' : 'false') + '">' + escapeHtml(tab[1]) + '</button>';
}).join('');
}
function pageAiStatusTabsHtml() {
return '' +
'<button type="button" class="wolai-page-ai-tab" data-page-ai-tab="chat" role="tab" aria-selected="false">聊天</button>' +
'<button type="button" class="wolai-page-ai-tab is-active" data-page-ai-tab="status" role="tab" aria-selected="true">状态</button>' +
'<button type="button" class="wolai-page-ai-tab" data-page-ai-tab="agent" role="tab" aria-selected="false">设置</button>';
}
function renderPageAiProviderButtons() {
var drawer = ensurePageAiDrawer();
var isAcp = pageUiState.pageAiAcpRuntime !== '';
@@ -303,6 +448,8 @@ export function createSidebarPageAiRenderRuntime(context) {
var isAcp = pageUiState.pageAiAcpRuntime !== '';
var activeAgentId = pageAiCurrentAgentId();
var activeProfile = pageAiCurrentProfile();
if (pageUiState.pageAiPage === 'runtime') pageUiState.pageAiPage = 'status';
if (pageUiState.pageAiPage === 'chat-only-settings') pageUiState.pageAiPage = 'agent';
drawer.setAttribute('data-page-ai-page', pageUiState.pageAiPage || 'chat');
drawer.setAttribute('data-page-ai-active-agent-id', activeAgentId);
drawer.setAttribute('data-mnote-acp-runtime', pageUiState.pageAiAcpRuntime || 'reasonix');
@@ -478,16 +625,32 @@ export function createSidebarPageAiRenderRuntime(context) {
if (agentPanel instanceof HTMLElement) {
agentPanel.innerHTML = '' +
(pageUiState.pageAiAgentDescriptorError ? '<div class="wolai-page-ai-inline-error">' + escapeHtml(pageUiState.pageAiAgentDescriptorError) + '</div>' : '') +
'<section class="wolai-page-ai-memory-card" data-page-ai-agent-descriptor-card="mnote">' +
'<div class="wolai-page-ai-memory-head"><div><div class="wolai-page-ai-memory-title">MNote</div><div class="wolai-page-ai-memory-scope">上下文、权限、工具、会话历史和审计</div></div></div>' +
'<div class="wolai-page-ai-tool-meta">这里配置 MNote 发送给 agent 的 envelope,不配置 Reasonix 或 Hermes 原生 runtime。</div>' +
'</section>' +
'<section class="wolai-page-ai-memory-card">' +
'<div class="wolai-page-ai-memory-head"><div class="wolai-page-ai-memory-title">授权区域</div><div class="wolai-page-ai-memory-scope">' + escapeHtml(pageAiBuildAllowedRoots().length ? 'SQLite directory_grants' : '需要授权') + '</div></div>' +
'<div class="wolai-page-ai-memory-head"><div class="wolai-page-ai-memory-title">授权区域</div><div class="wolai-page-ai-memory-scope">SQLite directory_grants</div></div>' +
'<div class="wolai-page-ai-tool-list" data-page-ai-allowed-roots></div>' +
'</section>' +
pageAiRenderAgentDescriptorCard(activeAgentId, pageAiCurrentAgentSelectionLabel()) +
'<section class="wolai-page-ai-memory-card">' +
'<div class="wolai-page-ai-memory-head"><div class="wolai-page-ai-memory-title">默认上下文</div><div class="wolai-page-ai-memory-scope">ContextRefs</div></div>' +
'<div class="wolai-page-ai-tool-meta">由 MNote 生成 run envelope,不写入 Reasonix/Hermes native memory。</div>' +
'</section>' +
'<section class="wolai-page-ai-memory-card">' +
'<div class="wolai-page-ai-memory-head"><div class="wolai-page-ai-memory-title">审计</div><div class="wolai-page-ai-memory-scope">changed files 默认摘要</div></div>' +
'<div class="wolai-page-ai-memory-head"><div class="wolai-page-ai-memory-title">会话与审计</div><div class="wolai-page-ai-memory-scope">history / export / changed files</div></div>' +
'<div class="wolai-page-ai-tool-meta">删除会话只删除 MNote history;外部 Hermes/provider 会话不会默认删除。</div>' +
'</section>';
var mnoteAllowedRootsNode = agentPanel.querySelector('[data-page-ai-allowed-roots]');
if (mnoteAllowedRootsNode instanceof HTMLElement) {
var mnoteAllowedRoots = pageAiBuildAllowedRoots();
mnoteAllowedRootsNode.innerHTML = mnoteAllowedRoots.length
? mnoteAllowedRoots.map(function(root) {
var label = root.rootUri.replace(/^file:\/\//, '') || root.rootUri;
return '<span class="wolai-page-ai-model-chip is-active" data-page-ai-allowed-root="' + escapeHtml(root.rootUri) + '" data-page-ai-allowed-root-permission="' + escapeHtml(root.permission) + '">' + escapeHtml(root.permission + ' · ' + label) + '</span>';
}).join('')
: '<span class="wolai-page-ai-tool-meta">' + escapeHtml(String(pageUiState.pageAiAllowedRootsError || '').trim() || '未选择授权区域') + '</span>';
}
}
var profileSummary = drawer.querySelector('[data-page-ai-profile-summary]');
if (profileSummary instanceof HTMLElement) profileSummary.textContent = '上下文可选';
@@ -512,6 +675,40 @@ export function createSidebarPageAiRenderRuntime(context) {
var queueSuffix = pageUiState.pageAiQueueLength > 0 ? ' · 队列 ' + pageUiState.pageAiQueueLength : '';
runStatus.textContent = pageAiRunStatusLabel(pageUiState.pageAiRunStatus) + queueSuffix;
}
drawer.querySelectorAll('[data-page-ai-runtime-badge]').forEach(function(runtimeBadge) {
if (runtimeBadge instanceof HTMLElement) runtimeBadge.textContent = pageAiRuntimeStatusLabel();
});
var runtimeActive = drawer.querySelector('[data-page-ai-runtime-active-run]');
if (runtimeActive instanceof HTMLElement) {
var activeRunStatus = pageAiRuntimeStatusValue('activeRun.status', pageUiState.pageAiRunStatus || 'idle');
runtimeActive.textContent = pageAiRunStatusLabel(activeRunStatus);
}
var runtimeAcp = drawer.querySelector('[data-page-ai-runtime-acp]');
if (runtimeAcp instanceof HTMLElement) runtimeAcp.textContent = pageAiRuntimeStatusValue('acp.acpSessionId', '无 ACP session');
var runtimeDetail = drawer.querySelector('[data-page-ai-runtime-detail]');
if (runtimeDetail instanceof HTMLElement) runtimeDetail.innerHTML = pageAiRuntimeDetailHtml();
var jobsList = drawer.querySelector('[data-page-ai-jobs-list]');
if (jobsList instanceof HTMLElement) {
var runtimeStatus = pageUiState.pageAiRuntimeStatus || {};
var jobs = runtimeStatus.jobs && Array.isArray(runtimeStatus.jobs.items) ? runtimeStatus.jobs.items : [];
jobsList.innerHTML = jobs.length
? jobs.map(function(job) {
return '<div class="wolai-page-ai-tool-row"><span class="wolai-page-ai-tool-name">' + escapeHtml(String(job.id || job.name || 'job')) + '</span><span class="wolai-page-ai-tool-meta">' + escapeHtml(String(job.status || 'running')) + '</span></div>';
}).join('')
: '<div class="wolai-page-ai-empty">暂无运行中的 job。</div>';
}
var logsList = drawer.querySelector('[data-page-ai-logs-list]');
if (logsList instanceof HTMLElement) {
var logStatus = pageUiState.pageAiRuntimeStatus || {};
var logs = logStatus.logs && Array.isArray(logStatus.logs.items) ? logStatus.logs.items : [];
logsList.innerHTML = logs.length
? logs.slice(-8).map(function(item) {
return '<div class="wolai-page-ai-tool-row"><span class="wolai-page-ai-tool-name">' + escapeHtml(String(item.kind || 'log')) + '</span><span class="wolai-page-ai-tool-meta">' + escapeHtml(String(item.seq || '')) + '</span></div>';
}).join('')
: '<div class="wolai-page-ai-empty">暂无 runtime logs。</div>';
}
var usageDetail = drawer.querySelector('[data-page-ai-usage-detail]');
if (usageDetail instanceof HTMLElement) usageDetail.innerHTML = pageAiUsageDetailHtml();
var stopButton = drawer.querySelector('[data-page-ai-action="stop-run"]');
if (stopButton instanceof HTMLButtonElement) {
var canStop = ['queued', 'running', 'tool_calling'].indexOf(pageUiState.pageAiRunStatus) >= 0 && pageUiState.pageAiCurrentRunId;
@@ -530,9 +727,10 @@ export function createSidebarPageAiRenderRuntime(context) {
} else {
queueList.innerHTML = pageUiState.pageAiQueuedItems.map(function(item, index) {
var label = '队列 ' + (index + 1);
var preview = String(item.preview || item.traceId || item.queueId || '').trim();
return '' +
'<div class="wolai-page-ai-tool-row" data-page-ai-queue-item="' + escapeHtml(item.queueId) + '">' +
'<div><strong>' + escapeHtml(label) + '</strong><br /><span>' + escapeHtml(item.queueId) + '</span></div>' +
'<div><strong>' + escapeHtml(label) + '</strong><br /><span>' + escapeHtml(preview || item.queueId) + '</span></div>' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-action="cancel-queued-run" data-page-ai-queue-id="' + escapeHtml(item.queueId) + '">取消</button>' +
'</div>';
}).join('');
@@ -547,7 +745,17 @@ export function createSidebarPageAiRenderRuntime(context) {
: '等待 AI session';
}
var modelNode = drawer.querySelector('[data-page-ai-model-status]');
if (modelNode instanceof HTMLElement) modelNode.textContent = pageAiBuildAllowedRoots().length ? '已授权' : '需授权';
if (modelNode instanceof HTMLElement) modelNode.textContent = pageAiCurrentModelLabel();
var reasonixControls = drawer.querySelector('[data-page-ai-reasonix-quick-controls]');
if (reasonixControls instanceof HTMLElement) {
if (pageAiCurrentAgentId() === 'reasonix') {
reasonixControls.innerHTML = pageAiReasonixQuickControlsHtml().replace(/^<div[^>]*>|<\/div>$/g, '');
reasonixControls.hidden = false;
} else {
reasonixControls.innerHTML = '';
reasonixControls.hidden = true;
}
}
var scopeSelect = drawer.querySelector('[data-page-ai-context-scope]');
if (scopeSelect instanceof HTMLSelectElement) scopeSelect.value = pageUiState.pageAiContextScope;
var scopeLabel = drawer.querySelector('[data-page-ai-context-scope-label]');
@@ -630,6 +838,20 @@ export function createSidebarPageAiRenderRuntime(context) {
sessionAgentFilter.value = activeFilter;
pageUiState.pageAiSessionAgentFilter = activeFilter;
}
var sessionStatusFilter = drawer.querySelector('[data-page-ai-session-status-filter]');
if (sessionStatusFilter instanceof HTMLSelectElement) {
var historyRowsForStatusFilter = pageUiState.pageAiSessionSearchResults.length
? pageUiState.pageAiSessionSearchResults
: pageUiState.pageAiSessions;
var statusOptions = pageAiSessionStatusFilterOptions(historyRowsForStatusFilter);
var activeStatusFilter = String(pageUiState.pageAiSessionStatusFilter || 'all').trim() || 'all';
if (!statusOptions.some(function(option) { return option.value === activeStatusFilter; })) activeStatusFilter = 'all';
sessionStatusFilter.innerHTML = statusOptions.map(function(option) {
return '<option value="' + escapeHtml(option.value) + '"' + (option.value === activeStatusFilter ? ' selected' : '') + '>' + escapeHtml(option.label) + '</option>';
}).join('');
sessionStatusFilter.value = activeStatusFilter;
pageUiState.pageAiSessionStatusFilter = activeStatusFilter;
}
var skillSearch = drawer.querySelector('[data-page-ai-skill-search]');
if (skillSearch instanceof HTMLInputElement && documentRef.activeElement !== skillSearch) {
skillSearch.value = pageUiState.pageAiSkillQuery;
@@ -754,7 +976,7 @@ export function createSidebarPageAiRenderRuntime(context) {
}
if (gatewayDetail instanceof HTMLElement) {
if (!gatewayHealth) {
gatewayDetail.innerHTML = '<div class="wolai-page-ai-empty">打开高级后会检查 agent runtime 与当前 profile。</div>';
gatewayDetail.innerHTML = '<div class="wolai-page-ai-empty">打开 Hermes 设置后会检查 gateway 与当前 profile。</div>';
} else {
var gatewayInfo = gatewayHealth.gateway || {};
var profileInfo = gatewayHealth.profile || {};
@@ -798,8 +1020,8 @@ export function createSidebarPageAiRenderRuntime(context) {
}
var chatOnlyPanel = drawer.querySelector('[data-page-ai-chat-only-panel]');
if (chatOnlyPanel instanceof HTMLElement) {
chatOnlyPanel.innerHTML = pageAiRenderAgentDescriptorCard('chat_only', 'Chat-only') +
'<section class="wolai-page-ai-memory-card"><div class="wolai-page-ai-memory-head"><div class="wolai-page-ai-memory-title">Chat-only</div><div class="wolai-page-ai-memory-scope">默认不申请文件写权限</div></div></section>';
chatOnlyPanel.innerHTML = pageAiRenderAgentDescriptorCard('chat_only', 'API Chat / Chat-only profile') +
'<section class="wolai-page-ai-memory-card"><div class="wolai-page-ai-memory-head"><div class="wolai-page-ai-memory-title">API Chat / Chat-only profile</div><div class="wolai-page-ai-memory-scope">默认不申请文件写权限,归属 MNote 设置。</div></div></section>';
}
}
@@ -859,6 +1081,11 @@ export function createSidebarPageAiRenderRuntime(context) {
'<button type="button" class="wolai-page-ai-icon" data-page-ai-action="close" aria-label="关闭页面 AI">×</button>' +
'</div>' +
'</div>' +
'<button type="button" class="wolai-page-ai-runtime-strip" data-page-ai-runtime-strip data-page-ai-tab="status" aria-label="打开 Page AI 状态">' +
'<span data-page-ai-runtime-badge>reasonix · new-session</span>' +
'<span data-page-ai-runtime-active-run>空闲</span>' +
'<span data-page-ai-runtime-acp>无 ACP session</span>' +
'</button>' +
'<div class="wolai-page-ai-body">' +
'<section class="wolai-page-ai-panel-section" data-page-ai-panel="chat">' +
'<div class="wolai-page-ai-chat-meta">' +
@@ -885,26 +1112,36 @@ export function createSidebarPageAiRenderRuntime(context) {
'<div class="wolai-page-ai-settings-head">' +
'<button type="button" class="wolai-page-ai-back" data-page-ai-tab="chat">← 聊天</button>' +
'<div class="wolai-page-ai-settings-tabs" role="tablist" aria-label="页面 AI 设置">' +
'<button type="button" class="wolai-page-ai-tab is-active" data-page-ai-tab="agent" role="tab" aria-selected="true">Common</button>' +
'<button type="button" class="wolai-page-ai-tab" data-page-ai-tab="hermes-settings" role="tab" aria-selected="false">Hermes</button>' +
'<button type="button" class="wolai-page-ai-tab" data-page-ai-tab="reasonix-settings" role="tab" aria-selected="false">Reasonix</button>' +
'<button type="button" class="wolai-page-ai-tab" data-page-ai-tab="chat-only-settings" role="tab" aria-selected="false">Chat-only</button>' +
'<button type="button" class="wolai-page-ai-tab" data-page-ai-tab="runtime" role="tab" aria-selected="false">高级</button>' +
pageAiSettingsTabsHtml('agent') +
'</div>' +
'</div>' +
'<div class="wolai-page-ai-inline-error" data-page-ai-profile-error hidden></div>' +
'<div class="wolai-page-ai-inline-error" data-page-ai-memory-error hidden></div>' +
'<div class="wolai-page-ai-memory-grid" data-page-ai-agent-panel></div>' +
'<label class="wolai-page-ai-context-select">' +
'<span>默认上下文 scope</span>' +
'<select data-page-ai-context-scope>' +
'<option value="page">当前页</option>' +
'<option value="selection">当前选区</option>' +
'<option value="block">当前块</option>' +
'<option value="options">页面设置</option>' +
'</select>' +
'</label>' +
'<div class="wolai-page-ai-settings-stack" data-page-ai-agent-panel></div>' +
'<div class="wolai-page-ai-settings-stack" data-page-ai-chat-only-panel></div>' +
'<div class="wolai-page-ai-tools-panel">' +
'<div class="wolai-page-ai-tools-head">' +
'<span>MNote 工具</span>' +
'<span data-page-ai-last-tool>暂无 tool call</span>' +
'</div>' +
'<div class="wolai-page-ai-inline-error" data-page-ai-tool-error hidden></div>' +
'<div class="wolai-page-ai-tool-list" data-page-ai-tool-list></div>' +
'</div>' +
'</section>' +
'<section class="wolai-page-ai-panel-section" data-page-ai-panel="hermes-settings" hidden>' +
'<div class="wolai-page-ai-settings-head">' +
'<button type="button" class="wolai-page-ai-back" data-page-ai-tab="chat">← 聊天</button>' +
'<div class="wolai-page-ai-settings-tabs" role="tablist" aria-label="页面 AI 设置">' +
'<button type="button" class="wolai-page-ai-tab" data-page-ai-tab="agent" role="tab" aria-selected="false">Common</button>' +
'<button type="button" class="wolai-page-ai-tab is-active" data-page-ai-tab="hermes-settings" role="tab" aria-selected="true">Hermes</button>' +
'<button type="button" class="wolai-page-ai-tab" data-page-ai-tab="reasonix-settings" role="tab" aria-selected="false">Reasonix</button>' +
'<button type="button" class="wolai-page-ai-tab" data-page-ai-tab="chat-only-settings" role="tab" aria-selected="false">Chat-only</button>' +
'<button type="button" class="wolai-page-ai-tab" data-page-ai-tab="runtime" role="tab" aria-selected="false">高级</button>' +
pageAiSettingsTabsHtml('hermes-settings') +
'</div>' +
'</div>' +
'<div class="wolai-page-ai-settings-grid">' +
@@ -914,17 +1151,21 @@ export function createSidebarPageAiRenderRuntime(context) {
'</label>' +
'</div>' +
'<button type="button" class="wolai-page-ai-settings-link" data-page-ai-action="open-hermes-settings">打开 Hermes 设置</button>' +
'<div class="wolai-page-ai-inline-error" data-page-ai-gateway-error hidden></div>' +
'<div class="wolai-page-ai-tools-panel">' +
'<div class="wolai-page-ai-tools-head">' +
'<span>Hermes gateway</span>' +
'<span data-page-ai-gateway-status>未检查</span>' +
'</div>' +
'<div class="wolai-page-ai-tool-list" data-page-ai-gateway-detail></div>' +
'</div>' +
'<div class="wolai-page-ai-memory-grid" data-page-ai-hermes-panel></div>' +
'</section>' +
'<section class="wolai-page-ai-panel-section" data-page-ai-panel="reasonix-settings" hidden>' +
'<div class="wolai-page-ai-settings-head">' +
'<button type="button" class="wolai-page-ai-back" data-page-ai-tab="chat">← 聊天</button>' +
'<div class="wolai-page-ai-settings-tabs" role="tablist" aria-label="页面 AI 设置">' +
'<button type="button" class="wolai-page-ai-tab" data-page-ai-tab="agent" role="tab" aria-selected="false">Common</button>' +
'<button type="button" class="wolai-page-ai-tab" data-page-ai-tab="hermes-settings" role="tab" aria-selected="false">Hermes</button>' +
'<button type="button" class="wolai-page-ai-tab is-active" data-page-ai-tab="reasonix-settings" role="tab" aria-selected="true">Reasonix</button>' +
'<button type="button" class="wolai-page-ai-tab" data-page-ai-tab="chat-only-settings" role="tab" aria-selected="false">Chat-only</button>' +
'<button type="button" class="wolai-page-ai-tab" data-page-ai-tab="runtime" role="tab" aria-selected="false">高级</button>' +
pageAiSettingsTabsHtml('reasonix-settings') +
'</div>' +
'</div>' +
'<div class="wolai-page-ai-settings-grid">' +
@@ -935,26 +1176,13 @@ export function createSidebarPageAiRenderRuntime(context) {
'</div>' +
'<div class="wolai-page-ai-memory-grid" data-page-ai-reasonix-panel></div>' +
'</section>' +
'<section class="wolai-page-ai-panel-section" data-page-ai-panel="chat-only-settings" hidden>' +
'<div class="wolai-page-ai-settings-head">' +
'<button type="button" class="wolai-page-ai-back" data-page-ai-tab="chat">← 聊天</button>' +
'<div class="wolai-page-ai-settings-tabs" role="tablist" aria-label="页面 AI 设置">' +
'<button type="button" class="wolai-page-ai-tab" data-page-ai-tab="agent" role="tab" aria-selected="false">Common</button>' +
'<button type="button" class="wolai-page-ai-tab" data-page-ai-tab="hermes-settings" role="tab" aria-selected="false">Hermes</button>' +
'<button type="button" class="wolai-page-ai-tab" data-page-ai-tab="reasonix-settings" role="tab" aria-selected="false">Reasonix</button>' +
'<button type="button" class="wolai-page-ai-tab is-active" data-page-ai-tab="chat-only-settings" role="tab" aria-selected="true">Chat-only</button>' +
'<button type="button" class="wolai-page-ai-tab" data-page-ai-tab="runtime" role="tab" aria-selected="false">高级</button>' +
'</div>' +
'</div>' +
'<div class="wolai-page-ai-memory-grid" data-page-ai-chat-only-panel></div>' +
'</section>' +
'<section class="wolai-page-ai-panel-section" data-page-ai-panel="skills" hidden>' +
'<div class="wolai-page-ai-settings-head">' +
'<button type="button" class="wolai-page-ai-back" data-page-ai-tab="chat">← 聊天</button>' +
'<div class="wolai-page-ai-settings-tabs" role="tablist" aria-label="页面 AI 设置">' +
'<button type="button" class="wolai-page-ai-tab" data-page-ai-tab="agent" role="tab" aria-selected="false">Agent</button>' +
'<button type="button" class="wolai-page-ai-tab" data-page-ai-tab="agent" role="tab" aria-selected="false">设置</button>' +
'<button type="button" class="wolai-page-ai-tab is-active" data-page-ai-tab="skills" role="tab" aria-selected="true">能力</button>' +
'<button type="button" class="wolai-page-ai-tab" data-page-ai-tab="runtime" role="tab" aria-selected="false">Runtime</button>' +
'<button type="button" class="wolai-page-ai-tab" data-page-ai-tab="status" role="tab" aria-selected="false">状态</button>' +
'</div>' +
'</div>' +
'<div class="wolai-page-ai-skills-toolbar">' +
@@ -974,47 +1202,51 @@ export function createSidebarPageAiRenderRuntime(context) {
'<div class="wolai-page-ai-inline-error" data-page-ai-skill-error hidden></div>' +
'<div class="wolai-page-ai-skill-list" data-page-ai-skill-list></div>' +
'</section>' +
'<section class="wolai-page-ai-panel-section" data-page-ai-panel="runtime" hidden>' +
'<section class="wolai-page-ai-panel-section" data-page-ai-panel="status" hidden>' +
'<div class="wolai-page-ai-settings-head">' +
'<button type="button" class="wolai-page-ai-back" data-page-ai-tab="chat">← 聊天</button>' +
'<div class="wolai-page-ai-settings-tabs" role="tablist" aria-label="页面 AI 设置">' +
'<button type="button" class="wolai-page-ai-tab" data-page-ai-tab="agent" role="tab" aria-selected="false">Agent</button>' +
'<button type="button" class="wolai-page-ai-tab" data-page-ai-tab="skills" role="tab" aria-selected="false">能力</button>' +
'<button type="button" class="wolai-page-ai-tab is-active" data-page-ai-tab="runtime" role="tab" aria-selected="true">Runtime</button>' +
pageAiStatusTabsHtml() +
'</div>' +
'</div>' +
'<button type="button" class="wolai-page-ai-settings-link" data-page-ai-action="open-hermes-settings">打开 Hermes 设置</button>' +
'<label class="wolai-page-ai-context-select">' +
'<span>兼容上下文 scope</span>' +
'<select data-page-ai-context-scope>' +
'<option value="page">当前页</option>' +
'<option value="selection">当前选区</option>' +
'<option value="block">当前块</option>' +
'<option value="options">页面设置</option>' +
'</select>' +
'</label>' +
'<div class="wolai-page-ai-inline-error" data-page-ai-gateway-error hidden></div>' +
'<div class="wolai-page-ai-tools-panel">' +
'<div class="wolai-page-ai-tools-head">' +
'<span>gateway</span>' +
'<span data-page-ai-gateway-status>未检查</span>' +
'</div>' +
'<div class="wolai-page-ai-tool-list" data-page-ai-gateway-detail></div>' +
'<div class="wolai-page-ai-toolbar">' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-action="stop-run">Stop turn</button>' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-action="reset-runtime">Reset current runtime</button>' +
'</div>' +
'<div class="wolai-page-ai-tools-panel">' +
'<div class="wolai-page-ai-tools-head">' +
'<span>queue</span>' +
'<span>Current runtime</span>' +
'<span data-page-ai-runtime-badge>未加载</span>' +
'</div>' +
'<div class="wolai-page-ai-tool-list" data-page-ai-runtime-detail></div>' +
'</div>' +
'<div class="wolai-page-ai-tools-panel">' +
'<div class="wolai-page-ai-tools-head">' +
'<span>Queue</span>' +
'<span data-page-ai-queue-status>由 mnote-web 管理</span>' +
'</div>' +
'<div class="wolai-page-ai-tool-list" data-page-ai-queue-list></div>' +
'</div>' +
'<div class="wolai-page-ai-tools-panel" hidden>' +
'<div class="wolai-page-ai-tools-panel">' +
'<div class="wolai-page-ai-tools-head">' +
'<span>MNote 工具调试</span>' +
'<span data-page-ai-last-tool>暂无 tool call</span>' +
'<span>Jobs</span>' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-action="stop-all-jobs">Stop all</button>' +
'</div>' +
'<div class="wolai-page-ai-inline-error" data-page-ai-tool-error hidden></div>' +
'<div class="wolai-page-ai-tool-list" data-page-ai-tool-list></div>' +
'<div class="wolai-page-ai-tool-list" data-page-ai-jobs-list></div>' +
'</div>' +
'<div class="wolai-page-ai-tools-panel">' +
'<div class="wolai-page-ai-tools-head">' +
'<span>Logs tail</span>' +
'<span>ACP stderr / tool failed / MCP failed</span>' +
'</div>' +
'<div class="wolai-page-ai-tool-list" data-page-ai-logs-list></div>' +
'</div>' +
'<div class="wolai-page-ai-tools-panel">' +
'<div class="wolai-page-ai-tools-head">' +
'<span>Usage</span>' +
'<span>last run / session / cache</span>' +
'</div>' +
'<div class="wolai-page-ai-tool-list" data-page-ai-usage-detail></div>' +
'</div>' +
'</section>' +
'<section class="wolai-page-ai-panel-section" data-page-ai-panel="history" hidden>' +
@@ -1034,6 +1266,10 @@ export function createSidebarPageAiRenderRuntime(context) {
'<span>按 agent 筛选</span>' +
'<select data-page-ai-session-agent-filter><option value="all">全部 agent</option></select>' +
'</label>' +
'<label class="wolai-page-ai-skill-search">' +
'<span>按状态筛选</span>' +
'<select data-page-ai-session-status-filter><option value="all">全部状态</option></select>' +
'</label>' +
'<div class="wolai-page-ai-inline-error" data-page-ai-session-error hidden></div>' +
'<div class="wolai-page-ai-conversation" data-page-ai-conversation></div>' +
'</section>' +
@@ -1041,6 +1277,7 @@ export function createSidebarPageAiRenderRuntime(context) {
'<div class="wolai-page-ai-footer">' +
'<div class="wolai-page-ai-composer">' +
'<textarea class="wolai-page-ai-input" data-page-ai-input rows="3" placeholder="输入消息…"></textarea>' +
'<div class="wolai-page-ai-reasonix-controls" data-page-ai-reasonix-quick-controls hidden></div>' +
'<div class="wolai-page-ai-composer-bar">' +
'<button type="button" class="wolai-page-ai-tool-button" data-page-ai-action="new-session" title="新会话"></button>' +
'<div class="wolai-page-ai-agent-picker">' +
@@ -1209,6 +1446,98 @@ export function createSidebarPageAiRenderRuntime(context) {
'</div>';
}
function pageAiLooksLikeReasonixPlanMessage(item) {
if (!item || item.role !== 'assistant') return false;
var text = String(item.content || '').trim();
if (!text || text.length < 40) return false;
if (/ACP connection closed|AI 当前请求失败|run failed/i.test(text)) return false;
var hasPlanWord = /计划|步骤|执行|修改|写入|新建|验证|plan/i.test(text);
var awaitsApproval = /请批准|待批准|批准后|确认后|审批|批准,|开始执行|approve/i.test(text);
var hasStepShape = /(^|\n)\s*(?:\d+[\.\、)]|[-*]\s+|\|\s*\d+\s*\|)/.test(text);
return hasPlanWord && awaitsApproval && hasStepShape;
}
function pageAiExtractReasonixPlanSteps(text) {
var seen = {};
var steps = [];
String(text || '').replace(/\r\n/g, '\n').split('\n').forEach(function(line) {
var trimmed = line.trim();
if (!trimmed) return;
var step = '';
var tableCells = trimmed.indexOf('|') >= 0
? trimmed.split('|').map(function(cell) { return cell.trim(); }).filter(Boolean)
: [];
if (tableCells.length >= 2 && /^\d+$/.test(tableCells[0])) {
step = tableCells.slice(1).join(' · ');
} else {
var numbered = trimmed.match(/^\s*\d+[\.\、)]\s+(.+)$/);
var bullet = trimmed.match(/^\s*[-*]\s+(?:\[[ xX~>|-]\]\s*)?(.+)$/);
step = numbered ? numbered[1] : (bullet ? bullet[1] : '');
}
step = String(step || '').replace(/\s+/g, ' ').trim();
if (!step || step.length < 3 || /^[-:|]+$/.test(step)) return;
if (seen[step]) return;
seen[step] = true;
var lower = step.toLowerCase();
var status = /✅|已完成|done|completed/.test(step) ? 'completed'
: (/当前|进行中|执行中|待批准|in progress|running/.test(lower) ? 'inProgress' : 'pending');
steps.push({ text: step, status: status });
});
return steps.slice(0, 8);
}
function pageAiPlanStepIcon(status) {
if (status === 'completed') return '✓';
if (status === 'inProgress') return '…';
return String.fromCharCode(8226);
}
function pageAiRenderReasonixPlanCard(item) {
var text = String(item.content || '').trim();
var steps = pageAiExtractReasonixPlanSteps(text);
var stepHtml = steps.length ? (
'<ol class="wolai-page-ai-plan-step-list">' + steps.map(function(step) {
return '' +
'<li class="wolai-page-ai-plan-step-item" data-status="' + escapeHtml(step.status || 'pending') + '">' +
'<span class="wolai-page-ai-plan-step-status" data-status="' + escapeHtml(step.status || 'pending') + '">' + escapeHtml(pageAiPlanStepIcon(step.status)) + '</span>' +
'<span class="wolai-page-ai-plan-step-text">' + escapeHtml(step.text || '') + '</span>' +
'</li>';
}).join('') + '</ol>'
) : '';
return '' +
'<div class="wolai-page-ai-message wolai-page-ai-message--assistant wolai-page-ai-plan-card" data-page-ai-reasonix-plan-card="true">' +
'<div class="wolai-page-ai-plan-card-header">' +
'<div class="wolai-page-ai-plan-card-title-wrap">' +
'<span class="wolai-page-ai-plan-card-kicker">Reasonix</span>' +
'<strong class="wolai-page-ai-plan-card-title">执行计划</strong>' +
'</div>' +
'<span class="wolai-page-ai-plan-card-badge">待批准</span>' +
'</div>' +
'<div class="wolai-page-ai-plan-card-markdown">' + renderPageAiMarkdown(text) + '</div>' +
stepHtml +
'<div class="wolai-page-ai-plan-card-actions">' +
'<button type="button" class="wolai-page-ai-plan-card-button wolai-page-ai-plan-card-button--primary" data-page-ai-plan-action="approve">批准执行</button>' +
'<button type="button" class="wolai-page-ai-plan-card-button" data-page-ai-plan-action="revise">要求修改</button>' +
'<button type="button" class="wolai-page-ai-plan-card-button wolai-page-ai-plan-card-button--danger" data-page-ai-plan-action="cancel">取消</button>' +
'</div>' +
'</div>';
}
function pageAiRenderPermissionDetails(item) {
var details = item && item.permissionDetails && typeof item.permissionDetails === 'object'
? item.permissionDetails
: {};
var rows = Array.isArray(details.rows) ? details.rows : [];
if (!rows.length) {
rows = [
{ label: '请求', value: item.argsSummary || item.content || '' }
];
}
return '<div class="wolai-page-ai-permission-summary">' + rows.slice(0, 6).map(function(row) {
return '<div class="wolai-page-ai-permission-row"><span>' + escapeHtml(row.label || '') + '</span><strong>' + escapeHtml(row.value || '') + '</strong></div>';
}).join('') + '</div>';
}
function pageAiRenderNonToolMessage(item) {
var roleLabel = item.role === 'user' ? '你' : (item.role === 'tool' ? '工具' : 'AI');
if (item.kind === 'thought') {
@@ -1224,18 +1553,34 @@ export function createSidebarPageAiRenderRuntime(context) {
'</div>';
}
if (item.kind === 'permission') {
var permissionActions = item.resolved ? '' : (
var permissionOptions = Array.isArray(item.options) ? item.options : [];
var permissionActions = '';
if (!item.resolved) {
if (permissionOptions.length) {
permissionActions = '<div class="wolai-page-ai-message-actions">' + permissionOptions.map(function(option) {
var optionText = String(option.optionId || '') + ' ' + String(option.kind || '') + ' ' + String(option.name || '');
var denyLike = /reject|deny|cancel|stop|no/.test(optionText.toLowerCase());
return '<button type="button" class="wolai-page-ai-ghost' + (denyLike ? ' wolai-page-ai-ghost--danger' : '') + '" data-page-ai-permission-action="' + (denyLike ? 'deny' : 'allow') + '" data-page-ai-permission-option-id="' + escapeHtml(option.optionId || '') + '" data-page-ai-permission-id="' + escapeHtml(item.permissionId || '') + '">' + escapeHtml(option.name || option.optionId || '选择') + '</button>';
}).join('') + '</div>';
} else {
permissionActions = '' +
'<div class="wolai-page-ai-message-actions">' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-permission-action="allow" data-page-ai-permission-id="' + escapeHtml(item.permissionId || '') + '">允许</button>' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-permission-action="deny" data-page-ai-permission-id="' + escapeHtml(item.permissionId || '') + '">拒绝</button>' +
'</div>'
);
'</div>';
}
}
return '' +
'<div class="wolai-page-ai-message wolai-page-ai-message--tool" data-page-ai-permission-request="' + escapeHtml(item.permissionId || '') + '">' +
'<div class="wolai-page-ai-message-role">权限</div>' +
'<div class="wolai-page-ai-message wolai-page-ai-message--tool wolai-page-ai-permission-card" data-page-ai-permission-request="' + escapeHtml(item.permissionId || '') + '">' +
'<div class="wolai-page-ai-permission-card-header">' +
'<div class="wolai-page-ai-plan-card-title-wrap">' +
'<span class="wolai-page-ai-plan-card-kicker">权限</span>' +
'<strong class="wolai-page-ai-plan-card-title">' + escapeHtml(item.toolName || 'session/request_permission') + '</strong>' +
'</div>' +
'<span class="wolai-page-ai-permission-badge">' + escapeHtml(item.resolved ? (item.decision === 'allow' || item.decision === 'allowed' ? '已允许' : '已处理') : '待确认') + '</span>' +
'</div>' +
'<div class="wolai-page-ai-message-text">' +
'<strong>' + escapeHtml(item.toolName || 'session/request_permission') + '</strong><br />' +
'<span>' + escapeHtml(item.argsSummary || item.content || '') + '</span>' +
pageAiRenderPermissionDetails(item) +
permissionActions +
'</div>' +
'</div>';
@@ -1255,6 +1600,7 @@ export function createSidebarPageAiRenderRuntime(context) {
'</details>' +
'</div>';
}
if (pageAiLooksLikeReasonixPlanMessage(item)) return pageAiRenderReasonixPlanCard(item);
var streamingAttr = item.streaming ? ' data-page-ai-streaming="true"' : '';
return '' +
'<div class="wolai-page-ai-message wolai-page-ai-message--' + escapeHtml(item.role || 'assistant') + '"' + streamingAttr + '>' +
@@ -1345,9 +1691,10 @@ export function createSidebarPageAiRenderRuntime(context) {
var selected = pageAiSessionSelected(session.id);
var usage = pageAiUsageSummary(session.usage);
var agentLabel = pageAiSessionAgentLabel(session);
var meta = [agentLabel, pageAiSessionStorageLabel(session), session.permissionLevel, session.shareId, session.status, usage].filter(Boolean).join(' · ');
var statusValue = pageAiSessionStatusFilterValue(session);
var meta = [agentLabel, pageAiSessionStorageLabel(session), session.runtimeMode, session.permissionLevel, session.shareId, pageAiSessionStatusLabel(statusValue), usage].filter(Boolean).join(' · ');
return '' +
'<div class="wolai-page-ai-message wolai-page-ai-message--history' + (active ? ' is-active' : '') + (selected ? ' is-selected' : '') + '" data-page-ai-session-row="' + escapeHtml(session.id) + '" data-page-ai-session-agent="' + escapeHtml(pageAiSessionAgentFilterValue(session)) + '">' +
'<div class="wolai-page-ai-message wolai-page-ai-message--history' + (active ? ' is-active' : '') + (selected ? ' is-selected' : '') + '" data-page-ai-session-row="' + escapeHtml(session.id) + '" data-page-ai-session-agent="' + escapeHtml(pageAiSessionAgentFilterValue(session)) + '" data-page-ai-session-status="' + escapeHtml(statusValue) + '">' +
'<button type="button" class="wolai-page-ai-session-check" data-page-ai-session-select="' + escapeHtml(session.id) + '" aria-checked="' + (selected ? 'true' : 'false') + '" aria-label="选择会话 ' + escapeHtml(session.title || session.id) + '">' +
'<span>' + (selected ? '✓' : '') + '</span>' +
'</button>' +
@@ -140,6 +140,7 @@ export function createSidebarPageAiRuntime(context) {
pageAiAcpRuntimes: [],
pageAiQueueLength: 0,
pageAiQueuedItems: [],
pageAiClientQueuedPrompts: [],
pageAiStoppedRunIds: {},
pageAiAbortController: null,
pageAiContextScope: 'page',
@@ -184,8 +185,11 @@ export function createSidebarPageAiRuntime(context) {
pageAiSessionSearchResults: [],
pageAiSessionSearchTimer: 0,
pageAiSessionAgentFilter: 'all',
pageAiSessionStatusFilter: 'all',
pageAiSelectedSessionIds: {},
pageAiSessionError: '',
pageAiRuntimeStatus: null,
pageAiRuntimeStatusError: '',
pageAiPermissionRequests: []
};
Object.keys(defaults).forEach(function(key) {
@@ -205,6 +209,9 @@ export function createSidebarPageAiRuntime(context) {
if (!state.pageAiSkills || typeof state.pageAiSkills !== 'object') {
state.pageAiSkills = clonePageAiDefaultValue(defaults.pageAiSkills);
}
if (!Array.isArray(state.pageAiClientQueuedPrompts)) {
state.pageAiClientQueuedPrompts = [];
}
return state;
}
@@ -435,6 +442,9 @@ export function createSidebarPageAiRuntime(context) {
const pageAiSessionAgentLabel = (...args) => pageAiProfileRuntime.pageAiSessionAgentLabel(...args);
const pageAiSessionPreviewText = (...args) => pageAiProfileRuntime.pageAiSessionPreviewText(...args);
const pageAiSessionAgentFilterOptions = (...args) => pageAiProfileRuntime.pageAiSessionAgentFilterOptions(...args);
const pageAiSessionStatusFilterValue = (...args) => pageAiProfileRuntime.pageAiSessionStatusFilterValue(...args);
const pageAiSessionStatusLabel = (...args) => pageAiProfileRuntime.pageAiSessionStatusLabel(...args);
const pageAiSessionStatusFilterOptions = (...args) => pageAiProfileRuntime.pageAiSessionStatusFilterOptions(...args);
const pageAiFilteredHistoryRows = (...args) => pageAiProfileRuntime.pageAiFilteredHistoryRows(...args);
const pageAiTimestamp = (...args) => pageAiProfileRuntime.pageAiTimestamp(...args);
const pageAiUsageSummary = (...args) => pageAiProfileRuntime.pageAiUsageSummary(...args);
@@ -447,6 +457,7 @@ export function createSidebarPageAiRuntime(context) {
const pageAiPermissionMessage = (...args) => pageAiPermissionRuntime.pageAiPermissionMessage(...args);
const pageAiApplyPermissionEvent = (...args) => pageAiPermissionRuntime.pageAiApplyPermissionEvent(...args);
const pageAiResolvePermission = (...args) => pageAiPermissionRuntime.pageAiResolvePermission(...args);
const pageAiRestorePermissionRequests = (...args) => pageAiPermissionRuntime.pageAiRestorePermissionRequests(...args);
const pageAiHidePermissionDialog = (...args) => pageAiPermissionRuntime.pageAiHidePermissionDialog(...args);
const pageAiShowPermissionDialog = (...args) => pageAiPermissionRuntime.pageAiShowPermissionDialog(...args);
const pageAiSessionRuntime = createSidebarPageAiSessionRuntime({
@@ -638,6 +649,9 @@ export function createSidebarPageAiRuntime(context) {
pageAiSessionAgentFilterValue,
pageAiSessionAgentLabel,
pageAiSessionPreviewText,
pageAiSessionStatusFilterOptions,
pageAiSessionStatusFilterValue,
pageAiSessionStatusLabel,
pageAiSessionStorageLabel,
pageAiSessionSelected,
pageAiSelectedSessionCount,
@@ -806,6 +820,28 @@ export function createSidebarPageAiRuntime(context) {
renderPageAiControls();
}
function pageAiReasonixPreference(key, fallback) {
var preferences = pageAiDescriptorPreferenceValues();
var value = preferences[key];
if (value == null || String(value).trim() === '') return fallback;
return String(value).trim();
}
function pageAiReasonixControlState() {
return {
modelId: pageAiReasonixPreference('ai.agent.reasonix.model_id', 'deepseek-flash'),
approvalMode: pageAiReasonixPreference('ai.agent.reasonix.approval_mode', 'ask'),
planMode: pageAiReasonixPreference('ai.agent.reasonix.plan_mode', 'manual')
};
}
function pageAiApplyReasonixPlanMode(prompt) {
if (pageAiCurrentAgentId() !== 'reasonix') return prompt;
var control = pageAiReasonixControlState();
if (control.planMode !== 'auto') return prompt;
return '请按 Reasonix 自动计划模式处理:复杂或会写文件的任务先给出简短计划,并等待我批准后再执行。\n\n' + prompt;
}
function pageAiApplyAgentDescriptorsPayload(payload) {
var descriptors = pageAiNormalizeArray(payload && payload.descriptors)
.map(pageAiNormalizeAgentDescriptor)
@@ -1316,6 +1352,68 @@ export function createSidebarPageAiRuntime(context) {
}
}
function pageAiApplyRuntimeStatus(payload) {
if (!payload || typeof payload !== 'object') return null;
pageUiState.pageAiRuntimeStatus = payload;
pageUiState.pageAiRuntimeStatusError = '';
var activeRun = payload.activeRun && typeof payload.activeRun === 'object' ? payload.activeRun : null;
if (activeRun && activeRun.hostRunId) {
pageAiSetRunStatus(String(activeRun.status || 'running'), String(activeRun.hostRunId || ''));
} else if (pageAiRunStatusIsTerminal(pageUiState.pageAiRunStatus)) {
pageAiUpdateActiveRunSnapshotFromStatus(pageUiState.pageAiRunStatus, pageUiState.pageAiCurrentRunId);
} else if (!activeRun && ['running', 'tool_calling', 'queued', 'pending', 'acp_pending'].indexOf(pageUiState.pageAiRunStatus) >= 0) {
pageAiSetRunStatus('idle', pageUiState.pageAiCurrentRunId);
}
var queue = payload.session && payload.session.queue && typeof payload.session.queue === 'object' ? payload.session.queue : null;
if (queue) {
var queuedCount = Number(queue.queuedCount || 0);
pageUiState.pageAiQueueLength = Number.isFinite(queuedCount) ? Math.max(0, queuedCount) : 0;
if (Array.isArray(queue.items)) {
pageUiState.pageAiQueuedItems = queue.items.map(function(item) {
return {
queueId: String(item.queueId || ''),
sessionId: String(item.sessionId || payload.mnoteSessionId || ''),
traceId: String(item.traceId || ''),
preview: String(item.preview || ''),
queuedAt: Number(item.queuedAt || Date.now())
};
}).filter(function(item) { return item.queueId; });
}
}
var acpSessionId = payload.acp && typeof payload.acp === 'object' ? String(payload.acp.acpSessionId || '') : '';
var current = pageAiCurrentSession();
if (current && acpSessionId) {
current.acpSessionId = acpSessionId;
current.runtimeMode = String(payload.mode || '');
current.updatedAt = Date.now();
pageAiPersistSessions();
}
document.documentElement.setAttribute('data-mnote-page-ai-runtime-status-loaded', 'true');
document.documentElement.setAttribute('data-mnote-page-ai-runtime', String(payload.runtime || ''));
document.documentElement.setAttribute('data-mnote-page-ai-runtime-mode', String(payload.mode || ''));
return payload;
}
async function pageAiLoadRuntimeStatus() {
var params = new URLSearchParams();
var sessionId = String(pageUiState.pageAiActiveSessionId || '').trim();
if (sessionId) params.set('sessionId', sessionId);
var workspaceId = String(resolveWorkspaceId(document.body) || '').trim();
if (workspaceId) params.set('workspaceId', workspaceId);
var documentId = String(currentDocumentId() || '').trim();
if (documentId) params.set('documentId', documentId);
var profile = String(pageAiCurrentProfile() || '').trim();
if (profile) params.set('profile', profile);
var response = await fetch('/api/page-ai/runtime/status?' + params.toString(), {
headers: { 'accept': 'application/json' }
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(pageAiErrorMessage(payload, 'page_ai_runtime_status_failed_' + response.status));
}
return pageAiApplyRuntimeStatus(payload);
}
function pageAiActiveRunSnapshotKey() {
return pageAiStorageKey() + ':active-run';
}
@@ -1409,6 +1507,60 @@ export function createSidebarPageAiRuntime(context) {
return true;
}
function pageAiRefreshClientQueueState() {
var clientItems = pageAiNormalizeArray(pageUiState.pageAiClientQueuedPrompts);
var serverItems = pageAiNormalizeArray(pageUiState.pageAiQueuedItems)
.filter(function(item) { return String(item.queueId || '').indexOf('client_') !== 0; });
pageUiState.pageAiQueuedItems = serverItems.concat(clientItems.map(function(item) {
return {
queueId: item.queueId,
sessionId: item.sessionId || pageUiState.pageAiActiveSessionId || '',
traceId: item.traceId || '',
preview: item.preview || item.prompt || '',
queuedAt: item.queuedAt || Date.now()
};
}));
pageUiState.pageAiQueueLength = pageUiState.pageAiQueuedItems.length;
}
function pageAiEnqueueClientPrompt(prompt) {
var queueId = 'client_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8);
pageUiState.pageAiClientQueuedPrompts.push({
queueId: queueId,
prompt: prompt,
preview: String(prompt || '').slice(0, 120),
sessionId: pageUiState.pageAiActiveSessionId || '',
queuedAt: Date.now()
});
pageAiRefreshClientQueueState();
pageAiSetRunStatus('queued', pageUiState.pageAiCurrentRunId);
return queueId;
}
function pageAiRemoveClientQueuedPrompt(queueId) {
var before = pageUiState.pageAiClientQueuedPrompts.length;
pageUiState.pageAiClientQueuedPrompts = pageUiState.pageAiClientQueuedPrompts.filter(function(item) {
return item.queueId !== queueId;
});
pageAiRefreshClientQueueState();
return before !== pageUiState.pageAiClientQueuedPrompts.length;
}
function pageAiStartNextClientQueuedPrompt() {
if (pageUiState.pageAiBusy) return false;
if (['running', 'tool_calling', 'pending', 'acp_pending'].indexOf(pageUiState.pageAiRunStatus) >= 0) return false;
var next = pageUiState.pageAiClientQueuedPrompts.shift();
if (!next) {
pageAiRefreshClientQueueState();
return false;
}
pageAiRefreshClientQueueState();
window.setTimeout(function() {
void sendPageAiMessage(next.prompt);
}, 0);
return true;
}
function pageAiUnwrapSearchCitationUrl(url) {
try {
var raw = '';
@@ -1668,13 +1820,21 @@ export function createSidebarPageAiRuntime(context) {
async function pageAiCancelQueuedRun(queueId) {
queueId = String(queueId || '').trim();
if (!queueId) return;
if (queueId.indexOf('client_') === 0) {
if (pageAiRemoveClientQueuedPrompt(queueId)) {
pageUiState.pageAiMessages.push({ role: 'assistant', content: '已取消一条 AI 队列项。' });
}
renderPageAiConversation();
renderPageAiControls();
return;
}
var item = pageUiState.pageAiQueuedItems.find(function(entry) {
return entry.queueId === queueId;
});
var sessionId = String(item && item.sessionId || pageUiState.pageAiActiveSessionId || '').trim();
if (!sessionId) return;
try {
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(sessionId) + '/queue/' + encodeURIComponent(queueId), {
var response = await fetch('/api/page-ai/sessions/' + encodeURIComponent(sessionId) + '/queue/' + encodeURIComponent(queueId), {
method: 'DELETE',
headers: { 'accept': 'application/json' }
});
@@ -1696,6 +1856,40 @@ export function createSidebarPageAiRuntime(context) {
renderPageAiControls();
}
async function pageAiResetRuntime() {
var sessionId = String(pageUiState.pageAiActiveSessionId || '').trim();
if (!sessionId) return;
try {
var response = await fetch('/api/page-ai/runtime/reset', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
sessionId: sessionId,
runtime: pageUiState.pageAiAcpRuntime || '',
profile: pageAiCurrentProfile()
})
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(pageAiErrorMessage(payload, 'runtime_reset_failed_' + response.status));
}
pageUiState.pageAiRuntimeStatus = null;
pageUiState.pageAiRuntimeStatusError = '';
pageUiState.pageAiMessages.push({
role: 'assistant',
content: '已重置 runtimeMNote 会话历史保留,外部 provider 会话未删除。'
});
await pageAiLoadRuntimeStatus().catch(function(error) {
pageUiState.pageAiRuntimeStatusError = error instanceof Error ? error.message : String(error);
return null;
});
} catch (error) {
pageUiState.pageAiRuntimeStatusError = error instanceof Error ? error.message : String(error);
}
renderPageAiConversation();
renderPageAiControls();
}
function pageAiSetContextScope(scope) {
var next = String(scope || '').trim() || 'page';
pageUiState.pageAiContextScope = next;
@@ -2141,6 +2335,7 @@ export function createSidebarPageAiRuntime(context) {
pageUiState.pageAiAgentId = pageAiCurrentAgentId();
pageAiEnsureContextRefState();
pageAiLoadSessions();
pageAiRestorePermissionRequests();
renderPageAiSuggestions();
renderPageAiConversation();
renderPageAiProviderButtons();
@@ -2153,6 +2348,12 @@ export function createSidebarPageAiRuntime(context) {
void pageAiCheckAndResumeActiveRun().catch(function(error) {
pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error);
renderPageAiControls();
}).then(function() {
return pageAiLoadRuntimeStatus().catch(function(error) {
pageUiState.pageAiRuntimeStatusError = error instanceof Error ? error.message : String(error);
renderPageAiControls();
return null;
});
});
void pageAiLoadBackendSessions().then(function() {
return pageAiCheckAndResumeActiveRun();
@@ -2169,7 +2370,8 @@ export function createSidebarPageAiRuntime(context) {
pageAiLoadGatewayHealth(),
pageAiLoadAiPreferences(),
pageAiLoadAllowedRoots(),
pageAiLoadBackendSessions()
pageAiLoadBackendSessions(),
pageAiLoadRuntimeStatus()
]).then(function() {
renderPageAiControls();
}).then(function() {
@@ -2803,10 +3005,27 @@ export function createSidebarPageAiRuntime(context) {
}
async function sendPageAiMessage(text) {
var allowQueue = ['running', 'tool_calling'].indexOf(pageUiState.pageAiRunStatus) >= 0;
var allowQueue = pageUiState.pageAiBusy
|| ['queued', 'running', 'tool_calling', 'pending', 'acp_pending'].indexOf(pageUiState.pageAiRunStatus) >= 0;
if (pageUiState.pageAiBusy && !allowQueue) return;
var prompt = searchText(text);
if (!prompt) return;
if (allowQueue) {
pageAiEnqueueClientPrompt(prompt);
pageUiState.pageAiMessages.push({
role: 'assistant',
content: '已加入 AI 队列,前一条 run 完成后继续处理。'
});
var queuedSession = pageAiCurrentSession();
if (queuedSession) {
queuedSession.messages = pageUiState.pageAiMessages.slice();
queuedSession.updatedAt = Date.now();
}
pageAiPersistSessions();
renderPageAiConversation();
renderPageAiControls();
return;
}
if (!allowQueue) pageUiState.pageAiBusy = true;
var currentSession = null;
try {
@@ -2846,6 +3065,8 @@ export function createSidebarPageAiRuntime(context) {
if (!allowQueue && await pageAiTryBlockEditWorkflow(prompt, scopedContext, runTargetSnapshot)) {
return;
}
var reasonixSettings = pageAiCurrentAgentId() === 'reasonix' ? pageAiReasonixControlState() : null;
var promptForRun = pageAiApplyReasonixPlanMode(prompt);
var response = await fetch('/api/hermes/client/runs', {
method: 'POST',
headers: { 'content-type': 'application/json' },
@@ -2863,13 +3084,14 @@ export function createSidebarPageAiRuntime(context) {
contextScope: pageUiState.pageAiContextScope,
contextRefs: contextRefs,
allowedRoots: allowedRoots,
reasonixSettings: reasonixSettings,
skillPreferences: {
mnote: pageAiSkillPreferenceTable('mnote', ''),
reasonix: pageAiSkillPreferenceTable('reasonix', ''),
hermes: pageAiSkillPreferenceTable('hermes', runProfile)
},
message: prompt,
model: pageAiMnoteToolModel(),
message: promptForRun,
model: reasonixSettings && reasonixSettings.modelId ? reasonixSettings.modelId : pageAiMnoteToolModel(),
pageContext: requestPageContext,
editorTarget: scopedContext.editorTarget,
runTargetSnapshot: runTargetSnapshot,
@@ -2896,6 +3118,10 @@ export function createSidebarPageAiRuntime(context) {
pageAiPersistSessions();
renderPageAiConversation();
renderPageAiControls();
void pageAiLoadRuntimeStatus().catch(function(error) {
pageUiState.pageAiRuntimeStatusError = error instanceof Error ? error.message : String(error);
renderPageAiControls();
});
return;
}
if (allowQueue) {
@@ -3114,8 +3340,13 @@ export function createSidebarPageAiRuntime(context) {
pageAiPersistSessions();
} finally {
if (!allowQueue) pageUiState.pageAiBusy = false;
await pageAiLoadRuntimeStatus().catch(function(error) {
pageUiState.pageAiRuntimeStatusError = error instanceof Error ? error.message : String(error);
return null;
});
renderPageAiConversation();
renderPageAiControls();
pageAiStartNextClientQueuedPrompt();
}
}
@@ -3148,6 +3379,20 @@ export function createSidebarPageAiRuntime(context) {
void pageAiStopRun();
return true;
}
var pageAiReset = closestAction(event.target, '[data-page-ai-action="reset-runtime"]');
if (pageAiReset) {
event.preventDefault();
void pageAiResetRuntime();
return true;
}
var pageAiStopAllJobs = closestAction(event.target, '[data-page-ai-action="stop-all-jobs"]');
if (pageAiStopAllJobs) {
event.preventDefault();
pageUiState.pageAiMessages.push({ role: 'assistant', content: '当前没有可停止的后台 job;长任务 job 将在 runtime status 中独立显示。' });
renderPageAiConversation();
renderPageAiControls();
return true;
}
var pageAiRotate = closestAction(event.target, '[data-page-ai-action="rotate"]');
if (pageAiRotate) {
event.preventDefault();
@@ -3167,7 +3412,15 @@ export function createSidebarPageAiRuntime(context) {
if (pageAiTab) {
event.preventDefault();
pageUiState.pageAiPage = pageAiTab.getAttribute('data-page-ai-tab') || 'chat';
if (pageUiState.pageAiPage === 'runtime') void pageAiLoadGatewayHealth();
if (pageUiState.pageAiPage === 'runtime') pageUiState.pageAiPage = 'status';
if (pageUiState.pageAiPage === 'chat-only-settings') pageUiState.pageAiPage = 'agent';
if (pageUiState.pageAiPage === 'status') {
void pageAiLoadGatewayHealth();
void pageAiLoadRuntimeStatus().catch(function(error) {
pageUiState.pageAiRuntimeStatusError = error instanceof Error ? error.message : String(error);
renderPageAiControls();
});
}
if (pageUiState.pageAiPage === 'skills') void pageAiLoadSkills();
renderPageAiControls();
renderPageAiConversation();
@@ -3351,7 +3604,30 @@ export function createSidebarPageAiRuntime(context) {
var pageAiPermissionAction = closestAction(event.target, '[data-page-ai-permission-action]');
if (pageAiPermissionAction) {
event.preventDefault();
pageAiResolvePermission(pageAiPermissionAction.getAttribute('data-page-ai-permission-id') || '', pageAiPermissionAction.getAttribute('data-page-ai-permission-action') || 'deny');
pageAiResolvePermission(
pageAiPermissionAction.getAttribute('data-page-ai-permission-id') || '',
pageAiPermissionAction.getAttribute('data-page-ai-permission-action') || 'deny',
pageAiPermissionAction.getAttribute('data-page-ai-permission-option-id') || ''
);
return true;
}
var pageAiPlanAction = closestAction(event.target, '[data-page-ai-plan-action]');
if (pageAiPlanAction) {
event.preventDefault();
var action = String(pageAiPlanAction.getAttribute('data-page-ai-plan-action') || '').trim();
var inputNode = ensurePageAiDrawer().querySelector('[data-page-ai-input]');
if (action === 'approve') {
void sendPageAiMessage('批准,开始执行。');
return true;
}
if (action === 'cancel') {
void sendPageAiMessage('取消执行。');
return true;
}
if (inputNode instanceof HTMLTextAreaElement) {
inputNode.value = action === 'revise' ? '请修改计划:' : '';
inputNode.focus();
}
return true;
}
var pageAiOpenLocationAction = closestAction(event.target, '[data-page-ai-open-location]');
@@ -3474,6 +3750,13 @@ export function createSidebarPageAiRuntime(context) {
renderPageAiConversation();
return true;
}
var pageAiSessionStatusFilter = closestAction(event.target, '[data-page-ai-session-status-filter]');
if (pageAiSessionStatusFilter instanceof HTMLSelectElement) {
pageUiState.pageAiSessionStatusFilter = String(pageAiSessionStatusFilter.value || 'all').trim() || 'all';
renderPageAiControls();
renderPageAiConversation();
return true;
}
var pageAiHideHermesBuiltin = closestAction(event.target, '[data-page-ai-hide-hermes-builtin]');
if (pageAiHideHermesBuiltin instanceof HTMLInputElement) {
pageAiSetHideHermesBuiltinSkills(pageAiHideHermesBuiltin.checked);
@@ -98,12 +98,16 @@ export function createSidebarPageAiSessionRuntime(context) {
acpSessionId: String(session && (session.acpSessionId || session.acp_session_id) || '').trim(),
runId: String(session && (session.runId || session.run_id) || '').trim(),
status: String(session && session.status || '').trim(),
runtimeMode: String(session && (session.runtimeMode || session.runtime_mode) || '').trim(),
replaySeen: Boolean(session && session.replaySeen),
usage: session && session.usage && typeof session.usage === 'object' ? session.usage : null,
preview: String(session && session.preview || '').trim(),
messages: Array.isArray(session && session.messages) ? session.messages.slice(-300) : []
};
})
.sort(function(a, b) {
if (a.id === pageUiState.pageAiActiveSessionId && b.id !== pageUiState.pageAiActiveSessionId) return -1;
if (b.id === pageUiState.pageAiActiveSessionId && a.id !== pageUiState.pageAiActiveSessionId) return 1;
return Number(b.updatedAt || 0) - Number(a.updatedAt || 0);
});
}
@@ -117,7 +121,8 @@ export function createSidebarPageAiSessionRuntime(context) {
if (title.length > 28) title = title.slice(0, 28) + '…';
var persistence = String(row.persistence || payload.persistence || '').trim();
var sessionStorage = String(row.sessionStorage || row.session_storage || payload.sessionStorage || '').trim();
var status = String(row.status || (row.runtime && row.runtime.status) || '').trim();
var runtime = row.runtime && typeof row.runtime === 'object' ? row.runtime : {};
var status = String(row.status || runtime.status || '').trim();
var hasConversationSignal = String(payload.message || payload.input || row.snippet || '').trim()
|| pageAiNormalizeArray(row.messages).length > 0;
if (status === 'session.created' && !hasConversationSignal) return null;
@@ -148,6 +153,8 @@ export function createSidebarPageAiSessionRuntime(context) {
acpSessionId: String(row.acpSessionId || row.acp_session_id || payload.acpSessionId || payload.acp_session_id || '').trim(),
runId: String(row.runId || row.run_id || '').trim(),
status: status,
runtimeMode: String(row.runtimeMode || row.runtime_mode || runtime.mode || payload.reasonixSessionMode || '').trim(),
replaySeen: Boolean(row.replaySeen || row.replay_seen || runtime.replaySeen || payload.replay === true),
usage: row.usage && typeof row.usage === 'object' ? row.usage : null,
preview: String(payload.message || row.snippet || '').trim(),
messages: []
@@ -209,7 +216,7 @@ export function createSidebarPageAiSessionRuntime(context) {
}
async function pageAiLoadBackendSessions() {
var response = await fetch('/api/hermes/client/sessions?' + pageAiBackendSessionQuery({ limit: 50 }), {
var response = await fetch('/api/page-ai/sessions?' + pageAiBackendSessionQuery({ limit: 50 }), {
headers: { 'accept': 'application/json' }
});
var payload = await response.json().catch(function(){ return null; });
@@ -374,7 +381,7 @@ export function createSidebarPageAiSessionRuntime(context) {
async function pageAiLoadBackendSessionDetail(sessionId) {
sessionId = String(sessionId || pageUiState.pageAiActiveSessionId || '').trim();
if (!sessionId) return null;
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(sessionId) + '?' + pageAiBackendSessionQuery({ limit: 200 }), {
var response = await fetch('/api/page-ai/sessions/' + encodeURIComponent(sessionId) + '?' + pageAiBackendSessionQuery({ limit: 200 }), {
headers: { 'accept': 'application/json' }
});
var payload = await response.json().catch(function(){ return null; });
@@ -395,7 +402,7 @@ export function createSidebarPageAiSessionRuntime(context) {
renderPageAiConversation();
return [];
}
var response = await fetch('/api/hermes/client/sessions/search?' + pageAiBackendSessionQuery({ q: q, limit: 20 }), {
var response = await fetch('/api/page-ai/sessions/search?' + pageAiBackendSessionQuery({ q: q, limit: 20 }), {
headers: { 'accept': 'application/json' }
});
var payload = await response.json().catch(function(){ return null; });
@@ -414,7 +421,7 @@ export function createSidebarPageAiSessionRuntime(context) {
async function pageAiExportBackendSession(sessionId) {
sessionId = String(sessionId || pageUiState.pageAiActiveSessionId || '').trim();
if (!sessionId) return null;
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(sessionId) + '/export?' + pageAiBackendSessionQuery({ limit: 200 }), {
var response = await fetch('/api/page-ai/sessions/' + encodeURIComponent(sessionId) + '/export?' + pageAiBackendSessionQuery({ limit: 200 }), {
headers: { 'accept': 'application/json' }
});
var payload = await response.json().catch(function(){ return null; });
@@ -454,7 +461,7 @@ export function createSidebarPageAiSessionRuntime(context) {
var current = pageAiCurrentSession();
var runProfile = pageAiRunProfile();
if (!forceCreate && current && String(current.id || '').startsWith('mnote_') && current.profile === runProfile) return current;
var response = await fetch('/api/hermes/client/sessions', {
var response = await fetch('/api/page-ai/sessions', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
@@ -504,7 +511,7 @@ export function createSidebarPageAiSessionRuntime(context) {
async function pageAiRestoreHermesSession() {
var current = pageAiCurrentSession();
if (!current || !String(current.id || '').startsWith('mnote_')) return;
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(current.id) + '/resume?' + pageAiBackendSessionQuery({}), {
var response = await fetch('/api/page-ai/sessions/' + encodeURIComponent(current.id) + '/resume?' + pageAiBackendSessionQuery({}), {
method: 'POST',
headers: { 'accept': 'application/json' }
});
@@ -637,7 +644,7 @@ export function createSidebarPageAiSessionRuntime(context) {
if (title === null) return;
title = String(title || '').trim();
if (!title) return;
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(sessionId) + '/rename?' + pageAiBackendSessionQuery({}), {
var response = await fetch('/api/page-ai/sessions/' + encodeURIComponent(sessionId) + '/rename?' + pageAiBackendSessionQuery({}), {
method: 'POST',
headers: { 'content-type': 'application/json', 'accept': 'application/json' },
body: JSON.stringify({ title: title })
@@ -712,13 +719,13 @@ export function createSidebarPageAiSessionRuntime(context) {
var sessions = ids.map(pageAiFindSessionById).filter(Boolean);
if (!sessions.length) return;
var confirmText = sessions.length === 1
? '确定删除 AI 会话“' + (sessions[0].title || sessions[0].id) + '”吗?'
: '确定删除选中的 ' + String(sessions.length) + ' 个 AI 会话吗?';
? '确定删除 AI 会话“' + (sessions[0].title || sessions[0].id) + '”吗?仅删除 MNote 历史,不删除外部 Hermes/provider 会话。'
: '确定删除选中的 ' + String(sessions.length) + ' 个 AI 会话吗?仅删除 MNote 历史,不删除外部 Hermes/provider 会话。';
if (!win.confirm(confirmText)) return;
for (var index = 0; index < sessions.length; index += 1) {
var session = sessions[index];
if (!pageAiSessionRequiresBackendDelete(session)) continue;
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(session.id) + '?' + pageAiBackendSessionQuery({}), {
var response = await fetch('/api/page-ai/sessions/' + encodeURIComponent(session.id) + '?' + pageAiBackendSessionQuery({}), {
method: 'DELETE',
headers: { 'accept': 'application/json' }
});
@@ -745,7 +752,7 @@ export function createSidebarPageAiSessionRuntime(context) {
sessionId = String(sessionId || pageUiState.pageAiActiveSessionId || '').trim();
if (!sessionId) return;
pageAiSetActiveSession(sessionId);
var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(sessionId) + '/resume?' + pageAiBackendSessionQuery({}), {
var response = await fetch('/api/page-ai/sessions/' + encodeURIComponent(sessionId) + '/resume?' + pageAiBackendSessionQuery({}), {
method: 'POST',
headers: { 'accept': 'application/json' }
});
@@ -1684,11 +1684,12 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
}
var controller = typeof AbortController === 'function' ? new AbortController() : null;
var timer = 0;
var timeout = Math.max(1000, Number(timeoutMs) || 15000);
try {
if (controller) {
timer = window.setTimeout(function() {
controller.abort();
}, Math.max(1000, Number(timeoutMs) || 15000));
}, timeout);
}
var nextInit = Object.assign({}, init || {});
if (controller) nextInit.signal = controller.signal;
@@ -1703,6 +1704,16 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
}
}
function uploadTimeoutMsForFile(file, requestedMs) {
var runtimeFn = localUploadRuntimeFunction('uploadTimeoutMsForFile');
if (runtimeFn) return runtimeFn(file, requestedMs);
var requested = Number(requestedMs) || 0;
var size = Number(file && file.size || 0);
if (!Number.isFinite(size) || size <= 0) return Math.max(requested, 15000);
var sizeBased = 15000 + Math.ceil(size / (1024 * 1024)) * 12000;
return Math.min(10 * 60 * 1000, Math.max(requested, 60000, sizeBased));
}
async function insertUploadedAssetIntoEditor(asset, targetRoot) {
var runtimeFn = localUploadRuntimeFunction('insertUploadedAssetIntoEditor');
if (runtimeFn) {
@@ -1842,7 +1853,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
var localResult = await localUploadRuntime(file, plan, {
rootUri: rootUri,
documentId: documentId,
timeoutMs: 15000
timeoutMs: uploadTimeoutMsForFile(file, 15000)
});
localPayload = localResult && localResult.asset ? { asset: localResult.asset } : null;
} else {
@@ -1857,7 +1868,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
method: 'POST',
credentials: 'include',
body: localForm
}, 15000, '本地上传');
}, uploadTimeoutMsForFile(file, 15000), '本地上传');
localPayload = await localResponse.json().catch(function() { return null; });
if (!localResponse.ok || !localPayload || !localPayload.asset) {
throw new Error(localPayload && localPayload.error ? localPayload.error : '上传失败');
+19 -10
View File
@@ -219,7 +219,7 @@ impl AcpClient {
///
/// Returns `Result<R>` where `R` is the deserialized `result` field.
/// On JSON-RPC error, returns [`AcpError::JsonRpc`].
/// Default timeout: 300 seconds.
/// Default timeout: 300 seconds. Override with `MNOTE_ACP_REQUEST_TIMEOUT_SECS`.
///
/// Reference: `acpClient.ts` L95-110 (`call()` method)
pub async fn request<P: Serialize, R: DeserializeOwned>(
@@ -227,7 +227,12 @@ impl AcpClient {
method: &str,
params: P,
) -> Result<R, AcpError> {
self.request_with_timeout(method, params, Duration::from_secs(300))
let timeout_secs = std::env::var("MNOTE_ACP_REQUEST_TIMEOUT_SECS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.filter(|value| *value >= 30)
.unwrap_or(300);
self.request_with_timeout(method, params, Duration::from_secs(timeout_secs))
.await
}
@@ -252,10 +257,12 @@ impl AcpClient {
let line = serde_json::to_string(&req)?;
debug!("ACP --> {} #{} ({} bytes)", method, id, line.len());
let mut writer = self.writer.lock().await;
writer.write_all(line.as_bytes()).await?;
writer.write_all(b"\n").await?;
writer.flush().await?;
{
let mut writer = self.writer.lock().await;
writer.write_all(line.as_bytes()).await?;
writer.write_all(b"\n").await?;
writer.flush().await?;
}
match timeout(dur, rx).await {
Ok(Ok(Ok(value))) => {
@@ -280,10 +287,12 @@ impl AcpClient {
let line = serde_json::to_string(&msg)?;
debug!("ACP ~~> {} ({} bytes)", method, line.len());
let mut writer = self.writer.lock().await;
writer.write_all(line.as_bytes()).await?;
writer.write_all(b"\n").await?;
writer.flush().await?;
{
let mut writer = self.writer.lock().await;
writer.write_all(line.as_bytes()).await?;
writer.write_all(b"\n").await?;
writer.flush().await?;
}
Ok(())
}
+307 -15
View File
@@ -187,6 +187,33 @@ impl AcpSessionManager {
.unwrap_or("session/request_permission")
.to_string();
if acp_permission_tool_auto_allowed(&tool_name)
|| acp_permission_params_contain_auto_allowed_tool(&params)
{
let response = permission_response_for_decision(&params, "allow");
if let Some(Ok(result)) = response {
let handler = event_handler_for_incoming.lock().unwrap();
if let Some(ref h) = *handler {
h(AcpSessionEvent::PermissionRequest {
permission_id: permission_id.clone(),
tool_name: tool_name.clone(),
params: params.clone(),
decision: "allowed".into(),
});
}
drop(handler);
let client_for_allow = client_for_incoming.clone();
let allow_jsonrpc_id = id.clone();
tokio::spawn(async move {
let _ = client_for_allow
.respond_to_incoming(allow_jsonrpc_id, result)
.await;
});
return true;
}
}
// 记录到 pending map,等待前端 allow / deny。
{
let mut pending = pending_for_incoming.lock().unwrap();
@@ -336,7 +363,8 @@ impl AcpSessionManager {
pub async fn resolve_permission(
&self,
permission_id: &str,
decision: &str,
decision: Option<&str>,
option_id: Option<&str>,
) -> Result<(), String> {
let pending = {
let mut map = self.pending_permissions.lock().unwrap();
@@ -367,20 +395,46 @@ impl AcpSessionManager {
.unwrap_or("session/request_permission")
.to_string();
let normalized_decision = match decision {
"allow" | "allowed" => "allow",
"deny" | "denied" => "deny",
other => return Err(format!("unknown decision: {other} (expected allow/deny)")),
let selected_option_id = option_id
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let normalized_decision = if let Some(option_id) = selected_option_id.as_deref() {
if permission_option_is_reject_like(&pending.params, option_id) {
"deny"
} else {
"allow"
}
} else {
match decision.unwrap_or("").trim() {
"allow" | "allowed" => "allow",
"deny" | "denied" => "deny",
other => {
return Err(format!(
"unknown decision: {other} (expected allow/deny or optionId)"
))
}
}
};
let response = permission_response_for_decision(&pending.params, normalized_decision)
.unwrap_or_else(|| {
if normalized_decision == "allow" {
Err((-32000, "permission allow option not available".into()))
} else {
Err((-32000, "permission denied by user".into()))
}
});
let response = if let Some(option_id) = selected_option_id.as_deref() {
permission_response_for_option_id(&pending.params, option_id).unwrap_or_else(|| {
Err((
-32000,
format!("permission option not available: {option_id}"),
))
})
} else {
permission_response_for_decision(&pending.params, normalized_decision).unwrap_or_else(
|| {
if normalized_decision == "allow" {
Err((-32000, "permission allow option not available".into()))
} else {
Err((-32000, "permission denied by user".into()))
}
},
)
};
match response {
Ok(result) => {
@@ -867,10 +921,69 @@ fn permission_response_for_decision(
}
}
fn permission_response_for_option_id(
params: &Value,
option_id: &str,
) -> Option<Result<Value, (i64, String)>> {
let options = params.get("options").and_then(Value::as_array)?;
let selected = option_id.trim();
if selected.is_empty() {
return None;
}
let exists = options
.iter()
.filter_map(permission_option_id)
.any(|id| id == selected);
if !exists {
return None;
}
Some(Ok(json!({
"outcome": {
"outcome": "selected",
"optionId": selected
}
})))
}
fn acp_permission_tool_auto_allowed(tool_name: &str) -> bool {
ACP_AUTO_ALLOWED_PERMISSION_TOOLS.contains(&tool_name)
}
const ACP_AUTO_ALLOWED_PERMISSION_TOOLS: &[&str] = &[
"mcp__mnote_lightrag_bridge__mnote_knowledge_rag_status",
"mcp__mnote_lightrag_bridge__mnote_knowledge_rag_query",
"mcp__mnote_lightrag_bridge__mnote_knowledge_rag_section_context",
"mcp__mnote_lightrag_bridge__mnote_knowledge_rag_open_reference",
"mcp__mnote_lightrag_bridge__query_knowledge_graph",
"mcp__mnote_lightrag_bridge__open_mnote_reference",
];
fn acp_permission_params_contain_auto_allowed_tool(value: &Value) -> bool {
match value {
Value::String(text) => ACP_AUTO_ALLOWED_PERMISSION_TOOLS
.iter()
.any(|tool_name| text.contains(tool_name)),
Value::Array(items) => items
.iter()
.any(acp_permission_params_contain_auto_allowed_tool),
Value::Object(map) => map
.values()
.any(acp_permission_params_contain_auto_allowed_tool),
_ => false,
}
}
fn permission_option_id_by_decision(params: &Value, decision: &str) -> Option<String> {
let options = params.get("options").and_then(Value::as_array)?;
let preferred: &[&str] = if decision == "allow" {
&["allow_once", "allow", "approve", "yes"]
&[
"allow_once",
"allow",
"approve",
"yes",
"allow_always",
"allow_persistent",
]
} else {
&["deny_once", "reject_once", "deny", "reject", "no"]
};
@@ -922,6 +1035,36 @@ fn permission_option_id(option: &Value) -> Option<String> {
.map(ToOwned::to_owned)
}
fn permission_option_is_reject_like(params: &Value, option_id: &str) -> bool {
let selected = option_id.trim();
if selected.is_empty() {
return false;
}
let Some(options) = params.get("options").and_then(Value::as_array) else {
return false;
};
options.iter().any(|option| {
if permission_option_id(option).as_deref() != Some(selected) {
return false;
}
let haystack = [
option
.get("optionId")
.or_else(|| option.get("option_id"))
.or_else(|| option.get("id"))
.and_then(Value::as_str)
.unwrap_or(""),
option.get("kind").and_then(Value::as_str).unwrap_or(""),
option.get("name").and_then(Value::as_str).unwrap_or(""),
]
.join(" ")
.to_ascii_lowercase();
["reject", "deny", "cancel", "stop", "no"]
.iter()
.any(|needle| haystack.contains(needle))
})
}
// ── Tests ────────────────────────────────────────────
#[cfg(test)]
@@ -979,6 +1122,55 @@ rl.on('line', (line) => {
Arc::new(client)
}
/// Creates a mock ACP that asks for permission during session/prompt and
/// only completes after MNote responds to that incoming request.
async fn spawn_mock_acp_with_permission_gate() -> Arc<AcpClient> {
let script = r#"
import * as readline from 'node:readline';
import { stdin as input, stdout as output } from 'node:process';
const rl = readline.createInterface({ input, output, terminal: false });
let promptId = null;
let sessionId = 'test_session_1';
rl.on('line', (line) => {
const msg = JSON.parse(line);
if (msg.method === 'initialize') {
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { protocolVersion: 1, agentCapabilities: {}, agentInfo: { name: 'mock', version: '1.0' }, authMethods: [] } }) + '\n');
} else if (msg.method === 'session/new') {
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { sessionId } }) + '\n');
} else if (msg.method === 'session/prompt') {
promptId = msg.id;
process.stdout.write(JSON.stringify({
jsonrpc: '2.0',
id: 7001,
method: 'session/request_permission',
params: {
permissionId: 'perm_prompt_gate_1',
toolCall: { toolCallId: 'perm_prompt_gate_1', title: 'bash', kind: 'execute' },
options: [
{ optionId: 'allow_once', name: 'Allow', kind: 'allow_once' },
{ optionId: 'reject_once', name: 'Reject', kind: 'reject_once' }
]
}
}) + '\n');
} else if (msg.id === 7001 && msg.result) {
process.stdout.write(JSON.stringify({
jsonrpc: '2.0',
method: 'session/update',
params: { sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'PERMISSION_GATE_CONTINUED' } } }
}) + '\n');
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: promptId, result: { stopReason: 'end_turn' } }) + '\n');
}
});
"#;
let dir = std::env::temp_dir();
let script_path = dir.join("acp_session_permission_gate_mock.mjs");
std::fs::write(&script_path, script).expect("write permission mock");
let client = AcpClient::spawn("node", &[script_path.to_str().unwrap()])
.await
.expect("spawn permission mock");
Arc::new(client)
}
#[tokio::test]
async fn test_create_session() {
let client = spawn_mock_acp().await;
@@ -1048,6 +1240,58 @@ rl.on('line', (line) => {
);
}
#[tokio::test]
async fn permission_resolution_unblocks_prompt_request() {
let client = spawn_mock_acp_with_permission_gate().await;
let mgr = Arc::new(AcpSessionManager::new(client));
let permission_id = Arc::new(std::sync::Mutex::new(None::<String>));
let permission_id_for_event = permission_id.clone();
mgr.on_event(move |event| {
if let AcpSessionEvent::PermissionRequest {
permission_id,
decision,
..
} = event
{
if decision == "requested" {
*permission_id_for_event.lock().unwrap() = Some(permission_id);
}
}
});
mgr.create_session(Some("/test"), None)
.await
.expect("create_session");
let mgr_for_prompt = mgr.clone();
let prompt_task = tokio::spawn(async move {
mgr_for_prompt
.run_prompt(vec![ContentBlock::Text {
text: "needs permission".into(),
}])
.await
});
let mut observed_permission = None;
for _ in 0..50 {
observed_permission = permission_id.lock().unwrap().clone();
if observed_permission.is_some() {
break;
}
sleep(Duration::from_millis(20)).await;
}
let observed_permission = observed_permission.expect("permission requested");
mgr.resolve_permission(&observed_permission, Some("allow"), Some("allow_once"))
.await
.expect("resolve permission");
let result = tokio::time::timeout(Duration::from_secs(2), prompt_task)
.await
.expect("prompt should not remain blocked")
.expect("prompt task join")
.expect("prompt result");
assert_eq!(format!("{:?}", result.stop_reason), "EndTurn".to_string());
}
/// Creates a mock ACP that supports session/load (success case).
async fn spawn_mock_acp_with_load() -> Arc<AcpClient> {
let script = r#"
@@ -1488,7 +1732,10 @@ rl.on('line', (line) => {
.await
.expect("prompt after load");
let output = chunks.lock().unwrap().join("\n");
assert!(output.contains("CURRENT_PROMPT_AFTER_LOAD"), "prompt output missing: {output}");
assert!(
output.contains("CURRENT_PROMPT_AFTER_LOAD"),
"prompt output missing: {output}"
);
}
#[test]
@@ -1563,4 +1810,49 @@ rl.on('line', (line) => {
})
);
}
#[test]
fn permission_response_can_select_exact_plan_option() {
let params = json!({
"toolCall": {
"toolCallId": "gate-plan_1",
"title": "Approve plan",
"kind": "other"
},
"options": [
{"optionId": "allow_once", "name": "Approve plan", "kind": "allow_once"},
{"optionId": "refine", "name": "Refine", "kind": "allow_once"},
{"optionId": "cancel", "name": "Cancel", "kind": "reject_once"}
]
});
let refine = permission_response_for_option_id(&params, "refine")
.expect("exact option response")
.expect("refine should select exact option");
assert_eq!(refine["outcome"]["optionId"], "refine");
assert!(!permission_option_is_reject_like(&params, "refine"));
assert!(permission_option_is_reject_like(&params, "cancel"));
}
#[test]
fn mnote_lightrag_bridge_read_tools_are_auto_allowed() {
assert!(acp_permission_tool_auto_allowed(
"mcp__mnote_lightrag_bridge__mnote_knowledge_rag_query"
));
assert!(acp_permission_tool_auto_allowed(
"mcp__mnote_lightrag_bridge__mnote_knowledge_rag_section_context"
));
assert!(acp_permission_params_contain_auto_allowed_tool(&json!({
"toolCall": {
"title": "调用工具",
"rawInput": {
"name": "mcp__mnote_lightrag_bridge__mnote_knowledge_rag_section_context"
}
}
})));
assert!(acp_permission_tool_auto_allowed(
"mcp__mnote_lightrag_bridge__open_mnote_reference"
));
assert!(!acp_permission_tool_auto_allowed("bash"));
}
}
@@ -3,7 +3,8 @@ use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::ToolCallInput;
use crate::routes::knowledge_rag::{
KnowledgeRagOpenReferenceRequest, KnowledgeRagQueryRequest, KnowledgeRagStatusQuery,
KnowledgeRagOpenReferenceRequest, KnowledgeRagQueryRequest, KnowledgeRagSectionContextRequest,
KnowledgeRagStatusQuery,
};
use axum::extract::{Extension, Json, Query, State};
use serde_json::{json, Value};
@@ -100,6 +101,39 @@ pub async fn open_reference(
Ok(payload)
}
pub async fn section_context(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let mut args = input.args.clone().unwrap_or_else(|| json!({}));
if args.get("workspaceId").is_none() {
if let Some(workspace_id) = input.effective_workspace_id() {
args["workspaceId"] = json!(workspace_id);
}
}
if args.get("rootUri").is_none() {
if let Some(root_uri) = input.effective_root_uri() {
args["rootUri"] = json!(root_uri);
}
}
let body =
serde_json::from_value::<KnowledgeRagSectionContextRequest>(args).map_err(|error| {
WebError::bad_request_code(
"mnote_knowledge_rag_section_context_payload_invalid",
format!("资料库章节上下文参数无效: {error}"),
)
.with_context(context)
})?;
let Json(payload) = crate::routes::knowledge_rag::section_context(
State(state.clone()),
Extension(context.clone()),
Json(body),
)
.await?;
Ok(compact_section_context_for_agent(payload))
}
fn compact_query_result_for_agent(payload: Value) -> Value {
let references = payload
.get("references")
@@ -139,16 +173,25 @@ fn compact_query_result_for_agent(payload: Value) -> Value {
})
.collect::<Vec<_>>();
let ui_citations = citations.clone();
let document_structure_index = payload
.get("documentStructureIndex")
.cloned()
.filter(|value| !value.is_null())
.unwrap_or(Value::Null);
json!({
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
"schema": "mnote.knowledge_rag.agent_query_result.v1",
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("lightrag")),
"answerGuidance": "Final answers should answer the user in plain text. Do not copy citationMarkdown, citationUrl, /documents, mnote://, or search-engine wrapped local links into the answer. MNote UI will render citations[] / references[].citationMarkdown after the answer as clickable source locators. Treat rawMetadata.keywords and entity/relation counts only as query analysis, not proof that a source contains those words. Use references[].quote/contentDiagnostics as evidence. If locatorDegraded is true, say the source location is degraded instead of inventing page or bbox. If contentDiagnostics.ocrTextExposed is false, say OCR text was not exposed in the returned reference instead of claiming OCR succeeded.",
"answerGuidance": "Final answers should answer the user in plain text. Do not copy citationMarkdown, citationUrl, /documents, mnote://, or search-engine wrapped local links into the answer. MNote UI will render citations[] / references[].citationMarkdown after the answer as clickable source locators. Treat rawMetadata.keywords and entity/relation counts only as query analysis, not proof that a source contains those words. Use references[].quote/contentDiagnostics as evidence. For book-like or skip-KG documents, call the tool with sourcePaths and includeDocumentStructureIndex=true; MNote fixes their effective retrieval mode to naive because they intentionally do not build KG. If documentStructureIndex is present, use it as a section map and call mnote.knowledge_rag.section_context with the section range when you need bounded chapter text for second-pass reading; do not cite the map itself unless the same claim appears in references[].quote or section_context text. If locatorDegraded is true, say the source location is degraded instead of inventing page or bbox. If contentDiagnostics.ocrTextExposed is false, say OCR text was not exposed in the returned reference instead of claiming OCR succeeded.",
"references": references,
"citations": citations,
"citationMarkdowns": citation_markdowns,
"uiCitations": ui_citations,
"documentStructureIndex": compact_document_structure_index_for_agent(&document_structure_index),
"citationRendering": "MNote UI appends uiCitations/citations after the final answer; agent must not hand-write citation links.",
"requestedRetrievalMode": payload.get("requestedRetrievalMode").cloned().unwrap_or(Value::Null),
"effectiveRetrievalMode": payload.get("effectiveRetrievalMode").cloned().or_else(|| payload.get("retrievalMode").cloned()).unwrap_or(Value::Null),
"retrievalModeReason": payload.get("retrievalModeReason").cloned().unwrap_or(Value::Null),
"sourceScope": payload.get("sourceScope").cloned().unwrap_or(Value::Array(Vec::new())),
"sourceScopeMode": payload.get("sourceScopeMode").cloned().unwrap_or_else(|| json!("post_filter_mapped_references")),
"rawScopeFiltered": payload.get("rawScopeFiltered").cloned().unwrap_or(Value::Bool(false)),
@@ -159,6 +202,168 @@ fn compact_query_result_for_agent(payload: Value) -> Value {
})
}
fn compact_document_structure_index_for_agent(value: &Value) -> Value {
if value.is_null() {
return Value::Null;
}
let documents = value
.get("documents")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.take(4)
.map(|doc| {
let mut sections = doc
.get("sections")
.and_then(Value::as_array)
.map(|sections| {
sections
.iter()
.filter(|section| {
section
.get("queryMatchCount")
.and_then(Value::as_u64)
.unwrap_or(0)
> 0
|| section
.get("matchedReferenceCount")
.and_then(Value::as_u64)
.unwrap_or(0)
> 0
})
.take(12)
.map(compact_structure_section_for_agent)
.collect::<Vec<_>>()
})
.unwrap_or_default();
if sections.is_empty() {
sections = doc
.get("sections")
.and_then(Value::as_array)
.map(|sections| {
sections
.iter()
.take(12)
.map(compact_structure_section_for_agent)
.collect::<Vec<_>>()
})
.unwrap_or_default();
}
json!({
"sourceRootRelativePath": doc.get("sourceRootRelativePath").cloned().unwrap_or(Value::Null),
"sourceId": doc.get("sourceId").cloned().unwrap_or(Value::Null),
"lightRagDocId": doc.get("lightRagDocId").cloned().unwrap_or(Value::Null),
"sectionCount": doc.get("sectionCount").cloned().unwrap_or(Value::Null),
"queryMatchedSections": doc.get("queryMatchedSections").cloned().unwrap_or(Value::Null),
"referenceMatchedSections": doc.get("referenceMatchedSections").cloned().unwrap_or(Value::Null),
"sections": sections,
})
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
json!({
"schema": value.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.document_structure_index.v1")),
"mode": value.get("mode").cloned().unwrap_or(Value::Null),
"referenceCount": value.get("referenceCount").cloned().unwrap_or(Value::Null),
"documents": documents,
})
}
fn compact_structure_section_for_agent(section: &Value) -> Value {
json!({
"sectionId": section.get("sectionId").cloned().unwrap_or(Value::Null),
"title": section.get("title").cloned().unwrap_or(Value::Null),
"level": section.get("level").cloned().unwrap_or(Value::Null),
"headingPath": section.get("headingPath").cloned().unwrap_or_else(|| json!([])),
"startBlockOrdinal": section.get("startBlockOrdinal").cloned().unwrap_or(Value::Null),
"endBlockOrdinal": section.get("endBlockOrdinal").cloned().unwrap_or(Value::Null),
"startParagraphOrdinal": section.get("startParagraphOrdinal").cloned().unwrap_or(Value::Null),
"endParagraphOrdinal": section.get("endParagraphOrdinal").cloned().unwrap_or(Value::Null),
"blockCount": section.get("blockCount").cloned().unwrap_or(Value::Null),
"queryMatchCount": section.get("queryMatchCount").cloned().unwrap_or(Value::Null),
"matchedReferenceCount": section.get("matchedReferenceCount").cloned().unwrap_or(Value::Null),
"sample": section
.get("sample")
.and_then(Value::as_str)
.map(|value| value.chars().take(220).collect::<String>())
.unwrap_or_default(),
})
}
fn compact_section_context_for_agent(payload: Value) -> Value {
let blocks = payload
.get("blocks")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.take(40)
.map(|block| {
let text = block
.get("text")
.and_then(Value::as_str)
.map(|value| value.chars().take(900).collect::<String>())
.unwrap_or_default();
json!({
"blockOrdinal": block.get("blockOrdinal").cloned().unwrap_or(Value::Null),
"blockId": block.get("blockId").cloned().unwrap_or(Value::Null),
"paragraphOrdinal": block.get("paragraphOrdinal").cloned().unwrap_or(Value::Null),
"headingPath": block.get("headingPath").cloned().unwrap_or_else(|| json!([])),
"text": text,
"textTruncated": block.get("textTruncated").cloned().unwrap_or(Value::Bool(false)),
"locator": block.get("locator").cloned().unwrap_or(Value::Null),
})
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
let text = payload
.get("text")
.and_then(Value::as_str)
.map(|value| value.chars().take(12_000).collect::<String>())
.unwrap_or_default();
let chunks = payload
.get("chunks")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.take(20)
.map(|chunk| {
let text = chunk
.get("text")
.and_then(Value::as_str)
.map(|value| value.chars().take(2_400).collect::<String>())
.unwrap_or_default();
json!({
"chunkOrdinal": chunk.get("chunkOrdinal").cloned().unwrap_or(Value::Null),
"startBlockOrdinal": chunk.get("startBlockOrdinal").cloned().unwrap_or(Value::Null),
"endBlockOrdinal": chunk.get("endBlockOrdinal").cloned().unwrap_or(Value::Null),
"blockIds": chunk.get("blockIds").cloned().unwrap_or_else(|| json!([])),
"text": text,
})
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
json!({
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
"schema": payload.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.section_context.v1")),
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("lightrag")),
"sourceId": payload.get("sourceId").cloned().unwrap_or(Value::Null),
"sourceRootRelativePath": payload.get("sourceRootRelativePath").cloned().unwrap_or(Value::Null),
"lightRagDocId": payload.get("lightRagDocId").cloned().unwrap_or(Value::Null),
"section": payload.get("section").cloned().unwrap_or(Value::Null),
"limits": payload.get("limits").cloned().unwrap_or(Value::Null),
"blocks": blocks,
"chunks": chunks,
"text": text,
"usageGuidance": "This is bounded sidecar context for interpreting a documentStructureIndex section. Use it as supporting reading context, but cite final answers with references/citations returned by mnote.knowledge_rag.query when possible.",
})
}
fn compact_reference_for_agent(reference: &Value) -> Value {
let quote = reference
.get("displayQuote")
@@ -14,6 +14,7 @@ pub fn manifest() -> Value {
doc_find_tool(),
knowledge_rag_status_tool(),
knowledge_rag_query_tool(),
knowledge_rag_section_context_tool(),
knowledge_rag_open_reference_tool(),
block_fetch_tool(),
doc_plan_update_tool(),
@@ -328,6 +329,14 @@ fn knowledge_rag_query_tool() -> Value {
"includeChunkContent".into(),
json!({ "type": "boolean", "default": true }),
);
map.insert(
"includeDocumentStructureIndex".into(),
json!({
"type": "boolean",
"default": false,
"description": "返回由 LightRAG sidecar headings 派生的 document_structure_index;适合大书/长文档问题做章节导航和上下文扩展,不替代 references 引用证据。"
}),
);
map.insert(
"sourcePaths".into(),
json!({
@@ -376,6 +385,51 @@ fn knowledge_rag_open_reference_tool() -> Value {
})
}
fn knowledge_rag_section_context_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
map.insert("rootUri".into(), json!({ "type": "string" }));
map.insert("sourcePath".into(), json!({ "type": "string" }));
map.insert("sourceId".into(), json!({ "type": "string" }));
map.insert("lightRagDocId".into(), json!({ "type": "string" }));
map.insert("filePath".into(), json!({ "type": "string" }));
map.insert("sectionId".into(), json!({ "type": "string" }));
map.insert("startBlockOrdinal".into(), json!({ "type": "integer" }));
map.insert("endBlockOrdinal".into(), json!({ "type": "integer" }));
map.insert("startParagraphOrdinal".into(), json!({ "type": "integer" }));
map.insert("endParagraphOrdinal".into(), json!({ "type": "integer" }));
map.insert(
"contextBefore".into(),
json!({ "type": "integer", "default": 1 }),
);
map.insert(
"contextAfter".into(),
json!({ "type": "integer", "default": 1 }),
);
map.insert(
"maxBlocks".into(),
json!({ "type": "integer", "default": 24 }),
);
map.insert(
"maxChars".into(),
json!({ "type": "integer", "default": 12000 }),
);
}
json!({
"name": "mnote.knowledge_rag.section_context",
"description": "按 documentStructureIndex section 的 block/paragraph range,从 LightRAG native sidecar 拉取有限正文 blocks/chunks,供大书/长文档二次解读;不做检索、重排或 fallback。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"required": ["workspaceId", "rootUri"],
"properties": properties
}
})
}
// 旧 local index agent 工具仅保留为历史对照;当前 manifest() 不注册这些工具。
#[allow(dead_code)]
fn index_status_tool() -> Value {
@@ -49,6 +49,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
"mnote.context.resolve_target",
"mnote.knowledge_rag.status",
"mnote.knowledge_rag.query",
"mnote.knowledge_rag.section_context",
"mnote.knowledge_rag.open_reference",
],
content: include_str!("../../../../../skills/mnote-knowledge-rag/SKILL.md"),
File diff suppressed because it is too large Load Diff
@@ -374,6 +374,9 @@ pub(crate) async fn execute_mnote_tool_call(
}
"mnote.knowledge_rag.status" => knowledge_rag::status(&state, &context, &input).await,
"mnote.knowledge_rag.query" => knowledge_rag::query(&state, &context, &input).await,
"mnote.knowledge_rag.section_context" => {
knowledge_rag::section_context(&state, &context, &input).await
}
"mnote.knowledge_rag.open_reference" => {
knowledge_rag::open_reference(&state, &context, &input).await
}
@@ -726,6 +729,7 @@ fn is_read_tool(tool_name: &str) -> bool {
| "mnote.evidence.open"
| "mnote.knowledge_rag.status"
| "mnote.knowledge_rag.query"
| "mnote.knowledge_rag.section_context"
| "mnote.knowledge_rag.open_reference"
| "mnote.index.status"
| "mnote.index.refresh"
@@ -758,6 +762,7 @@ fn is_evidence_receipt_tool(tool_name: &str) -> bool {
| "mnote.evidence.open"
| "mnote.knowledge_rag.status"
| "mnote.knowledge_rag.query"
| "mnote.knowledge_rag.section_context"
| "mnote.knowledge_rag.open_reference"
)
}
File diff suppressed because it is too large Load Diff
+46
View File
@@ -91,6 +91,10 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/knowledge-rag/ingest", post(knowledge_rag::ingest))
.route("/api/knowledge-rag/search", post(knowledge_rag::search))
.route("/api/knowledge-rag/query", post(knowledge_rag::query_rag))
.route(
"/api/knowledge-rag/section-context",
post(knowledge_rag::section_context),
)
.route(
"/api/knowledge-rag/open-reference",
post(knowledge_rag::open_reference),
@@ -517,6 +521,46 @@ pub fn build_router(state: AppState) -> Router {
"/api/page-ai/sessions/{session_id}/active-run",
get(hermes_client::get_page_ai_session_active_run),
)
.route(
"/api/page-ai/runtime/status",
get(hermes_client::get_page_ai_runtime_status),
)
.route(
"/api/page-ai/runtime/reset",
post(hermes_client::reset_page_ai_runtime),
)
.route(
"/api/page-ai/sessions",
get(hermes_client::list_sessions).post(hermes_client::create_session),
)
.route(
"/api/page-ai/sessions/search",
get(hermes_client::search_sessions),
)
.route(
"/api/page-ai/sessions/{session_id}",
get(hermes_client::get_session).delete(hermes_client::delete_session),
)
.route(
"/api/page-ai/sessions/{session_id}/resume",
post(hermes_client::resume_session),
)
.route(
"/api/page-ai/sessions/{session_id}/rename",
post(hermes_client::rename_session),
)
.route(
"/api/page-ai/sessions/{session_id}/export",
get(hermes_client::export_session),
)
.route(
"/api/page-ai/sessions/{session_id}/auto-title",
post(hermes_client::auto_title_session),
)
.route(
"/api/page-ai/sessions/{session_id}/queue/{queue_id}",
delete(hermes_client::cancel_queued_run),
)
.route(
"/api/onlyoffice/bridge/commands",
post(onlyoffice_bridge::enqueue_command),
@@ -936,6 +980,8 @@ mod tests {
assert!(!html.contains(">下载<"));
assert!(html.contains(r#"data-page-width-content-type="word""#));
assert!(html.contains("applyPreviewWidthPreference"));
assert!(html.contains("data-mnote-office-preview-footnotes-disabled"));
assert!(html.contains("renderFootnotes: false"));
}
#[tokio::test]
+16 -2
View File
@@ -1930,13 +1930,27 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
}}
const buffer = await fetchArrayBuffer();
viewer.replaceChildren();
await window.docx.renderAsync(buffer, viewer, null, {{
const options = {{
className: 'mnote-docx',
inWrapper: true,
breakPages: true,
renderHeaders: true,
renderFooters: true
}});
}};
try {{
await window.docx.renderAsync(buffer, viewer, null, options);
}} catch (error) {{
if (!String(error && error.message || '').includes("reading 'type'")) {{
throw error;
}}
viewer.replaceChildren();
document.documentElement.setAttribute('data-mnote-office-preview-footnotes-disabled', 'true');
await window.docx.renderAsync(buffer.slice(0), viewer, null, {{
...options,
renderFootnotes: false,
renderEndnotes: false
}});
}}
}}
async function renderWorkbook() {{
+298 -2
View File
@@ -2626,7 +2626,7 @@ body {
}
.mnote-main-tab {
height: 34px;
height: 36px;
max-width: 240px;
min-width: 0;
display: inline-flex;
@@ -2639,7 +2639,7 @@ body {
padding: 0 9px;
cursor: pointer;
font-size: 13px;
line-height: 1;
line-height: 20px;
}
.mnote-main-tab:hover {
@@ -2725,6 +2725,7 @@ body {
.mnote-main-tab-title {
min-width: 0;
line-height: 20px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -4731,6 +4732,39 @@ html[data-mnote-page-ai-resizing="true"] {
content: "·";
}
.wolai-page-ai-runtime-strip {
display: flex;
width: 100%;
min-height: 28px;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 0 14px 8px;
border: 0;
background: transparent;
color: #6B6762;
cursor: pointer;
font-size: 11px;
line-height: 16px;
text-align: left;
}
.wolai-page-ai-runtime-strip:hover span {
border-color: rgba(27, 28, 28, 0.16);
background: #F1F1EF;
}
.wolai-page-ai-runtime-strip span {
max-width: 100%;
overflow: hidden;
padding: 2px 6px;
border: 1px solid rgba(27, 28, 28, 0.08);
border-radius: 6px;
background: #F8F8F7;
text-overflow: ellipsis;
white-space: nowrap;
}
.wolai-page-ai-statusvalue {
display: block;
min-width: 0;
@@ -5024,6 +5058,11 @@ html[data-mnote-page-ai-resizing="true"] {
overflow: auto;
}
.wolai-page-ai-settings-stack {
display: grid;
gap: 10px;
}
.wolai-page-ai-memory-card {
display: grid;
gap: 8px;
@@ -5396,6 +5435,228 @@ html[data-mnote-page-ai-resizing="true"] {
background: rgba(27, 28, 28, 0.12);
}
.wolai-page-ai-plan-card {
gap: 10px;
border: 1px solid rgba(14, 116, 144, 0.22);
background: #F0F9FF;
color: #0F172A;
}
.wolai-page-ai-plan-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.wolai-page-ai-plan-card-title-wrap {
display: grid;
min-width: 0;
gap: 1px;
}
.wolai-page-ai-plan-card-kicker {
color: #0369A1;
font-size: 11px;
line-height: 16px;
font-weight: 600;
}
.wolai-page-ai-plan-card-title {
color: #0C4A6E;
font-size: 14px;
line-height: 20px;
}
.wolai-page-ai-plan-card-badge {
display: inline-flex;
flex-shrink: 0;
align-items: center;
border-radius: 999px;
padding: 2px 8px;
background: #BAE6FD;
color: #075985;
font-size: 11px;
line-height: 16px;
font-weight: 600;
}
.wolai-page-ai-plan-card-markdown {
display: grid;
gap: 6px;
color: #1E293B;
font-size: 13px;
line-height: 20px;
}
.wolai-page-ai-plan-card-markdown > * {
margin-top: 0 !important;
margin-bottom: 0 !important;
}
.wolai-page-ai-plan-card-markdown .wolai-page-ai-markdown-table-wrap {
border: 1px solid rgba(14, 116, 144, 0.16);
border-radius: 8px;
background: rgba(255, 255, 255, 0.72);
}
.wolai-page-ai-plan-step-list {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.wolai-page-ai-plan-step-item {
display: flex;
align-items: flex-start;
gap: 8px;
min-width: 0;
padding: 8px 10px;
border: 1px solid rgba(255, 255, 255, 0.78);
border-radius: 8px;
background: rgba(255, 255, 255, 0.78);
color: #334155;
font-size: 12px;
line-height: 18px;
}
.wolai-page-ai-plan-step-item[data-status="completed"] {
border-color: rgba(16, 185, 129, 0.28);
background: rgba(236, 253, 245, 0.88);
}
.wolai-page-ai-plan-step-item[data-status="inProgress"] {
border-color: rgba(245, 158, 11, 0.28);
background: rgba(255, 251, 235, 0.9);
}
.wolai-page-ai-plan-step-status {
display: inline-flex;
width: 18px;
height: 18px;
flex: 0 0 18px;
align-items: center;
justify-content: center;
border-radius: 999px;
background: #E2E8F0;
color: #475569;
font-size: 11px;
line-height: 18px;
font-weight: 700;
}
.wolai-page-ai-plan-step-status[data-status="completed"] {
background: #BBF7D0;
color: #166534;
}
.wolai-page-ai-plan-step-status[data-status="inProgress"] {
background: #FDE68A;
color: #92400E;
}
.wolai-page-ai-plan-step-text {
min-width: 0;
overflow-wrap: anywhere;
}
.wolai-page-ai-plan-card-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
}
.wolai-page-ai-plan-card-button {
height: 30px;
padding: 0 10px;
border: 1px solid rgba(14, 116, 144, 0.14);
border-radius: 999px;
background: rgba(255, 255, 255, 0.86);
color: #0F172A;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.wolai-page-ai-plan-card-button--primary {
border-color: #0284C7;
background: #0284C7;
color: #FFFFFF;
}
.wolai-page-ai-plan-card-button--danger {
color: #B33A3A;
}
.wolai-page-ai-permission-card {
gap: 10px;
border: 1px solid rgba(180, 83, 9, 0.18);
background: #FFF7ED;
}
.wolai-page-ai-permission-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.wolai-page-ai-permission-badge {
display: inline-flex;
flex-shrink: 0;
align-items: center;
border-radius: 999px;
padding: 2px 8px;
background: #FED7AA;
color: #9A3412;
font-size: 11px;
line-height: 16px;
font-weight: 600;
}
.wolai-page-ai-permission-summary {
display: grid;
gap: 6px;
}
.wolai-page-ai-permission-row {
display: grid;
grid-template-columns: 54px minmax(0, 1fr);
gap: 8px;
align-items: start;
padding: 7px 8px;
border: 1px solid rgba(180, 83, 9, 0.12);
border-radius: 8px;
background: rgba(255, 255, 255, 0.74);
}
.wolai-page-ai-permission-row span {
color: #9A3412;
font-size: 11px;
line-height: 17px;
font-weight: 600;
}
.wolai-page-ai-permission-row strong {
min-width: 0;
overflow-wrap: anywhere;
color: #1B1C1C;
font-size: 12px;
line-height: 18px;
font-weight: 600;
}
.wolai-page-ai-permission-card .wolai-page-ai-message-actions,
.wolai-page-ai-permission-dialog .wolai-page-ai-message-actions {
margin-top: 8px;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.wolai-page-ai-markdown-table-wrap {
max-width: 100%;
overflow-x: auto;
@@ -5802,6 +6063,37 @@ button.wolai-page-ai-history-main span {
border-color: rgba(27, 28, 28, 0.32);
}
.wolai-page-ai-reasonix-controls {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
padding: 0 8px 6px;
}
.wolai-page-ai-reasonix-controls[hidden] {
display: none !important;
}
.wolai-page-ai-reasonix-control {
display: grid;
gap: 2px;
min-width: 0;
color: #8B8782;
font-size: 11px;
}
.wolai-page-ai-reasonix-control select {
width: 100%;
min-width: 0;
height: 28px;
padding: 0 8px;
border: 1px solid rgba(27, 28, 28, 0.1);
border-radius: 7px;
background: #FFF;
color: #1B1C1C;
font-size: 12px;
}
.wolai-page-ai-composer-bar {
display: flex;
min-height: 36px;
@@ -6037,6 +6329,10 @@ mod tests {
assert!(MNOTE_CSS.contains("[data-mnote-tab-badge-kind=\"code\"]"));
assert!(MNOTE_CSS.contains("[data-mnote-tab-badge-kind=\"pdf\"]"));
assert!(MNOTE_CSS.contains("[data-mnote-tab-badge-kind=\"file\"]"));
assert!(MNOTE_CSS.contains(".mnote-main-tab {\n height: 36px;"));
assert!(
MNOTE_CSS.contains(".mnote-main-tab-title {\n min-width: 0;\n line-height: 20px;")
);
assert!(MNOTE_CSS.contains("background: #e74c3c"));
}
+3
View File
@@ -19,6 +19,9 @@ API_KEY_VALUE="${LIGHTRAG_API_KEY:-$(read_env_value LIGHTRAG_API_KEY "$ENV_FILE"
HOST_VALUE="${HOST_VALUE:-127.0.0.1}"
PORT_VALUE="${PORT_VALUE:-9621}"
if [[ "$HOST_VALUE" == "0.0.0.0" || "$HOST_VALUE" == "::" ]]; then
HOST_VALUE="127.0.0.1"
fi
export LIGHTRAG_SERVER_URL="${LIGHTRAG_SERVER_URL:-http://${HOST_VALUE}:${PORT_VALUE}}"
export LIGHTRAG_API_KEY="$API_KEY_VALUE"
+184 -28
View File
@@ -13,6 +13,7 @@ from typing import Any
import httpx
from mcp.server.fastmcp import FastMCP
from mcp.types import ToolAnnotations
DEFAULT_ENV_FILE = "/mnt/Data1T/Mnote_data/lightrag/LightRAG/.env"
@@ -21,6 +22,7 @@ DEFAULT_ROOT_URI = "file:///mnt/Data1T/Mnote_data/users/mnote-e2e/workspaces/my-
DEFAULT_WORKSPACE_ID = "local-ws:mnote-e2e:my-space"
mcp = FastMCP("MNote-LightRAG-Server")
READ_ONLY_TOOL = ToolAnnotations(readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=False)
def _read_env_value(key: str, env_file: str) -> str:
@@ -43,6 +45,8 @@ def _lightrag_base_url() -> str:
env_file = os.environ.get("MNOTE_LIGHTRAG_ENV_FILE", DEFAULT_ENV_FILE)
host = os.environ.get("LIGHTRAG_HOST") or _read_env_value("HOST", env_file) or "127.0.0.1"
port = os.environ.get("LIGHTRAG_PORT") or _read_env_value("PORT", env_file) or "9621"
if host in {"0.0.0.0", "::"}:
host = "127.0.0.1"
return f"http://{host}:{port}".rstrip("/")
@@ -85,9 +89,48 @@ async def _request_lightrag(path: str, *, method: str = "GET", json_body: Any =
return {"status": "success", "response": payload, "error": None, "httpStatus": response.status_code}
async def _request_mnote(path: str, *, method: str = "GET", json_body: Any = None, params: dict[str, Any] | None = None) -> Any:
headers = _mnote_headers()
if method.upper() == "GET":
headers = {key: value for key, value in headers.items() if key != "content-type"}
async with httpx.AsyncClient(timeout=180) as client:
response = await client.request(
method,
f"{_mnote_web_url()}{path}",
headers=headers,
json=json_body,
params=params,
)
try:
payload = response.json()
except Exception:
payload = {"text": response.text}
if response.status_code >= 400:
return {"status": "error", "response": None, "error": payload, "httpStatus": response.status_code}
return {"status": "success", "response": payload, "error": None, "httpStatus": response.status_code}
@mcp.tool(
name="connect",
description="No-op connection probe for agents that expect MCP servers to expose a connect tool.",
annotations=READ_ONLY_TOOL,
)
async def connect() -> Any:
return {
"status": "success",
"response": {
"server": "mnote_lightrag_bridge",
"connected": True,
},
"error": None,
"httpStatus": 200,
}
@mcp.tool(
name="verify_server_health",
description="Check whether the configured local LightRAG server is healthy.",
annotations=READ_ONLY_TOOL,
)
async def verify_server_health() -> Any:
return await _request_lightrag("/health")
@@ -96,6 +139,7 @@ async def verify_server_health() -> Any:
@mcp.tool(
name="check_indexing_status",
description="Check the LightRAG document processing pipeline status.",
annotations=READ_ONLY_TOOL,
)
async def check_indexing_status() -> Any:
return await _request_lightrag("/documents/pipeline_status")
@@ -104,6 +148,7 @@ async def check_indexing_status() -> Any:
@mcp.tool(
name="list_all_docs",
description="List documents currently known to LightRAG.",
annotations=READ_ONLY_TOOL,
)
async def list_all_docs() -> Any:
return await _request_lightrag("/documents")
@@ -111,39 +156,135 @@ async def list_all_docs() -> Any:
@mcp.tool(
name="query_knowledge_graph",
description="Search the local LightRAG knowledge base. Use mix by default; references include provider file_path/chunk_id for MNote citation mapping.",
description="Ask MNote knowledge_rag.query. This calls MNote's LightRAG facade so references/citations are already mapped to MNote source registry and clickable locators.",
annotations=READ_ONLY_TOOL,
)
async def query_knowledge_graph(
prompt: str,
search_mode: str = "mix",
limit: int = 60,
context_only: bool = False,
prompt_only: bool = False,
include_references: bool = True,
include_chunk_content: bool = True,
include_document_structure_index: bool = False,
source_paths: list[str] | None = None,
workspace_id: str = DEFAULT_WORKSPACE_ID,
root_uri: str = DEFAULT_ROOT_URI,
) -> Any:
return await mnote_knowledge_rag_query(
query=prompt,
mode=search_mode,
top_k=limit,
chunk_top_k=limit,
include_chunk_content=include_chunk_content,
include_document_structure_index=include_document_structure_index,
source_paths=source_paths,
workspace_id=workspace_id,
root_uri=root_uri,
)
@mcp.tool(
name="mnote_knowledge_rag_status",
description="Call MNote mnote.knowledge_rag.status for provider health, source registry and sync state.",
annotations=READ_ONLY_TOOL,
)
async def mnote_knowledge_rag_status(
workspace_id: str = DEFAULT_WORKSPACE_ID,
root_uri: str = DEFAULT_ROOT_URI,
) -> Any:
return await _request_mnote(
"/api/knowledge-rag/status",
method="GET",
params={"workspaceId": workspace_id, "rootUri": root_uri},
)
@mcp.tool(
name="mnote_knowledge_rag_query",
description="Call MNote mnote.knowledge_rag.query. Use this for knowledge-library answers that need references/citations/clickable MNote locators. For book-like files pass source_paths and include_document_structure_index=true.",
annotations=READ_ONLY_TOOL,
)
async def mnote_knowledge_rag_query(
query: str,
mode: str = "mix",
top_k: int = 40,
chunk_top_k: int = 20,
include_chunk_content: bool = True,
include_document_structure_index: bool = False,
source_paths: list[str] | None = None,
workspace_id: str = DEFAULT_WORKSPACE_ID,
root_uri: str = DEFAULT_ROOT_URI,
) -> Any:
mode_aliases = {
"keyword": "naive",
"semantic": "hybrid",
}
mode = mode_aliases.get(search_mode, search_mode)
resolved_mode = mode_aliases.get(mode, mode)
body = {
"query": prompt,
"mode": mode,
"top_k": limit,
"chunk_top_k": limit,
"only_need_context": context_only,
"only_need_prompt": prompt_only,
"include_references": include_references,
"include_chunk_content": include_chunk_content,
"response_type": "Multiple Paragraphs",
"workspaceId": workspace_id,
"rootUri": root_uri,
"query": query,
"mode": resolved_mode,
"topK": top_k,
"chunkTopK": chunk_top_k,
"includeChunkContent": include_chunk_content,
"includeDocumentStructureIndex": include_document_structure_index,
}
return await _request_lightrag("/query", method="POST", json_body=body)
if source_paths:
body["sourcePaths"] = source_paths
return await _request_mnote("/api/knowledge-rag/query", method="POST", json_body=body)
@mcp.tool(
name="mnote_knowledge_rag_section_context",
description="Call MNote mnote.knowledge_rag.section_context. Use documentStructureIndex section ranges to fetch bounded LightRAG sidecar blocks/chunks for second-pass reading of book-like/long documents.",
annotations=READ_ONLY_TOOL,
)
async def mnote_knowledge_rag_section_context(
source_path: str = "",
source_id: str = "",
light_rag_doc_id: str = "",
file_path: str = "",
section_id: str = "",
start_block_ordinal: int | None = None,
end_block_ordinal: int | None = None,
start_paragraph_ordinal: int | None = None,
end_paragraph_ordinal: int | None = None,
context_before: int = 1,
context_after: int = 1,
max_blocks: int = 24,
max_chars: int = 12000,
workspace_id: str = DEFAULT_WORKSPACE_ID,
root_uri: str = DEFAULT_ROOT_URI,
) -> Any:
body: dict[str, Any] = {
"workspaceId": workspace_id,
"rootUri": root_uri,
"contextBefore": context_before,
"contextAfter": context_after,
"maxBlocks": max_blocks,
"maxChars": max_chars,
}
optional_values = {
"sourcePath": source_path,
"sourceId": source_id,
"lightRagDocId": light_rag_doc_id,
"filePath": file_path,
"sectionId": section_id,
"startBlockOrdinal": start_block_ordinal,
"endBlockOrdinal": end_block_ordinal,
"startParagraphOrdinal": start_paragraph_ordinal,
"endParagraphOrdinal": end_paragraph_ordinal,
}
for key, value in optional_values.items():
if value is not None and value != "":
body[key] = value
return await _request_mnote("/api/knowledge-rag/section-context", method="POST", json_body=body)
@mcp.tool(
name="open_mnote_reference",
description="Map a LightRAG reference/file_path/chunk_id to an MNote clickable citationUrl using MNote source registry.",
annotations=READ_ONLY_TOOL,
)
async def open_mnote_reference(
file_path: str,
@@ -162,21 +303,36 @@ async def open_mnote_reference(
body["chunkId"] = chunk_id
if reference_id:
body["referenceId"] = reference_id
async with httpx.AsyncClient(timeout=60) as client:
response = await client.post(
f"{_mnote_web_url()}/api/knowledge-rag/open-reference",
headers=_mnote_headers(),
json=body,
)
try:
payload = response.json()
except Exception:
payload = {"text": response.text}
if response.status_code >= 400:
return {"status": "error", "response": None, "error": payload, "httpStatus": response.status_code}
result = await _request_mnote("/api/knowledge-rag/open-reference", method="POST", json_body=body)
payload = result.get("response")
if result.get("status") != "success":
return result
if not include_registry and isinstance(payload, dict):
payload = {key: value for key, value in payload.items() if key != "registry"}
return {"status": "success", "response": payload, "error": None, "httpStatus": response.status_code}
return {"status": "success", "response": payload, "error": None, "httpStatus": result.get("httpStatus")}
@mcp.tool(
name="mnote_knowledge_rag_open_reference",
description="Call MNote mnote.knowledge_rag.open_reference to convert a returned reference/file_path/chunk_id into a clickable MNote locator.",
annotations=READ_ONLY_TOOL,
)
async def mnote_knowledge_rag_open_reference(
file_path: str,
chunk_id: str = "",
reference_id: str = "",
workspace_id: str = DEFAULT_WORKSPACE_ID,
root_uri: str = DEFAULT_ROOT_URI,
include_registry: bool = False,
) -> Any:
return await open_mnote_reference(
file_path=file_path,
chunk_id=chunk_id,
reference_id=reference_id,
workspace_id=workspace_id,
root_uri=root_uri,
include_registry=include_registry,
)
if __name__ == "__main__":
+3
View File
@@ -20,6 +20,9 @@ API_KEY_VALUE="${LIGHTRAG_API_KEY:-$(read_env_value LIGHTRAG_API_KEY "$ENV_FILE"
HOST_VALUE="${HOST_VALUE:-127.0.0.1}"
PORT_VALUE="${PORT_VALUE:-9621}"
if [[ "$HOST_VALUE" == "0.0.0.0" || "$HOST_VALUE" == "::" ]]; then
HOST_VALUE="127.0.0.1"
fi
if [[ ! -x "$PYTHON" ]]; then
echo "python not found or not executable: $PYTHON" >&2
+38 -1
View File
@@ -48,6 +48,7 @@ const MNOTE_TOOL_NAMES = [
'mnote.context.read_current_page',
'mnote.knowledge_rag.status',
'mnote.knowledge_rag.query',
'mnote.knowledge_rag.section_context',
'mnote.knowledge_rag.open_reference',
];
@@ -58,6 +59,7 @@ const REASONIX_TOOL_TO_MNOTE_TOOL = {
mnote_context_read_current_page: 'mnote.context.read_current_page',
mnote_knowledge_rag_status: 'mnote.knowledge_rag.status',
mnote_knowledge_rag_query: 'mnote.knowledge_rag.query',
mnote_knowledge_rag_section_context: 'mnote.knowledge_rag.section_context',
mnote_knowledge_rag_open_reference: 'mnote.knowledge_rag.open_reference',
};
@@ -153,6 +155,7 @@ function isWriteMnoteTool(toolName) {
'mnote.context.read_current_page',
'mnote.knowledge_rag.status',
'mnote.knowledge_rag.query',
'mnote.knowledge_rag.section_context',
'mnote.knowledge_rag.open_reference',
'mnote.doc.fetch',
'mnote.page.get',
@@ -693,6 +696,10 @@ function compactMnoteToolResultForReasonix(toolName, payload) {
answerGuidance: result.answerGuidance || '',
references: Array.isArray(result.references) ? result.references.slice(0, 8) : [],
citations: citationMarkdowns,
documentStructureIndex: result.documentStructureIndex || null,
requestedRetrievalMode: result.requestedRetrievalMode || null,
effectiveRetrievalMode: result.effectiveRetrievalMode || result.retrievalMode || null,
retrievalModeReason: result.retrievalModeReason || null,
sourceScope: result.sourceScope || [],
sourceScopeMode: result.sourceScopeMode || '',
rawScopeFiltered: Boolean(result.rawScopeFiltered),
@@ -875,6 +882,10 @@ function fallbackMnoteToolSpecs() {
topK: { type: 'integer', description: 'LightRAG top_k' },
chunkTopK: { type: 'integer', description: 'LightRAG chunk_top_k' },
includeChunkContent: { type: 'boolean', description: '是否在 reference 中包含 chunk 内容' },
includeDocumentStructureIndex: {
type: 'boolean',
description: '是否返回由 LightRAG sidecar headings 派生的 document_structure_index;适合大书/长文档章节导航,不替代 references 引用证据。',
},
sourcePaths: {
type: 'array',
items: { type: 'string' },
@@ -903,6 +914,32 @@ function fallbackMnoteToolSpecs() {
},
parallelSafe: true,
},
{
mnoteToolName: 'mnote.knowledge_rag.section_context',
name: 'mnote_knowledge_rag_section_context',
description: '按 documentStructureIndex section 的 block/paragraph range,从 LightRAG native sidecar 拉取有限正文 blocks/chunks,供大书/长文档二次解读;不做检索、重排或 fallback。',
parameters: {
type: 'object',
properties: {
sourcePath: { type: 'string', description: 'MNote workspace 相对路径,推荐从 documentStructureIndex.documents[].sourceRootRelativePath 传入' },
sourceId: { type: 'string' },
lightRagDocId: { type: 'string' },
filePath: { type: 'string', description: 'LightRAG provider file_path' },
sectionId: { type: 'string' },
startBlockOrdinal: { type: 'integer' },
endBlockOrdinal: { type: 'integer' },
startParagraphOrdinal: { type: 'integer' },
endParagraphOrdinal: { type: 'integer' },
contextBefore: { type: 'integer' },
contextAfter: { type: 'integer' },
maxBlocks: { type: 'integer' },
maxChars: { type: 'integer' },
workspaceId: { type: 'string', description: 'MNote workspace ID,可省略并使用当前上下文' },
rootUri: { type: 'string', description: 'local folder rootUri,可省略并使用当前上下文' },
},
},
parallelSafe: true,
},
];
}
@@ -1001,7 +1038,7 @@ onRequest('session/new', async (params) => {
'<available-skills>',
'Use mnote_skill_read to load the full content of any skill listed by the current prompt capabilities.',
'- mnote-current-page — Read the current MNote Markdown page when the task needs page content.',
'- mnote-knowledge-rag — Ask the LightRAG-backed knowledge library across books, papers, PDFs, Office files, images, and attachments. Use mnote_knowledge_rag_query for knowledge-library questions and answers that require links/sources/citations. Returned uiCitations/citationMarkdown values are MNote clickable source locators and are rendered by the MNote UI after the final answer. Do not copy citationMarkdown into the answer, do not hand-write /documents or mnote:// links, and never wrap local citation URLs with search engines. Mention source titles in plain text only when useful; sourcePaths filters returned references after provider retrieval, so do not cite raw chunks. If locatorDegraded is true, say the source location is degraded rather than inventing page/bbox.',
'- mnote-knowledge-rag — Ask the LightRAG-backed knowledge library across books, papers, PDFs, Office files, images, and attachments. Use mnote_knowledge_rag_query for knowledge-library questions and answers that require links/sources/citations. For book-like results, request includeDocumentStructureIndex and then use mnote_knowledge_rag_section_context to read bounded section blocks when the map is not enough. Returned uiCitations/citationMarkdown values are MNote clickable source locators and are rendered by the MNote UI after the final answer. Do not copy citationMarkdown into the answer, do not hand-write /documents or mnote:// links, and never wrap local citation URLs with search engines. Mention source titles in plain text only when useful; sourcePaths filters returned references after provider retrieval, so do not cite raw chunks. If locatorDegraded is true, say the source location is degraded rather than inventing page/bbox.',
'- mnote-local-file — Resolve MNote targets and then use native file tools inside allowed roots.',
'- mnote-chat-only — Reply conversationally without MNote file/page tools.',
'</available-skills>',
+2 -2
View File
@@ -54,8 +54,8 @@ function startGateway(port) {
async function validateAuthEntry(baseUrl) {
const root = await fetchWithTimeout(`${baseUrl}/`);
const rootText = await root.text();
assert.equal(root.status, 303, `/ 未登录应跳转 /auth: ${root.status} ${rootText.slice(0, 160)}`);
assert.equal(root.headers.get("location"), "/auth");
assert.equal(root.status, 303, `/ 未登录应跳转 /auth 并保留 next: ${root.status} ${rootText.slice(0, 160)}`);
assert.equal(root.headers.get("location"), "/auth?next=%2F");
assert.equal(root.headers.get("x-mnote-web-owner"), "mnote-web");
const auth = await fetchWithTimeout(`${baseUrl}/auth`);
@@ -113,6 +113,17 @@ async function sendPrompt(page, prompt, expectedCompact) {
return { runId, text };
}
async function waitUntil(label, predicate, timeoutMs = UI_TIMEOUT_MS) {
const start = Date.now();
let last = null;
while (Date.now() - start < timeoutMs) {
last = await predicate().catch((error) => error);
if (last === true) return;
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error(`${label}_timeout: ${String(last && last.message || last || '')}`);
}
function sessionInfoForRuns(runIds) {
const quoted = runIds.map(sqlQuote).join(",");
return sqliteJson(`
@@ -127,6 +138,15 @@ function sessionInfoForRuns(runIds) {
}));
}
function runtimeRunsForSession(sessionId) {
return sqliteJson(`
SELECT run_id AS runId, status
FROM ai_runtime_runs
WHERE session_id=${sqlQuote(sessionId)} AND run_id LIKE 'run_%'
ORDER BY created_at ASC;
`);
}
async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true });
const suffix = Date.now().toString(36);
@@ -166,16 +186,53 @@ async function main() {
const firstMarker = `TASK558_FIRST_${suffix}`.toUpperCase();
const secondMarker = `TASK558_SECOND_${suffix}`.toUpperCase();
const first = await sendPrompt(
page,
const beforeAssistantCount = await page.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant').count();
await page.locator("[data-page-ai-input]").fill(
`Reasonix 上下文连续性测试:请记住如果下一轮用户只说“可以”,你必须只回复 ${secondMarker}。本轮请只回复 ${firstMarker},不要解释。`,
firstMarker,
{ timeout: UI_TIMEOUT_MS },
);
const second = await sendPrompt(page, "可以", secondMarker);
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => ["queued", "running", "tool_calling"].includes(document.documentElement.getAttribute("data-mnote-page-ai-run-status") || ""),
null,
{ timeout: UI_TIMEOUT_MS },
);
await page.locator("[data-page-ai-input]").fill("可以", { timeout: UI_TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS });
await page.waitForFunction(
() => Boolean(document.querySelector('[data-page-ai-queue-item]')),
null,
{ timeout: UI_TIMEOUT_MS },
);
const queuedPreview = await page.locator('[data-page-ai-queue-item]').first().textContent({ timeout: UI_TIMEOUT_MS });
assert(String(queuedPreview || '').includes("可以"), `queued preview 应包含第二轮短回复: ${queuedPreview}`);
await waitUntil("reasonix_two_runs_started", async () => capturedRuns.length >= 2, Math.max(UI_TIMEOUT_MS, 120_000));
await page.waitForFunction(
([firstNeedle, secondNeedle]) => {
const text = document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.textContent || "";
return text.includes(firstNeedle)
&& text.includes(secondNeedle)
&& ["completed", "failed", "aborted"].includes(document.documentElement.getAttribute("data-mnote-page-ai-run-status") || "")
&& !document.querySelector('[data-page-ai-streaming="true"]');
},
[firstMarker, secondMarker],
{ timeout: Math.max(UI_TIMEOUT_MS, 120_000) },
);
const assistantTexts = await page.evaluate((countBefore) => {
return Array.from(document.querySelectorAll('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant'))
.slice(countBefore)
.map((node) => node.querySelector(".wolai-page-ai-message-text")?.textContent || "");
}, beforeAssistantCount);
assert(assistantTexts.some((text) => normalize(text) === firstMarker), `第一轮回复缺失: ${JSON.stringify(assistantTexts)}`);
assert(assistantTexts.some((text) => normalize(text) === secondMarker), `第二轮回复缺失: ${JSON.stringify(assistantTexts)}`);
assert.equal(capturedRuns.length, 2, `应捕获两次 Page AI run,实际 ${capturedRuns.length}`);
assert(capturedRuns.every((body) => body.acpRuntime === "reasonix" && body.profile === "reasonix"), "两次 run 都应走 Reasonix ACP");
assert(capturedRuns[1].acpSessionId, "第二轮请求应携带上一轮 acpSessionId");
const runtimeRuns = runtimeRunsForSession(capturedRuns[0].sessionId);
assert(runtimeRuns.length >= 2, `应有至少两条 runtime run: ${JSON.stringify(runtimeRuns)}`);
const first = { runId: runtimeRuns[0].runId, text: firstMarker };
const second = { runId: runtimeRuns[1].runId, text: secondMarker };
const infos = sessionInfoForRuns([first.runId, second.runId]);
assert.equal(infos.length, 2, `应有两条 session.info.updated,实际 ${infos.length}: ${JSON.stringify(infos)}`);
const acpSessionIds = infos.map((info) => String(info.payload.acpSessionId || "")).filter(Boolean);
@@ -194,6 +251,7 @@ async function main() {
documentId,
first,
second,
queuedPreview,
acpSessionId: acpSessionIds[0],
capturedRuns: capturedRuns.map((body) => ({ message: body.message, acpRuntime: body.acpRuntime, profile: body.profile, acpSessionId: body.acpSessionId || "" })),
sessionInfo: infos,
@@ -163,13 +163,43 @@ async function main() {
assert(runId, "缺少 runId");
const text = await page.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant .wolai-page-ai-message-text').last().textContent({ timeout: TIMEOUT_MS });
assert.equal(String(text || '').replace(/\s+/g, '').trim(), marker);
const runtimeStrip = await page.locator('[data-page-ai-runtime-strip]').textContent({ timeout: TIMEOUT_MS });
assert(String(runtimeStrip || '').includes('reasonix'), `runtime strip 应显示 reasonix: ${runtimeStrip}`);
assert(!String(runtimeStrip || '').includes('输出中'), `完成后 runtime strip 不应残留输出中: ${runtimeStrip}`);
const rows = sqliteJson(`SELECT status FROM ai_runtime_runs WHERE run_id=${sqlQuote(runId)};`);
assert.equal(rows[0] && rows[0].status, "completed", "SQLite run status 应为 completed");
const terminalEvents = sqliteJson(`SELECT COUNT(*) AS count FROM ai_runtime_events WHERE run_id=${sqlQuote(runId)} AND event_type='run.completed';`);
assert(Number(terminalEvents[0] && terminalEvents[0].count) >= 1, "应持久化 run.completed");
const screenshotPath = path.join(OUT_DIR, "task559-terminal-status.png");
await page.screenshot({ path: screenshotPath, fullPage: true });
const result = { ok: true, baseUrl, outDir: OUT_DIR, runId, marker, screenshotPath };
await page.locator('[data-page-ai-runtime-strip]').click({ timeout: TIMEOUT_MS });
await page.waitForFunction(() => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute('data-page-ai-page') === 'status', null, { timeout: TIMEOUT_MS });
await page.waitForFunction(() => {
const detail = document.querySelector('[data-page-ai-runtime-detail]')?.textContent || '';
const jobs = document.querySelector('[data-page-ai-jobs-list]')?.textContent || '';
const logs = document.querySelector('[data-page-ai-logs-list]')?.textContent || '';
const usage = document.querySelector('[data-page-ai-usage-detail]')?.textContent || '';
return detail.includes('runtime') && detail.includes('MCP/tools') && jobs.length > 0 && logs.length > 0 && usage.length > 0;
}, null, { timeout: TIMEOUT_MS });
const statusText = await page.locator('[data-page-ai-panel="status"]').textContent({ timeout: TIMEOUT_MS });
assert(String(statusText || '').includes('Current runtime'), '状态页应显示 Current runtime');
assert(String(statusText || '').includes('Queue'), '状态页应显示 Queue');
assert(String(statusText || '').includes('Jobs'), '状态页应显示 Jobs');
assert(String(statusText || '').includes('Logs tail'), '状态页应显示 Logs tail');
assert(String(statusText || '').includes('Usage'), '状态页应显示 Usage');
const runtimePanelScreenshotPath = path.join(OUT_DIR, "task559-runtime-panel.png");
await page.screenshot({ path: runtimePanelScreenshotPath, fullPage: true });
await page.setViewportSize({ width: 452, height: 900 });
await page.locator('[data-page-ai-tab="agent"]').first().click({ timeout: TIMEOUT_MS });
const mobileTabs = await page.locator('[data-page-ai-panel="agent"] .wolai-page-ai-settings-tabs [data-page-ai-tab]').evaluateAll((nodes) => nodes.map((node) => {
const rect = node.getBoundingClientRect();
return { text: node.textContent.trim(), box: { width: rect.width, height: rect.height } };
}));
assert.deepEqual(mobileTabs.map((tab) => tab.text), ['MNote', 'Reasonix', 'Hermes'], `移动宽度设置 tabs 应保持三层: ${JSON.stringify(mobileTabs)}`);
assert(mobileTabs.every((tab) => tab.box.width > 40 && tab.box.height > 24), `移动宽度 tabs 不应挤压不可点: ${JSON.stringify(mobileTabs)}`);
const mobileSettingsScreenshotPath = path.join(OUT_DIR, "task559-settings-mobile.png");
await page.screenshot({ path: mobileSettingsScreenshotPath, fullPage: true });
const result = { ok: true, baseUrl, outDir: OUT_DIR, runId, marker, screenshotPath, runtimePanelScreenshotPath, mobileSettingsScreenshotPath };
fs.writeFileSync(path.join(OUT_DIR, "result.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
await context.close().catch(() => undefined);
@@ -124,11 +124,48 @@ async function main() {
url.searchParams.set("workspaceId", workspaceId);
await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: TIMEOUT_MS });
await page.getByTestId("wolai-floating-ai").click({ timeout: TIMEOUT_MS });
await page.locator('[data-page-ai-tab="agent"]').first().click({ timeout: TIMEOUT_MS });
const settingTabs = page.locator('[data-page-ai-panel="agent"] .wolai-page-ai-settings-tabs [data-page-ai-tab]');
const settingTabTexts = await settingTabs.evaluateAll((nodes) => nodes.map((node) => node.textContent.trim()).filter(Boolean));
assert.deepEqual(settingTabTexts, ['MNote', 'Reasonix', 'Hermes'], `设置页 tabs 应只包含 MNote / Reasonix / Hermes: ${JSON.stringify(settingTabTexts)}`);
assert(!settingTabTexts.includes('Common') && !settingTabTexts.includes('Chat-only') && !settingTabTexts.includes('高级') && !settingTabTexts.includes('Runtime'), `设置页不应显示旧一级 tab: ${JSON.stringify(settingTabTexts)}`);
const mnotePanelText = await page.locator('[data-page-ai-panel="agent"]').textContent({ timeout: TIMEOUT_MS });
assert(String(mnotePanelText || '').includes('授权区域'), 'MNote tab 应显示授权区域');
assert(String(mnotePanelText || '').includes('默认上下文'), 'MNote tab 应显示默认上下文');
assert(String(mnotePanelText || '').includes('MNote 工具'), 'MNote tab 应显示 MNote 工具');
assert(String(mnotePanelText || '').includes('会话与审计'), 'MNote tab 应显示会话与审计');
await page.locator('[data-page-ai-panel="agent"] [data-page-ai-tab="reasonix-settings"]').click({ timeout: TIMEOUT_MS });
const reasonixPanelText = await page.locator('[data-page-ai-panel="reasonix-settings"]').textContent({ timeout: TIMEOUT_MS });
assert(String(reasonixPanelText || '').includes('ACP runtime'), 'Reasonix tab 应显示 ACP runtime');
assert(!/MNOTE_WEB_HERMES_UPSTREAM_URL|API key|Hermes 设置入口/.test(String(reasonixPanelText || '')), `Reasonix tab 不应显示 Hermes gateway/API key 错误: ${reasonixPanelText}`);
await page.locator('[data-page-ai-panel="reasonix-settings"] [data-page-ai-tab="hermes-settings"]').click({ timeout: TIMEOUT_MS });
const hermesPanelText = await page.locator('[data-page-ai-panel="hermes-settings"]').textContent({ timeout: TIMEOUT_MS });
assert(String(hermesPanelText || '').includes('Hermes profile'), 'Hermes tab 应显示 profile');
assert(String(hermesPanelText || '').includes('Hermes gateway'), 'Hermes tab 应显示 gateway');
assert(!String(hermesPanelText || '').includes('native-live queue'), 'Hermes tab 不应显示 Reasonix native-live queue 当前状态');
const settingsScreenshotPath = path.join(OUT_DIR, "task561-settings-ia.png");
await page.screenshot({ path: settingsScreenshotPath, fullPage: true });
await page.locator('[data-page-ai-action="history"]').first().click({ timeout: TIMEOUT_MS });
const row = page.locator(`[data-page-ai-session-row="${sessionId}"]`).first();
await row.waitFor({ state: "visible", timeout: TIMEOUT_MS });
const statusFilter = page.locator('[data-page-ai-session-status-filter]');
await statusFilter.waitFor({ state: "visible", timeout: TIMEOUT_MS });
const statusValues = await statusFilter.locator('option').evaluateAll((options) => options.map((option) => option.value));
assert(statusValues.includes('completed'), `status filter 应包含 completed: ${JSON.stringify(statusValues)}`);
assert.equal(await row.getAttribute('data-page-ai-session-status'), 'completed');
await statusFilter.selectOption('completed', { timeout: TIMEOUT_MS });
await row.waitFor({ state: "visible", timeout: TIMEOUT_MS });
await page.locator('[data-page-ai-session-search]').fill(`dashboard seed ${suffix}`, { timeout: TIMEOUT_MS });
await row.waitFor({ state: "visible", timeout: TIMEOUT_MS });
const beforeDeleteScreenshotPath = path.join(OUT_DIR, "task561-session-dashboard-before-delete.png");
await page.screenshot({ path: beforeDeleteScreenshotPath, fullPage: true });
const exportApiResponse = await context.request.fetch(`${baseUrl}/api/page-ai/sessions/${encodeURIComponent(sessionId)}/export?workspaceId=${encodeURIComponent(workspaceId)}&documentId=${encodeURIComponent(documentId)}&limit=200`, { timeout: TIMEOUT_MS });
const exportApiText = await exportApiResponse.text();
assert(exportApiResponse.ok(), `/api/page-ai/sessions export 失败: ${exportApiResponse.status()} ${exportApiText.slice(0, 500)}`);
const exportApiPayload = JSON.parse(exportApiText);
assert.deepEqual(exportApiPayload.export.formats, ['json', 'markdown', 'jsonl']);
assert(String(exportApiPayload.export.markdown || '').includes(sessionId), 'markdown export 应包含 sessionId');
assert(String(exportApiPayload.export.jsonl || '').includes('"eventType"'), 'jsonl export 应包含事件行');
await page.locator(`[data-page-ai-session-export="${sessionId}"]`).click({ timeout: TIMEOUT_MS });
await page.waitForFunction((id) => document.documentElement.getAttribute('data-mnote-page-ai-session-exported') === id, sessionId, { timeout: TIMEOUT_MS });
await page.evaluate(() => { window.prompt = () => 'Task561 Renamed'; });
@@ -144,7 +181,7 @@ async function main() {
assert(deletedRows[0] && deletedRows[0].deletedAt, "delete 应软删除 SQLite session runs");
const screenshotPath = path.join(OUT_DIR, "task561-session-dashboard.png");
await page.screenshot({ path: screenshotPath, fullPage: true });
const result = { ok: true, baseUrl, outDir: OUT_DIR, workspaceId, documentId, sessionId, runId, screenshotPath };
const result = { ok: true, baseUrl, outDir: OUT_DIR, workspaceId, documentId, sessionId, runId, settingsScreenshotPath, beforeDeleteScreenshotPath, screenshotPath };
fs.writeFileSync(path.join(OUT_DIR, "result.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8");
console.log(JSON.stringify(result, null, 2));
await context.close().catch(() => undefined);
@@ -0,0 +1,304 @@
#!/usr/bin/env node
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const http = require("node:http");
const net = require("node:net");
const os = require("node:os");
const path = require("node:path");
const { spawn, execFileSync } = require("node:child_process");
const { chromium } = require("playwright");
const ROOT = path.resolve(__dirname, "..");
const CONTROL_PLANE_DB = process.env.MNOTE_CONTROL_PLANE_DB_PATH || "/mnt/Data1T/Mnote_data/control-plane/control-plane.db";
const OUT_DIR = process.env.MNOTE_PAGE_AI_REASONIX_APPROVAL_OUTPUT_DIR || fs.mkdtempSync(path.join(ROOT, "tmp", "task562-reasonix-approval-"));
const TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 90_000);
const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome";
const ACTOR = "mnote-e2e";
const PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!";
function sqlQuote(value) { return `'${String(value).replaceAll("'", "''")}'`; }
function sqliteExec(sql) { execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); }
function sqliteJson(sql) { const out = execFileSync("sqlite3", ["-json", CONTROL_PLANE_DB, sql], { encoding: "utf8" }); return out.trim() ? JSON.parse(out) : []; }
function fileUrl(localPath) { return `file://${localPath}`; }
function localMdDocumentId(relativePath) { return `local-md:${relativePath.replaceAll("/", "~2F")}`; }
function pickPort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port = address && typeof address === "object" ? address.port : 0;
server.close(() => resolve(port));
});
server.on("error", reject);
});
}
function waitForHttpOk(url, timeoutMs) {
const deadline = Date.now() + timeoutMs;
return new Promise((resolve, reject) => {
const tick = () => {
const request = http.get(url, (response) => {
response.resume();
if (response.statusCode >= 200 && response.statusCode < 500) return resolve();
retry();
});
request.on("error", retry);
request.setTimeout(1000, () => { request.destroy(); retry(); });
};
const retry = () => {
if (Date.now() > deadline) return reject(new Error(`server_not_ready: ${url}`));
setTimeout(tick, 250);
};
tick();
});
}
function writeFakeReasonixAcp(scriptPath) {
fs.writeFileSync(scriptPath, `
import * as readline from 'node:readline';
import { appendFileSync } from 'node:fs';
import { stdin as input, stdout as output } from 'node:process';
const rl = readline.createInterface({ input, output, terminal: false });
const LOG = process.env.TASK562_FAKE_LOG || '';
let nextSessionId = 1;
let pendingPrompt = null;
let permissionRequestId = 7;
let permissionId = 'task562_perm_1';
let marker = 'TASK562_DONE';
function log(value) { if (LOG) appendFileSync(LOG, JSON.stringify(value) + '\\n', 'utf8'); }
function send(value) { process.stdout.write(JSON.stringify(value) + '\\n'); }
function promptText(prompt) { return (Array.isArray(prompt) ? prompt : []).map((block) => block && block.type === 'text' ? String(block.text || '') : '').join('\\n'); }
rl.on('line', (line) => {
const msg = JSON.parse(line);
log({ dir: 'in', method: msg.method || '', id: msg.id || null, hasResult: Boolean(msg.result), hasError: Boolean(msg.error) });
if (msg.method === 'initialize') {
send({ jsonrpc: '2.0', id: msg.id, result: { protocolVersion: 1, agentCapabilities: { loadSession: false, promptCapabilities: { embeddedContext: true } }, agentInfo: { name: 'task562-fake-reasonix', version: '1.0' }, authMethods: [] } });
return;
}
if (msg.method === 'session/new') {
send({ jsonrpc: '2.0', id: msg.id, result: { sessionId: 'task562_reasonix_session_' + nextSessionId++ } });
return;
}
if (msg.method === 'session/prompt') {
const sessionId = msg.params?.sessionId || '';
const prompt = promptText(msg.params?.prompt);
marker = (String(prompt).match(/TASK562_[A-Z0-9_]+/) || ['TASK562_DONE'])[0];
pendingPrompt = { id: msg.id, sessionId, prompt };
send({
jsonrpc: '2.0',
id: permissionRequestId,
method: 'session/request_permission',
params: {
permissionId,
toolName: 'mcp__demo__write_file',
toolCall: {
toolCallId: 'gate-call_1',
title: 'bash',
kind: 'execute',
rawInput: { command: 'echo reasonix permission' }
},
options: [
{ optionId: 'allow_once', name: 'Allow once', kind: 'allow_once' },
{ optionId: 'reject_once', name: 'Reject once', kind: 'reject_once' }
]
}
});
log({ dir: 'out', method: 'session/request_permission', id: permissionRequestId, permissionId });
return;
}
if (msg.id === permissionRequestId && msg.result) {
log({ dir: 'permission-resolved', result: msg.result });
if (!pendingPrompt) return;
send({ jsonrpc: '2.0', method: 'session/update', params: { sessionId: pendingPrompt.sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: marker } } } });
send({ jsonrpc: '2.0', id: pendingPrompt.id, result: { stopReason: 'end_turn' } });
pendingPrompt = null;
}
});
`, "utf8");
}
async function signIn(context, baseUrl) {
const response = await context.request.fetch(`${baseUrl}/api/auth`, {
method: "POST",
headers: { "content-type": "application/json" },
data: {
action: "auth:signIn",
args: {
provider: "password",
params: { account: ACTOR, password: PASSWORD, flow: "signIn" },
},
},
timeout: TIMEOUT_MS,
});
const text = await response.text();
assert(response.ok(), `/api/auth 登录失败: ${response.status()} ${text.slice(0, 500)}`);
const whoami = await context.request.fetch(`${baseUrl}/api/auth/whoami`, { timeout: TIMEOUT_MS });
const whoamiText = await whoami.text();
assert(whoami.ok(), `/api/auth/whoami 失败: ${whoami.status()} ${whoamiText.slice(0, 500)}`);
return JSON.parse(whoamiText);
}
async function selectReasonix(page) {
await page.locator("[data-page-ai-agent-button]").click({ timeout: TIMEOUT_MS });
await page.locator("[data-page-ai-agent-popover]").waitFor({ state: "visible", timeout: TIMEOUT_MS });
await page.locator('[data-page-ai-agent-id="reasonix"]').first().click({ timeout: TIMEOUT_MS });
await page.waitForFunction(
() => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute("data-mnote-acp-runtime") === "reasonix",
null,
{ timeout: TIMEOUT_MS },
);
}
async function main() {
fs.mkdirSync(OUT_DIR, { recursive: true });
const fakeScript = path.join(OUT_DIR, "fake-reasonix-acp.mjs");
const fakeLog = path.join(OUT_DIR, "fake-reasonix-acp.jsonl");
writeFakeReasonixAcp(fakeScript);
const port = await pickPort();
const baseUrl = `http://127.0.0.1:${port}`;
const runtimes = JSON.stringify([{ name: "reasonix", bin: "node", args: [fakeScript], env: { TASK562_FAKE_LOG: fakeLog }, title: "Reasonix Fake" }]);
const server = spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], {
cwd: path.join(ROOT, "rust"),
env: { ...process.env, MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`, MNOTE_WEB_ACP_RUNTIMES: runtimes, MNOTE_WEB_ACP_DEFAULT_RUNTIME: "reasonix" },
stdio: ["ignore", "pipe", "pipe"],
});
let stderr = "";
server.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task562-reasonix-approval-"));
let browser;
try {
await waitForHttpOk(`${baseUrl}/health`, TIMEOUT_MS);
browser = await chromium.launch({ headless: process.env.HEADFUL !== "1", executablePath: fs.existsSync(CHROME) ? CHROME : undefined });
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const page = await context.newPage();
const viewer = await signIn(context, baseUrl);
const actorId = viewer.userId || ACTOR;
const suffix = Date.now().toString(36).toUpperCase();
const workspaceId = `local-ws:${actorId}:task562-${suffix.toLowerCase()}`;
const rootUri = fileUrl(root);
const relativePath = `Task562-${suffix}.md`;
const documentId = localMdDocumentId(relativePath);
fs.mkdirSync(path.join(root, ".mnote"), { recursive: true });
fs.writeFileSync(path.join(root, ".mnote", "workspace.json"), `${JSON.stringify({ workspaceId, ownerId: actorId }, null, 2)}\n`, "utf8");
fs.writeFileSync(path.join(root, relativePath), `# Task562 Reasonix Approval\n\n${suffix}\n`, "utf8");
sqliteExec(`
INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision)
VALUES (${sqlQuote(actorId)}, ${sqlQuote(`${actorId}@example.com`)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'user', 'active', ${sqlQuote(new Date().toISOString())}, ${sqlQuote(new Date().toISOString())}, 1);
INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision)
VALUES (${sqlQuote(workspaceId)}, ${sqlQuote(actorId)}, 'Task562 Reasonix Approval', 'personal', ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'local_folder', 'active', ${sqlQuote(new Date().toISOString())}, ${sqlQuote(new Date().toISOString())}, 1);
`);
sqliteExec(`
INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision)
VALUES (${sqlQuote(`grant_task562_${suffix}`)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'write', 1, '["ai"]', 'smoke', 'active', ${sqlQuote(actorId)}, ${sqlQuote(new Date().toISOString())}, ${sqlQuote(new Date().toISOString())}, 1);
`);
const url = new URL(`${baseUrl}/documents/${encodeURIComponent(documentId)}`);
url.searchParams.set("sourceKind", "local_folder");
url.searchParams.set("rootUri", rootUri);
url.searchParams.set("workspaceId", workspaceId);
await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: TIMEOUT_MS });
await page.getByTestId("wolai-floating-ai").click({ timeout: TIMEOUT_MS });
await selectReasonix(page);
await page.locator('[data-page-ai-tab="agent"]').first().click({ timeout: TIMEOUT_MS });
await page.locator('[data-page-ai-tab="reasonix-settings"]').first().click({ timeout: TIMEOUT_MS });
await page.waitForFunction(
() => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute('data-page-ai-page') === 'reasonix-settings',
null,
{ timeout: TIMEOUT_MS },
);
const modelSelect = page.locator('[data-page-ai-reasonix-panel] [data-page-ai-descriptor-field="ai.agent.reasonix.model_id"]');
const approvalSelect = page.locator('[data-page-ai-reasonix-panel] [data-page-ai-descriptor-field="ai.agent.reasonix.approval_mode"]');
const planSelect = page.locator('[data-page-ai-reasonix-panel] [data-page-ai-descriptor-field="ai.agent.reasonix.plan_mode"]');
await modelSelect.selectOption("mimo-pro", { timeout: TIMEOUT_MS });
await approvalSelect.selectOption("ask", { timeout: TIMEOUT_MS });
await planSelect.selectOption("auto", { timeout: TIMEOUT_MS });
await page.waitForFunction(
() => {
const text = document.querySelector('[data-page-ai-model-status]')?.textContent || '';
return text.includes('Reasonix') && text.includes('mimo-pro') && text.includes('审批:询问') && text.includes('计划:自动');
},
null,
{ timeout: TIMEOUT_MS },
);
await page.evaluate(() => {
const drawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
const buttons = Array.from(drawer ? drawer.querySelectorAll('[data-page-ai-tab="chat"]') : []);
const button = buttons.find((node) => node instanceof HTMLElement && node.offsetParent !== null);
if (button) button.click();
});
await page.waitForFunction(
() => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute('data-page-ai-page') === 'chat',
null,
{ timeout: TIMEOUT_MS },
);
await page.waitForSelector('[data-page-ai-reasonix-quick-controls]:not([hidden])', { timeout: TIMEOUT_MS });
const quickControlsText = await page.locator('[data-page-ai-reasonix-quick-controls]').textContent({ timeout: TIMEOUT_MS });
assert(String(quickControlsText || '').includes('模型'), `聊天输入区应显示模型选择: ${quickControlsText}`);
assert(String(quickControlsText || '').includes('审批'), `聊天输入区应显示审批选择: ${quickControlsText}`);
assert(String(quickControlsText || '').includes('Plan'), `聊天输入区应显示 Plan 选择: ${quickControlsText}`);
assert.equal(await page.locator('[data-page-ai-reasonix-quick-control="ai.agent.reasonix.model_id"]').inputValue({ timeout: TIMEOUT_MS }), 'mimo-pro', '模型 quick control 应同步设置值');
assert.equal(await page.locator('[data-page-ai-reasonix-quick-control="ai.agent.reasonix.approval_mode"]').inputValue({ timeout: TIMEOUT_MS }), 'ask', '审批 quick control 应同步设置值');
assert.equal(await page.locator('[data-page-ai-reasonix-quick-control="ai.agent.reasonix.plan_mode"]').inputValue({ timeout: TIMEOUT_MS }), 'auto', 'Plan quick control 应同步设置值');
const marker = `TASK562_DONE_${suffix}`;
await page.locator('[data-page-ai-input]').fill(`只回复 ${marker},不要解释。`, { timeout: TIMEOUT_MS });
await page.locator('[data-page-ai-action="send"]').click({ timeout: TIMEOUT_MS });
await page.waitForFunction(
() => !!document.querySelector('[data-page-ai-permission-dialog]') && !document.querySelector('[data-page-ai-permission-dialog]')?.hidden,
null,
{ timeout: TIMEOUT_MS },
);
const permissionText = await page.locator('[data-page-ai-permission-dialog]').textContent({ timeout: TIMEOUT_MS });
assert(String(permissionText || '').includes('bash') && String(permissionText || '').includes('echo reasonix permission'), `权限弹窗应展示 tool 和命令: ${permissionText}`);
const resolveRequestPromise = page.waitForRequest(
(request) => request.url().includes('/resolve-permission') && request.method() === 'POST',
{ timeout: 5000 },
).catch(() => null);
const resolveResponsePromise = page.waitForResponse(
(response) => response.url().includes('/resolve-permission'),
{ timeout: 5000 },
).catch(() => null);
await page.evaluate(() => {
const button = document.querySelector('[data-page-ai-permission-dialog] [data-page-ai-permission-action="allow"]');
if (button instanceof HTMLElement) button.click();
});
const resolveRequest = await resolveRequestPromise;
assert(resolveRequest, '点击允许后应发出 resolve-permission 请求');
const resolveResponse = await resolveResponsePromise;
assert(resolveResponse && resolveResponse.ok(), `resolve-permission 应成功,实际 status=${resolveResponse && resolveResponse.status()} url=${resolveRequest.url()} body=${resolveRequest.postData() || ''}`);
await page.waitForFunction(() => document.documentElement.getAttribute('data-mnote-page-ai-run-status') === 'completed', null, { timeout: TIMEOUT_MS });
await page.waitForFunction(() => !document.querySelector('[data-page-ai-streaming="true"]'), null, { timeout: TIMEOUT_MS });
const runId = await page.evaluate(() => document.documentElement.getAttribute('data-mnote-page-ai-run-id') || '');
assert(runId, '缺少 runId');
const text = await page.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant .wolai-page-ai-message-text').last().textContent({ timeout: TIMEOUT_MS });
assert.equal(String(text || '').replace(/\s+/g, '').trim(), marker, `AI 回复不符合预期: ${JSON.stringify(text && text.slice(0, 800))}`);
const runtimeStrip = await page.locator('[data-page-ai-runtime-strip]').textContent({ timeout: TIMEOUT_MS });
assert(String(runtimeStrip || '').includes('Reasonix'), `runtime strip 应显示 Reasonix: ${runtimeStrip}`);
assert(String(runtimeStrip || '').includes('mimo-pro'), `runtime strip 应显示已选 model: ${runtimeStrip}`);
assert(String(runtimeStrip || '').includes('审批:询问'), `runtime strip 应显示审批模式: ${runtimeStrip}`);
assert(String(runtimeStrip || '').includes('计划:自动'), `runtime strip 应显示计划模式: ${runtimeStrip}`);
const rows = sqliteJson(`SELECT status FROM ai_runtime_runs WHERE run_id=${sqlQuote(runId)};`);
assert.equal(rows[0] && rows[0].status, 'completed', 'SQLite run status 应为 completed');
const screenshotPath = path.join(OUT_DIR, 'task562-reasonix-approval.png');
await page.screenshot({ path: screenshotPath, fullPage: true });
const result = { ok: true, baseUrl, outDir: OUT_DIR, runId, marker, screenshotPath };
fs.writeFileSync(path.join(OUT_DIR, 'result.json'), `${JSON.stringify(result, null, 2)}\n`, 'utf8');
console.log(JSON.stringify(result, null, 2));
await context.close().catch(() => undefined);
} catch (error) {
fs.writeFileSync(path.join(OUT_DIR, 'failure.json'), `${JSON.stringify({ ok: false, error: String(error && error.stack || error), stderr }, null, 2)}\n`, 'utf8');
throw error;
} finally {
if (browser) await browser.close().catch(() => undefined);
server.kill('SIGTERM');
await new Promise((resolve) => server.once('exit', resolve));
if (process.env.MNOTE_KEEP_TASK562_ROOT !== '1') fs.rmSync(root, { recursive: true, force: true });
}
}
main().catch((error) => { console.error(error && error.stack ? error.stack : error); process.exit(1); });
+3 -2
View File
@@ -6,9 +6,10 @@
- 先用 `mnote.knowledge_rag.status` 检查 LightRAG provider 是否可用、当前 root 是否有 source registry。
- 用 `mnote.knowledge_rag.query` 提问。默认 `mode=mix`,需要跨资料总结时可用 `global`。指定资料细节时可传 `sourcePaths`,但当前语义是 LightRAG provider 检索后,MNote 只过滤返回的 `references`;不要把 `raw` 当作已被 scope 限制的来源。
- 如果返回 `documentStructureIndex`,它只是一张章节地图。需要阅读某个章节范围时,调用 `mnote.knowledge_rag.section_context`,传 `sourcePath` 以及 `startBlockOrdinal/endBlockOrdinal``startParagraphOrdinal/endParagraphOrdinal`,并限制 `maxBlocks/maxChars`
- 回答必须引用 `references` 中的来源;优先使用返回的 `citationMarkdown`
- 若需要打开来源,调用 `mnote.knowledge_rag.open_reference`,不要手拼 LightRAG dashboard、`file://` 或 provider 内部路径。
- 如果当前 Agent runtime 额外挂载了 `lightrag_native`,可用其 `query_text` / `query_data` 完成 LightRAG 原生检索;拿到 provider 的 `file_path` / `chunk_id` 后,用 `mnote_lightrag_bridge.open_mnote_reference``mnote.knowledge_rag.open_reference` 转成 MNote 可点击定位
- 如果当前 Agent runtime 只有 MCP 入口,优先调用 `mnote_lightrag_bridge.mnote_knowledge_rag_query`;它会转到 MNote `/api/knowledge-rag/query`,保留 source registry、citation contract 和定位信息。`lightrag_native.query_text/query_data` 只用于原生 graph/documents/pipeline 管理或显式调试,不作为普通问答首选
- 引入或变更 LightRAG MCP、`mnote.knowledge_rag.*` 工具、citation 渲染或 open-reference 映射后,必须跑 Reasonix/Page AI 浏览器验收:AI 可见回答包含 citation 链接,点击该链接后 MNote 能打开对应资源并落到 locator。
边界:
@@ -17,4 +18,4 @@
- `filePath` 是 LightRAG provider-local 名称;只有 registry 命中的 reference 才能映射回 MNote source。
- 如果返回 `locatorDegraded: true`,可以使用返回的 `citationMarkdown`,但必须明说“来源定位降级”,不要伪造页码、bbox 或 provider 内部路径。
- LightRAG 不可用时,不要声称资料库已查到;提示用户启动或检查 LightRAG provider。旧 LiteParse / evidence 检索已退役,不作为 fallback。
- MNote 的职责不是重排、补召回或再做一套全文搜索;MNote 只做 allowed roots、source registry、结果过滤和 citation/open-reference 映射。
- MNote 的职责不是重排、补召回或再做一套全文搜索;MNote 只做 allowed roots、source registry、结果过滤、有限 section context 和 citation/open-reference 映射。
+12 -8
View File
@@ -1,27 +1,31 @@
---
name: mnote_lightrag_bridge
description: Use when an agent has LightRAG references or file_path/chunk_id and must convert them into MNote clickable citation links or openable local resource locators. Pair with lightrag_native for retrieval, but use this bridge for MNote source registry and citation/open-reference mapping.
description: Use when an agent must query MNote's LightRAG-backed knowledge library through MNote source registry, or convert LightRAG references/file_path/chunk_id into clickable MNote locators.
---
# MNote LightRAG Bridge
用途:把 LightRAG 原生检索结果映射成 MNote 可点击、可打开、可定位的引用。它不是检索系统本身
用途:把 agent 的资料库查询稳定路由到 MNote `knowledge_rag` facade,并把引用映射成 MNote 可点击、可打开、可定位的 locator
## 工具分层
- `lightrag_native`LightRAG 原生 MCP,负责 `query_text``query_data`、graph、documents、pipeline
- `mnote_lightrag_bridge`MNote bridge,负责 source registry、`file_path/chunk_id` 映射、`citationUrl``openAction`
- `mnote_lightrag_bridge.mnote_knowledge_rag_query`:首选资料库问答入口,调用 MNote `/api/knowledge-rag/query`,返回已映射的 references/citations
- `mnote_lightrag_bridge.mnote_knowledge_rag_section_context`:按 `documentStructureIndex` 中的 section range 拉取有限 sidecar blocks/chunks,用于大书/长文档二次阅读,不做召回或重排
- `mnote_lightrag_bridge.mnote_knowledge_rag_open_reference` / `open_mnote_reference`:把 `file_path/chunk_id` 映射为 `citationUrl``openAction`
- `lightrag_native`:只在需要 LightRAG 原生 graph/documents/pipeline 管理时使用;普通资料库问答优先走 MNote bridge,避免绕过 source registry 和 citation contract。
## 使用流程
1. 先用 `lightrag_native.query_text``lightrag_native.query_data` 做资料库检索
2. 返回的 `references[]` 里取 `file_path`,有 `chunk_id` 时一并保留
3. 调用 `mnote_lightrag_bridge.open_mnote_reference`输入 `file_path` / `chunk_id` / `workspace_id` / `root_uri`
4. 最终回答引用 bridge 返回的 `citationMarkdown` 或让 MNote UI 渲染 citation;不要手写 `/documents``file://`、LightRAG dashboard URL
1. 资料库问答直接调用 `mnote_lightrag_bridge.mnote_knowledge_rag_query`;大书/长文档问题传 `source_paths` 并设置 `include_document_structure_index=true`
2. 回答依据必须来自返回的 `references[]` / `citations[]``documentStructureIndex` 只作章节导航,不单独当证据
3. 需要阅读某章节时,调用 `mnote_knowledge_rag_section_context` `source_path` 以及 `start_block_ordinal/end_block_ordinal` `start_paragraph_ordinal/end_paragraph_ordinal`,并限制 `max_blocks/max_chars`
4. 如已有 LightRAG 原生 `file_path/chunk_id`,调用 `mnote_lightrag_bridge.open_mnote_reference``mnote_knowledge_rag_open_reference` 转成 MNote locator
5. 最终回答引用 bridge 返回的 `citationMarkdown` 或让 MNote UI 渲染 citation;不要手写 `/documents``file://`、LightRAG dashboard URL。
## 边界
- `file_path` 是 LightRAG provider 内部文件名,不等于用户本地相对路径。
- 普通问答不要直接调用 `lightrag_native.query_*` 绕过 MNote;否则 citations 可能没有 source registry 和定位信息。
- 只有 MNote registry 命中的 reference 才能映射回本地资源和页面。
- `locatorDegraded=true` 时必须说明“来源定位降级”,不能伪造页码、bbox、段落或坐标。
- 不要用 bridge 做召回、重排、全文 fallback 或资料管理。