From a8bc87173fca732c26af920d4e425f5433f0bffa Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Tue, 26 May 2026 04:01:52 +0800 Subject: [PATCH] refactor: split editor mount and sidebar page ai --- ...ime-module-maintainability-checklist-v1.md | 6 +- .../browser/sidebar-page-ai-runtime.js | 2824 +++++++++++++++++ .../mnote-web/browser/sidebar-tree-runtime.js | 2820 +--------------- rust/crates/mnote-web/src/routes/mod.rs | 5 + rust/crates/mnote-web/src/routes/web_shell.rs | 14 + rust/crates/mnote-web/src/ssr/pages/layout.rs | 88 +- .../src/editor_runtime/mindmap_node_view.rs | 2 +- .../src/editor_runtime/mod.rs | 1 + .../src/editor_runtime/mount.rs | 165 + rust/spikes/leptos-tiptap-spike/src/lib.rs | 172 +- 10 files changed, 3124 insertions(+), 2973 deletions(-) create mode 100644 rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js create mode 100644 rust/spikes/leptos-tiptap-spike/src/editor_runtime/mount.rs diff --git a/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md b/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md index 6f465e32..d59caf80 100644 --- a/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md +++ b/design/10-review/process/16-mnote-web-runtime-module-maintainability-checklist-v1.md @@ -244,7 +244,7 @@ cargo test --manifest-path rust/Cargo.toml -p mnote-web --lib routes::tree -- -- 拆分项: - [x] E1. `style.rs`:迁出 `SPIKE_STYLE`,根 `lib.rs` 只引用常量。 -- [ ] E2. `mount.rs`:迁出 mount context、mounted handles、`mount_app_into` 周边可独立部分;`#[wasm_bindgen]` wrapper 可暂留根文件。 +- [x] E2. `mount.rs`:迁出 mount context、mounted handles、`mount_app_into` 周边可独立部分;`#[wasm_bindgen]` wrapper 可暂留根文件。 - [x] E3. `runtime_bridge.rs`:迁出 `dispatch_runtime_event`、`dispatch_*_to_target`、host command listener install。 - [x] E4. `block_transform.rs`:迁出 `run_block_turn_into_action`、`top_level_block_matches_action`、page/block transform helpers。 - [ ] E5. `slash_menu_view.rs`:迁出 slash menu Leptos view 和 click handling。 @@ -272,8 +272,8 @@ cargo test --manifest-path rust/Cargo.toml -p mnote-web --lib routes::web_shell 拆分项: -- [ ] F1. 审计 `sidebar-tree-runtime.js` 中所有 Page AI / ACP / Hermes / Reasonix 字符串和函数。 -- [ ] F2. 纯 UI panel host 可以迁入 `browser/page-ai-panel-runtime.js`。 +- [x] F1. 审计 `sidebar-tree-runtime.js` 中所有 Page AI / ACP / Hermes / Reasonix 字符串和函数。 +- [x] F2. 纯 UI panel host 已迁入 `browser/sidebar-page-ai-runtime.js`。 - [ ] F3. ACP/Hermes session 行为不得迁入 tree runtime;需要新建或更新 `design/07-ai/process/*` checklist 后再实施。 验收命令: diff --git a/rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js new file mode 100644 index 00000000..fb81db4d --- /dev/null +++ b/rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js @@ -0,0 +1,2824 @@ +export function createSidebarPageAiRuntime(context) { + const { + buildLocalFileOpenUrl, + currentDocumentId, + currentPageAggregate, + currentPageOptions, + currentRootUri, + currentSourceKind, + escapeHtml, + cssEscape, + openLocalResourceInActiveTab, + pageUiState, + resolveWorkspaceId, + searchText, + } = context; + var PAGE_AI_SESSION_STORAGE_VERSION = 3; + + function textFromUnknown(value) { + if (value == null) return ''; + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value); + if (Array.isArray(value)) return value.map(textFromUnknown).filter(Boolean).join(' '); + if (typeof value !== 'object') return ''; + var parts = []; + ['text', 'title', 'content', 'children', 'blocks', 'body'].forEach(function(key) { + if (Object.prototype.hasOwnProperty.call(value, key)) { + var text = textFromUnknown(value[key]); + if (text) parts.push(text); + } + }); + return parts.join(' '); + } + + function readLocalEditorBlocks() { + var editor = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror'); + if (!(editor instanceof HTMLElement)) return []; + return Array.from(editor.children).filter(function(node) { + return node instanceof HTMLElement; + }).map(function(node, index) { + var tag = String(node.tagName || '').toUpperCase(); + var headingMatch = tag.match(/^H([1-6])$/); + var type = headingMatch ? 'heading' : 'paragraph'; + var text = searchText(node.textContent || ''); + var id = searchText(node.getAttribute('data-id') || node.id || 'local-block-' + index); + var props = headingMatch ? { level: Number(headingMatch[1]) } : {}; + return { id: id, type: type, props: props, content: text }; + }).filter(function(block) { + return block.content || block.type === 'heading'; + }); + } + + function buildPageAiLocalSubtree(blocks, title) { + var documentId = currentDocumentId() || 'current-page'; + var rootNodeId = 'page:' + documentId; + var headingCounters = [0, 0, 0, 0, 0, 0]; + var headingStack = []; + var nodes = [{ + id: rootNodeId, + nodeId: rootNodeId, + nodeType: 'page', + blockId: null, + blockType: 'page', + title: title || '', + parentNodeId: null, + headingLevel: null + }]; + var outline = []; + var evidence = []; + blocks.forEach(function(block, index) { + var level = block.type === 'heading' ? Math.max(1, Math.min(6, Number(block.props && block.props.level || 1))) : null; + if (level != null) { + while (headingStack.length && headingStack[headingStack.length - 1].level >= level) headingStack.pop(); + } + var parentNodeId = headingStack.length ? headingStack[headingStack.length - 1].nodeId : rootNodeId; + var nodeId = 'local-node:' + String(block.id || index); + var titleText = block.content || (block.type === 'heading' ? '未命名标题' : ''); + nodes.push({ + id: nodeId, + nodeId: nodeId, + nodeType: 'block', + blockId: block.id, + blockType: block.type, + title: titleText, + parentNodeId: parentNodeId, + headingLevel: level + }); + if (level != null) { + headingCounters[level - 1] += 1; + for (var i = level; i < headingCounters.length; i += 1) headingCounters[i] = 0; + var numbering = headingCounters.slice(0, level).filter(function(count) { return count > 0; }).join('.'); + outline.push({ + id: 'local-outline:' + String(block.id || index), + nodeId: nodeId, + anchorBlockId: block.id, + level: level, + title: titleText, + numbering: numbering + }); + headingStack.push({ level: level, nodeId: nodeId }); + } + if (titleText) { + evidence.push({ + id: 'local-evidence:' + String(block.id || index), + nodeId: nodeId, + anchorBlockId: block.id, + text: titleText, + kind: block.type + }); + } + }); + return { + projectionId: 'local-editor-dom:' + documentId, + rootNode: { + id: rootNodeId, + documentId: documentId, + title: title || '', + nodeType: 'page' + }, + subtree: { + rootNodeId: rootNodeId, + nodes: nodes + }, + outline: outline, + evidence: evidence, + stats: { + nodeCount: nodes.length, + headingCount: outline.length, + evidenceCount: evidence.length + }, + source: 'local' + }; + } + + function currentPageAiContextSnapshot() { + var aggregate = currentPageAggregate(); + var body = aggregate.body || {}; + var serverSubtree = aggregate.tree && aggregate.tree.pageSubtree ? aggregate.tree.pageSubtree : null; + return { + aggregate: aggregate, + body: body, + subtree: serverSubtree, + pageSubtreeSource: serverSubtree ? 'server' : 'none' + }; + } + + function currentPageAiSelectedText() { + try { + var selection = window.getSelection ? window.getSelection() : null; + return selection ? searchText(selection.toString() || '') : ''; + } catch (_) { + return ''; + } + } + + function pageAiProjectionBlocks(aggregate) { + var blocks = aggregate && aggregate.body && aggregate.body.blockDocument && aggregate.body.blockDocument.blocks; + return Array.isArray(blocks) ? blocks : []; + } + + function pageAiBlockText(block) { + return searchText(block && (block.text || block.title || block.content) || ''); + } + + function pageAiSelectedBlockIdsFromSelection() { + try { + var selection = window.getSelection ? window.getSelection() : null; + if (!selection || selection.rangeCount === 0 || searchText(selection.toString() || '') === '') return []; + var range = selection.getRangeAt(0); + var editor = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror'); + if (!(editor instanceof HTMLElement)) return []; + return Array.from(editor.children).filter(function(node) { + if (!(node instanceof HTMLElement)) return false; + try { + return range.intersectsNode(node); + } catch (_) { + return false; + } + }).map(function(node) { + return searchText(node.getAttribute('data-id') || node.id || ''); + }).filter(Boolean); + } catch (_) { + return []; + } + } + + function pageAiBlocksToPageXml(blocks, aggregate) { + var revision = aggregate && aggregate.body ? String(aggregate.body.revision || '') : ''; + var pageId = currentDocumentId() || 'current-page'; + var lines = ['']; + blocks.forEach(function(block) { + var blockId = String(block && (block.blockId || block.id) || ''); + var type = String(block && block.type || 'paragraph'); + var revisionRef = String(block && block.revisionRef || ''); + var level = block && block.attrs && block.attrs.level ? ' level="' + escapeHtml(block.attrs.level) + '"' : ''; + lines.push(' ' + escapeHtml(pageAiBlockText(block)) + ''); + }); + lines.push(''); + return lines.join('\n'); + } + + function buildPageAiContext(contextSnapshot, scope, selectedText) { + var aggregate = contextSnapshot.aggregate || {}; + var body = aggregate.body || {}; + var allBlocks = pageAiProjectionBlocks(aggregate); + var selectedBlockIds = scope === 'selection' ? pageAiSelectedBlockIdsFromSelection() : []; + var selectedSet = {}; + selectedBlockIds.forEach(function(id) { selectedSet[id] = true; }); + var selectedBlocks = selectedBlockIds.length + ? allBlocks.filter(function(block) { return selectedSet[String(block.blockId || block.id || '')]; }) + : []; + var contextBlocks = selectedBlocks.length ? selectedBlocks : allBlocks.slice(0, 120); + var truncated = !selectedBlocks.length && allBlocks.length > contextBlocks.length; + return { + schema: 'mnote.page_ai_context.v1', + workspaceId: resolveWorkspaceId(document.body), + documentId: currentDocumentId(), + scope: scope, + revision: body.revision || null, + conflictDetectionKey: body.conflictDetectionKey || null, + selectedText: selectedText || '', + selectedBlockIds: selectedBlockIds, + allowedTargetBlockIds: selectedBlockIds, + selectedBlocks: selectedBlocks, + contextBlocks: contextBlocks, + pageText: contextBlocks.map(pageAiBlockText).filter(Boolean).join('\n'), + pageXml: pageAiBlocksToPageXml(contextBlocks, aggregate), + truncated: truncated, + warnings: truncated ? [{ code: 'page_ai_context_truncated', message: '页面 AI context 已按前 120 个块裁剪' }] : [] + }; + } + + function pageAiScopedPageContext(contextSnapshot) { + var aggregate = contextSnapshot.aggregate || {}; + var body = contextSnapshot.body || {}; + var subtree = contextSnapshot.subtree || null; + var outline = subtree && subtree.outline ? subtree.outline : null; + var scope = pageUiState.pageAiContextScope || 'page'; + var title = aggregate.head && aggregate.head.title ? aggregate.head.title : ''; + var selectedText = scope === 'selection' ? currentPageAiSelectedText() : ''; + var aiContext = buildPageAiContext(contextSnapshot, scope, selectedText); + return { + pageContext: { + contextScope: scope, + documentBlocks: null, + node: { + documentId: currentDocumentId(), + title: title + }, + subtree: null, + outline: null, + pageSubtreeSource: scope === 'page' ? (contextSnapshot.pageSubtreeSource || 'none') : 'scope:' + scope, + evidence: selectedText ? [{ kind: 'selection', text: selectedText }] : null, + pageOptions: scope === 'options' || scope === 'page' ? currentPageOptions() : null, + contentAccess: 'mnote.doc.fetch', + aiContext: aiContext + }, + selectedText: selectedText, + selectedBlockId: aiContext.selectedBlockIds[0] || null + }; + } + + + function updatePageAiTriggerState() { + var trigger = document.querySelector('[data-testid="wolai-floating-ai"]'); + if (!(trigger instanceof HTMLElement)) return; + trigger.setAttribute('data-state', pageUiState.pageAiOpen ? 'open' : 'closed'); + trigger.setAttribute('aria-expanded', pageUiState.pageAiOpen ? 'true' : 'false'); + } + + + function pageAiSuggestions() { + var title = searchText(document.querySelector('[data-page-title-current="true"]')?.textContent) || '当前页面'; + return [ + '帮我总结《' + title + '》当前内容', + '把当前页面改写得更简洁一些', + '提炼当前页的关键待办和行动项', + '基于当前页内容生成一个三段式摘要' + ]; + } + + function pageAiStorageKey() { + return 'hermes_page_ai_session:' + currentDocumentId(); + } + + function pageAiTimestamp(value) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim()) { + var parsed = Date.parse(value); + if (Number.isFinite(parsed)) return parsed; + } + return Date.now(); + } + + function pageAiBackendSessionQuery(extra) { + var params = new URLSearchParams(); + params.set('source', 'acp'); + params.set('workspaceId', resolveWorkspaceId(document.body)); + params.set('documentId', currentDocumentId()); + params.set('profile', pageAiRunProfile()); + params.set('sourceKind', currentSourceKind()); + if (currentRootUri()) params.set('rootUri', currentRootUri()); + Object.keys(extra || {}).forEach(function(key) { + var value = extra[key]; + if (value !== undefined && value !== null && String(value).trim() !== '') { + params.set(key, String(value)); + } + }); + return params.toString(); + } + + function pageAiNewSession(title) { + var now = Date.now(); + return { + id: 'sess_' + now + '_' + Math.random().toString(16).slice(2, 8), + title: title || '新会话', + profile: pageAiCurrentProfile(), + acpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix', + createdAt: now, + updatedAt: now, + source: 'local', + usage: null, + status: 'idle', + messages: [] + }; + } + + function pageAiUsageSummary(usage) { + if (!usage || typeof usage !== 'object') return ''; + var used = usage.used ?? usage.contextUsed ?? usage.input_tokens ?? usage.inputTokens ?? usage.prompt_tokens ?? usage.promptTokens; + var size = usage.size ?? usage.contextSize ?? usage.total_tokens ?? usage.totalTokens; + var output = usage.output_tokens ?? usage.outputTokens ?? usage.completion_tokens ?? usage.completionTokens; + var parts = []; + if (Number.isFinite(Number(used))) parts.push('ctx ' + Number(used)); + if (Number.isFinite(Number(size)) && Number(size) > 0) parts.push('/ ' + Number(size)); + if (Number.isFinite(Number(output)) && Number(output) > 0) parts.push('out ' + Number(output)); + return parts.join(' ') || ''; + } + + 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 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'; + return { + role: 'tool', + kind: 'permission', + permissionId: permissionId, + toolName: toolName, + argsSummary: pageAiPreviewValue(args), + content: decision === 'denied' ? '已自动拒绝权限请求' : (decision === 'allowed' ? '已自动允许权限请求' : '等待权限确认'), + resolved: decision === 'denied' || decision === 'allowed', + decision: decision + }; + } + + function pageAiApplyPermissionEvent(eventName, payloadText) { + var payload = null; + try { + payload = JSON.parse(payloadText || 'null'); + } catch (_) { + payload = {}; + } + var message = pageAiPermissionMessage(payload, eventName); + var existing = pageUiState.pageAiMessages.find(function(item) { + return item.kind === 'permission' && item.permissionId === message.permissionId; + }); + if (existing) { + Object.assign(existing, message); + } else { + pageUiState.pageAiMessages.push(message); + } + pageUiState.pageAiPermissionRequests = pageUiState.pageAiPermissionRequests.filter(function(item) { + return item.permissionId !== message.permissionId; + }).concat([message]).slice(-20); + if (!message.resolved) { + pageAiShowPermissionDialog(message); + } else { + pageAiHidePermissionDialog(); + } + } + + function pageAiResolvePermission(permissionId, decision) { + permissionId = String(permissionId || '').trim(); + if (!permissionId) return; + // 调用后端 resolve-permission 端点,让 ACP agent 得到真实响应 + var runId = pageUiState.pageAiCurrentRunId; + if (runId) { + fetch('/api/hermes/client/runs/' + encodeURIComponent(runId) + '/resolve-permission', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ permissionId: permissionId, decision: decision }) + }).then(function(response) { + if (!response.ok) console.warn('resolve-permission 后端返回异常', response.status); + }).catch(function(err) { + console.warn('resolve-permission 请求失败', err); + }); + } else { + console.warn('resolve-permission: 无活跃 runId,只能本地更新'); + } + // 本地乐观更新 UI + pageUiState.pageAiMessages.forEach(function(item) { + if (item.kind === 'permission' && item.permissionId === permissionId) { + item.resolved = true; + item.decision = decision; + item.content = decision === 'allow' ? '已允许权限请求' : '已拒绝权限请求'; + } + }); + var dialog = document.querySelector('[data-page-ai-permission-dialog]'); + if (dialog instanceof HTMLElement) dialog.hidden = true; + renderPageAiConversation(); + } + + function pageAiOpenLocation(loc) { + var path = String(loc || '').trim(); + if (!path) return; + openLocalResourceInActiveTab({ path: path }).then(function(opened) { + if (!opened) { + var href = buildLocalFileOpenUrl(path, false); + if (href) window.open(href, '_blank', 'noopener,noreferrer'); + } + }); + } + + function pageAiHidePermissionDialog() { + var dialog = document.querySelector('[data-page-ai-permission-dialog]'); + if (dialog instanceof HTMLElement) dialog.hidden = true; + } + + function pageAiShowPermissionDialog(message) { + if (!message || message.kind !== 'permission') return; + if (message.resolved) { + pageAiHidePermissionDialog(); + return; + } + var dialog = document.querySelector('[data-page-ai-permission-dialog]'); + if (!(dialog instanceof HTMLElement)) { + dialog = document.createElement('div'); + dialog.className = 'wolai-page-ai-permission-dialog'; + dialog.setAttribute('data-page-ai-permission-dialog', 'true'); + dialog.innerHTML = '' + + ''; + document.body.appendChild(dialog); + } + var tool = dialog.querySelector('[data-page-ai-permission-tool]'); + 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); + } + }); + dialog.hidden = false; + } + + function pageAiNormalizeSessions(sessions) { + return (Array.isArray(sessions) ? sessions : []) + .slice(0, 20) + .map(function(session) { + var sessionStorage = String(session && (session.sessionStorage || session.session_storage) || '').trim(); + var persistence = String(session && session.persistence || '').trim(); + return { + id: String(session && session.id || '').trim() || pageAiNewSession().id, + title: String(session && session.title || '').trim() || '新会话', + profile: String(session && session.profile || pageAiCurrentProfile()).trim() || 'default', + acpRuntime: String(session && session.acpRuntime || 'reasonix').trim(), + createdAt: pageAiTimestamp(session && session.createdAt), + updatedAt: pageAiTimestamp(session && session.updatedAt), + source: String(session && session.source || 'local').trim() || 'local', + persistence: persistence, + sessionStorage: sessionStorage, + permissionLevel: String(session && (session.permissionLevel || session.permission_level) || '').trim(), + shareId: String(session && (session.shareId || session.share_id) || '').trim(), + runId: String(session && (session.runId || session.run_id) || '').trim(), + status: String(session && session.status || '').trim(), + 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) { + return Number(b.updatedAt || 0) - Number(a.updatedAt || 0); + }); + } + + function pageAiNormalizeBackendSessionRow(row) { + if (!row || typeof row !== 'object') return null; + var payload = row.payload && typeof row.payload === 'object' ? row.payload : {}; + var sessionId = String(row.sessionId || row.session_id || '').trim(); + if (!sessionId) return null; + var title = String(row.title || payload.title || payload.message || '').trim(); + 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(); + if (!sessionStorage && persistence === 'convex_acp_runtime_store') sessionStorage = 'cloud'; + if (!sessionStorage && persistence === 'local_ai_session_jsonl') sessionStorage = String(row.shareId || payload.shareId || '').trim() ? 'local_shared' : 'local_private'; + return { + id: sessionId, + title: title || '当前页问答', + profile: String(row.profile || payload.profile || pageAiRunProfile()).trim() || 'default', + acpRuntime: String(row.acpRuntime || row.acp_runtime || payload.acpRuntime || 'reasonix').trim(), + createdAt: pageAiTimestamp(row.createdAt || row.created_at), + updatedAt: pageAiTimestamp(row.updatedAt || row.updated_at), + source: sessionStorage === 'local_private' || sessionStorage === 'local_shared' ? 'local' : 'acp', + persistence: persistence, + sessionStorage: sessionStorage, + permissionLevel: String(row.permissionLevel || row.permission_level || payload.permissionLevel || '').trim(), + shareId: String(row.shareId || row.share_id || payload.shareId || '').trim(), + runId: String(row.runId || row.run_id || '').trim(), + status: String(row.status || (row.runtime && row.runtime.status) || '').trim(), + usage: row.usage && typeof row.usage === 'object' ? row.usage : null, + preview: String(payload.message || row.snippet || '').trim(), + messages: [] + }; + } + + function pageAiMergeSessions(localSessions, backendSessions) { + var byId = {}; + pageAiNormalizeSessions(localSessions).forEach(function(session) { + byId[session.id] = session; + }); + pageAiNormalizeSessions(backendSessions).forEach(function(session) { + var existing = byId[session.id]; + byId[session.id] = Object.assign({}, existing || {}, session, { + messages: session.messages.length ? session.messages : (existing && existing.messages || []) + }); + }); + return pageAiNormalizeSessions(Object.keys(byId).map(function(id) { return byId[id]; })); + } + + function pageAiSessionStorageLabel(session) { + var storage = String(session && (session.sessionStorage || session.session_storage) || '').trim(); + var persistence = String(session && session.persistence || '').trim(); + if (storage === 'local_shared') return '共享会话'; + if (storage === 'local_private') return '本地私有'; + if (storage === 'cloud' || persistence === 'convex_acp_runtime_store') return '云端会话'; + if (persistence === 'local_ai_session_jsonl') return '本地私有'; + return String(session && session.source || '').trim() === 'acp' ? '云端会话' : '本地私有'; + } + + function pageAiLoadSessions() { + if (pageUiState.pageAiSessions.length && pageUiState.pageAiActiveSessionId) return; + try { + var raw = window.localStorage.getItem(pageAiStorageKey()); + var parsed = raw ? JSON.parse(raw) : null; + var activeId = String(parsed && parsed.activeSessionId || '').trim(); + var activeProfile = String(parsed && parsed.activeProfileName || '').trim(); + var activeAcpRuntime = String(parsed && parsed.activeAcpRuntime || '').trim(); + var sessions = pageAiNormalizeSessions(parsed && parsed.sessions); + var storageVersion = Number(parsed && parsed.version || 0); + if (storageVersion >= PAGE_AI_SESSION_STORAGE_VERSION && activeAcpRuntime) pageUiState.pageAiAcpRuntime = activeAcpRuntime; + if (activeProfile) pageAiSetActiveProfile(activeProfile); + if (sessions.length) { + pageUiState.pageAiSessions = sessions; + var activeSession = sessions.find(function(session) { return session.id === activeId; }) || sessions[0]; + pageUiState.pageAiActiveSessionId = activeSession.id; + if (activeSession.profile) pageAiSetActiveProfile(activeSession.profile); + if (!pageUiState.pageAiAcpRuntime && activeSession.acpRuntime) pageUiState.pageAiAcpRuntime = activeSession.acpRuntime; + pageUiState.pageAiMessages = Array.isArray(activeSession.messages) ? activeSession.messages.slice() : []; + return; + } + if (activeId) { + pageUiState.pageAiSessions = [pageAiNewSession('当前页问答')]; + pageUiState.pageAiSessions[0].id = activeId; + pageUiState.pageAiActiveSessionId = activeId; + pageUiState.pageAiMessages = []; + return; + } + } catch (_) {} + var fresh = pageAiNewSession(); + pageUiState.pageAiSessions = [fresh]; + pageUiState.pageAiActiveSessionId = fresh.id; + pageUiState.pageAiMessages = []; + } + + async function pageAiLoadBackendSessions() { + var response = await fetch('/api/hermes/client/sessions?' + pageAiBackendSessionQuery({ limit: 50 }), { + 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, 'session_list_failed_' + response.status)); + } + var backendSessions = pageAiNormalizeArray(payload.sessions).map(pageAiNormalizeBackendSessionRow).filter(Boolean); + if (!backendSessions.length) return []; + pageUiState.pageAiSessions = pageAiMergeSessions(pageUiState.pageAiSessions, backendSessions); + if (!pageUiState.pageAiActiveSessionId || !pageUiState.pageAiSessions.find(function(session) { return session.id === pageUiState.pageAiActiveSessionId; })) { + pageUiState.pageAiActiveSessionId = pageUiState.pageAiSessions[0].id; + } + var active = pageAiCurrentSession(); + if (active) { + if (active.profile) pageAiSetActiveProfile(active.profile); + if (!pageUiState.pageAiAcpRuntime && active.acpRuntime) pageUiState.pageAiAcpRuntime = active.acpRuntime; + pageUiState.pageAiMessages = Array.isArray(active.messages) ? active.messages.slice() : pageUiState.pageAiMessages; + } + pageUiState.pageAiSessionError = ''; + pageAiPersistSessions(); + renderPageAiConversation(); + renderPageAiControls(); + return backendSessions; + } + + function pageAiMessageFromRuntimeEvent(event) { + if (!event || typeof event !== 'object') return null; + var eventType = String(event.eventType || event.event_type || event.event || '').trim(); + var payload = event.payload && typeof event.payload === 'object' ? event.payload : event; + if (eventType === 'message.delta') { + var delta = String(payload.delta || payload.text || payload.output_text || '').trim(); + return delta ? { role: 'assistant', content: delta } : null; + } + if (eventType === 'thought.delta') { + var thought = String(payload.delta || payload.text || '').trim(); + return thought ? { role: 'assistant', kind: 'thought', content: thought } : null; + } + if (eventType === 'tool.started' || eventType === 'tool.completed' || eventType === 'tool.failed') { + var toolName = String(payload.toolName || payload.tool || payload.name || eventType).trim(); + var rawLocations = payload.locations; + return { + role: 'tool', + content: toolName, + toolName: toolName, + toolCallId: String(payload.toolCallId || payload.tool_call_id || payload.id || ''), + toolKind: String(payload.kind || ''), + status: eventType === 'tool.completed' ? 'completed' : (eventType === 'tool.failed' ? 'failed' : 'running'), + argsSummary: pageAiPreviewValue(payload.args || payload.arguments || payload.input), + resultSummary: pageAiPreviewValue(payload.summary || payload.result || payload.output || payload.error), + locations: Array.isArray(rawLocations) ? rawLocations.filter(function(l) { return typeof l === 'string' || (typeof l === 'object' && l && l.path); }).map(function(l) { return typeof l === 'string' ? l : l.path; }) : [], + traceId: String(payload.traceId || payload.trace_id || ''), + auditId: String(payload.auditId || payload.audit_id || '') + }; + } + if (eventType === 'permission.requested' || eventType === 'permission.denied' || eventType === 'permission.allowed') { + return pageAiPermissionMessage(payload, eventType); + } + return null; + } + + function pageAiApplyBackendSessionDetail(payload) { + var sessionPayload = payload && payload.session ? payload.session : {}; + var runs = pageAiNormalizeArray(sessionPayload.runs); + var latest = runs.length ? pageAiNormalizeBackendSessionRow(runs[0]) : null; + var events = pageAiNormalizeArray(payload && payload.events); + var messages = pageAiNormalizeArray(sessionPayload.messages).map(function(message) { + return { + role: message.role === 'user' ? 'user' : (message.role === 'tool' ? 'tool' : 'assistant'), + content: String(message.content || '') + }; + }).filter(function(message) { return message.content; }); + events.forEach(function(event) { + var message = pageAiMessageFromRuntimeEvent(event); + if (message) messages.push(message); + }); + var current = pageAiCurrentSession(); + if (latest && current) { + Object.assign(current, latest); + } + if (current) { + current.messages = messages.slice(-300); + current.updatedAt = Math.max(Number(current.updatedAt || 0), Date.now()); + if (latest && latest.usage) current.usage = latest.usage; + } + pageAiApplyRuntimeState(payload && payload.runtime); + pageUiState.pageAiMessages = messages.slice(-300); + pageAiPersistSessions(); + } + + 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 }), { + 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, 'session_detail_failed_' + response.status)); + } + pageAiApplyBackendSessionDetail(payload); + renderPageAiConversation(); + renderPageAiControls(); + return payload; + } + + async function pageAiSearchBackendSessions(query) { + var q = String(query || '').trim(); + pageUiState.pageAiSessionSearchQuery = q; + if (!q) { + pageUiState.pageAiSessionSearchResults = []; + renderPageAiConversation(); + return []; + } + var response = await fetch('/api/hermes/client/sessions/search?' + pageAiBackendSessionQuery({ q: q, limit: 20 }), { + 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, 'session_search_failed_' + response.status)); + } + pageUiState.pageAiSessionSearchResults = pageAiNormalizeArray(payload.results).map(function(row) { + var normalized = pageAiNormalizeBackendSessionRow(row) || {}; + normalized.snippet = String(row && row.snippet || normalized.preview || '').trim(); + return normalized; + }).filter(function(row) { return row.id; }); + renderPageAiConversation(); + return pageUiState.pageAiSessionSearchResults; + } + + function pageAiPersistSessions() { + document.documentElement.setAttribute('data-mnote-page-ai-session-owner', 'hermes'); + document.documentElement.setAttribute('data-mnote-page-ai-session-key', pageAiStorageKey()); + try { + pageAiSyncCurrentSessionMessages(); + window.localStorage.setItem(pageAiStorageKey(), JSON.stringify({ + version: PAGE_AI_SESSION_STORAGE_VERSION, + activeSessionId: pageUiState.pageAiActiveSessionId, + activeProfileName: pageAiCurrentProfile(), + activeAcpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix', + sessions: pageAiNormalizeSessions(pageUiState.pageAiSessions) + })); + } catch (_) {} + } + + async function pageAiEnsureHermesSession(forceCreate) { + pageAiLoadSessions(); + var current = pageAiCurrentSession(); + if (!forceCreate && current && String(current.id || '').startsWith('mnote_') && current.profile === pageAiCurrentProfile()) return current; + var response = await fetch('/api/hermes/client/sessions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: resolveWorkspaceId(document.body), + documentId: currentDocumentId(), + sourceKind: currentSourceKind(), + rootUri: currentRootUri(), + traceId: 'page-ai-' + Date.now().toString(36), + profile: pageAiCurrentProfile(), + title: current && current.title ? current.title : '当前页问答' + }) + }); + var payload = await response.json().catch(function(){ return null; }); + if (!response.ok || !payload || payload.ok !== true) { + throw new Error(pageAiErrorMessage(payload, 'hermes_session_failed_' + response.status)); + } + var session = { + id: String(payload.sessionId || '').trim(), + title: String(payload.title || '当前页问答'), + profile: String(payload.profile || pageAiCurrentProfile()).trim() || 'default', + acpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix', + persistence: String(payload.persistence || '').trim(), + sessionStorage: String(payload.sessionStorage || '').trim(), + permissionLevel: String(payload.permissionLevel || '').trim(), + shareId: String(payload.shareId || '').trim(), + createdAt: Date.now(), + updatedAt: Date.now(), + messages: pageUiState.pageAiMessages.slice() + }; + pageUiState.pageAiSessions = pageAiNormalizeSessions([session]); + pageUiState.pageAiActiveSessionId = session.id; + pageAiPersistSessions(); + renderPageAiConversation(); + return session; + } + + 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({}), { + method: 'POST', + headers: { 'accept': 'application/json' } + }); + if (!response.ok) return; + var payload = await response.json().catch(function(){ return null; }); + if (payload && payload.persistence === 'convex_acp_runtime_store') { + pageAiApplyBackendSessionDetail(payload); + renderPageAiConversation(); + renderPageAiControls(); + return; + } + var session = payload && (payload.session || (payload.upstream && payload.upstream.session) || payload); + var messages = session && Array.isArray(session.messages) ? session.messages : []; + pageAiApplyRuntimeState(payload && payload.runtime); + if (session && (session.profile || session.profileName)) { + current.profile = String(session.profile || session.profileName || current.profile || pageAiCurrentProfile()); + } + if (!messages.length) { + renderPageAiControls(); + return; + } + pageUiState.pageAiMessages = messages.slice(-300).map(function(message) { + return { + role: message.role === 'user' ? 'user' : (message.role === 'tool' ? 'tool' : 'assistant'), + content: String(message.content || '') + }; + }); + current.messages = pageUiState.pageAiMessages.slice(); + current.updatedAt = Date.now(); + renderPageAiConversation(); + renderPageAiControls(); + } + + function pageAiCurrentSession() { + return pageUiState.pageAiSessions.find(function(session) { + return session.id === pageUiState.pageAiActiveSessionId; + }) || null; + } + + function pageAiSyncCurrentSessionMessages() { + var session = pageAiCurrentSession(); + if (!session) return; + session.messages = Array.isArray(pageUiState.pageAiMessages) ? pageUiState.pageAiMessages.slice(-300) : []; + session.profile = pageAiCurrentProfile(); + session.acpRuntime = pageUiState.pageAiAcpRuntime || 'reasonix'; + session.updatedAt = Date.now(); + } + + function pageAiSetActiveSession(sessionId) { + var session = pageUiState.pageAiSessions.find(function(item) { return item.id === sessionId; }); + if (!session) return; + pageUiState.pageAiActiveSessionId = session.id; + pageUiState.pageAiMessages = Array.isArray(session.messages) ? session.messages.slice() : []; + pageUiState.pageAiPage = 'chat'; + pageAiPersistSessions(); + renderPageAiControls(); + renderPageAiConversation(); + if (session.source === 'acp' || String(session.id || '').startsWith('mnote_')) { + void pageAiLoadBackendSessionDetail(session.id).catch(function(error) { + pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error); + renderPageAiControls(); + }); + } + } + + function pageAiStartNewSession() { + var session = pageAiNewSession(); + pageUiState.pageAiSessions = pageAiNormalizeSessions([session].concat(pageUiState.pageAiSessions)); + pageUiState.pageAiActiveSessionId = session.id; + pageUiState.pageAiMessages = []; + pageUiState.pageAiPage = 'chat'; + pageAiPersistSessions(); + renderPageAiControls(); + renderPageAiConversation(); + } + + async function pageAiRenameBackendSession(sessionId) { + var session = pageUiState.pageAiSessions.find(function(item) { return item.id === sessionId; }); + if (!session) return; + var title = window.prompt('重命名 AI 会话', session.title || '当前页问答'); + if (title === null) return; + title = String(title || '').trim(); + if (!title) return; + var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(sessionId) + '/rename?' + pageAiBackendSessionQuery({}), { + method: 'POST', + headers: { 'content-type': 'application/json', 'accept': 'application/json' }, + body: JSON.stringify({ title: title }) + }); + var payload = await response.json().catch(function(){ return null; }); + if (!response.ok || !payload || payload.ok !== true) { + throw new Error(pageAiErrorMessage(payload, 'session_rename_failed_' + response.status)); + } + session.title = String((payload.result && payload.result.title) || title); + session.updatedAt = Date.now(); + pageAiPersistSessions(); + renderPageAiConversation(); + renderPageAiControls(); + } + + async function pageAiDeleteBackendSession(sessionId) { + var session = pageUiState.pageAiSessions.find(function(item) { return item.id === sessionId; }); + if (!session) return; + if (!window.confirm('确定删除 AI 会话“' + (session.title || sessionId) + '”吗?')) return; + var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(sessionId) + '?' + pageAiBackendSessionQuery({}), { + method: 'DELETE', + 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, 'session_delete_failed_' + response.status)); + } + pageUiState.pageAiSessions = pageUiState.pageAiSessions.filter(function(item) { return item.id !== sessionId; }); + if (pageUiState.pageAiActiveSessionId === sessionId) { + var next = pageUiState.pageAiSessions[0] || pageAiNewSession(); + if (!pageUiState.pageAiSessions.length) pageUiState.pageAiSessions = [next]; + pageUiState.pageAiActiveSessionId = next.id; + pageUiState.pageAiMessages = Array.isArray(next.messages) ? next.messages.slice() : []; + } + pageAiPersistSessions(); + renderPageAiConversation(); + renderPageAiControls(); + } + + async function pageAiResumeBackendSession(sessionId) { + sessionId = String(sessionId || pageUiState.pageAiActiveSessionId || '').trim(); + if (!sessionId) return; + pageAiSetActiveSession(sessionId); + var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(sessionId) + '/resume?' + pageAiBackendSessionQuery({}), { + method: 'POST', + 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, 'session_resume_failed_' + response.status)); + } + pageAiApplyBackendSessionDetail(payload); + pageUiState.pageAiPage = 'chat'; + renderPageAiConversation(); + renderPageAiControls(); + } + + function pageAiProviderLabel(provider) { + if (provider === 'codex') return 'Codex'; + if (provider === 'claudecode') return 'ClaudeCode'; + return 'Hermes'; + } + + function pageAiNormalizeArray(value) { + return Array.isArray(value) ? value : []; + } + + function pageAiDefaultAcpRuntimes() { + return [ + { + name: 'reasonix', + title: 'ACP · Reasonix', + description: '通过 ACP 协议直连 Reasonix(DeepSeek 缓存优先)', + model: 'deepseek-chat', + preset: 'auto' + }, + { + name: 'hermes', + title: 'ACP · Hermes', + description: '通过 ACP 协议直连 Hermes agent runtime' + } + ]; + } + + function pageAiNormalizeAcpRuntimes(runtimes) { + var byName = {}; + pageAiDefaultAcpRuntimes().forEach(function(runtime) { + byName[runtime.name] = Object.assign({}, runtime); + }); + pageAiNormalizeArray(runtimes).forEach(function(runtime) { + var name = String(runtime && runtime.name || '').trim(); + if (name !== 'reasonix' && name !== 'hermes') return; + byName[name] = Object.assign({}, byName[name] || {}, runtime, { name: name }); + }); + return ['reasonix', 'hermes'].map(function(name) { return byName[name]; }).filter(Boolean); + } + + function pageAiUnwrapUpstream(payload) { + if (payload && typeof payload === 'object' && payload.upstream) return payload.upstream; + return payload || null; + } + + function pageAiProfileValue(profile) { + if (profile && typeof profile === 'object') { + return String(profile.name || profile.profile || profile.id || '').trim(); + } + return String(profile || '').trim(); + } + + function pageAiCurrentProfile() { + var active = String(pageUiState.pageAiActiveProfileName || '').trim(); + if (active) return active; + var selected = pageUiState.pageAiProfiles.find(function(profile) { + return profile && profile.active; + }); + return pageAiProfileValue(selected) || 'mnoteai'; + } + + function pageAiRunProfile() { + return String(pageUiState.pageAiAcpRuntime || 'reasonix').trim() === 'reasonix' ? 'reasonix' : pageAiCurrentProfile(); + } + + function pageAiMnoteToolModel() { + return String(document.documentElement.getAttribute('data-mnote-page-ai-tool-model') || 'deepseek-v4-flash').trim() || 'deepseek-v4-flash'; + } + + function pageAiCurrentProfileRecord() { + var active = pageAiCurrentProfile(); + return pageUiState.pageAiProfiles.find(function(profile) { + return pageAiProfileValue(profile) === active; + }) || null; + } + + function pageAiCurrentModelLabel() { + var profile = pageAiCurrentProfileRecord(); + var toolModel = pageAiMnoteToolModel(); + 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 决定'; + return 'tool: ' + toolModel + ' · profile: ' + profileLabel; + } + + function pageAiNormalizeProfiles(payload) { + var upstream = pageAiUnwrapUpstream(payload); + var profiles = pageAiNormalizeArray(upstream && upstream.profiles ? upstream.profiles : upstream); + return profiles.map(function(profile) { + return { + name: pageAiProfileValue(profile) || 'default', + active: Boolean(profile && profile.active), + model: String(profile && profile.model || '').trim(), + gateway: String(profile && profile.gateway || '').trim(), + alias: String(profile && profile.alias || '').trim() + }; + }); + } + + function pageAiNormalizeSkills(payload) { + var upstream = pageAiUnwrapUpstream(payload); + var categories = pageAiNormalizeArray(upstream && upstream.categories ? upstream.categories : []); + var archived = pageAiNormalizeArray(upstream && upstream.archived ? upstream.archived : []); + return { + categories: categories.map(function(category) { + return { + name: String(category && category.name || '').trim() || 'misc', + description: String(category && category.description || '').trim(), + skills: pageAiNormalizeArray(category && category.skills).map(function(skill) { + return { + name: String(skill && skill.name || '').trim(), + description: String(skill && skill.description || '').trim(), + enabled: skill && skill.enabled !== false, + source: String(skill && skill.source || 'local').trim() || 'local', + origin: String(skill && skill.origin || '').trim(), + createdBy: String(skill && skill.createdBy || '').trim(), + patchCount: Number(skill && skill.patchCount || 0), + modified: Boolean(skill && skill.modified) + }; + }) + }; + }), + archived: archived.map(function(skill) { + return { + name: String(skill && skill.name || '').trim(), + description: String(skill && skill.description || '').trim(), + enabled: skill && skill.enabled !== false, + source: String(skill && skill.source || 'local').trim() || 'local', + origin: String(skill && skill.origin || '').trim(), + createdBy: String(skill && skill.createdBy || '').trim(), + patchCount: Number(skill && skill.patchCount || 0), + modified: Boolean(skill && skill.modified) + }; + }) + }; + } + + function pageAiSkillListEntries() { + var result = []; + pageAiNormalizeArray(pageUiState.pageAiSkills.categories).forEach(function(category) { + pageAiNormalizeArray(category.skills).forEach(function(skill) { + result.push({ + category: category.name, + name: skill.name, + description: skill.description, + enabled: skill.enabled !== false, + source: skill.source || 'local', + origin: skill.origin || '', + createdBy: skill.createdBy || '', + patchCount: Number(skill.patchCount || 0), + modified: Boolean(skill.modified) + }); + }); + }); + return result.concat(pageAiNormalizeArray(pageUiState.pageAiSkills.archived)); + } + + function pageAiSetActiveProfile(profileName) { + var next = String(profileName || '').trim() || 'mnoteai'; + pageUiState.pageAiActiveProfileName = next; + document.documentElement.setAttribute('data-mnote-page-ai-profile', next); + document.documentElement.setAttribute('data-mnote-page-ai-tool-model', pageAiMnoteToolModel()); + } + + function pageAiSetRunStatus(status, runId) { + pageUiState.pageAiRunStatus = status || 'idle'; + pageUiState.pageAiCurrentRunId = runId || pageUiState.pageAiCurrentRunId || ''; + document.documentElement.setAttribute('data-mnote-page-ai-run-status', pageUiState.pageAiRunStatus); + if (pageUiState.pageAiCurrentRunId) { + document.documentElement.setAttribute('data-mnote-page-ai-run-id', pageUiState.pageAiCurrentRunId); + } + } + + function pageAiApplyRuntimeState(runtime) { + if (!runtime || typeof runtime !== 'object') return; + var status = String(runtime.status || '').trim(); + var runId = String(runtime.runId || runtime.run_id || '').trim(); + if (status) pageAiSetRunStatus(status, runId); + var queueLength = Number(runtime.queueLength || runtime.queue_length || 0); + if (Number.isFinite(queueLength)) pageUiState.pageAiQueueLength = Math.max(0, queueLength); + var toolName = String(runtime.lastToolName || runtime.last_tool_name || '').trim(); + if (toolName) { + pageUiState.pageAiLastToolCall = { + event: String(runtime.lastEvent || runtime.last_event || ''), + name: toolName, + runId: runId, + traceId: String(runtime.traceId || runtime.trace_id || ''), + auditId: String(runtime.lastAuditId || runtime.last_audit_id || '') + }; + } + } + + function pageAiApplyQueuedRun(payload) { + if (!payload || payload.queued !== true) return false; + var queueId = String(payload.queueId || payload.queue_id || '').trim(); + var queueLength = Number(payload.queueLength || payload.queue_length || 0); + pageUiState.pageAiQueueLength = Number.isFinite(queueLength) ? Math.max(0, queueLength) : 1; + if (queueId) { + pageUiState.pageAiQueuedItems = pageUiState.pageAiQueuedItems.filter(function(item) { + return item.queueId !== queueId; + }).concat([{ + queueId: queueId, + sessionId: String(payload.sessionId || payload.session_id || pageUiState.pageAiActiveSessionId || ''), + traceId: String(payload.traceId || payload.trace_id || ''), + queuedAt: Number(payload.queuedAt || payload.queued_at || Date.now()) + }]); + } + pageAiSetRunStatus('queued', pageUiState.pageAiCurrentRunId); + return true; + } + + function pageAiPreviewValue(value) { + if (value == null || value === '') return ''; + if (typeof value === 'string') return value.length > 120 ? value.slice(0, 120) + '…' : value; + try { + var text = JSON.stringify(value); + return text.length > 120 ? text.slice(0, 120) + '…' : text; + } catch (_) { + return String(value); + } + } + + function pageAiNormalizeToolName(name) { + return String(name || '').trim().replace(/_/g, '.'); + } + + function pageAiFormatChangedFiles(files) { + return pageAiNormalizeArray(files).map(function(file) { + var path = String(file && file.path || '').trim(); + var changeType = String(file && file.changeType || file.change_type || 'modified').trim(); + var summary = String(file && file.summary || '').trim(); + return [changeType, path, summary].filter(Boolean).join(' · '); + }).filter(Boolean).join('\n'); + } + + function pageAiToolEventDeepFindString(value, keys, depth) { + if (!value || typeof value !== 'object' || depth > 5) return ''; + for (var index = 0; index < keys.length; index += 1) { + var key = keys[index]; + if (Object.prototype.hasOwnProperty.call(value, key) && typeof value[key] === 'string' && value[key].trim()) { + return value[key].trim(); + } + } + if (Array.isArray(value)) { + for (var arrayIndex = 0; arrayIndex < value.length; arrayIndex += 1) { + var fromArray = pageAiToolEventDeepFindString(value[arrayIndex], keys, depth + 1); + if (fromArray) return fromArray; + } + return ''; + } + var preferred = ['audit', 'args', 'arguments', 'input', 'result', 'summary', 'output', 'upstream']; + for (var prefIndex = 0; prefIndex < preferred.length; prefIndex += 1) { + var child = value[preferred[prefIndex]]; + var fromPreferred = pageAiToolEventDeepFindString(child, keys, depth + 1); + if (fromPreferred) return fromPreferred; + } + var objectKeys = Object.keys(value); + for (var objectIndex = 0; objectIndex < objectKeys.length; objectIndex += 1) { + var fromObject = pageAiToolEventDeepFindString(value[objectKeys[objectIndex]], keys, depth + 1); + if (fromObject) return fromObject; + } + return ''; + } + + function pageAiNotifyToolWriteCompleted(toolName, toolEvent, runId, runTraceId) { + var normalizedTool = pageAiNormalizeToolName(toolName); + var writesCurrentPage = [ + 'mnote.page.save', + 'mnote.page.update.title', + 'mnote.page.update.options', + 'mnote.doc.apply.block.ops', + 'mnote.block.replace', + 'mnote.block.insert.after', + 'mnote.block.delete', + 'mnote.block.move.after' + ].indexOf(normalizedTool) >= 0 || [ + 'mnote.page.update_title', + 'mnote.page.update_options', + 'mnote.doc.apply_block_ops', + 'mnote.block.replace', + 'mnote.block.insert_after', + 'mnote.block.delete', + 'mnote.block.move_after' + ].indexOf(String(toolName || '').trim()) >= 0; + if (!writesCurrentPage) return; + var documentId = pageAiToolEventDeepFindString(toolEvent, ['documentId', 'document_id'], 0) || currentDocumentId(); + var workspaceId = pageAiToolEventDeepFindString(toolEvent, ['workspaceId', 'workspace_id'], 0) || resolveWorkspaceId(document.body); + try { + window.dispatchEvent(new CustomEvent('mnote:page-ai-tool-write-completed', { + detail: { + toolName: toolName, + normalizedToolName: normalizedTool, + documentId: documentId, + workspaceId: workspaceId, + runId: String(toolEvent && (toolEvent.run_id || toolEvent.runId) || runId || ''), + traceId: String(toolEvent && (toolEvent.trace_id || toolEvent.traceId) || runTraceId || ''), + toolCallId: String(toolEvent && (toolEvent.tool_call_id || toolEvent.toolCallId || toolEvent.id) || '') + } + })); + } catch (error) { + console.warn('mnote 页面 AI 写入刷新事件派发失败', error); + } + } + + function pageAiApplyToolEvent(eventName, payloadText, runId, runTraceId) { + var toolEvent = null; + try { + toolEvent = JSON.parse(payloadText || 'null'); + } catch (_) { + toolEvent = {}; + } + var rawToolName = toolEvent && (toolEvent.name || toolEvent.tool || toolEvent.toolName || ''); + var toolName = String(rawToolName || eventName); + var toolCallId = String(toolEvent && (toolEvent.tool_call_id || toolEvent.toolCallId || toolEvent.id) || (runId + ':' + toolName)); + var eventStatus = String(toolEvent && toolEvent.status || '').trim(); + var status = eventName === 'tool.completed' + ? (toolEvent && toolEvent.error ? 'failed' : 'completed') + : (eventName === 'tool.failed' ? 'failed' : (eventStatus || 'running')); + if (status === 'in_progress' || status === 'pending') status = 'running'; + var traceId = String(toolEvent && (toolEvent.trace_id || toolEvent.traceId) || runTraceId || ''); + var auditId = String(toolEvent && (toolEvent.audit_id || toolEvent.auditId) || ''); + var argsSummary = pageAiPreviewValue(toolEvent && (toolEvent.arguments || toolEvent.args || toolEvent.input)); + var resultSource = toolEvent && (toolEvent.summary || toolEvent.result || toolEvent.output); + if (!resultSource && status === 'failed') { + resultSource = [toolEvent && toolEvent.code, toolEvent && toolEvent.error].filter(Boolean).join(' '); + } + var resultSummary = pageAiPreviewValue(resultSource); + pageUiState.pageAiLastToolCall = { + event: eventName, + name: toolName, + runId: String(toolEvent && (toolEvent.run_id || toolEvent.runId) || runId), + traceId: traceId, + auditId: auditId + }; + var rawLocations = toolEvent && toolEvent.locations; + var locations = Array.isArray(rawLocations) ? rawLocations.filter(function(l) { return typeof l === 'string' || (typeof l === 'object' && l && l.path); }).map(function(l) { return typeof l === 'string' ? l : l.path; }) : []; + var existing = pageUiState.pageAiMessages.find(function(item) { + return item.role === 'tool' && item.toolCallId === toolCallId; + }); + if (!rawToolName && existing && existing.toolName) toolName = existing.toolName; + if (!existing) { + existing = { + role: 'tool', + content: toolName, + toolCallId: toolCallId, + toolName: toolName, + toolKind: String(toolEvent && toolEvent.kind || ''), + status: status, + argsSummary: '', + resultSummary: '', + locations: locations, + traceId: traceId, + auditId: auditId + }; + pageUiState.pageAiMessages.push(existing); + } + existing.content = toolName; + existing.toolName = toolName; + existing.toolKind = String(toolEvent && toolEvent.kind || existing.toolKind || ''); + existing.status = status; + existing.traceId = traceId || existing.traceId || ''; + existing.auditId = auditId || existing.auditId || ''; + if (argsSummary) existing.argsSummary = argsSummary; + if (resultSummary) existing.resultSummary = resultSummary; + if (locations.length) existing.locations = locations; + if (status === 'completed') { + pageAiNotifyToolWriteCompleted(toolName, toolEvent, runId, runTraceId); + } + } + + async function pageAiCancelQueuedRun(queueId) { + queueId = String(queueId || '').trim(); + if (!queueId) 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), { + method: 'DELETE', + headers: { 'accept': 'application/json' } + }); + var payload = await response.json().catch(function(){ return null; }); + if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'queue_cancel_failed_' + response.status)); + pageUiState.pageAiQueuedItems = pageUiState.pageAiQueuedItems.filter(function(entry) { + return entry.queueId !== queueId; + }); + var queueLength = Number(payload && (payload.queueLength || payload.queue_length || 0)); + pageUiState.pageAiQueueLength = Number.isFinite(queueLength) ? Math.max(0, queueLength) : pageUiState.pageAiQueuedItems.length; + pageUiState.pageAiMessages.push({ role: 'assistant', content: '已取消一条 Hermes 队列项。' }); + } catch (error) { + pageUiState.pageAiMessages.push({ + role: 'assistant', + content: '取消 Hermes 队列项失败:' + (error instanceof Error ? error.message : String(error)) + }); + } + renderPageAiConversation(); + renderPageAiControls(); + } + + function pageAiSetContextScope(scope) { + var next = String(scope || '').trim() || 'page'; + pageUiState.pageAiContextScope = next; + document.documentElement.setAttribute('data-mnote-page-ai-context-scope', next); + } + + function pageAiRunStatusLabel(status) { + if (status === 'queued') return '排队中'; + if (status === 'running') return '运行中'; + if (status === 'tool_calling') return '调用工具'; + if (status === 'completed') return '已完成'; + if (status === 'failed') return '失败'; + if (status === 'aborted') return '已停止'; + return '空闲'; + } + + function pageAiMemoryFileLabel(section) { + if (section === 'soul') return 'SOUL.md'; + if (section === 'user') return 'USER.md'; + return 'MEMORY.md'; + } + + function pageAiContextScopeLabel(scope) { + if (scope === 'selection') return '当前选区'; + if (scope === 'block') return '当前块'; + if (scope === 'options') return '页面设置'; + return '当前页'; + } + + function pageAiHermesSettingsUrl() { + var configured = String(window.__mnoteHermesSettingsUrl || '').trim(); + return configured || ''; + } + + function pageAiOpenHermesSettings() { + var url = pageAiHermesSettingsUrl(); + if (url) { + window.open(url, '_blank', 'noopener,noreferrer'); + return; + } + pageUiState.pageAiProfileError = '未配置 Hermes 设置入口:请设置 MNOTE_WEB_HERMES_UPSTREAM_URL。'; + renderPageAiControls(); + } + + function pageAiNormalizeTools(payload) { + var upstream = pageAiUnwrapUpstream(payload) || {}; + var tools = pageAiNormalizeArray(upstream.tools || upstream); + return tools.map(function(tool) { + var name = String(tool && (tool.name || tool.toolName || tool.tool) || '').trim(); + if (!name) return null; + return { + name: name, + description: String(tool && tool.description || '').trim(), + scope: String(tool && (tool.scope || tool.requiredScope || '') || '').trim(), + kind: String(tool && (tool.kind || tool.mode || '') || '').trim(), + status: String(tool && (tool.status || tool.permission || 'available') || '').trim(), + enabled: tool && tool.enabled !== false, + unavailableReason: String(tool && (tool.unavailableReason || tool.reason || '') || '').trim() + }; + }).filter(Boolean); + } + + function pageAiErrorMessage(payload, fallback) { + if (!payload || typeof payload !== 'object') return fallback; + return String(payload.message || payload.error || payload.code || fallback || '').trim() || fallback; + } + + function pageAiNormalizeGatewayHealth(payload) { + var upstream = pageAiUnwrapUpstream(payload) || payload || {}; + return { + ok: Boolean(upstream.ok), + profile: upstream.profile || null, + gateway: upstream.gateway || null, + suggestions: pageAiNormalizeArray(upstream.suggestions).map(function(item) { + return String(item || '').trim(); + }).filter(Boolean) + }; + } + + function pageAiSetDraftForSection(section, value) { + pageUiState.pageAiProfileMemoryDrafts[section] = String(value == null ? '' : value); + } + + function pageAiApplyProfileMemory(payload) { + var upstream = pageAiUnwrapUpstream(payload) || {}; + pageUiState.pageAiProfileMemory = { + memory: String(upstream.memory || ''), + user: String(upstream.user || ''), + soul: String(upstream.soul || '') + }; + pageUiState.pageAiProfileMemoryDrafts = { + memory: pageUiState.pageAiProfileMemory.memory, + user: pageUiState.pageAiProfileMemory.user, + soul: pageUiState.pageAiProfileMemory.soul + }; + } + + async function pageAiLoadTools() { + try { + var response = await fetch('/api/hermes/client/tools?scope=mnote&profile=' + encodeURIComponent(pageAiCurrentProfile()), { + headers: { 'accept': 'application/json' } + }); + var payload = await response.json().catch(function(){ return null; }); + if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'tools_failed_' + response.status)); + pageUiState.pageAiTools = pageAiNormalizeTools(payload); + pageUiState.pageAiToolsError = ''; + } catch (error) { + pageUiState.pageAiToolsError = error instanceof Error ? error.message : String(error); + } + renderPageAiControls(); + } + + async function pageAiLoadGatewayHealth() { + try { + var response = await fetch('/api/hermes/client/gateway/health?profile=' + encodeURIComponent(pageAiCurrentProfile()), { + headers: { 'accept': 'application/json' } + }); + var payload = await response.json().catch(function(){ return null; }); + if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'gateway_health_failed_' + response.status)); + pageUiState.pageAiGatewayHealth = pageAiNormalizeGatewayHealth(payload); + pageUiState.pageAiGatewayHealthError = ''; + } catch (error) { + pageUiState.pageAiGatewayHealthError = error instanceof Error ? error.message : String(error); + } + renderPageAiControls(); + } + + async function pageAiStopRun() { + var runId = String(pageUiState.pageAiCurrentRunId || '').trim(); + if (!runId || pageUiState.pageAiRunStatus === 'idle' || pageUiState.pageAiRunStatus === 'completed') return; + pageUiState.pageAiStoppedRunIds[runId] = true; + try { + var response = await fetch('/api/hermes/client/runs/' + encodeURIComponent(runId) + '/abort', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + sessionId: pageUiState.pageAiActiveSessionId, + profile: pageAiRunProfile(), + acpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix', + reason: 'page_ai_user_stop' + }) + }); + var payload = await response.json().catch(function(){ return null; }); + if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'run_abort_failed_' + response.status)); + pageAiApplyRuntimeState(payload && payload.runtime); + pageAiSetRunStatus('aborted', runId); + pageUiState.pageAiMessages.push({ role: 'assistant', content: '已请求 Hermes 停止当前 run。' }); + } catch (error) { + pageAiSetRunStatus('failed', runId); + pageUiState.pageAiMessages.push({ + role: 'assistant', + content: '停止 Hermes run 失败:' + (error instanceof Error ? error.message : String(error)) + }); + } + renderPageAiControls(); + renderPageAiConversation(); + } + + function pageAiFilteredSkillEntries() { + var query = String(pageUiState.pageAiSkillQuery || '').trim().toLowerCase(); + return pageAiSkillListEntries().filter(function(skill) { + if (!query) return true; + return String(skill.name || '').toLowerCase().indexOf(query) >= 0 + || String(skill.description || '').toLowerCase().indexOf(query) >= 0 + || String(skill.category || '').toLowerCase().indexOf(query) >= 0 + || pageAiSkillOriginLabel(skill).toLowerCase().indexOf(query) >= 0; + }); + } + + function pageAiSkillOriginLabel(skill) { + var origin = String(skill && skill.origin || '').trim(); + if (origin === 'generated' || String(skill && skill.createdBy || '') === 'agent') return '生成'; + if (origin === 'installed') return '安装'; + if (origin === 'builtin') return '内置'; + if (origin === 'copied') return '本地'; + var source = String(skill && skill.source || '').trim(); + if (source === 'hub') return '安装'; + if (source === 'builtin') return '内置'; + if (source === 'reasonix') { + if (origin === 'project') return 'Reasonix 项目'; + if (origin === 'global') return 'Reasonix 全局'; + return 'Reasonix'; + } + return '本地'; + } + + async function pageAiLoadProfiles() { + try { + var response = await fetch('/api/hermes/client/profiles', { + headers: { 'accept': 'application/json' } + }); + var payload = await response.json().catch(function(){ return null; }); + if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'profiles_failed_' + response.status)); + var profiles = pageAiNormalizeProfiles(payload); + pageUiState.pageAiProfiles = profiles.length ? profiles : [{ name: 'mnoteai', active: true }]; + var acpRuntimes = Array.isArray(payload.acpRuntimes) ? payload.acpRuntimes : []; + pageUiState.pageAiAcpRuntimes = pageAiNormalizeAcpRuntimes(acpRuntimes); + var current = pageAiCurrentProfile(); + var hasCurrent = pageUiState.pageAiProfiles.some(function(profile) { return pageAiProfileValue(profile) === current; }); + var hasMnoteAi = pageUiState.pageAiProfiles.some(function(profile) { return pageAiProfileValue(profile) === 'mnoteai'; }); + var active = pageAiProfileValue(pageUiState.pageAiProfiles.find(function(profile) { return profile.active; })); + pageAiSetActiveProfile(hasCurrent && current !== 'default' ? current : (hasMnoteAi ? 'mnoteai' : (active || current))); + pageUiState.pageAiProfileError = ''; + void pageAiLoadGatewayHealth(); + } catch (error) { + pageUiState.pageAiProfileError = error instanceof Error ? error.message : String(error); + if (!pageUiState.pageAiProfiles.length) pageUiState.pageAiProfiles = [{ name: 'mnoteai', active: true }]; + pageUiState.pageAiAcpRuntimes = pageAiNormalizeAcpRuntimes(pageUiState.pageAiAcpRuntimes); + pageAiSetActiveProfile(pageAiCurrentProfile()); + } + renderPageAiProviderButtons(); + renderPageAiControls(); + } + + async function pageAiSwitchProfile(profileName) { + var next = String(profileName || '').trim(); + if (!next) return; + pageAiSetActiveProfile(next); + renderPageAiProviderButtons(); + renderPageAiControls(); + try { + var response = await fetch('/api/hermes/client/profiles/active', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: next }) + }); + var payload = await response.json().catch(function(){ return null; }); + if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'profile_switch_failed_' + response.status)); + pageUiState.pageAiSessions = pageUiState.pageAiSessions.filter(function(session) { + return session && session.profile === next; + }); + pageUiState.pageAiActiveSessionId = ''; + pageUiState.pageAiMessages = []; + pageAiPersistSessions(); + await pageAiEnsureHermesSession(true); + await pageAiLoadProfileMemory(); + await pageAiLoadSkills(); + await pageAiLoadTools(); + await pageAiLoadGatewayHealth(); + renderPageAiControls(); + } catch (error) { + pageUiState.pageAiProfileError = error instanceof Error ? error.message : String(error); + } + renderPageAiConversation(); + } + + async function pageAiLoadProfileMemory() { + try { + var response = await fetch('/api/hermes/client/profile-memory?profile=' + encodeURIComponent(pageAiCurrentProfile()), { + headers: { 'accept': 'application/json' } + }); + var payload = await response.json().catch(function(){ return null; }); + if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'profile_memory_failed_' + response.status)); + pageAiApplyProfileMemory(payload); + pageUiState.pageAiProfileMemoryError = ''; + } catch (error) { + pageUiState.pageAiProfileMemoryError = error instanceof Error ? error.message : String(error); + } + renderPageAiControls(); + renderPageAiConversation(); + } + + async function pageAiSaveProfileMemory(section) { + var normalized = String(section || '').trim(); + if (['memory', 'user', 'soul'].indexOf(normalized) < 0) return; + var content = String(pageUiState.pageAiProfileMemoryDrafts[normalized] || ''); + try { + var response = await fetch('/api/hermes/client/profile-memory', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + profile: pageAiCurrentProfile(), + section: normalized, + content: content + }) + }); + var payload = await response.json().catch(function(){ return null; }); + if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'profile_memory_save_failed_' + response.status)); + pageUiState.pageAiProfileMemory[normalized] = content; + pageUiState.pageAiProfileMemoryError = ''; + document.documentElement.setAttribute('data-mnote-page-ai-memory-saved', normalized); + } catch (error) { + pageUiState.pageAiProfileMemoryError = error instanceof Error ? error.message : String(error); + } + renderPageAiControls(); + renderPageAiConversation(); + } + + async function pageAiLoadSkills() { + try { + var runtime = String(pageUiState.pageAiAcpRuntime || 'reasonix').trim(); + var params = runtime === 'reasonix' + ? 'runtime=reasonix' + : 'profile=' + encodeURIComponent(pageAiCurrentProfile()); + var response = await fetch('/api/hermes/client/skills?' + params, { + headers: { 'accept': 'application/json' } + }); + var payload = await response.json().catch(function(){ return null; }); + if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'skills_failed_' + response.status)); + pageUiState.pageAiSkills = pageAiNormalizeSkills(payload); + pageUiState.pageAiSkillError = ''; + } catch (error) { + pageUiState.pageAiSkillError = error instanceof Error ? error.message : String(error); + pageUiState.pageAiSkills = { categories: [], archived: [] }; + } + renderPageAiControls(); + renderPageAiConversation(); + } + + async function pageAiToggleSkill(skillName, enabled) { + var name = String(skillName || '').trim(); + if (!name) return; + if (String(pageUiState.pageAiAcpRuntime || 'reasonix').trim() === 'reasonix') return; + var previous = null; + pageAiSkillListEntries().forEach(function(skill) { + if (skill.name === name && previous == null) previous = skill.enabled !== false; + }); + try { + var response = await fetch('/api/hermes/client/skills/toggle', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + profile: pageAiCurrentProfile(), + name: name, + enabled: Boolean(enabled) + }) + }); + var payload = await response.json().catch(function(){ return null; }); + if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'skill_toggle_failed_' + response.status)); + pageAiNormalizeArray(pageUiState.pageAiSkills.categories).forEach(function(category) { + pageAiNormalizeArray(category.skills).forEach(function(skill) { + if (skill.name === name) skill.enabled = Boolean(enabled); + }); + }); + document.documentElement.setAttribute('data-mnote-page-ai-skill-toggled', name); + pageUiState.pageAiSkillError = ''; + } catch (error) { + pageUiState.pageAiSkillError = error instanceof Error ? error.message : String(error); + if (previous != null) { + pageAiNormalizeArray(pageUiState.pageAiSkills.categories).forEach(function(category) { + pageAiNormalizeArray(category.skills).forEach(function(skill) { + if (skill.name === name) skill.enabled = previous; + }); + }); + } + } + renderPageAiControls(); + renderPageAiConversation(); + } + + async function pageAiToggleTool(toolName, enabled) { + var name = String(toolName || '').trim(); + if (!name) return; + var previous = null; + pageAiNormalizeArray(pageUiState.pageAiTools).forEach(function(tool) { + if (tool.name === name && previous == null) previous = tool.enabled !== false; + }); + try { + var response = await fetch('/api/hermes/client/tools/toggle', { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + profile: pageAiCurrentProfile(), + name: name, + enabled: Boolean(enabled) + }) + }); + var payload = await response.json().catch(function(){ return null; }); + if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'tool_toggle_failed_' + response.status)); + pageAiNormalizeArray(pageUiState.pageAiTools).forEach(function(tool) { + if (tool.name === name) { + tool.enabled = Boolean(enabled); + tool.status = Boolean(enabled) ? 'available' : 'disabled'; + tool.unavailableReason = Boolean(enabled) ? '' : '当前 Hermes profile 已关闭该 mnote tool'; + } + }); + document.documentElement.setAttribute('data-mnote-page-ai-tool-toggled', name); + pageUiState.pageAiToolsError = ''; + } catch (error) { + pageUiState.pageAiToolsError = error instanceof Error ? error.message : String(error); + if (previous != null) { + pageAiNormalizeArray(pageUiState.pageAiTools).forEach(function(tool) { + if (tool.name === name) tool.enabled = previous; + }); + } + } + renderPageAiControls(); + } + + function renderPageAiProviderButtons() { + var drawer = ensurePageAiDrawer(); + var isAcp = pageUiState.pageAiAcpRuntime !== ''; + drawer.querySelectorAll('[data-page-ai-provider]').forEach(function(button) { + var provider = button.getAttribute('data-page-ai-provider') || 'hermes'; + var active = provider === pageUiState.pageAiProvider; + button.classList.toggle('is-active', active); + button.setAttribute('aria-pressed', active ? 'true' : 'false'); + }); + var providerNode = drawer.querySelector('.wolai-page-ai-subtitle span:first-child'); + if (providerNode instanceof HTMLElement) { + providerNode.textContent = isAcp ? 'ACP · ' + (pageUiState.pageAiAcpRuntime === 'reasonix' ? 'Reasonix' : 'Hermes') : pageAiProviderLabel(pageUiState.pageAiProvider); + } + } + + function renderPageAiControls() { + var drawer = ensurePageAiDrawer(); + var isAcp = pageUiState.pageAiAcpRuntime !== ''; + var activeProfile = pageAiRunProfile(); + drawer.setAttribute('data-page-ai-page', pageUiState.pageAiPage || 'chat'); + drawer.setAttribute('data-mnote-acp-runtime', pageUiState.pageAiAcpRuntime || 'reasonix'); + // Populate ACP runtime dropdown + var acpSelect = drawer.querySelector('[data-page-ai-acp-runtime]'); + if (acpSelect instanceof HTMLSelectElement) { + var runtimes = pageUiState.pageAiAcpRuntimes.length ? pageUiState.pageAiAcpRuntimes : pageAiNormalizeAcpRuntimes([]); + acpSelect.innerHTML = runtimes.map(function(rt) { + return ''; + }).join(''); + acpSelect.value = pageUiState.pageAiAcpRuntime || 'reasonix'; + } + // Show/hide Hermes-specific profile select + var profileLabel = drawer.querySelector('[data-page-ai-hermes-profile]'); + if (profileLabel instanceof HTMLElement) { + profileLabel.style.display = pageUiState.pageAiAcpRuntime === 'reasonix' ? 'none' : ''; + } + // When ACP is selected, populate agent panel with ACP runtime info + var agentPanel = drawer.querySelector('[data-page-ai-agent-panel]'); + if (agentPanel instanceof HTMLElement) { + if (pageUiState.pageAiAcpRuntime === 'reasonix') { + var rt = pageUiState.pageAiAcpRuntimes.find(function(r) { return r.name === pageUiState.pageAiAcpRuntime; }) || {}; + var keyStatus = rt.apiKeyConfigured ? '已配置' : '未检测到 DEEPSEEK_API_KEY'; + agentPanel.innerHTML = '' + + '
' + + '
' + escapeHtml(rt.title || 'ACP Runtime') + '
' + + '
' + + '
' + + '
模型
' + escapeHtml(rt.model || 'deepseek-chat') + '
' + + '
' + + '
' + + '
Preset
' + escapeHtml(rt.preset || 'auto') + '
' + + '
' + + '
' + + '
API Key
' + escapeHtml(keyStatus) + '
' + + '
' + + '
' + + '
描述
' + escapeHtml(rt.description || '') + '
' + + '
'; + } + } + var profileSummary = drawer.querySelector('[data-page-ai-profile-summary]'); + if (profileSummary instanceof HTMLElement) profileSummary.textContent = activeProfile; + var profileSummary = drawer.querySelector('[data-page-ai-profile-summary]'); + if (profileSummary instanceof HTMLElement) profileSummary.textContent = activeProfile; + var profileSelect = drawer.querySelector('[data-page-ai-profile-select]'); + if (profileSelect instanceof HTMLSelectElement) { + var profiles = pageUiState.pageAiProfiles.length ? pageUiState.pageAiProfiles : [{ name: activeProfile, active: true }]; + profileSelect.innerHTML = profiles.map(function(profile) { + var name = pageAiProfileValue(profile) || 'default'; + var model = [profile.model, profile.gateway].filter(Boolean).join(' / '); + var label = [name, profile.alias, model].filter(Boolean).join(' · '); + return ''; + }).join(''); + profileSelect.value = activeProfile; + } + var runStatus = drawer.querySelector('[data-page-ai-run-status]'); + if (runStatus instanceof HTMLElement) { + var queueSuffix = pageUiState.pageAiQueueLength > 0 ? ' · 队列 ' + pageUiState.pageAiQueueLength : ''; + runStatus.textContent = pageAiRunStatusLabel(pageUiState.pageAiRunStatus) + queueSuffix; + } + 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; + stopButton.disabled = !canStop; + stopButton.setAttribute('aria-disabled', canStop ? 'false' : 'true'); + } + var settingsLink = drawer.querySelector('[data-page-ai-action="open-hermes-settings"]'); + if (settingsLink instanceof HTMLButtonElement) { + settingsLink.disabled = !pageAiHermesSettingsUrl(); + settingsLink.title = pageAiHermesSettingsUrl() ? '打开 Hermes 设置' : '未配置 MNOTE_WEB_HERMES_UPSTREAM_URL'; + } + var queueList = drawer.querySelector('[data-page-ai-queue-list]'); + if (queueList instanceof HTMLElement) { + if (!pageUiState.pageAiQueuedItems.length) { + queueList.innerHTML = '
暂无排队项。
'; + } else { + queueList.innerHTML = pageUiState.pageAiQueuedItems.map(function(item, index) { + var label = '队列 ' + (index + 1); + return '' + + '
' + + '
' + escapeHtml(label) + '
' + escapeHtml(item.queueId) + '
' + + '' + + '
'; + }).join(''); + } + } + var sessionNode = drawer.querySelector('[data-page-ai-session-status]'); + if (sessionNode instanceof HTMLElement) { + var session = pageAiCurrentSession(); + var usageText = session && session.usage ? pageAiUsageSummary(session.usage) : ''; + sessionNode.textContent = session && session.id + ? [String(session.title || '当前页问答'), pageAiSessionStorageLabel(session), usageText].filter(Boolean).join(' · ') + : '等待 Hermes session'; + } + var modelNode = drawer.querySelector('[data-page-ai-model-status]'); + if (modelNode instanceof HTMLElement) modelNode.textContent = pageAiCurrentModelLabel(); + 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]'); + if (scopeLabel instanceof HTMLElement) scopeLabel.textContent = pageAiContextScopeLabel(pageUiState.pageAiContextScope); + drawer.querySelectorAll('[data-page-ai-tab]').forEach(function(button) { + var target = button.getAttribute('data-page-ai-tab') || 'chat'; + var active = target === pageUiState.pageAiPage; + button.classList.toggle('is-active', active); + button.setAttribute('aria-selected', active ? 'true' : 'false'); + }); + drawer.querySelectorAll('[data-page-ai-panel]').forEach(function(panel) { + if (panel instanceof HTMLElement) { + panel.hidden = panel.getAttribute('data-page-ai-panel') !== pageUiState.pageAiPage; + } + }); + var profileError = drawer.querySelector('[data-page-ai-profile-error]'); + if (profileError instanceof HTMLElement) { + profileError.textContent = pageUiState.pageAiProfileError || ''; + profileError.hidden = !pageUiState.pageAiProfileError; + } + var memoryError = drawer.querySelector('[data-page-ai-memory-error]'); + if (memoryError instanceof HTMLElement) { + var memoryErrorText = pageUiState.pageAiProfileMemoryError || ''; + if (memoryErrorText && memoryErrorText === pageUiState.pageAiProfileError) memoryErrorText = ''; + memoryError.textContent = memoryErrorText; + memoryError.hidden = !memoryErrorText; + } + var agentPanel = drawer.querySelector('[data-page-ai-agent-panel]'); + if (agentPanel instanceof HTMLElement && pageUiState.pageAiAcpRuntime !== 'reasonix') { + agentPanel.innerHTML = ['soul', 'user', 'memory'].map(function(section) { + var label = pageAiMemoryFileLabel(section); + var value = pageUiState.pageAiProfileMemoryDrafts[section] || ''; + return '' + + '
' + + '
' + + '
' + + '
' + escapeHtml(label) + '
' + + '
保存到 Hermes profile: ' + escapeHtml(activeProfile) + '
' + + '
' + + '' + + '
' + + '' + + '
'; + }).join(''); + } + var skillError = drawer.querySelector('[data-page-ai-skill-error]'); + if (skillError instanceof HTMLElement) { + skillError.textContent = pageUiState.pageAiSkillError || ''; + skillError.hidden = !pageUiState.pageAiSkillError; + } + var sessionError = drawer.querySelector('[data-page-ai-session-error]'); + if (sessionError instanceof HTMLElement) { + sessionError.textContent = pageUiState.pageAiSessionError || ''; + sessionError.hidden = !pageUiState.pageAiSessionError; + } + var sessionSearch = drawer.querySelector('[data-page-ai-session-search]'); + if (sessionSearch instanceof HTMLInputElement && document.activeElement !== sessionSearch) { + sessionSearch.value = pageUiState.pageAiSessionSearchQuery || ''; + } + var skillSearch = drawer.querySelector('[data-page-ai-skill-search]'); + if (skillSearch instanceof HTMLInputElement && document.activeElement !== skillSearch) { + skillSearch.value = pageUiState.pageAiSkillQuery; + } + var skillList = drawer.querySelector('[data-page-ai-skill-list]'); + if (skillList instanceof HTMLElement) { + var skills = pageAiFilteredSkillEntries(); + if (!skills.length) { + var emptyText = String(pageUiState.pageAiAcpRuntime || 'reasonix').trim() === 'reasonix' + ? '没有匹配的 Reasonix skill。' + : '没有匹配的 Hermes skill。'; + skillList.innerHTML = '
' + escapeHtml(emptyText) + '
'; + } else { + skillList.innerHTML = skills.map(function(skill) { + var sourceText = pageAiSkillOriginLabel(skill) + (skill.modified ? ' · modified' : ''); + var description = String(skill.description || '').trim(); + var hasDescription = description && description !== '---' && description !== '无描述'; + var canToggle = skill.toggleable !== false && String(pageUiState.pageAiAcpRuntime || 'reasonix').trim() !== 'reasonix'; + return '' + + '
' + + '
' + + '
' + + '
' + escapeHtml(skill.name) + '
' + + '
' + escapeHtml(sourceText) + '
' + + '
' + + (hasDescription ? '
' + escapeHtml(description) + '
' : '') + + '
' + + '' + + '
'; + }).join(''); + } + } + var toolsList = drawer.querySelector('[data-page-ai-tool-list]'); + if (toolsList instanceof HTMLElement) { + var tools = pageAiNormalizeArray(pageUiState.pageAiTools); + if (!tools.length) { + toolsList.innerHTML = '
尚未读取到 mnote tool manifest。
'; + } else { + toolsList.innerHTML = tools.map(function(tool) { + return '' + + '
' + + '
' + + '
' + escapeHtml(tool.name) + '
' + + '
' + escapeHtml(tool.scope || tool.kind || 'mnote') + ' · ' + escapeHtml(tool.status || 'available') + '
' + + (tool.unavailableReason ? '
' + escapeHtml(tool.unavailableReason) + '
' : '') + + '
' + + '' + + '
'; + }).join(''); + } + } + var gatewayError = drawer.querySelector('[data-page-ai-gateway-error]'); + if (gatewayError instanceof HTMLElement) { + gatewayError.textContent = pageUiState.pageAiGatewayHealthError || ''; + gatewayError.hidden = !pageUiState.pageAiGatewayHealthError; + } + var gatewayStatusNode = drawer.querySelector('[data-page-ai-gateway-status]'); + var gatewayDetail = drawer.querySelector('[data-page-ai-gateway-detail]'); + var gatewayHealth = pageUiState.pageAiGatewayHealth; + if (gatewayStatusNode instanceof HTMLElement) { + if (!gatewayHealth) { + gatewayStatusNode.textContent = '未检查'; + } else { + var gateway = gatewayHealth.gateway || {}; + var profile = gatewayHealth.profile || {}; + gatewayStatusNode.textContent = gatewayHealth.ok ? '可用' : '需要设置'; + if (gateway.status) gatewayStatusNode.textContent += ' · ' + gateway.status; + if (profile.name) gatewayStatusNode.textContent += ' · ' + profile.name; + } + } + if (gatewayDetail instanceof HTMLElement) { + if (!gatewayHealth) { + gatewayDetail.innerHTML = '
打开 Runtime 后会检查 Hermes gateway 与当前 profile。
'; + } else { + var gatewayInfo = gatewayHealth.gateway || {}; + var profileInfo = gatewayHealth.profile || {}; + var suggestionText = pageAiNormalizeArray(gatewayHealth.suggestions).join(';'); + gatewayDetail.innerHTML = '' + + '
' + + '
' + escapeHtml(profileInfo.name || activeProfile) + '
' + + '
model.default: ' + escapeHtml(profileInfo.modelDefault || '未设置') + '
' + + '
provider: ' + escapeHtml(profileInfo.provider || '未设置') + ' · API key: ' + escapeHtml(profileInfo.apiKeyConfigured ? '已配置' : '未检测到') + '
' + + '
' + + '
' + + '
' + escapeHtml(gatewayInfo.upstream || '未配置 upstream') + '
' + + '
gateway: ' + escapeHtml(gatewayInfo.status || 'unknown') + (gatewayInfo.httpStatus ? ' · HTTP ' + escapeHtml(gatewayInfo.httpStatus) : '') + '
' + + (suggestionText ? '
' + escapeHtml(suggestionText) + '
' : '') + + '
'; + } + } + var toolError = drawer.querySelector('[data-page-ai-tool-error]'); + if (toolError instanceof HTMLElement) { + toolError.textContent = pageUiState.pageAiToolsError || ''; + toolError.hidden = !pageUiState.pageAiToolsError; + } + var lastTool = drawer.querySelector('[data-page-ai-last-tool]'); + if (lastTool instanceof HTMLElement) { + var call = pageUiState.pageAiLastToolCall; + lastTool.textContent = call + ? [call.event, call.name, call.runId, call.traceId || call.auditId].filter(Boolean).join(' · ') + : '暂无 tool call'; + } + } + + function humanizePageAiResponse(rawText, promptText) { + var providerLabel = pageAiProviderLabel(pageUiState.pageAiProvider); + var text = String(rawText || '').trim(); + if (!text) { + return providerLabel + ' 已收到你的问题“' + searchText(promptText) + '”,但这次没有返回可读内容。'; + } + if (text.startsWith('{')) { + try { + var payload = JSON.parse(text); + var operation = payload && payload.operation ? payload.operation : {}; + var normalized = operation && operation.normalized_input ? operation.normalized_input : {}; + var args = normalized && normalized.args ? normalized.args : {}; + var documentId = searchText(args.documentId || args.pageId || currentDocumentId()); + var toolName = searchText(operation.tool_name || normalized.toolName || 'doc_get'); + return providerLabel + ' 已收到你的问题“' + searchText(promptText) + '”。当前已通过 Hermes 调用 mnote plugin 工具,目标页面是 ' + (documentId || '当前页面') + ',本次选择的工具是 ' + toolName + '。如果你继续追问,我会沿当前页面上下文继续回复。'; + } catch (_) { + return providerLabel + ' 已返回结果,但当前结果是结构化文本,已为你保留原始内容。'; + } + } + return text; + } + + function ensurePageAiDrawer() { + var existing = document.querySelector('[data-testid="wolai-page-ai-drawer"]'); + if (existing instanceof HTMLElement) return existing; + var drawer = document.createElement('aside'); + drawer.className = 'wolai-page-ai-drawer'; + drawer.setAttribute('data-testid', 'wolai-page-ai-drawer'); + drawer.setAttribute('data-mnote-surface', 'page-ai'); + drawer.hidden = true; + drawer.innerHTML = '' + + ''; + document.body.appendChild(drawer); + return drawer; + } + + function renderPageAiSuggestions() { + var drawer = ensurePageAiDrawer(); + var list = drawer.querySelector('[data-page-ai-panel="chat"] [data-page-ai-suggestion-list]'); + if (!(list instanceof HTMLElement)) return; + var container = list.closest('.wolai-page-ai-suggestions'); + if (container instanceof HTMLElement) { + container.hidden = pageUiState.pageAiPage !== 'chat' || pageUiState.pageAiMessages.length > 0; + } + var suggestions = pageAiSuggestions(); + var offset = pageUiState.pageAiSuggestionIndex % suggestions.length; + var ordered = suggestions.slice(offset).concat(suggestions.slice(0, offset)).slice(0, 3); + list.innerHTML = ordered.map(function(text) { + return ''; + }).join(''); + } + + function renderPageAiConversation() { + var drawer = ensurePageAiDrawer(); + renderPageAiSuggestions(); + var conversation = drawer.querySelector('[data-page-ai-panel="' + cssEscape(pageUiState.pageAiPage || 'chat') + '"] [data-page-ai-conversation]'); + if (!(conversation instanceof HTMLElement)) return; + if (pageUiState.pageAiPage === 'history') { + var historyRows = pageUiState.pageAiSessionSearchResults.length + ? pageUiState.pageAiSessionSearchResults + : pageUiState.pageAiSessions; + if (!historyRows.length) { + conversation.innerHTML = '
尚未产生历史会话。
'; + return; + } + conversation.innerHTML = historyRows.map(function(session) { + var preview = Array.isArray(session.messages) && session.messages.length + ? session.messages.slice(-1)[0].content + : (session.snippet || session.preview || '暂无消息'); + var active = session.id === pageUiState.pageAiActiveSessionId; + var usage = pageAiUsageSummary(session.usage); + var meta = [pageAiSessionStorageLabel(session), session.permissionLevel, session.shareId, session.status, usage].filter(Boolean).join(' · '); + return '' + + '
' + + '' + + '
' + + '' + + '' + + '' + + '
' + + '
'; + }).join(''); + return; + } + if (!pageUiState.pageAiMessages.length) { + conversation.innerHTML = '
围绕当前页面提问,我会优先使用当前页内容、页面结构和已保存设置。
'; + return; + } + conversation.innerHTML = pageUiState.pageAiMessages.map(function(item) { + var roleLabel = item.role === 'user' ? '你' : (item.role === 'tool' ? '工具' : 'AI'); + if (item.role === 'tool') { + var statusLabel = item.status === 'completed' ? '完成' : (item.status === 'failed' ? '失败' : '运行中'); + var locationRows = Array.isArray(item.locations) && item.locations.length + ? '
位置:' + item.locations.map(function(loc, idx) { + return '' + + '' + escapeHtml(loc) + '' + + '' + + ''; + }).join('') + '
' + : ''; + var detailRows = [ + locationRows, + item.argsSummary ? '
参数 ' + escapeHtml(item.argsSummary) + '
' : '', + item.resultSummary ? '
结果 ' + escapeHtml(item.resultSummary) + '
' : '', + item.changedFiles && item.changedFiles.length ? '
' + escapeHtml(pageAiFormatChangedFiles(item.changedFiles)) + '
' : '', + (item.traceId || item.auditId) ? '
' + escapeHtml([item.traceId, item.auditId].filter(Boolean).join(' · ')) + '
' : '' + ].filter(Boolean).join(''); + return '' + + '
' + + '
' + escapeHtml(roleLabel) + '
' + + '
' + + '
' + + '' + + '' + escapeHtml(item.toolName || item.content || 'tool') + '' + + '' + escapeHtml([statusLabel, item.toolKind, item.toolCallId].filter(Boolean).join(' · ')) + '' + + '' + + (detailRows || '
暂无参数或结果详情。
') + + '
' + + '
' + + '
'; + } + if (item.kind === 'thought') { + return '' + + '
' + + '思考过程' + + '
' + escapeHtml(item.content || '') + '
' + + '
'; + } + if (item.kind === 'permission') { + var permissionActions = item.resolved ? '' : ( + '
' + + '' + + '' + + '
' + ); + return '' + + '
' + + '
权限
' + + '
' + + '' + escapeHtml(item.toolName || 'session/request_permission') + '
' + + '' + escapeHtml(item.argsSummary || item.content || '') + '' + + permissionActions + + '
' + + '
'; + } + if (item.kind === 'plan') { + var planEntries = Array.isArray(item.entries) ? item.entries : []; + var listHtml = planEntries.map(function(entry, idx) { + return '
  • ' + escapeHtml(String(entry || '')) + '
  • '; + }).join(''); + return '' + + '
    ' + + '
    ' + + '执行计划 · ' + planEntries.length + ' 步' + + '
    ' + + '
      ' + listHtml + '
    ' + + '
    ' + + '
    ' + + '
    '; + } + var streamingAttr = item.streaming ? ' data-page-ai-streaming="true"' : ''; + return '' + + '
    ' + + '
    ' + escapeHtml(roleLabel) + '
    ' + + '
    ' + escapeHtml(item.content || '') + '
    ' + + '
    '; + }).join(''); + conversation.scrollTop = conversation.scrollHeight; + } + + function openPageAiDrawer() { + pageAiLoadSessions(); + renderPageAiSuggestions(); + renderPageAiConversation(); + renderPageAiProviderButtons(); + renderPageAiControls(); + var drawer = ensurePageAiDrawer(); + drawer.hidden = false; + pageUiState.pageAiOpen = true; + updatePageAiTriggerState(); + Promise.all([ + pageAiLoadProfiles(), + pageAiLoadProfileMemory(), + pageAiLoadSkills(), + pageAiLoadTools(), + pageAiLoadGatewayHealth(), + pageAiLoadBackendSessions() + ]).then(function() { + renderPageAiControls(); + }).catch(function() {}).then(function() { + return pageAiEnsureHermesSession(); + }).then(function() { + return pageAiRestoreHermesSession(); + }).then(function() { + renderPageAiControls(); + }).catch(function(error) { + pageUiState.pageAiMessages.push({ + role: 'assistant', + content: 'Hermes 当前不可用:' + (error instanceof Error ? error.message : String(error)) + }); + renderPageAiConversation(); + renderPageAiControls(); + }); + } + + function closePageAiDrawer() { + var drawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]'); + if (drawer instanceof HTMLElement) drawer.hidden = true; + pageUiState.pageAiOpen = false; + updatePageAiTriggerState(); + } + + function isPageAiDrawerOpen() { + var drawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]'); + return drawer instanceof HTMLElement && !drawer.hidden; + } + + async function streamPageAiResponse(response, onEvent) { + if (!response.body || typeof response.body.getReader !== 'function') return; + var reader = response.body.getReader(); + var decoder = new TextDecoder(); + var buffer = ''; + while (true) { + var chunk = await reader.read(); + if (chunk.done) break; + buffer += decoder.decode(chunk.value, { stream: true }); + var frames = buffer.split('\n\n'); + buffer = frames.pop() || ''; + frames.forEach(function(frame) { + var eventName = ''; + var dataLines = []; + frame.split('\n').forEach(function(line) { + if (line.startsWith('event:')) eventName = line.slice(6).trim(); + if (line.startsWith('data:')) dataLines.push(line.slice(5).trim()); + }); + var payloadText = dataLines.join('\n'); + if (!eventName && payloadText) { + try { + var parsed = JSON.parse(payloadText); + eventName = parsed && parsed.event ? String(parsed.event) : ''; + } catch (_) {} + } + if (eventName) onEvent(eventName, payloadText); + }); + } + } + + function pageAiDecodeDeltaText(payloadText) { + try { + var payload = JSON.parse(payloadText || 'null'); + return String((payload && (payload.text || payload.delta)) || ''); + } catch (_) { + return String(payloadText || ''); + } + } + + function pageAiEnsureStreamingAssistantMessage(runId) { + var id = String(runId || pageUiState.pageAiCurrentRunId || ''); + var existing = pageUiState.pageAiMessages.find(function(item) { + return item.role === 'assistant' && item.streaming === true && item.runId === id; + }); + if (existing) return existing; + existing = { + role: 'assistant', + content: '', + runId: id, + streaming: true + }; + pageUiState.pageAiMessages.push(existing); + return existing; + } + + function pageAiAppendStreamingAssistantDelta(runId, deltaText) { + var delta = String(deltaText || ''); + if (!delta) return ''; + var message = pageAiEnsureStreamingAssistantMessage(runId); + message.content = String(message.content || '') + delta; + pageAiSyncCurrentSessionMessages(); + renderPageAiConversation(); + return message.content; + } + + function pageAiFinishStreamingAssistantMessage(runId, finalText, promptText) { + var id = String(runId || pageUiState.pageAiCurrentRunId || ''); + var message = pageUiState.pageAiMessages.find(function(item) { + return item.role === 'assistant' && item.streaming === true && item.runId === id; + }); + var text = String(finalText || (message && message.content) || ''); + var content = humanizePageAiResponse(text, promptText); + if (message) { + message.content = content; + message.streaming = false; + } else if (content) { + pageUiState.pageAiMessages.push({ + role: 'assistant', + content: content, + runId: id + }); + } + pageAiSyncCurrentSessionMessages(); + pageAiPersistSessions(); + } + + function pageAiLooksLikeBlockEdit(prompt) { + var text = searchText(prompt); + return ['新增', '添加', '插入', '删除', '删掉', '修改', '替换', '改成', '移动', '移到', 'move', 'replace', 'delete', 'insert'].some(function(word) { + return text.indexOf(word) >= 0; + }); + } + + async function pageAiTryBlockEditWorkflow(prompt, scopedContext) { + if (currentSourceKind() === 'local_folder') return false; + if (!pageAiLooksLikeBlockEdit(prompt)) return false; + var runId = 'page-ai-fast-' + Date.now().toString(36); + var traceId = 'page-ai-fast-' + Date.now().toString(36); + pageAiSetRunStatus('running', runId); + renderPageAiControls(); + var started = Date.now(); + var response = await fetch('/api/page-ai/block-edit-workflow', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: resolveWorkspaceId(document.body), + documentId: currentDocumentId(), + sourceKind: currentSourceKind(), + rootUri: currentRootUri(), + sessionId: pageUiState.pageAiActiveSessionId, + runId: runId, + profile: pageAiCurrentProfile(), + model: pageAiMnoteToolModel(), + message: prompt, + pageContext: scopedContext.pageContext, + selectedBlockId: scopedContext.selectedBlockId, + selectedText: scopedContext.selectedText, + traceId: traceId + }) + }); + var payload = await response.json().catch(function() { return null; }); + if (!response.ok || !payload || payload.ok !== true) { + var code = payload && payload.code ? String(payload.code) : ''; + if (code === 'page_ai_workflow_not_block_edit') return false; + pageUiState.pageAiMessages.push({ + role: 'tool', + toolName: 'mnote.page_ai.block_edit_workflow', + status: 'failed', + toolCallId: runId, + resultSummary: pageAiErrorMessage(payload, 'fast_path_failed_' + response.status) + }); + renderPageAiConversation(); + pageAiSetRunStatus('failed', runId); + renderPageAiControls(); + return true; + } + pageUiState.pageAiMessages.push({ + role: 'tool', + toolName: 'mnote.page_ai.block_edit_workflow', + status: 'completed', + toolCallId: runId, + resultSummary: 'operations=' + String(Array.isArray(payload.operations) ? payload.operations.length : 0) + ' · ' + String(Date.now() - started) + 'ms' + }); + pageUiState.pageAiMessages.push({ + role: 'assistant', + content: payload.message || '已通过页面块编辑快路径完成写入。' + }); + pageAiSetRunStatus('completed', runId); + try { + window.dispatchEvent(new CustomEvent('mnote:page-ai-tool-write-completed', { + detail: { + toolName: 'mnote.doc.apply_block_ops', + normalizedToolName: 'mnote.doc.apply.block.ops', + documentId: currentDocumentId(), + workspaceId: resolveWorkspaceId(document.body), + runId: runId, + traceId: traceId, + toolCallId: runId + } + })); + } catch (_) {} + var currentSession = pageAiCurrentSession(); + if (currentSession) { + currentSession.messages = pageUiState.pageAiMessages.slice(); + currentSession.updatedAt = Date.now(); + } + pageAiPersistSessions(); + renderPageAiConversation(); + renderPageAiControls(); + return true; + } + + async function sendPageAiMessage(text) { + var allowQueue = ['running', 'tool_calling'].indexOf(pageUiState.pageAiRunStatus) >= 0; + if (pageUiState.pageAiBusy && !allowQueue) return; + var prompt = searchText(text); + if (!prompt) return; + if (!allowQueue) pageUiState.pageAiBusy = true; + var currentSession = null; + try { + await pageAiEnsureHermesSession(); + if (!allowQueue) pageAiSetRunStatus('queued'); + renderPageAiControls(); + var contextSnapshot = currentPageAiContextSnapshot(); + var scopedContext = pageAiScopedPageContext(contextSnapshot); + pageUiState.pageAiMessages.push({ role: 'user', content: prompt }); + currentSession = pageAiCurrentSession(); + if (currentSession) { + if (currentSession.title === '新会话') { + currentSession.title = prompt.length > 18 ? prompt.slice(0, 18) + '…' : prompt; + } + currentSession.messages = pageUiState.pageAiMessages.slice(); + currentSession.updatedAt = Date.now(); + } + pageAiPersistSessions(); + renderPageAiConversation(); + if (!allowQueue && await pageAiTryBlockEditWorkflow(prompt, scopedContext)) { + return; + } + var response = await fetch('/api/hermes/client/runs', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: resolveWorkspaceId(document.body), + documentId: currentDocumentId(), + sourceKind: currentSourceKind(), + rootUri: currentRootUri(), + sessionId: pageUiState.pageAiActiveSessionId, + profile: pageAiRunProfile(), + acpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix', + contextScope: pageUiState.pageAiContextScope, + message: prompt, + model: pageAiMnoteToolModel(), + pageContext: scopedContext.pageContext, + selectedBlockId: scopedContext.selectedBlockId, + selectedText: scopedContext.selectedText, + traceId: 'page-ai-run-' + Date.now().toString(36) + }) + }); + if (!response.ok) { + var errorPayload = await response.json().catch(function(){ return null; }); + throw new Error(pageAiErrorMessage(errorPayload, 'page_ai_failed_' + response.status)); + } + var runPayload = await response.json().catch(function(){ return null; }); + if (pageAiApplyQueuedRun(runPayload)) { + pageUiState.pageAiMessages.push({ + role: 'assistant', + content: '已加入 Hermes 队列,前一条 run 完成后继续处理。' + }); + if (currentSession) { + currentSession.messages = pageUiState.pageAiMessages.slice(); + currentSession.updatedAt = Date.now(); + } + pageAiPersistSessions(); + renderPageAiConversation(); + renderPageAiControls(); + return; + } + if (allowQueue) { + throw new Error('hermes_queue_expected_queued_response'); + } + var upstream = runPayload && runPayload.upstream ? runPayload.upstream : runPayload; + var runId = upstream && (upstream.run_id || upstream.runId); + if (!runId) throw new Error('hermes_run_missing_run_id'); + var runTraceId = String((upstream && (upstream.trace_id || upstream.traceId)) || (runPayload && (runPayload.trace_id || runPayload.traceId)) || ''); + pageAiSetRunStatus('running', runId); + renderPageAiControls(); + var eventResponse = await fetch('/api/hermes/client/events/' + encodeURIComponent(runId), { + headers: { 'accept': 'text/event-stream' } + }); + if (!eventResponse.ok) { + var eventError = await eventResponse.json().catch(function(){ return null; }); + throw new Error(pageAiErrorMessage(eventError, 'hermes_events_failed_' + eventResponse.status)); + } + var assistantText = ''; + await streamPageAiResponse(eventResponse, function(eventName, payloadText) { + if (eventName === 'message.delta') { + if (pageUiState.pageAiStoppedRunIds[runId]) return; + assistantText = pageAiAppendStreamingAssistantDelta(runId, pageAiDecodeDeltaText(payloadText)); + } + if (eventName === 'thought.delta') { + try { + var thoughtPayload = JSON.parse(payloadText || 'null'); + var thoughtText = String((thoughtPayload && (thoughtPayload.delta || thoughtPayload.text)) || ''); + if (thoughtText) { + var msgs = pageUiState.pageAiMessages; + var lastThought = msgs.length > 0 && msgs[msgs.length - 1].kind === 'thought' ? msgs[msgs.length - 1] : null; + if (lastThought) { + lastThought.content += thoughtText; + } else { + msgs.push({ role: 'assistant', kind: 'thought', content: thoughtText }); + } + pageAiSyncCurrentSessionMessages(); + pageAiPersistSessions(); + renderPageAiConversation(); + } + } catch (_) {} + } + if (eventName === 'usage.updated') { + try { + var usagePayload = JSON.parse(payloadText || 'null') || {}; + var sessionForUsage = pageAiCurrentSession(); + if (sessionForUsage) { + sessionForUsage.usage = { + source: 'usage_update', + used: Number(usagePayload.used ?? usagePayload.contextUsed ?? 0), + size: Number(usagePayload.size ?? usagePayload.contextSize ?? 0) + }; + pageAiPersistSessions(); + } + } catch (_) {} + renderPageAiControls(); + } + if (eventName === 'permission.requested' || eventName === 'permission.denied' || eventName === 'permission.allowed') { + pageAiApplyPermissionEvent(eventName, payloadText); + pageAiSyncCurrentSessionMessages(); + pageAiPersistSessions(); + renderPageAiConversation(); + renderPageAiControls(); + } + if (eventName === 'run.completed') { + try { + var completed = JSON.parse(payloadText || 'null'); + if (completed && completed.output) assistantText = String(completed.output || ''); + if (completed && completed.usage) { + var completedSession = pageAiCurrentSession(); + if (completedSession) completedSession.usage = completed.usage; + } + var agentAudit = completed && completed.agentAudit && typeof completed.agentAudit === 'object' ? completed.agentAudit : null; + var changedFiles = pageAiNormalizeArray(agentAudit && agentAudit.changedFiles); + if (changedFiles.length) { + pageUiState.pageAiMessages.push({ + role: 'tool', + content: 'agent.changed_files', + toolCallId: runId + ':agent.changed_files', + toolName: 'agent.changed_files', + toolKind: 'audit', + status: 'completed', + argsSummary: String(agentAudit.rootUri || ''), + resultSummary: String(agentAudit.diffSummary || changedFiles.length + ' changed file(s)'), + changedFiles: changedFiles, + traceId: runTraceId, + auditId: String(agentAudit.eventId || '') + }); + try { + var currentId = currentDocumentId(); + var currentPath = String(currentId || '').replace(/^local-md:/, '').replace(/~2F/g, '/'); + var touchesCurrent = changedFiles.some(function(file) { + var path = String(file && (file.documentId || file.path || file.filePath || '') || ''); + return path === currentId || (currentPath && path.indexOf(currentPath) >= 0); + }); + if (touchesCurrent) { + window.dispatchEvent(new CustomEvent('mnote:page-ai-tool-write-completed', { + detail: { + toolName: 'agent.changed_files', + normalizedToolName: 'agent.changed_files', + documentId: currentId, + workspaceId: resolveWorkspaceId(document.body), + runId: runId, + traceId: runTraceId, + toolCallId: runId + ':agent.changed_files' + } + })); + } + } catch (_) {} + } + } catch (_) {} + pageAiSetRunStatus('completed', runId); + } + if (eventName === 'run.failed') { + try { + var failed = JSON.parse(payloadText || 'null'); + assistantText = String((failed && (failed.message || failed.code || failed.error)) || 'Hermes run failed'); + } catch (_) {} + pageAiSetRunStatus('failed', runId); + } + if (eventName === 'run.aborted' || eventName === 'run.cancelled' || eventName === 'run.canceled') { + pageUiState.pageAiStoppedRunIds[runId] = true; + pageAiSetRunStatus('aborted', runId); + } + if (eventName === 'session.info.updated') { + try { + var infoPayload = JSON.parse(payloadText || 'null') || {}; + var newTitle = String(infoPayload.title || '').trim(); + if (newTitle) { + var sessionForTitle = pageAiCurrentSession(); + if (sessionForTitle) { + sessionForTitle.title = newTitle; + sessionForTitle.updatedAt = Date.now(); + pageAiPersistSessions(); + renderPageAiControls(); + } + } + } catch (_) {} + } + if (eventName === 'plan.updated') { + try { + var planPayload = JSON.parse(payloadText || 'null') || {}; + var planEntries = Array.isArray(planPayload.entries) ? planPayload.entries : []; + if (planEntries.length) { + var planMsgs = pageUiState.pageAiMessages; + var existingPlan = planMsgs.length > 0 && planMsgs[planMsgs.length - 1].kind === 'plan' ? planMsgs[planMsgs.length - 1] : null; + if (existingPlan) { + existingPlan.entries = planEntries; + existingPlan.updatedAt = Date.now(); + } else { + planMsgs.push({ role: 'system', kind: 'plan', entries: planEntries, createdAt: Date.now(), updatedAt: Date.now() }); + } + pageAiSyncCurrentSessionMessages(); + pageAiPersistSessions(); + renderPageAiConversation(); + } + } catch (_) {} + } + if (eventName === 'tool.started' || eventName === 'tool.completed' || eventName === 'tool.failed') { + pageAiApplyToolEvent(eventName, payloadText, runId, runTraceId); + pageAiSyncCurrentSessionMessages(); + pageAiPersistSessions(); + renderPageAiConversation(); + pageAiSetRunStatus('tool_calling', runId); + renderPageAiControls(); + } + }); + if (!pageUiState.pageAiStoppedRunIds[runId]) { + pageAiFinishStreamingAssistantMessage(runId, assistantText, prompt); + } + currentSession = pageAiCurrentSession(); + if (currentSession) { + currentSession.messages = pageUiState.pageAiMessages.slice(); + currentSession.updatedAt = Date.now(); + } + pageAiPersistSessions(); + } catch (error) { + pageAiSetRunStatus('failed'); + pageUiState.pageAiMessages.push({ + role: 'assistant', + content: 'Hermes 当前请求失败:' + (error instanceof Error ? error.message : String(error)) + }); + currentSession = pageAiCurrentSession(); + if (currentSession) { + currentSession.messages = pageUiState.pageAiMessages.slice(); + currentSession.updatedAt = Date.now(); + } + pageAiPersistSessions(); + } finally { + if (!allowQueue) pageUiState.pageAiBusy = false; + renderPageAiConversation(); + renderPageAiControls(); + } + } + + + return { + openPageAiDrawer: (...args) => openPageAiDrawer(...args), + closePageAiDrawer: (...args) => closePageAiDrawer(...args), + isPageAiDrawerOpen: (...args) => isPageAiDrawerOpen(...args), + ensurePageAiDrawer: (...args) => ensurePageAiDrawer(...args), + sendPageAiMessage: (...args) => sendPageAiMessage(...args), + pageAiOpenHermesSettings: (...args) => pageAiOpenHermesSettings(...args), + pageAiStopRun: (...args) => pageAiStopRun(...args), + pageAiLoadGatewayHealth: (...args) => pageAiLoadGatewayHealth(...args), + renderPageAiControls: (...args) => renderPageAiControls(...args), + renderPageAiConversation: (...args) => renderPageAiConversation(...args), + renderPageAiProviderButtons: (...args) => renderPageAiProviderButtons(...args), + renderPageAiSuggestions: (...args) => renderPageAiSuggestions(...args), + pageAiSaveProfileMemory: (...args) => pageAiSaveProfileMemory(...args), + pageAiToggleSkill: (...args) => pageAiToggleSkill(...args), + pageAiToggleTool: (...args) => pageAiToggleTool(...args), + pageAiResumeBackendSession: (...args) => pageAiResumeBackendSession(...args), + pageAiRenameBackendSession: (...args) => pageAiRenameBackendSession(...args), + pageAiDeleteBackendSession: (...args) => pageAiDeleteBackendSession(...args), + pageAiResolvePermission: (...args) => pageAiResolvePermission(...args), + pageAiOpenLocation: (...args) => pageAiOpenLocation(...args), + pageAiSetActiveSession: (...args) => pageAiSetActiveSession(...args), + pageAiStartNewSession: (...args) => pageAiStartNewSession(...args), + pageAiLoadSessions: (...args) => pageAiLoadSessions(...args), + pageAiLoadBackendSessions: (...args) => pageAiLoadBackendSessions(...args), + pageAiCancelQueuedRun: (...args) => pageAiCancelQueuedRun(...args), + pageAiSearchBackendSessions: (...args) => pageAiSearchBackendSessions(...args), + pageAiPersistSessions: (...args) => pageAiPersistSessions(...args), + pageAiLoadProfiles: (...args) => pageAiLoadProfiles(...args), + pageAiLoadProfileMemory: (...args) => pageAiLoadProfileMemory(...args), + pageAiLoadSkills: (...args) => pageAiLoadSkills(...args), + pageAiSwitchProfile: (...args) => pageAiSwitchProfile(...args), + pageAiSetContextScope: (...args) => pageAiSetContextScope(...args), + updatePageAiTriggerState: (...args) => updatePageAiTriggerState(...args) + }; +} diff --git a/rust/crates/mnote-web/browser/sidebar-tree-runtime.js b/rust/crates/mnote-web/browser/sidebar-tree-runtime.js index f652df03..6d322941 100644 --- a/rust/crates/mnote-web/browser/sidebar-tree-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-tree-runtime.js @@ -5,12 +5,12 @@ import { createSidebarFileTreeOpenRuntime } from './sidebar-filetree-open-runtim import { createSidebarAttachmentOpenRuntime } from './sidebar-attachment-open-runtime.js'; import { createSidebarFileTreeCommandRuntime } from './sidebar-filetree-command-runtime.js'; import { createSidebarFileTreeUploadRuntime } from './sidebar-filetree-upload-runtime.js'; +import { createSidebarPageAiRuntime } from './sidebar-page-ai-runtime.js'; (function(){ if (window.__mnoteSidebarTreeRuntimeStarted) return; window.__mnoteSidebarTreeRuntimeStarted = true; - var PAGE_AI_SESSION_STORAGE_VERSION = 3; var FILETREE_DRAG_MIME = 'application/x-mnote-filetree-row-ids'; var MNOTE_SIDEBAR_TREE_MODE_KEY = 'mnote.sidebar.tree.mode'; var MNOTE_RECENT_LOCAL_ROOTS_KEY_PREFIX = 'mnote.localFolder.recentRoots:'; @@ -191,249 +191,6 @@ import { createSidebarFileTreeUploadRuntime } from './sidebar-filetree-upload-ru return parseJsonScript('__MNOTE_PAGE_AGGREGATE__') || {}; } - function textFromUnknown(value) { - if (value == null) return ''; - if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value); - if (Array.isArray(value)) return value.map(textFromUnknown).filter(Boolean).join(' '); - if (typeof value !== 'object') return ''; - var parts = []; - ['text', 'title', 'content', 'children', 'blocks', 'body'].forEach(function(key) { - if (Object.prototype.hasOwnProperty.call(value, key)) { - var text = textFromUnknown(value[key]); - if (text) parts.push(text); - } - }); - return parts.join(' '); - } - - function readLocalEditorBlocks() { - var editor = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror'); - if (!(editor instanceof HTMLElement)) return []; - return Array.from(editor.children).filter(function(node) { - return node instanceof HTMLElement; - }).map(function(node, index) { - var tag = String(node.tagName || '').toUpperCase(); - var headingMatch = tag.match(/^H([1-6])$/); - var type = headingMatch ? 'heading' : 'paragraph'; - var text = searchText(node.textContent || ''); - var id = searchText(node.getAttribute('data-id') || node.id || 'local-block-' + index); - var props = headingMatch ? { level: Number(headingMatch[1]) } : {}; - return { id: id, type: type, props: props, content: text }; - }).filter(function(block) { - return block.content || block.type === 'heading'; - }); - } - - function buildPageAiLocalSubtree(blocks, title) { - var documentId = currentDocumentId() || 'current-page'; - var rootNodeId = 'page:' + documentId; - var headingCounters = [0, 0, 0, 0, 0, 0]; - var headingStack = []; - var nodes = [{ - id: rootNodeId, - nodeId: rootNodeId, - nodeType: 'page', - blockId: null, - blockType: 'page', - title: title || '', - parentNodeId: null, - headingLevel: null - }]; - var outline = []; - var evidence = []; - blocks.forEach(function(block, index) { - var level = block.type === 'heading' ? Math.max(1, Math.min(6, Number(block.props && block.props.level || 1))) : null; - if (level != null) { - while (headingStack.length && headingStack[headingStack.length - 1].level >= level) headingStack.pop(); - } - var parentNodeId = headingStack.length ? headingStack[headingStack.length - 1].nodeId : rootNodeId; - var nodeId = 'local-node:' + String(block.id || index); - var titleText = block.content || (block.type === 'heading' ? '未命名标题' : ''); - nodes.push({ - id: nodeId, - nodeId: nodeId, - nodeType: 'block', - blockId: block.id, - blockType: block.type, - title: titleText, - parentNodeId: parentNodeId, - headingLevel: level - }); - if (level != null) { - headingCounters[level - 1] += 1; - for (var i = level; i < headingCounters.length; i += 1) headingCounters[i] = 0; - var numbering = headingCounters.slice(0, level).filter(function(count) { return count > 0; }).join('.'); - outline.push({ - id: 'local-outline:' + String(block.id || index), - nodeId: nodeId, - anchorBlockId: block.id, - level: level, - title: titleText, - numbering: numbering - }); - headingStack.push({ level: level, nodeId: nodeId }); - } - if (titleText) { - evidence.push({ - id: 'local-evidence:' + String(block.id || index), - nodeId: nodeId, - anchorBlockId: block.id, - text: titleText, - kind: block.type - }); - } - }); - return { - projectionId: 'local-editor-dom:' + documentId, - rootNode: { - id: rootNodeId, - documentId: documentId, - title: title || '', - nodeType: 'page' - }, - subtree: { - rootNodeId: rootNodeId, - nodes: nodes - }, - outline: outline, - evidence: evidence, - stats: { - nodeCount: nodes.length, - headingCount: outline.length, - evidenceCount: evidence.length - }, - source: 'local' - }; - } - - function currentPageAiContextSnapshot() { - var aggregate = currentPageAggregate(); - var body = aggregate.body || {}; - var serverSubtree = aggregate.tree && aggregate.tree.pageSubtree ? aggregate.tree.pageSubtree : null; - return { - aggregate: aggregate, - body: body, - subtree: serverSubtree, - pageSubtreeSource: serverSubtree ? 'server' : 'none' - }; - } - - function currentPageAiSelectedText() { - try { - var selection = window.getSelection ? window.getSelection() : null; - return selection ? searchText(selection.toString() || '') : ''; - } catch (_) { - return ''; - } - } - - function pageAiProjectionBlocks(aggregate) { - var blocks = aggregate && aggregate.body && aggregate.body.blockDocument && aggregate.body.blockDocument.blocks; - return Array.isArray(blocks) ? blocks : []; - } - - function pageAiBlockText(block) { - return searchText(block && (block.text || block.title || block.content) || ''); - } - - function pageAiSelectedBlockIdsFromSelection() { - try { - var selection = window.getSelection ? window.getSelection() : null; - if (!selection || selection.rangeCount === 0 || searchText(selection.toString() || '') === '') return []; - var range = selection.getRangeAt(0); - var editor = document.querySelector('[data-testid="mnote-leptos-tiptap-island-editor-root"] .editor-surface .ProseMirror'); - if (!(editor instanceof HTMLElement)) return []; - return Array.from(editor.children).filter(function(node) { - if (!(node instanceof HTMLElement)) return false; - try { - return range.intersectsNode(node); - } catch (_) { - return false; - } - }).map(function(node) { - return searchText(node.getAttribute('data-id') || node.id || ''); - }).filter(Boolean); - } catch (_) { - return []; - } - } - - function pageAiBlocksToPageXml(blocks, aggregate) { - var revision = aggregate && aggregate.body ? String(aggregate.body.revision || '') : ''; - var pageId = currentDocumentId() || 'current-page'; - var lines = ['']; - blocks.forEach(function(block) { - var blockId = String(block && (block.blockId || block.id) || ''); - var type = String(block && block.type || 'paragraph'); - var revisionRef = String(block && block.revisionRef || ''); - var level = block && block.attrs && block.attrs.level ? ' level="' + escapeHtml(block.attrs.level) + '"' : ''; - lines.push(' ' + escapeHtml(pageAiBlockText(block)) + ''); - }); - lines.push(''); - return lines.join('\n'); - } - - function buildPageAiContext(contextSnapshot, scope, selectedText) { - var aggregate = contextSnapshot.aggregate || {}; - var body = aggregate.body || {}; - var allBlocks = pageAiProjectionBlocks(aggregate); - var selectedBlockIds = scope === 'selection' ? pageAiSelectedBlockIdsFromSelection() : []; - var selectedSet = {}; - selectedBlockIds.forEach(function(id) { selectedSet[id] = true; }); - var selectedBlocks = selectedBlockIds.length - ? allBlocks.filter(function(block) { return selectedSet[String(block.blockId || block.id || '')]; }) - : []; - var contextBlocks = selectedBlocks.length ? selectedBlocks : allBlocks.slice(0, 120); - var truncated = !selectedBlocks.length && allBlocks.length > contextBlocks.length; - return { - schema: 'mnote.page_ai_context.v1', - workspaceId: resolveWorkspaceId(document.body), - documentId: currentDocumentId(), - scope: scope, - revision: body.revision || null, - conflictDetectionKey: body.conflictDetectionKey || null, - selectedText: selectedText || '', - selectedBlockIds: selectedBlockIds, - allowedTargetBlockIds: selectedBlockIds, - selectedBlocks: selectedBlocks, - contextBlocks: contextBlocks, - pageText: contextBlocks.map(pageAiBlockText).filter(Boolean).join('\n'), - pageXml: pageAiBlocksToPageXml(contextBlocks, aggregate), - truncated: truncated, - warnings: truncated ? [{ code: 'page_ai_context_truncated', message: '页面 AI context 已按前 120 个块裁剪' }] : [] - }; - } - - function pageAiScopedPageContext(contextSnapshot) { - var aggregate = contextSnapshot.aggregate || {}; - var body = contextSnapshot.body || {}; - var subtree = contextSnapshot.subtree || null; - var outline = subtree && subtree.outline ? subtree.outline : null; - var scope = pageUiState.pageAiContextScope || 'page'; - var title = aggregate.head && aggregate.head.title ? aggregate.head.title : ''; - var selectedText = scope === 'selection' ? currentPageAiSelectedText() : ''; - var aiContext = buildPageAiContext(contextSnapshot, scope, selectedText); - return { - pageContext: { - contextScope: scope, - documentBlocks: null, - node: { - documentId: currentDocumentId(), - title: title - }, - subtree: null, - outline: null, - pageSubtreeSource: scope === 'page' ? (contextSnapshot.pageSubtreeSource || 'none') : 'scope:' + scope, - evidence: selectedText ? [{ kind: 'selection', text: selectedText }] : null, - pageOptions: scope === 'options' || scope === 'page' ? currentPageOptions() : null, - contentAccess: 'mnote.doc.fetch', - aiContext: aiContext - }, - selectedText: selectedText, - selectedBlockId: aiContext.selectedBlockIds[0] || null - }; - } - function defaultPageOptions() { return { wideLayout: false, @@ -2244,13 +2001,6 @@ import { createSidebarFileTreeUploadRuntime } from './sidebar-filetree-upload-ru trigger.setAttribute('aria-expanded', pageUiState.pageSettingsOpen ? 'true' : 'false'); } - function updatePageAiTriggerState() { - var trigger = document.querySelector('[data-testid="wolai-floating-ai"]'); - if (!(trigger instanceof HTMLElement)) return; - trigger.setAttribute('data-state', pageUiState.pageAiOpen ? 'open' : 'closed'); - trigger.setAttribute('aria-expanded', pageUiState.pageAiOpen ? 'true' : 'false'); - } - function createPageOptionRow(key, type) { var inputType = type || 'checkbox'; var supported = pageOptionIsSupported(key); @@ -2418,2512 +2168,53 @@ import { createSidebarFileTreeUploadRuntime } from './sidebar-filetree-upload-ru document.documentElement.removeAttribute('data-mnote-page-share-open'); } - function pageAiSuggestions() { - var title = searchText(document.querySelector('[data-page-title-current="true"]')?.textContent) || '当前页面'; - return [ - '帮我总结《' + title + '》当前内容', - '把当前页面改写得更简洁一些', - '提炼当前页的关键待办和行动项', - '基于当前页内容生成一个三段式摘要' - ]; - } - - function pageAiStorageKey() { - return 'hermes_page_ai_session:' + currentDocumentId(); - } - - function pageAiTimestamp(value) { - if (typeof value === 'number' && Number.isFinite(value)) return value; - if (typeof value === 'string' && value.trim()) { - var parsed = Date.parse(value); - if (Number.isFinite(parsed)) return parsed; - } - return Date.now(); - } - - function pageAiBackendSessionQuery(extra) { - var params = new URLSearchParams(); - params.set('source', 'acp'); - params.set('workspaceId', resolveWorkspaceId(document.body)); - params.set('documentId', currentDocumentId()); - params.set('profile', pageAiRunProfile()); - params.set('sourceKind', currentSourceKind()); - if (currentRootUri()) params.set('rootUri', currentRootUri()); - Object.keys(extra || {}).forEach(function(key) { - var value = extra[key]; - if (value !== undefined && value !== null && String(value).trim() !== '') { - params.set(key, String(value)); - } - }); - return params.toString(); - } - - function pageAiNewSession(title) { - var now = Date.now(); - return { - id: 'sess_' + now + '_' + Math.random().toString(16).slice(2, 8), - title: title || '新会话', - profile: pageAiCurrentProfile(), - acpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix', - createdAt: now, - updatedAt: now, - source: 'local', - usage: null, - status: 'idle', - messages: [] - }; - } - - function pageAiUsageSummary(usage) { - if (!usage || typeof usage !== 'object') return ''; - var used = usage.used ?? usage.contextUsed ?? usage.input_tokens ?? usage.inputTokens ?? usage.prompt_tokens ?? usage.promptTokens; - var size = usage.size ?? usage.contextSize ?? usage.total_tokens ?? usage.totalTokens; - var output = usage.output_tokens ?? usage.outputTokens ?? usage.completion_tokens ?? usage.completionTokens; - var parts = []; - if (Number.isFinite(Number(used))) parts.push('ctx ' + Number(used)); - if (Number.isFinite(Number(size)) && Number(size) > 0) parts.push('/ ' + Number(size)); - if (Number.isFinite(Number(output)) && Number(output) > 0) parts.push('out ' + Number(output)); - return parts.join(' ') || ''; - } - - 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 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'; - return { - role: 'tool', - kind: 'permission', - permissionId: permissionId, - toolName: toolName, - argsSummary: pageAiPreviewValue(args), - content: decision === 'denied' ? '已自动拒绝权限请求' : (decision === 'allowed' ? '已自动允许权限请求' : '等待权限确认'), - resolved: decision === 'denied' || decision === 'allowed', - decision: decision - }; - } - - function pageAiApplyPermissionEvent(eventName, payloadText) { - var payload = null; - try { - payload = JSON.parse(payloadText || 'null'); - } catch (_) { - payload = {}; - } - var message = pageAiPermissionMessage(payload, eventName); - var existing = pageUiState.pageAiMessages.find(function(item) { - return item.kind === 'permission' && item.permissionId === message.permissionId; - }); - if (existing) { - Object.assign(existing, message); - } else { - pageUiState.pageAiMessages.push(message); - } - pageUiState.pageAiPermissionRequests = pageUiState.pageAiPermissionRequests.filter(function(item) { - return item.permissionId !== message.permissionId; - }).concat([message]).slice(-20); - if (!message.resolved) { - pageAiShowPermissionDialog(message); - } else { - pageAiHidePermissionDialog(); - } - } - - function pageAiResolvePermission(permissionId, decision) { - permissionId = String(permissionId || '').trim(); - if (!permissionId) return; - // 调用后端 resolve-permission 端点,让 ACP agent 得到真实响应 - var runId = pageUiState.pageAiCurrentRunId; - if (runId) { - fetch('/api/hermes/client/runs/' + encodeURIComponent(runId) + '/resolve-permission', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ permissionId: permissionId, decision: decision }) - }).then(function(response) { - if (!response.ok) console.warn('resolve-permission 后端返回异常', response.status); - }).catch(function(err) { - console.warn('resolve-permission 请求失败', err); - }); - } else { - console.warn('resolve-permission: 无活跃 runId,只能本地更新'); - } - // 本地乐观更新 UI - pageUiState.pageAiMessages.forEach(function(item) { - if (item.kind === 'permission' && item.permissionId === permissionId) { - item.resolved = true; - item.decision = decision; - item.content = decision === 'allow' ? '已允许权限请求' : '已拒绝权限请求'; - } - }); - var dialog = document.querySelector('[data-page-ai-permission-dialog]'); - if (dialog instanceof HTMLElement) dialog.hidden = true; - renderPageAiConversation(); - } - - function pageAiHidePermissionDialog() { - var dialog = document.querySelector('[data-page-ai-permission-dialog]'); - if (dialog instanceof HTMLElement) dialog.hidden = true; - } - - function pageAiShowPermissionDialog(message) { - if (!message || message.kind !== 'permission') return; - if (message.resolved) { - pageAiHidePermissionDialog(); - return; - } - var dialog = document.querySelector('[data-page-ai-permission-dialog]'); - if (!(dialog instanceof HTMLElement)) { - dialog = document.createElement('div'); - dialog.className = 'wolai-page-ai-permission-dialog'; - dialog.setAttribute('data-page-ai-permission-dialog', 'true'); - dialog.innerHTML = '' + - ''; - document.body.appendChild(dialog); - } - var tool = dialog.querySelector('[data-page-ai-permission-tool]'); - 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); - } - }); - dialog.hidden = false; - } - - function pageAiNormalizeSessions(sessions) { - return (Array.isArray(sessions) ? sessions : []) - .slice(0, 20) - .map(function(session) { - var sessionStorage = String(session && (session.sessionStorage || session.session_storage) || '').trim(); - var persistence = String(session && session.persistence || '').trim(); - return { - id: String(session && session.id || '').trim() || pageAiNewSession().id, - title: String(session && session.title || '').trim() || '新会话', - profile: String(session && session.profile || pageAiCurrentProfile()).trim() || 'default', - acpRuntime: String(session && session.acpRuntime || 'reasonix').trim(), - createdAt: pageAiTimestamp(session && session.createdAt), - updatedAt: pageAiTimestamp(session && session.updatedAt), - source: String(session && session.source || 'local').trim() || 'local', - persistence: persistence, - sessionStorage: sessionStorage, - permissionLevel: String(session && (session.permissionLevel || session.permission_level) || '').trim(), - shareId: String(session && (session.shareId || session.share_id) || '').trim(), - runId: String(session && (session.runId || session.run_id) || '').trim(), - status: String(session && session.status || '').trim(), - 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) { - return Number(b.updatedAt || 0) - Number(a.updatedAt || 0); - }); - } - - function pageAiNormalizeBackendSessionRow(row) { - if (!row || typeof row !== 'object') return null; - var payload = row.payload && typeof row.payload === 'object' ? row.payload : {}; - var sessionId = String(row.sessionId || row.session_id || '').trim(); - if (!sessionId) return null; - var title = String(row.title || payload.title || payload.message || '').trim(); - 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(); - if (!sessionStorage && persistence === 'convex_acp_runtime_store') sessionStorage = 'cloud'; - if (!sessionStorage && persistence === 'local_ai_session_jsonl') sessionStorage = String(row.shareId || payload.shareId || '').trim() ? 'local_shared' : 'local_private'; - return { - id: sessionId, - title: title || '当前页问答', - profile: String(row.profile || payload.profile || pageAiRunProfile()).trim() || 'default', - acpRuntime: String(row.acpRuntime || row.acp_runtime || payload.acpRuntime || 'reasonix').trim(), - createdAt: pageAiTimestamp(row.createdAt || row.created_at), - updatedAt: pageAiTimestamp(row.updatedAt || row.updated_at), - source: sessionStorage === 'local_private' || sessionStorage === 'local_shared' ? 'local' : 'acp', - persistence: persistence, - sessionStorage: sessionStorage, - permissionLevel: String(row.permissionLevel || row.permission_level || payload.permissionLevel || '').trim(), - shareId: String(row.shareId || row.share_id || payload.shareId || '').trim(), - runId: String(row.runId || row.run_id || '').trim(), - status: String(row.status || (row.runtime && row.runtime.status) || '').trim(), - usage: row.usage && typeof row.usage === 'object' ? row.usage : null, - preview: String(payload.message || row.snippet || '').trim(), - messages: [] - }; - } - - function pageAiMergeSessions(localSessions, backendSessions) { - var byId = {}; - pageAiNormalizeSessions(localSessions).forEach(function(session) { - byId[session.id] = session; - }); - pageAiNormalizeSessions(backendSessions).forEach(function(session) { - var existing = byId[session.id]; - byId[session.id] = Object.assign({}, existing || {}, session, { - messages: session.messages.length ? session.messages : (existing && existing.messages || []) - }); - }); - return pageAiNormalizeSessions(Object.keys(byId).map(function(id) { return byId[id]; })); - } - - function pageAiSessionStorageLabel(session) { - var storage = String(session && (session.sessionStorage || session.session_storage) || '').trim(); - var persistence = String(session && session.persistence || '').trim(); - if (storage === 'local_shared') return '共享会话'; - if (storage === 'local_private') return '本地私有'; - if (storage === 'cloud' || persistence === 'convex_acp_runtime_store') return '云端会话'; - if (persistence === 'local_ai_session_jsonl') return '本地私有'; - return String(session && session.source || '').trim() === 'acp' ? '云端会话' : '本地私有'; - } - - function pageAiLoadSessions() { - if (pageUiState.pageAiSessions.length && pageUiState.pageAiActiveSessionId) return; - try { - var raw = window.localStorage.getItem(pageAiStorageKey()); - var parsed = raw ? JSON.parse(raw) : null; - var activeId = String(parsed && parsed.activeSessionId || '').trim(); - var activeProfile = String(parsed && parsed.activeProfileName || '').trim(); - var activeAcpRuntime = String(parsed && parsed.activeAcpRuntime || '').trim(); - var sessions = pageAiNormalizeSessions(parsed && parsed.sessions); - var storageVersion = Number(parsed && parsed.version || 0); - if (storageVersion >= PAGE_AI_SESSION_STORAGE_VERSION && activeAcpRuntime) pageUiState.pageAiAcpRuntime = activeAcpRuntime; - if (activeProfile) pageAiSetActiveProfile(activeProfile); - if (sessions.length) { - pageUiState.pageAiSessions = sessions; - var activeSession = sessions.find(function(session) { return session.id === activeId; }) || sessions[0]; - pageUiState.pageAiActiveSessionId = activeSession.id; - if (activeSession.profile) pageAiSetActiveProfile(activeSession.profile); - if (!pageUiState.pageAiAcpRuntime && activeSession.acpRuntime) pageUiState.pageAiAcpRuntime = activeSession.acpRuntime; - pageUiState.pageAiMessages = Array.isArray(activeSession.messages) ? activeSession.messages.slice() : []; - return; - } - if (activeId) { - pageUiState.pageAiSessions = [pageAiNewSession('当前页问答')]; - pageUiState.pageAiSessions[0].id = activeId; - pageUiState.pageAiActiveSessionId = activeId; - pageUiState.pageAiMessages = []; - return; - } - } catch (_) {} - var fresh = pageAiNewSession(); - pageUiState.pageAiSessions = [fresh]; - pageUiState.pageAiActiveSessionId = fresh.id; - pageUiState.pageAiMessages = []; - } - - async function pageAiLoadBackendSessions() { - var response = await fetch('/api/hermes/client/sessions?' + pageAiBackendSessionQuery({ limit: 50 }), { - 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, 'session_list_failed_' + response.status)); - } - var backendSessions = pageAiNormalizeArray(payload.sessions).map(pageAiNormalizeBackendSessionRow).filter(Boolean); - if (!backendSessions.length) return []; - pageUiState.pageAiSessions = pageAiMergeSessions(pageUiState.pageAiSessions, backendSessions); - if (!pageUiState.pageAiActiveSessionId || !pageUiState.pageAiSessions.find(function(session) { return session.id === pageUiState.pageAiActiveSessionId; })) { - pageUiState.pageAiActiveSessionId = pageUiState.pageAiSessions[0].id; - } - var active = pageAiCurrentSession(); - if (active) { - if (active.profile) pageAiSetActiveProfile(active.profile); - if (!pageUiState.pageAiAcpRuntime && active.acpRuntime) pageUiState.pageAiAcpRuntime = active.acpRuntime; - pageUiState.pageAiMessages = Array.isArray(active.messages) ? active.messages.slice() : pageUiState.pageAiMessages; - } - pageUiState.pageAiSessionError = ''; - pageAiPersistSessions(); - renderPageAiConversation(); - renderPageAiControls(); - return backendSessions; - } - - function pageAiMessageFromRuntimeEvent(event) { - if (!event || typeof event !== 'object') return null; - var eventType = String(event.eventType || event.event_type || event.event || '').trim(); - var payload = event.payload && typeof event.payload === 'object' ? event.payload : event; - if (eventType === 'message.delta') { - var delta = String(payload.delta || payload.text || payload.output_text || '').trim(); - return delta ? { role: 'assistant', content: delta } : null; - } - if (eventType === 'thought.delta') { - var thought = String(payload.delta || payload.text || '').trim(); - return thought ? { role: 'assistant', kind: 'thought', content: thought } : null; - } - if (eventType === 'tool.started' || eventType === 'tool.completed' || eventType === 'tool.failed') { - var toolName = String(payload.toolName || payload.tool || payload.name || eventType).trim(); - var rawLocations = payload.locations; - return { - role: 'tool', - content: toolName, - toolName: toolName, - toolCallId: String(payload.toolCallId || payload.tool_call_id || payload.id || ''), - toolKind: String(payload.kind || ''), - status: eventType === 'tool.completed' ? 'completed' : (eventType === 'tool.failed' ? 'failed' : 'running'), - argsSummary: pageAiPreviewValue(payload.args || payload.arguments || payload.input), - resultSummary: pageAiPreviewValue(payload.summary || payload.result || payload.output || payload.error), - locations: Array.isArray(rawLocations) ? rawLocations.filter(function(l) { return typeof l === 'string' || (typeof l === 'object' && l && l.path); }).map(function(l) { return typeof l === 'string' ? l : l.path; }) : [], - traceId: String(payload.traceId || payload.trace_id || ''), - auditId: String(payload.auditId || payload.audit_id || '') - }; - } - if (eventType === 'permission.requested' || eventType === 'permission.denied' || eventType === 'permission.allowed') { - return pageAiPermissionMessage(payload, eventType); - } - return null; - } - - function pageAiApplyBackendSessionDetail(payload) { - var sessionPayload = payload && payload.session ? payload.session : {}; - var runs = pageAiNormalizeArray(sessionPayload.runs); - var latest = runs.length ? pageAiNormalizeBackendSessionRow(runs[0]) : null; - var events = pageAiNormalizeArray(payload && payload.events); - var messages = pageAiNormalizeArray(sessionPayload.messages).map(function(message) { - return { - role: message.role === 'user' ? 'user' : (message.role === 'tool' ? 'tool' : 'assistant'), - content: String(message.content || '') - }; - }).filter(function(message) { return message.content; }); - events.forEach(function(event) { - var message = pageAiMessageFromRuntimeEvent(event); - if (message) messages.push(message); - }); - var current = pageAiCurrentSession(); - if (latest && current) { - Object.assign(current, latest); - } - if (current) { - current.messages = messages.slice(-300); - current.updatedAt = Math.max(Number(current.updatedAt || 0), Date.now()); - if (latest && latest.usage) current.usage = latest.usage; - } - pageAiApplyRuntimeState(payload && payload.runtime); - pageUiState.pageAiMessages = messages.slice(-300); - pageAiPersistSessions(); - } - - 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 }), { - 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, 'session_detail_failed_' + response.status)); - } - pageAiApplyBackendSessionDetail(payload); - renderPageAiConversation(); - renderPageAiControls(); - return payload; - } - - async function pageAiSearchBackendSessions(query) { - var q = String(query || '').trim(); - pageUiState.pageAiSessionSearchQuery = q; - if (!q) { - pageUiState.pageAiSessionSearchResults = []; - renderPageAiConversation(); - return []; - } - var response = await fetch('/api/hermes/client/sessions/search?' + pageAiBackendSessionQuery({ q: q, limit: 20 }), { - 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, 'session_search_failed_' + response.status)); - } - pageUiState.pageAiSessionSearchResults = pageAiNormalizeArray(payload.results).map(function(row) { - var normalized = pageAiNormalizeBackendSessionRow(row) || {}; - normalized.snippet = String(row && row.snippet || normalized.preview || '').trim(); - return normalized; - }).filter(function(row) { return row.id; }); - renderPageAiConversation(); - return pageUiState.pageAiSessionSearchResults; - } - - function pageAiPersistSessions() { - document.documentElement.setAttribute('data-mnote-page-ai-session-owner', 'hermes'); - document.documentElement.setAttribute('data-mnote-page-ai-session-key', pageAiStorageKey()); - try { - pageAiSyncCurrentSessionMessages(); - window.localStorage.setItem(pageAiStorageKey(), JSON.stringify({ - version: PAGE_AI_SESSION_STORAGE_VERSION, - activeSessionId: pageUiState.pageAiActiveSessionId, - activeProfileName: pageAiCurrentProfile(), - activeAcpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix', - sessions: pageAiNormalizeSessions(pageUiState.pageAiSessions) - })); - } catch (_) {} - } - - async function pageAiEnsureHermesSession(forceCreate) { - pageAiLoadSessions(); - var current = pageAiCurrentSession(); - if (!forceCreate && current && String(current.id || '').startsWith('mnote_') && current.profile === pageAiCurrentProfile()) return current; - var response = await fetch('/api/hermes/client/sessions', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - workspaceId: resolveWorkspaceId(document.body), - documentId: currentDocumentId(), - sourceKind: currentSourceKind(), - rootUri: currentRootUri(), - traceId: 'page-ai-' + Date.now().toString(36), - profile: pageAiCurrentProfile(), - title: current && current.title ? current.title : '当前页问答' - }) - }); - var payload = await response.json().catch(function(){ return null; }); - if (!response.ok || !payload || payload.ok !== true) { - throw new Error(pageAiErrorMessage(payload, 'hermes_session_failed_' + response.status)); - } - var session = { - id: String(payload.sessionId || '').trim(), - title: String(payload.title || '当前页问答'), - profile: String(payload.profile || pageAiCurrentProfile()).trim() || 'default', - acpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix', - persistence: String(payload.persistence || '').trim(), - sessionStorage: String(payload.sessionStorage || '').trim(), - permissionLevel: String(payload.permissionLevel || '').trim(), - shareId: String(payload.shareId || '').trim(), - createdAt: Date.now(), - updatedAt: Date.now(), - messages: pageUiState.pageAiMessages.slice() - }; - pageUiState.pageAiSessions = pageAiNormalizeSessions([session]); - pageUiState.pageAiActiveSessionId = session.id; - pageAiPersistSessions(); - renderPageAiConversation(); - return session; - } - - 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({}), { - method: 'POST', - headers: { 'accept': 'application/json' } - }); - if (!response.ok) return; - var payload = await response.json().catch(function(){ return null; }); - if (payload && payload.persistence === 'convex_acp_runtime_store') { - pageAiApplyBackendSessionDetail(payload); - renderPageAiConversation(); - renderPageAiControls(); - return; - } - var session = payload && (payload.session || (payload.upstream && payload.upstream.session) || payload); - var messages = session && Array.isArray(session.messages) ? session.messages : []; - pageAiApplyRuntimeState(payload && payload.runtime); - if (session && (session.profile || session.profileName)) { - current.profile = String(session.profile || session.profileName || current.profile || pageAiCurrentProfile()); - } - if (!messages.length) { - renderPageAiControls(); - return; - } - pageUiState.pageAiMessages = messages.slice(-300).map(function(message) { - return { - role: message.role === 'user' ? 'user' : (message.role === 'tool' ? 'tool' : 'assistant'), - content: String(message.content || '') - }; - }); - current.messages = pageUiState.pageAiMessages.slice(); - current.updatedAt = Date.now(); - renderPageAiConversation(); - renderPageAiControls(); - } - - function pageAiCurrentSession() { - return pageUiState.pageAiSessions.find(function(session) { - return session.id === pageUiState.pageAiActiveSessionId; - }) || null; - } - - function pageAiSyncCurrentSessionMessages() { - var session = pageAiCurrentSession(); - if (!session) return; - session.messages = Array.isArray(pageUiState.pageAiMessages) ? pageUiState.pageAiMessages.slice(-300) : []; - session.profile = pageAiCurrentProfile(); - session.acpRuntime = pageUiState.pageAiAcpRuntime || 'reasonix'; - session.updatedAt = Date.now(); - } - - function pageAiSetActiveSession(sessionId) { - var session = pageUiState.pageAiSessions.find(function(item) { return item.id === sessionId; }); - if (!session) return; - pageUiState.pageAiActiveSessionId = session.id; - pageUiState.pageAiMessages = Array.isArray(session.messages) ? session.messages.slice() : []; - pageUiState.pageAiPage = 'chat'; - pageAiPersistSessions(); - renderPageAiControls(); - renderPageAiConversation(); - if (session.source === 'acp' || String(session.id || '').startsWith('mnote_')) { - void pageAiLoadBackendSessionDetail(session.id).catch(function(error) { - pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error); - renderPageAiControls(); - }); - } - } - - function pageAiStartNewSession() { - var session = pageAiNewSession(); - pageUiState.pageAiSessions = pageAiNormalizeSessions([session].concat(pageUiState.pageAiSessions)); - pageUiState.pageAiActiveSessionId = session.id; - pageUiState.pageAiMessages = []; - pageUiState.pageAiPage = 'chat'; - pageAiPersistSessions(); - renderPageAiControls(); - renderPageAiConversation(); - } - - async function pageAiRenameBackendSession(sessionId) { - var session = pageUiState.pageAiSessions.find(function(item) { return item.id === sessionId; }); - if (!session) return; - var title = window.prompt('重命名 AI 会话', session.title || '当前页问答'); - if (title === null) return; - title = String(title || '').trim(); - if (!title) return; - var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(sessionId) + '/rename?' + pageAiBackendSessionQuery({}), { - method: 'POST', - headers: { 'content-type': 'application/json', 'accept': 'application/json' }, - body: JSON.stringify({ title: title }) - }); - var payload = await response.json().catch(function(){ return null; }); - if (!response.ok || !payload || payload.ok !== true) { - throw new Error(pageAiErrorMessage(payload, 'session_rename_failed_' + response.status)); - } - session.title = String((payload.result && payload.result.title) || title); - session.updatedAt = Date.now(); - pageAiPersistSessions(); - renderPageAiConversation(); - renderPageAiControls(); - } - - async function pageAiDeleteBackendSession(sessionId) { - var session = pageUiState.pageAiSessions.find(function(item) { return item.id === sessionId; }); - if (!session) return; - if (!window.confirm('确定删除 AI 会话“' + (session.title || sessionId) + '”吗?')) return; - var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(sessionId) + '?' + pageAiBackendSessionQuery({}), { - method: 'DELETE', - 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, 'session_delete_failed_' + response.status)); - } - pageUiState.pageAiSessions = pageUiState.pageAiSessions.filter(function(item) { return item.id !== sessionId; }); - if (pageUiState.pageAiActiveSessionId === sessionId) { - var next = pageUiState.pageAiSessions[0] || pageAiNewSession(); - if (!pageUiState.pageAiSessions.length) pageUiState.pageAiSessions = [next]; - pageUiState.pageAiActiveSessionId = next.id; - pageUiState.pageAiMessages = Array.isArray(next.messages) ? next.messages.slice() : []; - } - pageAiPersistSessions(); - renderPageAiConversation(); - renderPageAiControls(); - } - - async function pageAiResumeBackendSession(sessionId) { - sessionId = String(sessionId || pageUiState.pageAiActiveSessionId || '').trim(); - if (!sessionId) return; - pageAiSetActiveSession(sessionId); - var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(sessionId) + '/resume?' + pageAiBackendSessionQuery({}), { - method: 'POST', - 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, 'session_resume_failed_' + response.status)); - } - pageAiApplyBackendSessionDetail(payload); - pageUiState.pageAiPage = 'chat'; - renderPageAiConversation(); - renderPageAiControls(); - } - - function pageAiProviderLabel(provider) { - if (provider === 'codex') return 'Codex'; - if (provider === 'claudecode') return 'ClaudeCode'; - return 'Hermes'; - } - - function pageAiNormalizeArray(value) { - return Array.isArray(value) ? value : []; - } - - function pageAiDefaultAcpRuntimes() { - return [ - { - name: 'reasonix', - title: 'ACP · Reasonix', - description: '通过 ACP 协议直连 Reasonix(DeepSeek 缓存优先)', - model: 'deepseek-chat', - preset: 'auto' - }, - { - name: 'hermes', - title: 'ACP · Hermes', - description: '通过 ACP 协议直连 Hermes agent runtime' - } - ]; - } - - function pageAiNormalizeAcpRuntimes(runtimes) { - var byName = {}; - pageAiDefaultAcpRuntimes().forEach(function(runtime) { - byName[runtime.name] = Object.assign({}, runtime); - }); - pageAiNormalizeArray(runtimes).forEach(function(runtime) { - var name = String(runtime && runtime.name || '').trim(); - if (name !== 'reasonix' && name !== 'hermes') return; - byName[name] = Object.assign({}, byName[name] || {}, runtime, { name: name }); - }); - return ['reasonix', 'hermes'].map(function(name) { return byName[name]; }).filter(Boolean); - } - - function pageAiUnwrapUpstream(payload) { - if (payload && typeof payload === 'object' && payload.upstream) return payload.upstream; - return payload || null; - } - - function pageAiProfileValue(profile) { - if (profile && typeof profile === 'object') { - return String(profile.name || profile.profile || profile.id || '').trim(); - } - return String(profile || '').trim(); - } - - function pageAiCurrentProfile() { - var active = String(pageUiState.pageAiActiveProfileName || '').trim(); - if (active) return active; - var selected = pageUiState.pageAiProfiles.find(function(profile) { - return profile && profile.active; - }); - return pageAiProfileValue(selected) || 'mnoteai'; - } - - function pageAiRunProfile() { - return String(pageUiState.pageAiAcpRuntime || 'reasonix').trim() === 'reasonix' ? 'reasonix' : pageAiCurrentProfile(); - } - - function pageAiMnoteToolModel() { - return String(document.documentElement.getAttribute('data-mnote-page-ai-tool-model') || 'deepseek-v4-flash').trim() || 'deepseek-v4-flash'; - } - - function pageAiCurrentProfileRecord() { - var active = pageAiCurrentProfile(); - return pageUiState.pageAiProfiles.find(function(profile) { - return pageAiProfileValue(profile) === active; - }) || null; - } - - function pageAiCurrentModelLabel() { - var profile = pageAiCurrentProfileRecord(); - var toolModel = pageAiMnoteToolModel(); - 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 决定'; - return 'tool: ' + toolModel + ' · profile: ' + profileLabel; - } - - function pageAiNormalizeProfiles(payload) { - var upstream = pageAiUnwrapUpstream(payload); - var profiles = pageAiNormalizeArray(upstream && upstream.profiles ? upstream.profiles : upstream); - return profiles.map(function(profile) { - return { - name: pageAiProfileValue(profile) || 'default', - active: Boolean(profile && profile.active), - model: String(profile && profile.model || '').trim(), - gateway: String(profile && profile.gateway || '').trim(), - alias: String(profile && profile.alias || '').trim() - }; - }); - } - - function pageAiNormalizeSkills(payload) { - var upstream = pageAiUnwrapUpstream(payload); - var categories = pageAiNormalizeArray(upstream && upstream.categories ? upstream.categories : []); - var archived = pageAiNormalizeArray(upstream && upstream.archived ? upstream.archived : []); - return { - categories: categories.map(function(category) { - return { - name: String(category && category.name || '').trim() || 'misc', - description: String(category && category.description || '').trim(), - skills: pageAiNormalizeArray(category && category.skills).map(function(skill) { - return { - name: String(skill && skill.name || '').trim(), - description: String(skill && skill.description || '').trim(), - enabled: skill && skill.enabled !== false, - source: String(skill && skill.source || 'local').trim() || 'local', - origin: String(skill && skill.origin || '').trim(), - createdBy: String(skill && skill.createdBy || '').trim(), - patchCount: Number(skill && skill.patchCount || 0), - modified: Boolean(skill && skill.modified) - }; - }) - }; - }), - archived: archived.map(function(skill) { - return { - name: String(skill && skill.name || '').trim(), - description: String(skill && skill.description || '').trim(), - enabled: skill && skill.enabled !== false, - source: String(skill && skill.source || 'local').trim() || 'local', - origin: String(skill && skill.origin || '').trim(), - createdBy: String(skill && skill.createdBy || '').trim(), - patchCount: Number(skill && skill.patchCount || 0), - modified: Boolean(skill && skill.modified) - }; - }) - }; - } - - function pageAiSkillListEntries() { - var result = []; - pageAiNormalizeArray(pageUiState.pageAiSkills.categories).forEach(function(category) { - pageAiNormalizeArray(category.skills).forEach(function(skill) { - result.push({ - category: category.name, - name: skill.name, - description: skill.description, - enabled: skill.enabled !== false, - source: skill.source || 'local', - origin: skill.origin || '', - createdBy: skill.createdBy || '', - patchCount: Number(skill.patchCount || 0), - modified: Boolean(skill.modified) - }); - }); - }); - return result.concat(pageAiNormalizeArray(pageUiState.pageAiSkills.archived)); - } - - function pageAiSetActiveProfile(profileName) { - var next = String(profileName || '').trim() || 'mnoteai'; - pageUiState.pageAiActiveProfileName = next; - document.documentElement.setAttribute('data-mnote-page-ai-profile', next); - document.documentElement.setAttribute('data-mnote-page-ai-tool-model', pageAiMnoteToolModel()); - } - - function pageAiSetRunStatus(status, runId) { - pageUiState.pageAiRunStatus = status || 'idle'; - pageUiState.pageAiCurrentRunId = runId || pageUiState.pageAiCurrentRunId || ''; - document.documentElement.setAttribute('data-mnote-page-ai-run-status', pageUiState.pageAiRunStatus); - if (pageUiState.pageAiCurrentRunId) { - document.documentElement.setAttribute('data-mnote-page-ai-run-id', pageUiState.pageAiCurrentRunId); - } - } - - function pageAiApplyRuntimeState(runtime) { - if (!runtime || typeof runtime !== 'object') return; - var status = String(runtime.status || '').trim(); - var runId = String(runtime.runId || runtime.run_id || '').trim(); - if (status) pageAiSetRunStatus(status, runId); - var queueLength = Number(runtime.queueLength || runtime.queue_length || 0); - if (Number.isFinite(queueLength)) pageUiState.pageAiQueueLength = Math.max(0, queueLength); - var toolName = String(runtime.lastToolName || runtime.last_tool_name || '').trim(); - if (toolName) { - pageUiState.pageAiLastToolCall = { - event: String(runtime.lastEvent || runtime.last_event || ''), - name: toolName, - runId: runId, - traceId: String(runtime.traceId || runtime.trace_id || ''), - auditId: String(runtime.lastAuditId || runtime.last_audit_id || '') - }; - } - } - - function pageAiApplyQueuedRun(payload) { - if (!payload || payload.queued !== true) return false; - var queueId = String(payload.queueId || payload.queue_id || '').trim(); - var queueLength = Number(payload.queueLength || payload.queue_length || 0); - pageUiState.pageAiQueueLength = Number.isFinite(queueLength) ? Math.max(0, queueLength) : 1; - if (queueId) { - pageUiState.pageAiQueuedItems = pageUiState.pageAiQueuedItems.filter(function(item) { - return item.queueId !== queueId; - }).concat([{ - queueId: queueId, - sessionId: String(payload.sessionId || payload.session_id || pageUiState.pageAiActiveSessionId || ''), - traceId: String(payload.traceId || payload.trace_id || ''), - queuedAt: Number(payload.queuedAt || payload.queued_at || Date.now()) - }]); - } - pageAiSetRunStatus('queued', pageUiState.pageAiCurrentRunId); - return true; - } - - function pageAiPreviewValue(value) { - if (value == null || value === '') return ''; - if (typeof value === 'string') return value.length > 120 ? value.slice(0, 120) + '…' : value; - try { - var text = JSON.stringify(value); - return text.length > 120 ? text.slice(0, 120) + '…' : text; - } catch (_) { - return String(value); - } - } - - function pageAiNormalizeToolName(name) { - return String(name || '').trim().replace(/_/g, '.'); - } - - function pageAiFormatChangedFiles(files) { - return pageAiNormalizeArray(files).map(function(file) { - var path = String(file && file.path || '').trim(); - var changeType = String(file && file.changeType || file.change_type || 'modified').trim(); - var summary = String(file && file.summary || '').trim(); - return [changeType, path, summary].filter(Boolean).join(' · '); - }).filter(Boolean).join('\n'); - } - - function pageAiToolEventDeepFindString(value, keys, depth) { - if (!value || typeof value !== 'object' || depth > 5) return ''; - for (var index = 0; index < keys.length; index += 1) { - var key = keys[index]; - if (Object.prototype.hasOwnProperty.call(value, key) && typeof value[key] === 'string' && value[key].trim()) { - return value[key].trim(); - } - } - if (Array.isArray(value)) { - for (var arrayIndex = 0; arrayIndex < value.length; arrayIndex += 1) { - var fromArray = pageAiToolEventDeepFindString(value[arrayIndex], keys, depth + 1); - if (fromArray) return fromArray; - } - return ''; - } - var preferred = ['audit', 'args', 'arguments', 'input', 'result', 'summary', 'output', 'upstream']; - for (var prefIndex = 0; prefIndex < preferred.length; prefIndex += 1) { - var child = value[preferred[prefIndex]]; - var fromPreferred = pageAiToolEventDeepFindString(child, keys, depth + 1); - if (fromPreferred) return fromPreferred; - } - var objectKeys = Object.keys(value); - for (var objectIndex = 0; objectIndex < objectKeys.length; objectIndex += 1) { - var fromObject = pageAiToolEventDeepFindString(value[objectKeys[objectIndex]], keys, depth + 1); - if (fromObject) return fromObject; - } - return ''; - } - - function pageAiNotifyToolWriteCompleted(toolName, toolEvent, runId, runTraceId) { - var normalizedTool = pageAiNormalizeToolName(toolName); - var writesCurrentPage = [ - 'mnote.page.save', - 'mnote.page.update.title', - 'mnote.page.update.options', - 'mnote.doc.apply.block.ops', - 'mnote.block.replace', - 'mnote.block.insert.after', - 'mnote.block.delete', - 'mnote.block.move.after' - ].indexOf(normalizedTool) >= 0 || [ - 'mnote.page.update_title', - 'mnote.page.update_options', - 'mnote.doc.apply_block_ops', - 'mnote.block.replace', - 'mnote.block.insert_after', - 'mnote.block.delete', - 'mnote.block.move_after' - ].indexOf(String(toolName || '').trim()) >= 0; - if (!writesCurrentPage) return; - var documentId = pageAiToolEventDeepFindString(toolEvent, ['documentId', 'document_id'], 0) || currentDocumentId(); - var workspaceId = pageAiToolEventDeepFindString(toolEvent, ['workspaceId', 'workspace_id'], 0) || resolveWorkspaceId(document.body); - try { - window.dispatchEvent(new CustomEvent('mnote:page-ai-tool-write-completed', { - detail: { - toolName: toolName, - normalizedToolName: normalizedTool, - documentId: documentId, - workspaceId: workspaceId, - runId: String(toolEvent && (toolEvent.run_id || toolEvent.runId) || runId || ''), - traceId: String(toolEvent && (toolEvent.trace_id || toolEvent.traceId) || runTraceId || ''), - toolCallId: String(toolEvent && (toolEvent.tool_call_id || toolEvent.toolCallId || toolEvent.id) || '') - } - })); - } catch (error) { - console.warn('mnote 页面 AI 写入刷新事件派发失败', error); - } - } - - function pageAiApplyToolEvent(eventName, payloadText, runId, runTraceId) { - var toolEvent = null; - try { - toolEvent = JSON.parse(payloadText || 'null'); - } catch (_) { - toolEvent = {}; - } - var rawToolName = toolEvent && (toolEvent.name || toolEvent.tool || toolEvent.toolName || ''); - var toolName = String(rawToolName || eventName); - var toolCallId = String(toolEvent && (toolEvent.tool_call_id || toolEvent.toolCallId || toolEvent.id) || (runId + ':' + toolName)); - var eventStatus = String(toolEvent && toolEvent.status || '').trim(); - var status = eventName === 'tool.completed' - ? (toolEvent && toolEvent.error ? 'failed' : 'completed') - : (eventName === 'tool.failed' ? 'failed' : (eventStatus || 'running')); - if (status === 'in_progress' || status === 'pending') status = 'running'; - var traceId = String(toolEvent && (toolEvent.trace_id || toolEvent.traceId) || runTraceId || ''); - var auditId = String(toolEvent && (toolEvent.audit_id || toolEvent.auditId) || ''); - var argsSummary = pageAiPreviewValue(toolEvent && (toolEvent.arguments || toolEvent.args || toolEvent.input)); - var resultSource = toolEvent && (toolEvent.summary || toolEvent.result || toolEvent.output); - if (!resultSource && status === 'failed') { - resultSource = [toolEvent && toolEvent.code, toolEvent && toolEvent.error].filter(Boolean).join(' '); - } - var resultSummary = pageAiPreviewValue(resultSource); - pageUiState.pageAiLastToolCall = { - event: eventName, - name: toolName, - runId: String(toolEvent && (toolEvent.run_id || toolEvent.runId) || runId), - traceId: traceId, - auditId: auditId - }; - var rawLocations = toolEvent && toolEvent.locations; - var locations = Array.isArray(rawLocations) ? rawLocations.filter(function(l) { return typeof l === 'string' || (typeof l === 'object' && l && l.path); }).map(function(l) { return typeof l === 'string' ? l : l.path; }) : []; - var existing = pageUiState.pageAiMessages.find(function(item) { - return item.role === 'tool' && item.toolCallId === toolCallId; - }); - if (!rawToolName && existing && existing.toolName) toolName = existing.toolName; - if (!existing) { - existing = { - role: 'tool', - content: toolName, - toolCallId: toolCallId, - toolName: toolName, - toolKind: String(toolEvent && toolEvent.kind || ''), - status: status, - argsSummary: '', - resultSummary: '', - locations: locations, - traceId: traceId, - auditId: auditId - }; - pageUiState.pageAiMessages.push(existing); - } - existing.content = toolName; - existing.toolName = toolName; - existing.toolKind = String(toolEvent && toolEvent.kind || existing.toolKind || ''); - existing.status = status; - existing.traceId = traceId || existing.traceId || ''; - existing.auditId = auditId || existing.auditId || ''; - if (argsSummary) existing.argsSummary = argsSummary; - if (resultSummary) existing.resultSummary = resultSummary; - if (locations.length) existing.locations = locations; - if (status === 'completed') { - pageAiNotifyToolWriteCompleted(toolName, toolEvent, runId, runTraceId); - } - } - - async function pageAiCancelQueuedRun(queueId) { - queueId = String(queueId || '').trim(); - if (!queueId) 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), { - method: 'DELETE', - headers: { 'accept': 'application/json' } - }); - var payload = await response.json().catch(function(){ return null; }); - if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'queue_cancel_failed_' + response.status)); - pageUiState.pageAiQueuedItems = pageUiState.pageAiQueuedItems.filter(function(entry) { - return entry.queueId !== queueId; - }); - var queueLength = Number(payload && (payload.queueLength || payload.queue_length || 0)); - pageUiState.pageAiQueueLength = Number.isFinite(queueLength) ? Math.max(0, queueLength) : pageUiState.pageAiQueuedItems.length; - pageUiState.pageAiMessages.push({ role: 'assistant', content: '已取消一条 Hermes 队列项。' }); - } catch (error) { - pageUiState.pageAiMessages.push({ - role: 'assistant', - content: '取消 Hermes 队列项失败:' + (error instanceof Error ? error.message : String(error)) - }); - } - renderPageAiConversation(); - renderPageAiControls(); - } - - function pageAiSetContextScope(scope) { - var next = String(scope || '').trim() || 'page'; - pageUiState.pageAiContextScope = next; - document.documentElement.setAttribute('data-mnote-page-ai-context-scope', next); - } - - function pageAiRunStatusLabel(status) { - if (status === 'queued') return '排队中'; - if (status === 'running') return '运行中'; - if (status === 'tool_calling') return '调用工具'; - if (status === 'completed') return '已完成'; - if (status === 'failed') return '失败'; - if (status === 'aborted') return '已停止'; - return '空闲'; - } - - function pageAiMemoryFileLabel(section) { - if (section === 'soul') return 'SOUL.md'; - if (section === 'user') return 'USER.md'; - return 'MEMORY.md'; - } - - function pageAiContextScopeLabel(scope) { - if (scope === 'selection') return '当前选区'; - if (scope === 'block') return '当前块'; - if (scope === 'options') return '页面设置'; - return '当前页'; - } - - function pageAiHermesSettingsUrl() { - var configured = String(window.__mnoteHermesSettingsUrl || '').trim(); - return configured || ''; - } - - function pageAiOpenHermesSettings() { - var url = pageAiHermesSettingsUrl(); - if (url) { - window.open(url, '_blank', 'noopener,noreferrer'); - return; - } - pageUiState.pageAiProfileError = '未配置 Hermes 设置入口:请设置 MNOTE_WEB_HERMES_UPSTREAM_URL。'; - renderPageAiControls(); - } - - function pageAiNormalizeTools(payload) { - var upstream = pageAiUnwrapUpstream(payload) || {}; - var tools = pageAiNormalizeArray(upstream.tools || upstream); - return tools.map(function(tool) { - var name = String(tool && (tool.name || tool.toolName || tool.tool) || '').trim(); - if (!name) return null; - return { - name: name, - description: String(tool && tool.description || '').trim(), - scope: String(tool && (tool.scope || tool.requiredScope || '') || '').trim(), - kind: String(tool && (tool.kind || tool.mode || '') || '').trim(), - status: String(tool && (tool.status || tool.permission || 'available') || '').trim(), - enabled: tool && tool.enabled !== false, - unavailableReason: String(tool && (tool.unavailableReason || tool.reason || '') || '').trim() - }; - }).filter(Boolean); - } - - function pageAiErrorMessage(payload, fallback) { - if (!payload || typeof payload !== 'object') return fallback; - return String(payload.message || payload.error || payload.code || fallback || '').trim() || fallback; - } - - function pageAiNormalizeGatewayHealth(payload) { - var upstream = pageAiUnwrapUpstream(payload) || payload || {}; - return { - ok: Boolean(upstream.ok), - profile: upstream.profile || null, - gateway: upstream.gateway || null, - suggestions: pageAiNormalizeArray(upstream.suggestions).map(function(item) { - return String(item || '').trim(); - }).filter(Boolean) - }; - } - - function pageAiSetDraftForSection(section, value) { - pageUiState.pageAiProfileMemoryDrafts[section] = String(value == null ? '' : value); - } - - function pageAiApplyProfileMemory(payload) { - var upstream = pageAiUnwrapUpstream(payload) || {}; - pageUiState.pageAiProfileMemory = { - memory: String(upstream.memory || ''), - user: String(upstream.user || ''), - soul: String(upstream.soul || '') - }; - pageUiState.pageAiProfileMemoryDrafts = { - memory: pageUiState.pageAiProfileMemory.memory, - user: pageUiState.pageAiProfileMemory.user, - soul: pageUiState.pageAiProfileMemory.soul - }; - } - - async function pageAiLoadTools() { - try { - var response = await fetch('/api/hermes/client/tools?scope=mnote&profile=' + encodeURIComponent(pageAiCurrentProfile()), { - headers: { 'accept': 'application/json' } - }); - var payload = await response.json().catch(function(){ return null; }); - if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'tools_failed_' + response.status)); - pageUiState.pageAiTools = pageAiNormalizeTools(payload); - pageUiState.pageAiToolsError = ''; - } catch (error) { - pageUiState.pageAiToolsError = error instanceof Error ? error.message : String(error); - } - renderPageAiControls(); - } - - async function pageAiLoadGatewayHealth() { - try { - var response = await fetch('/api/hermes/client/gateway/health?profile=' + encodeURIComponent(pageAiCurrentProfile()), { - headers: { 'accept': 'application/json' } - }); - var payload = await response.json().catch(function(){ return null; }); - if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'gateway_health_failed_' + response.status)); - pageUiState.pageAiGatewayHealth = pageAiNormalizeGatewayHealth(payload); - pageUiState.pageAiGatewayHealthError = ''; - } catch (error) { - pageUiState.pageAiGatewayHealthError = error instanceof Error ? error.message : String(error); - } - renderPageAiControls(); - } - - async function pageAiStopRun() { - var runId = String(pageUiState.pageAiCurrentRunId || '').trim(); - if (!runId || pageUiState.pageAiRunStatus === 'idle' || pageUiState.pageAiRunStatus === 'completed') return; - pageUiState.pageAiStoppedRunIds[runId] = true; - try { - var response = await fetch('/api/hermes/client/runs/' + encodeURIComponent(runId) + '/abort', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - sessionId: pageUiState.pageAiActiveSessionId, - profile: pageAiRunProfile(), - acpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix', - reason: 'page_ai_user_stop' - }) - }); - var payload = await response.json().catch(function(){ return null; }); - if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'run_abort_failed_' + response.status)); - pageAiApplyRuntimeState(payload && payload.runtime); - pageAiSetRunStatus('aborted', runId); - pageUiState.pageAiMessages.push({ role: 'assistant', content: '已请求 Hermes 停止当前 run。' }); - } catch (error) { - pageAiSetRunStatus('failed', runId); - pageUiState.pageAiMessages.push({ - role: 'assistant', - content: '停止 Hermes run 失败:' + (error instanceof Error ? error.message : String(error)) - }); - } - renderPageAiControls(); - renderPageAiConversation(); - } - - function pageAiFilteredSkillEntries() { - var query = String(pageUiState.pageAiSkillQuery || '').trim().toLowerCase(); - return pageAiSkillListEntries().filter(function(skill) { - if (!query) return true; - return String(skill.name || '').toLowerCase().indexOf(query) >= 0 - || String(skill.description || '').toLowerCase().indexOf(query) >= 0 - || String(skill.category || '').toLowerCase().indexOf(query) >= 0 - || pageAiSkillOriginLabel(skill).toLowerCase().indexOf(query) >= 0; - }); - } - - function pageAiSkillOriginLabel(skill) { - var origin = String(skill && skill.origin || '').trim(); - if (origin === 'generated' || String(skill && skill.createdBy || '') === 'agent') return '生成'; - if (origin === 'installed') return '安装'; - if (origin === 'builtin') return '内置'; - if (origin === 'copied') return '本地'; - var source = String(skill && skill.source || '').trim(); - if (source === 'hub') return '安装'; - if (source === 'builtin') return '内置'; - if (source === 'reasonix') { - if (origin === 'project') return 'Reasonix 项目'; - if (origin === 'global') return 'Reasonix 全局'; - return 'Reasonix'; - } - return '本地'; - } - - async function pageAiLoadProfiles() { - try { - var response = await fetch('/api/hermes/client/profiles', { - headers: { 'accept': 'application/json' } - }); - var payload = await response.json().catch(function(){ return null; }); - if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'profiles_failed_' + response.status)); - var profiles = pageAiNormalizeProfiles(payload); - pageUiState.pageAiProfiles = profiles.length ? profiles : [{ name: 'mnoteai', active: true }]; - var acpRuntimes = Array.isArray(payload.acpRuntimes) ? payload.acpRuntimes : []; - pageUiState.pageAiAcpRuntimes = pageAiNormalizeAcpRuntimes(acpRuntimes); - var current = pageAiCurrentProfile(); - var hasCurrent = pageUiState.pageAiProfiles.some(function(profile) { return pageAiProfileValue(profile) === current; }); - var hasMnoteAi = pageUiState.pageAiProfiles.some(function(profile) { return pageAiProfileValue(profile) === 'mnoteai'; }); - var active = pageAiProfileValue(pageUiState.pageAiProfiles.find(function(profile) { return profile.active; })); - pageAiSetActiveProfile(hasCurrent && current !== 'default' ? current : (hasMnoteAi ? 'mnoteai' : (active || current))); - pageUiState.pageAiProfileError = ''; - void pageAiLoadGatewayHealth(); - } catch (error) { - pageUiState.pageAiProfileError = error instanceof Error ? error.message : String(error); - if (!pageUiState.pageAiProfiles.length) pageUiState.pageAiProfiles = [{ name: 'mnoteai', active: true }]; - pageUiState.pageAiAcpRuntimes = pageAiNormalizeAcpRuntimes(pageUiState.pageAiAcpRuntimes); - pageAiSetActiveProfile(pageAiCurrentProfile()); - } - renderPageAiProviderButtons(); - renderPageAiControls(); - } - - async function pageAiSwitchProfile(profileName) { - var next = String(profileName || '').trim(); - if (!next) return; - pageAiSetActiveProfile(next); - renderPageAiProviderButtons(); - renderPageAiControls(); - try { - var response = await fetch('/api/hermes/client/profiles/active', { - method: 'PUT', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ name: next }) - }); - var payload = await response.json().catch(function(){ return null; }); - if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'profile_switch_failed_' + response.status)); - pageUiState.pageAiSessions = pageUiState.pageAiSessions.filter(function(session) { - return session && session.profile === next; - }); - pageUiState.pageAiActiveSessionId = ''; - pageUiState.pageAiMessages = []; - pageAiPersistSessions(); - await pageAiEnsureHermesSession(true); - await pageAiLoadProfileMemory(); - await pageAiLoadSkills(); - await pageAiLoadTools(); - await pageAiLoadGatewayHealth(); - renderPageAiControls(); - } catch (error) { - pageUiState.pageAiProfileError = error instanceof Error ? error.message : String(error); - } - renderPageAiConversation(); - } - - async function pageAiLoadProfileMemory() { - try { - var response = await fetch('/api/hermes/client/profile-memory?profile=' + encodeURIComponent(pageAiCurrentProfile()), { - headers: { 'accept': 'application/json' } - }); - var payload = await response.json().catch(function(){ return null; }); - if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'profile_memory_failed_' + response.status)); - pageAiApplyProfileMemory(payload); - pageUiState.pageAiProfileMemoryError = ''; - } catch (error) { - pageUiState.pageAiProfileMemoryError = error instanceof Error ? error.message : String(error); - } - renderPageAiControls(); - renderPageAiConversation(); - } - - async function pageAiSaveProfileMemory(section) { - var normalized = String(section || '').trim(); - if (['memory', 'user', 'soul'].indexOf(normalized) < 0) return; - var content = String(pageUiState.pageAiProfileMemoryDrafts[normalized] || ''); - try { - var response = await fetch('/api/hermes/client/profile-memory', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - profile: pageAiCurrentProfile(), - section: normalized, - content: content - }) - }); - var payload = await response.json().catch(function(){ return null; }); - if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'profile_memory_save_failed_' + response.status)); - pageUiState.pageAiProfileMemory[normalized] = content; - pageUiState.pageAiProfileMemoryError = ''; - document.documentElement.setAttribute('data-mnote-page-ai-memory-saved', normalized); - } catch (error) { - pageUiState.pageAiProfileMemoryError = error instanceof Error ? error.message : String(error); - } - renderPageAiControls(); - renderPageAiConversation(); - } - - async function pageAiLoadSkills() { - try { - var runtime = String(pageUiState.pageAiAcpRuntime || 'reasonix').trim(); - var params = runtime === 'reasonix' - ? 'runtime=reasonix' - : 'profile=' + encodeURIComponent(pageAiCurrentProfile()); - var response = await fetch('/api/hermes/client/skills?' + params, { - headers: { 'accept': 'application/json' } - }); - var payload = await response.json().catch(function(){ return null; }); - if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'skills_failed_' + response.status)); - pageUiState.pageAiSkills = pageAiNormalizeSkills(payload); - pageUiState.pageAiSkillError = ''; - } catch (error) { - pageUiState.pageAiSkillError = error instanceof Error ? error.message : String(error); - pageUiState.pageAiSkills = { categories: [], archived: [] }; - } - renderPageAiControls(); - renderPageAiConversation(); - } - - async function pageAiToggleSkill(skillName, enabled) { - var name = String(skillName || '').trim(); - if (!name) return; - if (String(pageUiState.pageAiAcpRuntime || 'reasonix').trim() === 'reasonix') return; - var previous = null; - pageAiSkillListEntries().forEach(function(skill) { - if (skill.name === name && previous == null) previous = skill.enabled !== false; - }); - try { - var response = await fetch('/api/hermes/client/skills/toggle', { - method: 'PUT', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - profile: pageAiCurrentProfile(), - name: name, - enabled: Boolean(enabled) - }) - }); - var payload = await response.json().catch(function(){ return null; }); - if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'skill_toggle_failed_' + response.status)); - pageAiNormalizeArray(pageUiState.pageAiSkills.categories).forEach(function(category) { - pageAiNormalizeArray(category.skills).forEach(function(skill) { - if (skill.name === name) skill.enabled = Boolean(enabled); - }); - }); - document.documentElement.setAttribute('data-mnote-page-ai-skill-toggled', name); - pageUiState.pageAiSkillError = ''; - } catch (error) { - pageUiState.pageAiSkillError = error instanceof Error ? error.message : String(error); - if (previous != null) { - pageAiNormalizeArray(pageUiState.pageAiSkills.categories).forEach(function(category) { - pageAiNormalizeArray(category.skills).forEach(function(skill) { - if (skill.name === name) skill.enabled = previous; - }); - }); - } - } - renderPageAiControls(); - renderPageAiConversation(); - } - - async function pageAiToggleTool(toolName, enabled) { - var name = String(toolName || '').trim(); - if (!name) return; - var previous = null; - pageAiNormalizeArray(pageUiState.pageAiTools).forEach(function(tool) { - if (tool.name === name && previous == null) previous = tool.enabled !== false; - }); - try { - var response = await fetch('/api/hermes/client/tools/toggle', { - method: 'PUT', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - profile: pageAiCurrentProfile(), - name: name, - enabled: Boolean(enabled) - }) - }); - var payload = await response.json().catch(function(){ return null; }); - if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'tool_toggle_failed_' + response.status)); - pageAiNormalizeArray(pageUiState.pageAiTools).forEach(function(tool) { - if (tool.name === name) { - tool.enabled = Boolean(enabled); - tool.status = Boolean(enabled) ? 'available' : 'disabled'; - tool.unavailableReason = Boolean(enabled) ? '' : '当前 Hermes profile 已关闭该 mnote tool'; - } - }); - document.documentElement.setAttribute('data-mnote-page-ai-tool-toggled', name); - pageUiState.pageAiToolsError = ''; - } catch (error) { - pageUiState.pageAiToolsError = error instanceof Error ? error.message : String(error); - if (previous != null) { - pageAiNormalizeArray(pageUiState.pageAiTools).forEach(function(tool) { - if (tool.name === name) tool.enabled = previous; - }); - } - } - renderPageAiControls(); - } - - function renderPageAiProviderButtons() { - var drawer = ensurePageAiDrawer(); - var isAcp = pageUiState.pageAiAcpRuntime !== ''; - drawer.querySelectorAll('[data-page-ai-provider]').forEach(function(button) { - var provider = button.getAttribute('data-page-ai-provider') || 'hermes'; - var active = provider === pageUiState.pageAiProvider; - button.classList.toggle('is-active', active); - button.setAttribute('aria-pressed', active ? 'true' : 'false'); - }); - var providerNode = drawer.querySelector('.wolai-page-ai-subtitle span:first-child'); - if (providerNode instanceof HTMLElement) { - providerNode.textContent = isAcp ? 'ACP · ' + (pageUiState.pageAiAcpRuntime === 'reasonix' ? 'Reasonix' : 'Hermes') : pageAiProviderLabel(pageUiState.pageAiProvider); - } - } - - function renderPageAiControls() { - var drawer = ensurePageAiDrawer(); - var isAcp = pageUiState.pageAiAcpRuntime !== ''; - var activeProfile = pageAiRunProfile(); - drawer.setAttribute('data-page-ai-page', pageUiState.pageAiPage || 'chat'); - drawer.setAttribute('data-mnote-acp-runtime', pageUiState.pageAiAcpRuntime || 'reasonix'); - // Populate ACP runtime dropdown - var acpSelect = drawer.querySelector('[data-page-ai-acp-runtime]'); - if (acpSelect instanceof HTMLSelectElement) { - var runtimes = pageUiState.pageAiAcpRuntimes.length ? pageUiState.pageAiAcpRuntimes : pageAiNormalizeAcpRuntimes([]); - acpSelect.innerHTML = runtimes.map(function(rt) { - return ''; - }).join(''); - acpSelect.value = pageUiState.pageAiAcpRuntime || 'reasonix'; - } - // Show/hide Hermes-specific profile select - var profileLabel = drawer.querySelector('[data-page-ai-hermes-profile]'); - if (profileLabel instanceof HTMLElement) { - profileLabel.style.display = pageUiState.pageAiAcpRuntime === 'reasonix' ? 'none' : ''; - } - // When ACP is selected, populate agent panel with ACP runtime info - var agentPanel = drawer.querySelector('[data-page-ai-agent-panel]'); - if (agentPanel instanceof HTMLElement) { - if (pageUiState.pageAiAcpRuntime === 'reasonix') { - var rt = pageUiState.pageAiAcpRuntimes.find(function(r) { return r.name === pageUiState.pageAiAcpRuntime; }) || {}; - var keyStatus = rt.apiKeyConfigured ? '已配置' : '未检测到 DEEPSEEK_API_KEY'; - agentPanel.innerHTML = '' + - '
    ' + - '
    ' + escapeHtml(rt.title || 'ACP Runtime') + '
    ' + - '
    ' + - '
    ' + - '
    模型
    ' + escapeHtml(rt.model || 'deepseek-chat') + '
    ' + - '
    ' + - '
    ' + - '
    Preset
    ' + escapeHtml(rt.preset || 'auto') + '
    ' + - '
    ' + - '
    ' + - '
    API Key
    ' + escapeHtml(keyStatus) + '
    ' + - '
    ' + - '
    ' + - '
    描述
    ' + escapeHtml(rt.description || '') + '
    ' + - '
    '; - } - } - var profileSummary = drawer.querySelector('[data-page-ai-profile-summary]'); - if (profileSummary instanceof HTMLElement) profileSummary.textContent = activeProfile; - var profileSummary = drawer.querySelector('[data-page-ai-profile-summary]'); - if (profileSummary instanceof HTMLElement) profileSummary.textContent = activeProfile; - var profileSelect = drawer.querySelector('[data-page-ai-profile-select]'); - if (profileSelect instanceof HTMLSelectElement) { - var profiles = pageUiState.pageAiProfiles.length ? pageUiState.pageAiProfiles : [{ name: activeProfile, active: true }]; - profileSelect.innerHTML = profiles.map(function(profile) { - var name = pageAiProfileValue(profile) || 'default'; - var model = [profile.model, profile.gateway].filter(Boolean).join(' / '); - var label = [name, profile.alias, model].filter(Boolean).join(' · '); - return ''; - }).join(''); - profileSelect.value = activeProfile; - } - var runStatus = drawer.querySelector('[data-page-ai-run-status]'); - if (runStatus instanceof HTMLElement) { - var queueSuffix = pageUiState.pageAiQueueLength > 0 ? ' · 队列 ' + pageUiState.pageAiQueueLength : ''; - runStatus.textContent = pageAiRunStatusLabel(pageUiState.pageAiRunStatus) + queueSuffix; - } - 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; - stopButton.disabled = !canStop; - stopButton.setAttribute('aria-disabled', canStop ? 'false' : 'true'); - } - var settingsLink = drawer.querySelector('[data-page-ai-action="open-hermes-settings"]'); - if (settingsLink instanceof HTMLButtonElement) { - settingsLink.disabled = !pageAiHermesSettingsUrl(); - settingsLink.title = pageAiHermesSettingsUrl() ? '打开 Hermes 设置' : '未配置 MNOTE_WEB_HERMES_UPSTREAM_URL'; - } - var queueList = drawer.querySelector('[data-page-ai-queue-list]'); - if (queueList instanceof HTMLElement) { - if (!pageUiState.pageAiQueuedItems.length) { - queueList.innerHTML = '
    暂无排队项。
    '; - } else { - queueList.innerHTML = pageUiState.pageAiQueuedItems.map(function(item, index) { - var label = '队列 ' + (index + 1); - return '' + - '
    ' + - '
    ' + escapeHtml(label) + '
    ' + escapeHtml(item.queueId) + '
    ' + - '' + - '
    '; - }).join(''); - } - } - var sessionNode = drawer.querySelector('[data-page-ai-session-status]'); - if (sessionNode instanceof HTMLElement) { - var session = pageAiCurrentSession(); - var usageText = session && session.usage ? pageAiUsageSummary(session.usage) : ''; - sessionNode.textContent = session && session.id - ? [String(session.title || '当前页问答'), pageAiSessionStorageLabel(session), usageText].filter(Boolean).join(' · ') - : '等待 Hermes session'; - } - var modelNode = drawer.querySelector('[data-page-ai-model-status]'); - if (modelNode instanceof HTMLElement) modelNode.textContent = pageAiCurrentModelLabel(); - 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]'); - if (scopeLabel instanceof HTMLElement) scopeLabel.textContent = pageAiContextScopeLabel(pageUiState.pageAiContextScope); - drawer.querySelectorAll('[data-page-ai-tab]').forEach(function(button) { - var target = button.getAttribute('data-page-ai-tab') || 'chat'; - var active = target === pageUiState.pageAiPage; - button.classList.toggle('is-active', active); - button.setAttribute('aria-selected', active ? 'true' : 'false'); - }); - drawer.querySelectorAll('[data-page-ai-panel]').forEach(function(panel) { - if (panel instanceof HTMLElement) { - panel.hidden = panel.getAttribute('data-page-ai-panel') !== pageUiState.pageAiPage; - } - }); - var profileError = drawer.querySelector('[data-page-ai-profile-error]'); - if (profileError instanceof HTMLElement) { - profileError.textContent = pageUiState.pageAiProfileError || ''; - profileError.hidden = !pageUiState.pageAiProfileError; - } - var memoryError = drawer.querySelector('[data-page-ai-memory-error]'); - if (memoryError instanceof HTMLElement) { - var memoryErrorText = pageUiState.pageAiProfileMemoryError || ''; - if (memoryErrorText && memoryErrorText === pageUiState.pageAiProfileError) memoryErrorText = ''; - memoryError.textContent = memoryErrorText; - memoryError.hidden = !memoryErrorText; - } - var agentPanel = drawer.querySelector('[data-page-ai-agent-panel]'); - if (agentPanel instanceof HTMLElement && pageUiState.pageAiAcpRuntime !== 'reasonix') { - agentPanel.innerHTML = ['soul', 'user', 'memory'].map(function(section) { - var label = pageAiMemoryFileLabel(section); - var value = pageUiState.pageAiProfileMemoryDrafts[section] || ''; - return '' + - '
    ' + - '
    ' + - '
    ' + - '
    ' + escapeHtml(label) + '
    ' + - '
    保存到 Hermes profile: ' + escapeHtml(activeProfile) + '
    ' + - '
    ' + - '' + - '
    ' + - '' + - '
    '; - }).join(''); - } - var skillError = drawer.querySelector('[data-page-ai-skill-error]'); - if (skillError instanceof HTMLElement) { - skillError.textContent = pageUiState.pageAiSkillError || ''; - skillError.hidden = !pageUiState.pageAiSkillError; - } - var sessionError = drawer.querySelector('[data-page-ai-session-error]'); - if (sessionError instanceof HTMLElement) { - sessionError.textContent = pageUiState.pageAiSessionError || ''; - sessionError.hidden = !pageUiState.pageAiSessionError; - } - var sessionSearch = drawer.querySelector('[data-page-ai-session-search]'); - if (sessionSearch instanceof HTMLInputElement && document.activeElement !== sessionSearch) { - sessionSearch.value = pageUiState.pageAiSessionSearchQuery || ''; - } - var skillSearch = drawer.querySelector('[data-page-ai-skill-search]'); - if (skillSearch instanceof HTMLInputElement && document.activeElement !== skillSearch) { - skillSearch.value = pageUiState.pageAiSkillQuery; - } - var skillList = drawer.querySelector('[data-page-ai-skill-list]'); - if (skillList instanceof HTMLElement) { - var skills = pageAiFilteredSkillEntries(); - if (!skills.length) { - var emptyText = String(pageUiState.pageAiAcpRuntime || 'reasonix').trim() === 'reasonix' - ? '没有匹配的 Reasonix skill。' - : '没有匹配的 Hermes skill。'; - skillList.innerHTML = '
    ' + escapeHtml(emptyText) + '
    '; - } else { - skillList.innerHTML = skills.map(function(skill) { - var sourceText = pageAiSkillOriginLabel(skill) + (skill.modified ? ' · modified' : ''); - var description = String(skill.description || '').trim(); - var hasDescription = description && description !== '---' && description !== '无描述'; - var canToggle = skill.toggleable !== false && String(pageUiState.pageAiAcpRuntime || 'reasonix').trim() !== 'reasonix'; - return '' + - '
    ' + - '
    ' + - '
    ' + - '
    ' + escapeHtml(skill.name) + '
    ' + - '
    ' + escapeHtml(sourceText) + '
    ' + - '
    ' + - (hasDescription ? '
    ' + escapeHtml(description) + '
    ' : '') + - '
    ' + - '' + - '
    '; - }).join(''); - } - } - var toolsList = drawer.querySelector('[data-page-ai-tool-list]'); - if (toolsList instanceof HTMLElement) { - var tools = pageAiNormalizeArray(pageUiState.pageAiTools); - if (!tools.length) { - toolsList.innerHTML = '
    尚未读取到 mnote tool manifest。
    '; - } else { - toolsList.innerHTML = tools.map(function(tool) { - return '' + - '
    ' + - '
    ' + - '
    ' + escapeHtml(tool.name) + '
    ' + - '
    ' + escapeHtml(tool.scope || tool.kind || 'mnote') + ' · ' + escapeHtml(tool.status || 'available') + '
    ' + - (tool.unavailableReason ? '
    ' + escapeHtml(tool.unavailableReason) + '
    ' : '') + - '
    ' + - '' + - '
    '; - }).join(''); - } - } - var gatewayError = drawer.querySelector('[data-page-ai-gateway-error]'); - if (gatewayError instanceof HTMLElement) { - gatewayError.textContent = pageUiState.pageAiGatewayHealthError || ''; - gatewayError.hidden = !pageUiState.pageAiGatewayHealthError; - } - var gatewayStatusNode = drawer.querySelector('[data-page-ai-gateway-status]'); - var gatewayDetail = drawer.querySelector('[data-page-ai-gateway-detail]'); - var gatewayHealth = pageUiState.pageAiGatewayHealth; - if (gatewayStatusNode instanceof HTMLElement) { - if (!gatewayHealth) { - gatewayStatusNode.textContent = '未检查'; - } else { - var gateway = gatewayHealth.gateway || {}; - var profile = gatewayHealth.profile || {}; - gatewayStatusNode.textContent = gatewayHealth.ok ? '可用' : '需要设置'; - if (gateway.status) gatewayStatusNode.textContent += ' · ' + gateway.status; - if (profile.name) gatewayStatusNode.textContent += ' · ' + profile.name; - } - } - if (gatewayDetail instanceof HTMLElement) { - if (!gatewayHealth) { - gatewayDetail.innerHTML = '
    打开 Runtime 后会检查 Hermes gateway 与当前 profile。
    '; - } else { - var gatewayInfo = gatewayHealth.gateway || {}; - var profileInfo = gatewayHealth.profile || {}; - var suggestionText = pageAiNormalizeArray(gatewayHealth.suggestions).join(';'); - gatewayDetail.innerHTML = '' + - '
    ' + - '
    ' + escapeHtml(profileInfo.name || activeProfile) + '
    ' + - '
    model.default: ' + escapeHtml(profileInfo.modelDefault || '未设置') + '
    ' + - '
    provider: ' + escapeHtml(profileInfo.provider || '未设置') + ' · API key: ' + escapeHtml(profileInfo.apiKeyConfigured ? '已配置' : '未检测到') + '
    ' + - '
    ' + - '
    ' + - '
    ' + escapeHtml(gatewayInfo.upstream || '未配置 upstream') + '
    ' + - '
    gateway: ' + escapeHtml(gatewayInfo.status || 'unknown') + (gatewayInfo.httpStatus ? ' · HTTP ' + escapeHtml(gatewayInfo.httpStatus) : '') + '
    ' + - (suggestionText ? '
    ' + escapeHtml(suggestionText) + '
    ' : '') + - '
    '; - } - } - var toolError = drawer.querySelector('[data-page-ai-tool-error]'); - if (toolError instanceof HTMLElement) { - toolError.textContent = pageUiState.pageAiToolsError || ''; - toolError.hidden = !pageUiState.pageAiToolsError; - } - var lastTool = drawer.querySelector('[data-page-ai-last-tool]'); - if (lastTool instanceof HTMLElement) { - var call = pageUiState.pageAiLastToolCall; - lastTool.textContent = call - ? [call.event, call.name, call.runId, call.traceId || call.auditId].filter(Boolean).join(' · ') - : '暂无 tool call'; - } - } - - function humanizePageAiResponse(rawText, promptText) { - var providerLabel = pageAiProviderLabel(pageUiState.pageAiProvider); - var text = String(rawText || '').trim(); - if (!text) { - return providerLabel + ' 已收到你的问题“' + searchText(promptText) + '”,但这次没有返回可读内容。'; - } - if (text.startsWith('{')) { - try { - var payload = JSON.parse(text); - var operation = payload && payload.operation ? payload.operation : {}; - var normalized = operation && operation.normalized_input ? operation.normalized_input : {}; - var args = normalized && normalized.args ? normalized.args : {}; - var documentId = searchText(args.documentId || args.pageId || currentDocumentId()); - var toolName = searchText(operation.tool_name || normalized.toolName || 'doc_get'); - return providerLabel + ' 已收到你的问题“' + searchText(promptText) + '”。当前已通过 Hermes 调用 mnote plugin 工具,目标页面是 ' + (documentId || '当前页面') + ',本次选择的工具是 ' + toolName + '。如果你继续追问,我会沿当前页面上下文继续回复。'; - } catch (_) { - return providerLabel + ' 已返回结果,但当前结果是结构化文本,已为你保留原始内容。'; - } - } - return text; - } - - function ensurePageAiDrawer() { - var existing = document.querySelector('[data-testid="wolai-page-ai-drawer"]'); - if (existing instanceof HTMLElement) return existing; - var drawer = document.createElement('aside'); - drawer.className = 'wolai-page-ai-drawer'; - drawer.setAttribute('data-testid', 'wolai-page-ai-drawer'); - drawer.setAttribute('data-mnote-surface', 'page-ai'); - drawer.hidden = true; - drawer.innerHTML = '' + - ''; - document.body.appendChild(drawer); - return drawer; - } - - function renderPageAiSuggestions() { - var drawer = ensurePageAiDrawer(); - var list = drawer.querySelector('[data-page-ai-panel="chat"] [data-page-ai-suggestion-list]'); - if (!(list instanceof HTMLElement)) return; - var container = list.closest('.wolai-page-ai-suggestions'); - if (container instanceof HTMLElement) { - container.hidden = pageUiState.pageAiPage !== 'chat' || pageUiState.pageAiMessages.length > 0; - } - var suggestions = pageAiSuggestions(); - var offset = pageUiState.pageAiSuggestionIndex % suggestions.length; - var ordered = suggestions.slice(offset).concat(suggestions.slice(0, offset)).slice(0, 3); - list.innerHTML = ordered.map(function(text) { - return ''; - }).join(''); - } - - function renderPageAiConversation() { - var drawer = ensurePageAiDrawer(); - renderPageAiSuggestions(); - var conversation = drawer.querySelector('[data-page-ai-panel="' + cssEscape(pageUiState.pageAiPage || 'chat') + '"] [data-page-ai-conversation]'); - if (!(conversation instanceof HTMLElement)) return; - if (pageUiState.pageAiPage === 'history') { - var historyRows = pageUiState.pageAiSessionSearchResults.length - ? pageUiState.pageAiSessionSearchResults - : pageUiState.pageAiSessions; - if (!historyRows.length) { - conversation.innerHTML = '
    尚未产生历史会话。
    '; - return; - } - conversation.innerHTML = historyRows.map(function(session) { - var preview = Array.isArray(session.messages) && session.messages.length - ? session.messages.slice(-1)[0].content - : (session.snippet || session.preview || '暂无消息'); - var active = session.id === pageUiState.pageAiActiveSessionId; - var usage = pageAiUsageSummary(session.usage); - var meta = [pageAiSessionStorageLabel(session), session.permissionLevel, session.shareId, session.status, usage].filter(Boolean).join(' · '); - return '' + - '
    ' + - '' + - '
    ' + - '' + - '' + - '' + - '
    ' + - '
    '; - }).join(''); - return; - } - if (!pageUiState.pageAiMessages.length) { - conversation.innerHTML = '
    围绕当前页面提问,我会优先使用当前页内容、页面结构和已保存设置。
    '; - return; - } - conversation.innerHTML = pageUiState.pageAiMessages.map(function(item) { - var roleLabel = item.role === 'user' ? '你' : (item.role === 'tool' ? '工具' : 'AI'); - if (item.role === 'tool') { - var statusLabel = item.status === 'completed' ? '完成' : (item.status === 'failed' ? '失败' : '运行中'); - var locationRows = Array.isArray(item.locations) && item.locations.length - ? '
    位置:' + item.locations.map(function(loc, idx) { - return '' + - '' + escapeHtml(loc) + '' + - '' + - ''; - }).join('') + '
    ' - : ''; - var detailRows = [ - locationRows, - item.argsSummary ? '
    参数 ' + escapeHtml(item.argsSummary) + '
    ' : '', - item.resultSummary ? '
    结果 ' + escapeHtml(item.resultSummary) + '
    ' : '', - item.changedFiles && item.changedFiles.length ? '
    ' + escapeHtml(pageAiFormatChangedFiles(item.changedFiles)) + '
    ' : '', - (item.traceId || item.auditId) ? '
    ' + escapeHtml([item.traceId, item.auditId].filter(Boolean).join(' · ')) + '
    ' : '' - ].filter(Boolean).join(''); - return '' + - '
    ' + - '
    ' + escapeHtml(roleLabel) + '
    ' + - '
    ' + - '
    ' + - '' + - '' + escapeHtml(item.toolName || item.content || 'tool') + '' + - '' + escapeHtml([statusLabel, item.toolKind, item.toolCallId].filter(Boolean).join(' · ')) + '' + - '' + - (detailRows || '
    暂无参数或结果详情。
    ') + - '
    ' + - '
    ' + - '
    '; - } - if (item.kind === 'thought') { - return '' + - '
    ' + - '思考过程' + - '
    ' + escapeHtml(item.content || '') + '
    ' + - '
    '; - } - if (item.kind === 'permission') { - var permissionActions = item.resolved ? '' : ( - '
    ' + - '' + - '' + - '
    ' - ); - return '' + - '
    ' + - '
    权限
    ' + - '
    ' + - '' + escapeHtml(item.toolName || 'session/request_permission') + '
    ' + - '' + escapeHtml(item.argsSummary || item.content || '') + '' + - permissionActions + - '
    ' + - '
    '; - } - if (item.kind === 'plan') { - var planEntries = Array.isArray(item.entries) ? item.entries : []; - var listHtml = planEntries.map(function(entry, idx) { - return '
  • ' + escapeHtml(String(entry || '')) + '
  • '; - }).join(''); - return '' + - '
    ' + - '
    ' + - '执行计划 · ' + planEntries.length + ' 步' + - '
    ' + - '
      ' + listHtml + '
    ' + - '
    ' + - '
    ' + - '
    '; - } - var streamingAttr = item.streaming ? ' data-page-ai-streaming="true"' : ''; - return '' + - '
    ' + - '
    ' + escapeHtml(roleLabel) + '
    ' + - '
    ' + escapeHtml(item.content || '') + '
    ' + - '
    '; - }).join(''); - conversation.scrollTop = conversation.scrollHeight; - } - - function openPageAiDrawer() { - pageAiLoadSessions(); - renderPageAiSuggestions(); - renderPageAiConversation(); - renderPageAiProviderButtons(); - renderPageAiControls(); - var drawer = ensurePageAiDrawer(); - drawer.hidden = false; - pageUiState.pageAiOpen = true; - updatePageAiTriggerState(); - Promise.all([ - pageAiLoadProfiles(), - pageAiLoadProfileMemory(), - pageAiLoadSkills(), - pageAiLoadTools(), - pageAiLoadGatewayHealth(), - pageAiLoadBackendSessions() - ]).then(function() { - renderPageAiControls(); - }).catch(function() {}).then(function() { - return pageAiEnsureHermesSession(); - }).then(function() { - return pageAiRestoreHermesSession(); - }).then(function() { - renderPageAiControls(); - }).catch(function(error) { - pageUiState.pageAiMessages.push({ - role: 'assistant', - content: 'Hermes 当前不可用:' + (error instanceof Error ? error.message : String(error)) - }); - renderPageAiConversation(); - renderPageAiControls(); - }); - } - - function closePageAiDrawer() { - var drawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]'); - if (drawer instanceof HTMLElement) drawer.hidden = true; - pageUiState.pageAiOpen = false; - updatePageAiTriggerState(); - } - - function isPageAiDrawerOpen() { - var drawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]'); - return drawer instanceof HTMLElement && !drawer.hidden; - } - - async function streamPageAiResponse(response, onEvent) { - if (!response.body || typeof response.body.getReader !== 'function') return; - var reader = response.body.getReader(); - var decoder = new TextDecoder(); - var buffer = ''; - while (true) { - var chunk = await reader.read(); - if (chunk.done) break; - buffer += decoder.decode(chunk.value, { stream: true }); - var frames = buffer.split('\n\n'); - buffer = frames.pop() || ''; - frames.forEach(function(frame) { - var eventName = ''; - var dataLines = []; - frame.split('\n').forEach(function(line) { - if (line.startsWith('event:')) eventName = line.slice(6).trim(); - if (line.startsWith('data:')) dataLines.push(line.slice(5).trim()); - }); - var payloadText = dataLines.join('\n'); - if (!eventName && payloadText) { - try { - var parsed = JSON.parse(payloadText); - eventName = parsed && parsed.event ? String(parsed.event) : ''; - } catch (_) {} - } - if (eventName) onEvent(eventName, payloadText); - }); - } - } - - function pageAiDecodeDeltaText(payloadText) { - try { - var payload = JSON.parse(payloadText || 'null'); - return String((payload && (payload.text || payload.delta)) || ''); - } catch (_) { - return String(payloadText || ''); - } - } - - function pageAiEnsureStreamingAssistantMessage(runId) { - var id = String(runId || pageUiState.pageAiCurrentRunId || ''); - var existing = pageUiState.pageAiMessages.find(function(item) { - return item.role === 'assistant' && item.streaming === true && item.runId === id; - }); - if (existing) return existing; - existing = { - role: 'assistant', - content: '', - runId: id, - streaming: true - }; - pageUiState.pageAiMessages.push(existing); - return existing; - } - - function pageAiAppendStreamingAssistantDelta(runId, deltaText) { - var delta = String(deltaText || ''); - if (!delta) return ''; - var message = pageAiEnsureStreamingAssistantMessage(runId); - message.content = String(message.content || '') + delta; - pageAiSyncCurrentSessionMessages(); - renderPageAiConversation(); - return message.content; - } - - function pageAiFinishStreamingAssistantMessage(runId, finalText, promptText) { - var id = String(runId || pageUiState.pageAiCurrentRunId || ''); - var message = pageUiState.pageAiMessages.find(function(item) { - return item.role === 'assistant' && item.streaming === true && item.runId === id; - }); - var text = String(finalText || (message && message.content) || ''); - var content = humanizePageAiResponse(text, promptText); - if (message) { - message.content = content; - message.streaming = false; - } else if (content) { - pageUiState.pageAiMessages.push({ - role: 'assistant', - content: content, - runId: id - }); - } - pageAiSyncCurrentSessionMessages(); - pageAiPersistSessions(); - } - - function pageAiLooksLikeBlockEdit(prompt) { - var text = searchText(prompt); - return ['新增', '添加', '插入', '删除', '删掉', '修改', '替换', '改成', '移动', '移到', 'move', 'replace', 'delete', 'insert'].some(function(word) { - return text.indexOf(word) >= 0; - }); - } - - async function pageAiTryBlockEditWorkflow(prompt, scopedContext) { - if (currentSourceKind() === 'local_folder') return false; - if (!pageAiLooksLikeBlockEdit(prompt)) return false; - var runId = 'page-ai-fast-' + Date.now().toString(36); - var traceId = 'page-ai-fast-' + Date.now().toString(36); - pageAiSetRunStatus('running', runId); - renderPageAiControls(); - var started = Date.now(); - var response = await fetch('/api/page-ai/block-edit-workflow', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - workspaceId: resolveWorkspaceId(document.body), - documentId: currentDocumentId(), - sourceKind: currentSourceKind(), - rootUri: currentRootUri(), - sessionId: pageUiState.pageAiActiveSessionId, - runId: runId, - profile: pageAiCurrentProfile(), - model: pageAiMnoteToolModel(), - message: prompt, - pageContext: scopedContext.pageContext, - selectedBlockId: scopedContext.selectedBlockId, - selectedText: scopedContext.selectedText, - traceId: traceId - }) - }); - var payload = await response.json().catch(function() { return null; }); - if (!response.ok || !payload || payload.ok !== true) { - var code = payload && payload.code ? String(payload.code) : ''; - if (code === 'page_ai_workflow_not_block_edit') return false; - pageUiState.pageAiMessages.push({ - role: 'tool', - toolName: 'mnote.page_ai.block_edit_workflow', - status: 'failed', - toolCallId: runId, - resultSummary: pageAiErrorMessage(payload, 'fast_path_failed_' + response.status) - }); - renderPageAiConversation(); - pageAiSetRunStatus('failed', runId); - renderPageAiControls(); - return true; - } - pageUiState.pageAiMessages.push({ - role: 'tool', - toolName: 'mnote.page_ai.block_edit_workflow', - status: 'completed', - toolCallId: runId, - resultSummary: 'operations=' + String(Array.isArray(payload.operations) ? payload.operations.length : 0) + ' · ' + String(Date.now() - started) + 'ms' - }); - pageUiState.pageAiMessages.push({ - role: 'assistant', - content: payload.message || '已通过页面块编辑快路径完成写入。' - }); - pageAiSetRunStatus('completed', runId); - try { - window.dispatchEvent(new CustomEvent('mnote:page-ai-tool-write-completed', { - detail: { - toolName: 'mnote.doc.apply_block_ops', - normalizedToolName: 'mnote.doc.apply.block.ops', - documentId: currentDocumentId(), - workspaceId: resolveWorkspaceId(document.body), - runId: runId, - traceId: traceId, - toolCallId: runId - } - })); - } catch (_) {} - var currentSession = pageAiCurrentSession(); - if (currentSession) { - currentSession.messages = pageUiState.pageAiMessages.slice(); - currentSession.updatedAt = Date.now(); - } - pageAiPersistSessions(); - renderPageAiConversation(); - renderPageAiControls(); - return true; - } - - async function sendPageAiMessage(text) { - var allowQueue = ['running', 'tool_calling'].indexOf(pageUiState.pageAiRunStatus) >= 0; - if (pageUiState.pageAiBusy && !allowQueue) return; - var prompt = searchText(text); - if (!prompt) return; - if (!allowQueue) pageUiState.pageAiBusy = true; - var currentSession = null; - try { - await pageAiEnsureHermesSession(); - if (!allowQueue) pageAiSetRunStatus('queued'); - renderPageAiControls(); - var contextSnapshot = currentPageAiContextSnapshot(); - var scopedContext = pageAiScopedPageContext(contextSnapshot); - pageUiState.pageAiMessages.push({ role: 'user', content: prompt }); - currentSession = pageAiCurrentSession(); - if (currentSession) { - if (currentSession.title === '新会话') { - currentSession.title = prompt.length > 18 ? prompt.slice(0, 18) + '…' : prompt; - } - currentSession.messages = pageUiState.pageAiMessages.slice(); - currentSession.updatedAt = Date.now(); - } - pageAiPersistSessions(); - renderPageAiConversation(); - if (!allowQueue && await pageAiTryBlockEditWorkflow(prompt, scopedContext)) { - return; - } - var response = await fetch('/api/hermes/client/runs', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - workspaceId: resolveWorkspaceId(document.body), - documentId: currentDocumentId(), - sourceKind: currentSourceKind(), - rootUri: currentRootUri(), - sessionId: pageUiState.pageAiActiveSessionId, - profile: pageAiRunProfile(), - acpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix', - contextScope: pageUiState.pageAiContextScope, - message: prompt, - model: pageAiMnoteToolModel(), - pageContext: scopedContext.pageContext, - selectedBlockId: scopedContext.selectedBlockId, - selectedText: scopedContext.selectedText, - traceId: 'page-ai-run-' + Date.now().toString(36) - }) - }); - if (!response.ok) { - var errorPayload = await response.json().catch(function(){ return null; }); - throw new Error(pageAiErrorMessage(errorPayload, 'page_ai_failed_' + response.status)); - } - var runPayload = await response.json().catch(function(){ return null; }); - if (pageAiApplyQueuedRun(runPayload)) { - pageUiState.pageAiMessages.push({ - role: 'assistant', - content: '已加入 Hermes 队列,前一条 run 完成后继续处理。' - }); - if (currentSession) { - currentSession.messages = pageUiState.pageAiMessages.slice(); - currentSession.updatedAt = Date.now(); - } - pageAiPersistSessions(); - renderPageAiConversation(); - renderPageAiControls(); - return; - } - if (allowQueue) { - throw new Error('hermes_queue_expected_queued_response'); - } - var upstream = runPayload && runPayload.upstream ? runPayload.upstream : runPayload; - var runId = upstream && (upstream.run_id || upstream.runId); - if (!runId) throw new Error('hermes_run_missing_run_id'); - var runTraceId = String((upstream && (upstream.trace_id || upstream.traceId)) || (runPayload && (runPayload.trace_id || runPayload.traceId)) || ''); - pageAiSetRunStatus('running', runId); - renderPageAiControls(); - var eventResponse = await fetch('/api/hermes/client/events/' + encodeURIComponent(runId), { - headers: { 'accept': 'text/event-stream' } - }); - if (!eventResponse.ok) { - var eventError = await eventResponse.json().catch(function(){ return null; }); - throw new Error(pageAiErrorMessage(eventError, 'hermes_events_failed_' + eventResponse.status)); - } - var assistantText = ''; - await streamPageAiResponse(eventResponse, function(eventName, payloadText) { - if (eventName === 'message.delta') { - if (pageUiState.pageAiStoppedRunIds[runId]) return; - assistantText = pageAiAppendStreamingAssistantDelta(runId, pageAiDecodeDeltaText(payloadText)); - } - if (eventName === 'thought.delta') { - try { - var thoughtPayload = JSON.parse(payloadText || 'null'); - var thoughtText = String((thoughtPayload && (thoughtPayload.delta || thoughtPayload.text)) || ''); - if (thoughtText) { - var msgs = pageUiState.pageAiMessages; - var lastThought = msgs.length > 0 && msgs[msgs.length - 1].kind === 'thought' ? msgs[msgs.length - 1] : null; - if (lastThought) { - lastThought.content += thoughtText; - } else { - msgs.push({ role: 'assistant', kind: 'thought', content: thoughtText }); - } - pageAiSyncCurrentSessionMessages(); - pageAiPersistSessions(); - renderPageAiConversation(); - } - } catch (_) {} - } - if (eventName === 'usage.updated') { - try { - var usagePayload = JSON.parse(payloadText || 'null') || {}; - var sessionForUsage = pageAiCurrentSession(); - if (sessionForUsage) { - sessionForUsage.usage = { - source: 'usage_update', - used: Number(usagePayload.used ?? usagePayload.contextUsed ?? 0), - size: Number(usagePayload.size ?? usagePayload.contextSize ?? 0) - }; - pageAiPersistSessions(); - } - } catch (_) {} - renderPageAiControls(); - } - if (eventName === 'permission.requested' || eventName === 'permission.denied' || eventName === 'permission.allowed') { - pageAiApplyPermissionEvent(eventName, payloadText); - pageAiSyncCurrentSessionMessages(); - pageAiPersistSessions(); - renderPageAiConversation(); - renderPageAiControls(); - } - if (eventName === 'run.completed') { - try { - var completed = JSON.parse(payloadText || 'null'); - if (completed && completed.output) assistantText = String(completed.output || ''); - if (completed && completed.usage) { - var completedSession = pageAiCurrentSession(); - if (completedSession) completedSession.usage = completed.usage; - } - var agentAudit = completed && completed.agentAudit && typeof completed.agentAudit === 'object' ? completed.agentAudit : null; - var changedFiles = pageAiNormalizeArray(agentAudit && agentAudit.changedFiles); - if (changedFiles.length) { - pageUiState.pageAiMessages.push({ - role: 'tool', - content: 'agent.changed_files', - toolCallId: runId + ':agent.changed_files', - toolName: 'agent.changed_files', - toolKind: 'audit', - status: 'completed', - argsSummary: String(agentAudit.rootUri || ''), - resultSummary: String(agentAudit.diffSummary || changedFiles.length + ' changed file(s)'), - changedFiles: changedFiles, - traceId: runTraceId, - auditId: String(agentAudit.eventId || '') - }); - try { - var currentId = currentDocumentId(); - var currentPath = String(currentId || '').replace(/^local-md:/, '').replace(/~2F/g, '/'); - var touchesCurrent = changedFiles.some(function(file) { - var path = String(file && (file.documentId || file.path || file.filePath || '') || ''); - return path === currentId || (currentPath && path.indexOf(currentPath) >= 0); - }); - if (touchesCurrent) { - window.dispatchEvent(new CustomEvent('mnote:page-ai-tool-write-completed', { - detail: { - toolName: 'agent.changed_files', - normalizedToolName: 'agent.changed_files', - documentId: currentId, - workspaceId: resolveWorkspaceId(document.body), - runId: runId, - traceId: runTraceId, - toolCallId: runId + ':agent.changed_files' - } - })); - } - } catch (_) {} - } - } catch (_) {} - pageAiSetRunStatus('completed', runId); - } - if (eventName === 'run.failed') { - try { - var failed = JSON.parse(payloadText || 'null'); - assistantText = String((failed && (failed.message || failed.code || failed.error)) || 'Hermes run failed'); - } catch (_) {} - pageAiSetRunStatus('failed', runId); - } - if (eventName === 'run.aborted' || eventName === 'run.cancelled' || eventName === 'run.canceled') { - pageUiState.pageAiStoppedRunIds[runId] = true; - pageAiSetRunStatus('aborted', runId); - } - if (eventName === 'session.info.updated') { - try { - var infoPayload = JSON.parse(payloadText || 'null') || {}; - var newTitle = String(infoPayload.title || '').trim(); - if (newTitle) { - var sessionForTitle = pageAiCurrentSession(); - if (sessionForTitle) { - sessionForTitle.title = newTitle; - sessionForTitle.updatedAt = Date.now(); - pageAiPersistSessions(); - renderPageAiControls(); - } - } - } catch (_) {} - } - if (eventName === 'plan.updated') { - try { - var planPayload = JSON.parse(payloadText || 'null') || {}; - var planEntries = Array.isArray(planPayload.entries) ? planPayload.entries : []; - if (planEntries.length) { - var planMsgs = pageUiState.pageAiMessages; - var existingPlan = planMsgs.length > 0 && planMsgs[planMsgs.length - 1].kind === 'plan' ? planMsgs[planMsgs.length - 1] : null; - if (existingPlan) { - existingPlan.entries = planEntries; - existingPlan.updatedAt = Date.now(); - } else { - planMsgs.push({ role: 'system', kind: 'plan', entries: planEntries, createdAt: Date.now(), updatedAt: Date.now() }); - } - pageAiSyncCurrentSessionMessages(); - pageAiPersistSessions(); - renderPageAiConversation(); - } - } catch (_) {} - } - if (eventName === 'tool.started' || eventName === 'tool.completed' || eventName === 'tool.failed') { - pageAiApplyToolEvent(eventName, payloadText, runId, runTraceId); - pageAiSyncCurrentSessionMessages(); - pageAiPersistSessions(); - renderPageAiConversation(); - pageAiSetRunStatus('tool_calling', runId); - renderPageAiControls(); - } - }); - if (!pageUiState.pageAiStoppedRunIds[runId]) { - pageAiFinishStreamingAssistantMessage(runId, assistantText, prompt); - } - currentSession = pageAiCurrentSession(); - if (currentSession) { - currentSession.messages = pageUiState.pageAiMessages.slice(); - currentSession.updatedAt = Date.now(); - } - pageAiPersistSessions(); - } catch (error) { - pageAiSetRunStatus('failed'); - pageUiState.pageAiMessages.push({ - role: 'assistant', - content: 'Hermes 当前请求失败:' + (error instanceof Error ? error.message : String(error)) - }); - currentSession = pageAiCurrentSession(); - if (currentSession) { - currentSession.messages = pageUiState.pageAiMessages.slice(); - currentSession.updatedAt = Date.now(); - } - pageAiPersistSessions(); - } finally { - if (!allowQueue) pageUiState.pageAiBusy = false; - renderPageAiConversation(); - renderPageAiControls(); - } - } + const sidebarPageAi = createSidebarPageAiRuntime({ + buildLocalFileOpenUrl, + currentDocumentId, + currentPageAggregate, + currentPageOptions, + currentRootUri, + currentSourceKind, + escapeHtml, + cssEscape, + openLocalResourceInActiveTab, + pageUiState, + resolveWorkspaceId, + searchText, + }); + const openPageAiDrawer = (...args) => sidebarPageAi.openPageAiDrawer(...args); + const closePageAiDrawer = (...args) => sidebarPageAi.closePageAiDrawer(...args); + const isPageAiDrawerOpen = (...args) => sidebarPageAi.isPageAiDrawerOpen(...args); + const ensurePageAiDrawer = (...args) => sidebarPageAi.ensurePageAiDrawer(...args); + const sendPageAiMessage = (...args) => sidebarPageAi.sendPageAiMessage(...args); + const pageAiOpenHermesSettings = (...args) => sidebarPageAi.pageAiOpenHermesSettings(...args); + const pageAiStopRun = (...args) => sidebarPageAi.pageAiStopRun(...args); + const pageAiLoadGatewayHealth = (...args) => sidebarPageAi.pageAiLoadGatewayHealth(...args); + const renderPageAiControls = (...args) => sidebarPageAi.renderPageAiControls(...args); + const renderPageAiConversation = (...args) => sidebarPageAi.renderPageAiConversation(...args); + const renderPageAiProviderButtons = (...args) => sidebarPageAi.renderPageAiProviderButtons(...args); + const renderPageAiSuggestions = (...args) => sidebarPageAi.renderPageAiSuggestions(...args); + const pageAiSaveProfileMemory = (...args) => sidebarPageAi.pageAiSaveProfileMemory(...args); + const pageAiToggleSkill = (...args) => sidebarPageAi.pageAiToggleSkill(...args); + const pageAiToggleTool = (...args) => sidebarPageAi.pageAiToggleTool(...args); + const pageAiResumeBackendSession = (...args) => sidebarPageAi.pageAiResumeBackendSession(...args); + const pageAiRenameBackendSession = (...args) => sidebarPageAi.pageAiRenameBackendSession(...args); + const pageAiDeleteBackendSession = (...args) => sidebarPageAi.pageAiDeleteBackendSession(...args); + const pageAiResolvePermission = (...args) => sidebarPageAi.pageAiResolvePermission(...args); + const pageAiOpenLocation = (...args) => sidebarPageAi.pageAiOpenLocation(...args); + const pageAiSetActiveSession = (...args) => sidebarPageAi.pageAiSetActiveSession(...args); + const pageAiStartNewSession = (...args) => sidebarPageAi.pageAiStartNewSession(...args); + const pageAiLoadSessions = (...args) => sidebarPageAi.pageAiLoadSessions(...args); + const pageAiLoadBackendSessions = (...args) => sidebarPageAi.pageAiLoadBackendSessions(...args); + const pageAiCancelQueuedRun = (...args) => sidebarPageAi.pageAiCancelQueuedRun(...args); + const pageAiSearchBackendSessions = (...args) => sidebarPageAi.pageAiSearchBackendSessions(...args); + const pageAiPersistSessions = (...args) => sidebarPageAi.pageAiPersistSessions(...args); + const pageAiLoadProfiles = (...args) => sidebarPageAi.pageAiLoadProfiles(...args); + const pageAiLoadProfileMemory = (...args) => sidebarPageAi.pageAiLoadProfileMemory(...args); + const pageAiLoadSkills = (...args) => sidebarPageAi.pageAiLoadSkills(...args); + const pageAiSwitchProfile = (...args) => sidebarPageAi.pageAiSwitchProfile(...args); + const pageAiSetContextScope = (...args) => sidebarPageAi.pageAiSetContextScope(...args); + const updatePageAiTriggerState = (...args) => sidebarPageAi.updatePageAiTriggerState(...args); function ensurePageSettingsPopover() { var existing = document.querySelector('[data-testid="wolai-page-settings-popover"]'); @@ -5488,18 +2779,11 @@ import { createSidebarFileTreeUploadRuntime } from './sidebar-filetree-upload-ru return; } - var pageAiOpenLocation = closestAction(e.target, '[data-page-ai-open-location]'); - if (pageAiOpenLocation) { + var pageAiOpenLocationAction = closestAction(e.target, '[data-page-ai-open-location]'); + if (pageAiOpenLocationAction) { e.preventDefault(); - var loc = String(pageAiOpenLocation.getAttribute('data-page-ai-open-location') || '').trim(); - if (loc) { - openLocalResourceInActiveTab({ path: loc }).then(function(opened) { - if (!opened) { - var href = buildLocalFileOpenUrl(loc, false); - if (href) window.open(href, '_blank', 'noopener,noreferrer'); - } - }); - } + var loc = String(pageAiOpenLocationAction.getAttribute('data-page-ai-open-location') || '').trim(); + if (loc) pageAiOpenLocation(loc); return; } diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index 6b875a83..5c393d98 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -154,6 +154,10 @@ pub fn build_router(state: AppState) -> Router { "/api/mnote-browser-runtime/sidebar-attachment-open-runtime.js", get(web_shell::sidebar_attachment_open_runtime_asset), ) + .route( + "/api/mnote-browser-runtime/sidebar-page-ai-runtime.js", + get(web_shell::sidebar_page_ai_runtime_asset), + ) .route( "/api/mnote-browser-runtime/sidebar-tree-runtime.js", get(web_shell::sidebar_tree_runtime_asset), @@ -650,6 +654,7 @@ mod tests { "/api/mnote-browser-runtime/sidebar-filetree-command-runtime.js", "/api/mnote-browser-runtime/sidebar-filetree-upload-runtime.js", "/api/mnote-browser-runtime/sidebar-attachment-open-runtime.js", + "/api/mnote-browser-runtime/sidebar-page-ai-runtime.js", "/api/mnote-browser-runtime/sidebar-tree-runtime.js", "/api/mnote-browser-runtime/tree-live-controller.js", "/api/mnote-browser-runtime/tree-shell-runtime.js", diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index a6271c02..71ec971d 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -728,6 +728,20 @@ pub async fn sidebar_tree_runtime_asset() -> Response { .unwrap_or_else(|_| Response::new(Body::empty())) } +pub async fn sidebar_page_ai_runtime_asset() -> Response { + const JS: &str = include_str!("../../browser/sidebar-page-ai-runtime.js"); + Response::builder() + .status(StatusCode::OK) + .header( + header::CONTENT_TYPE, + "application/javascript; charset=utf-8", + ) + .header(header::CACHE_CONTROL, "no-store") + .header(HEADER_MNOTE_WEB_OWNER, "mnote-web") + .body(Body::from(JS)) + .unwrap_or_else(|_| Response::new(Body::empty())) +} + pub async fn sidebar_shell_runtime_asset() -> Response { const JS: &str = include_str!("../../browser/sidebar-shell-runtime.js"); Response::builder() diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index d9658832..10524ae9 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -215,6 +215,8 @@ mod tests { include_str!("../../../browser/sidebar-workspace-runtime.js"); const SIDEBAR_PAGE_TREE_RUNTIME_JS: &str = include_str!("../../../browser/sidebar-page-tree-runtime.js"); + const SIDEBAR_PAGE_AI_RUNTIME_JS: &str = + include_str!("../../../browser/sidebar-page-ai-runtime.js"); const FILETREE_CONTEXT_MENU_RUNTIME_JS: &str = include_str!("../../../browser/filetree-context-menu-runtime.js"); const FILETREE_DND_RUNTIME_JS: &str = include_str!("../../../browser/filetree-dnd-runtime.js"); @@ -352,7 +354,7 @@ mod tests { #[test] fn page_ai_fast_path_is_not_local_first_main_path() { assert!( - SIDEBAR_TREE_RUNTIME_JS + SIDEBAR_PAGE_AI_RUNTIME_JS .contains("if (currentSourceKind() === 'local_folder') return false;"), "local-first 页面 AI 不应继续加厚 page-ai fast-path;本地编辑应走受控文件工具" ); @@ -361,15 +363,15 @@ mod tests { #[test] fn page_ai_local_source_passes_file_reference_fields_to_agent_run() { assert!(SIDEBAR_WORKSPACE_RUNTIME_JS.contains("function currentRootUri()")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("sourceKind: currentSourceKind()")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("rootUri: currentRootUri()")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("sourceKind: currentSourceKind()")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("rootUri: currentRootUri()")); assert!(SIDEBAR_WORKSPACE_RUNTIME_JS .contains("if (sourceKind && !payload.sourceKind) payload.sourceKind = sourceKind")); assert!(SIDEBAR_WORKSPACE_RUNTIME_JS .contains("if (rootUri && !payload.rootUri) payload.rootUri = rootUri")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("pageContext: scopedContext.pageContext")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageContext: scopedContext.pageContext")); assert!( - SIDEBAR_TREE_RUNTIME_JS + SIDEBAR_PAGE_AI_RUNTIME_JS .contains("if (currentSourceKind() === 'local_folder') return false;"), "local source 不应进入 page-ai fast-path,后端会把 run 收敛为文件引用 scope" ); @@ -377,70 +379,70 @@ mod tests { #[test] fn page_ai_uses_backend_acp_session_runtime_store() { - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function pageAiLoadBackendSessions")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/api/hermes/client/sessions?")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("params.set('source', 'acp')")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function pageAiLoadBackendSessionDetail")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function pageAiSearchBackendSessions")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-page-ai-session-rename")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-page-ai-session-delete")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-page-ai-session-resume")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-page-ai-session-search")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("pageAiUsageSummary")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("usage.updated")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("thought.delta")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("permission.requested")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-page-ai-permission-action=\"allow\"")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-page-ai-permission-action=\"deny\"")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function pageAiHidePermissionDialog")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("if (!message.resolved)")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiLoadBackendSessions")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("/api/hermes/client/sessions?")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("params.set('source', 'acp')")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiLoadBackendSessionDetail")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiSearchBackendSessions")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("data-page-ai-session-rename")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("data-page-ai-session-delete")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("data-page-ai-session-resume")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("data-page-ai-session-search")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiUsageSummary")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("usage.updated")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("thought.delta")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("permission.requested")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("data-page-ai-permission-action=\"allow\"")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("data-page-ai-permission-action=\"deny\"")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiHidePermissionDialog")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("if (!message.resolved)")); assert!( - !SIDEBAR_TREE_RUNTIME_JS.contains("(item.resolved ? ' disabled' : '')"), + !SIDEBAR_PAGE_AI_RUNTIME_JS.contains("(item.resolved ? ' disabled' : '')"), "已决 ACP permission 事件不能继续展示假审批按钮" ); assert!( - SIDEBAR_TREE_RUNTIME_JS.contains("session.info.updated"), + SIDEBAR_PAGE_AI_RUNTIME_JS.contains("session.info.updated"), "ACP SessionInfoUpdate 事件应通过 session.info.updated SSE 转发到前端" ); assert!( - SIDEBAR_TREE_RUNTIME_JS.contains("plan.updated"), + SIDEBAR_PAGE_AI_RUNTIME_JS.contains("plan.updated"), "ACP PlanUpdate 事件应通过 plan.updated SSE 转发到前端" ); assert!( - SIDEBAR_TREE_RUNTIME_JS.contains("data-page-ai-plan"), + SIDEBAR_PAGE_AI_RUNTIME_JS.contains("data-page-ai-plan"), "plan 消息应渲染为 data-page-ai-plan 标记的轻量系统状态面板" ); assert!( - SIDEBAR_TREE_RUNTIME_JS.contains("执行计划 · "), + SIDEBAR_PAGE_AI_RUNTIME_JS.contains("执行计划 · "), "plan 面板标题应显示执行计划和步数" ); } #[test] fn page_ai_session_ui_labels_local_shared_and_cloud_storage() { - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function pageAiSessionStorageLabel")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("local_private")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("local_shared")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("convex_acp_runtime_store")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("本地私有")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("共享会话")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("云端会话")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("sessionStorage:")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("permissionLevel:")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("shareId:")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiSessionStorageLabel")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("local_private")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("local_shared")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("convex_acp_runtime_store")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("本地私有")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("共享会话")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("云端会话")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("sessionStorage:")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("permissionLevel:")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("shareId:")); } #[test] fn page_ai_acp_runtime_defaults_to_reasonix_and_keeps_hermes_switch() { - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("var PAGE_AI_SESSION_STORAGE_VERSION = 3")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("var PAGE_AI_SESSION_STORAGE_VERSION = 3")); assert!(SIDEBAR_TREE_RUNTIME_JS.contains("pageAiAcpRuntime: 'reasonix'")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("function pageAiNormalizeAcpRuntimes")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("return ['reasonix', 'hermes']")); - assert!(SIDEBAR_TREE_RUNTIME_JS.contains("pageAiNormalizeAcpRuntimes(acpRuntimes)")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("function pageAiNormalizeAcpRuntimes")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("return ['reasonix', 'hermes']")); + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiNormalizeAcpRuntimes(acpRuntimes)")); assert!(SIDEBAR_TREE_RUNTIME_JS.contains("pageAiAcpRuntimeSelect.value || 'reasonix'")); - assert!(SIDEBAR_TREE_RUNTIME_JS + assert!(SIDEBAR_PAGE_AI_RUNTIME_JS .contains("activeAcpRuntime: pageUiState.pageAiAcpRuntime || 'reasonix'")); - assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("默认 (Hermes HTTP)")); + assert!(!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("默认 (Hermes HTTP)")); } #[test] diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mindmap_node_view.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mindmap_node_view.rs index cf8dc34f..34250df5 100644 --- a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mindmap_node_view.rs +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mindmap_node_view.rs @@ -20,7 +20,7 @@ use std::collections::HashMap; use wasm_bindgen::{JsCast, JsValue}; use web_sys::{CustomEvent, CustomEventInit, EventTarget, HtmlElement, HtmlInputElement}; -use crate::next_mount_id; +use crate::editor_runtime::mount::next_mount_id; thread_local! { static MOUNTED_MINDMAP_SHELLS: std::cell::RefCell> = std::cell::RefCell::new(HashMap::new()); diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mod.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mod.rs index f644afe9..5948425d 100644 --- a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mod.rs +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mod.rs @@ -14,6 +14,7 @@ pub(crate) mod dom_selection; pub(crate) mod editor_focus; pub(crate) mod history_safe_commands; pub(crate) mod mindmap_node_view; +pub(crate) mod mount; pub(crate) mod overlays; pub(crate) mod persistence; pub(crate) mod runtime_bridge; diff --git a/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mount.rs b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mount.rs new file mode 100644 index 00000000..ffc97b37 --- /dev/null +++ b/rust/spikes/leptos-tiptap-spike/src/editor_runtime/mount.rs @@ -0,0 +1,165 @@ +//! Leptos Tiptap island 挂载上下文与 embedded/standalone delivery mode。 + +use std::cell::Cell; + +use leptos::mount::mount_to; +use leptos::prelude::*; +use leptos_tiptap::TiptapContent; +use serde::Deserialize; +use serde_json::Value; +use wasm_bindgen::JsCast; +use web_sys::{window, EventTarget, HtmlElement}; + +use crate::editor_runtime::bridge_events::{RuntimePageOptions, COMMAND_EVENT}; +use crate::editor_runtime::runtime_bridge::{build_command_listener, register_unmount_handle}; +use crate::App; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum RuntimeDeliveryMode { + Standalone, + Embedded, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MountOptions { + pub(crate) document_id: Option, + pub(crate) workspace_id: Option, + pub(crate) title: Option, + pub(crate) content: Option, + pub(crate) html: Option, + pub(crate) editable: Option, + pub(crate) read_only: Option, + pub(crate) revision: Option, + pub(crate) conflict_detection_key: Option, + pub(crate) page_options: Option, + #[serde(default)] + pub(crate) standalone_object: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RuntimeStandaloneObject { + pub(crate) kind: Option, + pub(crate) document_id: Option, + pub(crate) mindmap_id: Option, +} + +struct RuntimeMountContext { + id: u32, + target: EventTarget, + mode: RuntimeDeliveryMode, +} + +#[derive(Clone, Debug)] +#[allow(dead_code)] +struct RuntimeMountOptions { + options: MountOptions, + mode: RuntimeDeliveryMode, +} + +thread_local! { + static RUNTIME_MOUNT_CONTEXT: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; + static RUNTIME_MOUNT_OPTIONS: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; + static NEXT_MOUNT_ID: Cell = const { Cell::new(1) }; +} + +pub(crate) fn next_mount_id() -> u32 { + NEXT_MOUNT_ID.with(|cell| { + let next = cell.get(); + cell.set(next.saturating_add(1).max(1)); + next + }) +} + +pub(crate) fn set_runtime_mount_context(context: Option<(u32, EventTarget, RuntimeDeliveryMode)>) { + RUNTIME_MOUNT_CONTEXT.with(|cell| { + *cell.borrow_mut() = context.map(|(id, target, mode)| RuntimeMountContext { + id, + target, + mode, + }); + }); +} + +pub(crate) fn runtime_mount_context() -> Option<(u32, EventTarget, RuntimeDeliveryMode)> { + RUNTIME_MOUNT_CONTEXT.with(|cell| { + cell.borrow() + .as_ref() + .map(|context| (context.id, context.target.clone(), context.mode)) + }) +} + +pub(crate) fn runtime_delivery_mode() -> RuntimeDeliveryMode { + resolve_runtime_delivery_mode( + runtime_mount_context().map(|(_, _, mode)| mode), + url_requests_embedded_mode(), + ) +} + +pub(crate) fn set_runtime_mount_options(options: Option, mode: RuntimeDeliveryMode) { + RUNTIME_MOUNT_OPTIONS.with(|cell| { + *cell.borrow_mut() = options.map(|options| RuntimeMountOptions { options, mode }); + }); +} + +pub(crate) fn runtime_editor_instance_id(mount_id: Option) -> String { + mount_id + .filter(|value| *value > 0) + .map(|value| format!("mnote-leptos-tiptap-spike-{value}")) + .unwrap_or_else(|| "mnote-leptos-tiptap-spike-standalone".to_string()) +} + +fn url_requests_embedded_mode() -> bool { + window() + .and_then(|win| win.location().search().ok()) + .map(|search| search.contains("embedded=1")) + .unwrap_or(false) +} + +pub(crate) fn resolve_runtime_delivery_mode( + explicit_mode: Option, + url_embedded: bool, +) -> RuntimeDeliveryMode { + if let Some(mode) = explicit_mode { + return mode; + } + + if url_embedded { + RuntimeDeliveryMode::Embedded + } else { + RuntimeDeliveryMode::Standalone + } +} + +pub(crate) fn mount_app_into( + target: HtmlElement, + options: MountOptions, + mode: RuntimeDeliveryMode, +) -> u32 { + let mount_id = next_mount_id(); + let context_target: EventTarget = target.clone().into(); + + set_runtime_mount_options(Some(options.clone()), mode); + set_runtime_mount_context(Some((mount_id, context_target.clone(), mode))); + + let handle = mount_to(target, move || { + view! { + + } + }); + + let command_listener = build_command_listener(mount_id); + let _ = context_target + .add_event_listener_with_callback(COMMAND_EVENT, command_listener.as_ref().unchecked_ref()); + + register_unmount_handle(mount_id, context_target, command_listener, handle); + mount_id +} + +pub(crate) fn initial_content() -> TiptapContent { + TiptapContent::json(serde_json::json!({ + "type": "doc", + "content": [] + })) +} diff --git a/rust/spikes/leptos-tiptap-spike/src/lib.rs b/rust/spikes/leptos-tiptap-spike/src/lib.rs index 5ff90a66..083b2e9a 100644 --- a/rust/spikes/leptos-tiptap-spike/src/lib.rs +++ b/rust/spikes/leptos-tiptap-spike/src/lib.rs @@ -1,5 +1,5 @@ use leptos::ev; -use leptos::mount::{mount_to, mount_to_body}; +use leptos::mount::mount_to_body; use leptos::prelude::*; use leptos_dom::helpers::window_event_listener; use leptos_tiptap::{ @@ -9,7 +9,7 @@ use leptos_tiptap::{ use send_wrapper::SendWrapper; use serde::Deserialize; use serde_json::{json, Value}; -use std::{cell::Cell, fmt::Display}; +use std::fmt::Display; use wasm_bindgen::{closure::Closure, prelude::*, JsCast, JsValue}; use wasm_bindgen_futures::spawn_local; use web_sys::{ @@ -37,8 +37,8 @@ use editor_runtime::block_transform::{ use editor_runtime::bridge_dispatch::HostCommandKind; use editor_runtime::bridge_events::{ BridgeSelectorsPayload, ChangeMetaPayload, ChangePayload, HeightPayload, HostCommandEnvelope, - HostCommandPayload, HostStatusPayload, ReadyPayload, RuntimePageOptions, StatePayload, - BLOCK_DELTA_EVENT, COMMAND_EVENT, HEIGHT_EVENT, PROTOCOL, RUNTIME_NAME, + HostCommandPayload, HostStatusPayload, ReadyPayload, StatePayload, BLOCK_DELTA_EVENT, + COMMAND_EVENT, HEIGHT_EVENT, PROTOCOL, RUNTIME_NAME, }; use editor_runtime::command_sync::{ read_editor_snapshot, sync_editor_outputs, sync_persisted_editor_command, @@ -53,6 +53,13 @@ use editor_runtime::dom_selection::{ }; use editor_runtime::editor_focus::{active_editor_stage, body_has_focus, schedule_editor_focus}; use editor_runtime::mindmap_node_view::{mount_mindmap_shell_impl, unmount_mindmap_shell_impl}; +use editor_runtime::mount::{ + initial_content, mount_app_into, runtime_delivery_mode, runtime_editor_instance_id, + runtime_mount_context, set_runtime_mount_context, set_runtime_mount_options, MountOptions, + RuntimeDeliveryMode, RuntimeStandaloneObject, +}; +#[cfg(test)] +use editor_runtime::mount::resolve_runtime_delivery_mode; use editor_runtime::overlays::{ clamp_overlay_anchor, close_editor_floating_overlays_if_escape, image_element_from_target, image_toolbar_anchor_from_image, open_block_menu_overlay, open_image_toolbar_overlay, @@ -68,10 +75,9 @@ use editor_runtime::persistence::{ persisted_document_identity, PersistedDocumentIdentity, }; use editor_runtime::runtime_bridge::{ - build_command_listener, dispatch_change_event_to_target, dispatch_ready_event_to_target, + dispatch_change_event_to_target, dispatch_ready_event_to_target, dispatch_runtime_event, dispatch_selection_event_to_target, dispatch_state_event_to_target, - dispatch_status_event_to_target, register_runtime_listener, register_unmount_handle, - take_unmount_handle, + dispatch_status_event_to_target, register_runtime_listener, take_unmount_handle, }; use editor_runtime::slash_actions::{SlashActionKind, SLASH_ACTIONS}; use editor_runtime::style::SPIKE_STYLE; @@ -515,98 +521,6 @@ mod tests { } } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum RuntimeDeliveryMode { - Standalone, - Embedded, -} - -#[derive(Clone, Debug, Default, Deserialize)] -#[serde(rename_all = "camelCase")] -struct MountOptions { - document_id: Option, - workspace_id: Option, - title: Option, - content: Option, - html: Option, - editable: Option, - read_only: Option, - revision: Option, - conflict_detection_key: Option, - page_options: Option, - #[serde(default)] - standalone_object: Option, -} - -#[derive(Clone, Debug, Default, Deserialize)] -#[serde(rename_all = "camelCase")] -struct RuntimeStandaloneObject { - kind: Option, - document_id: Option, - mindmap_id: Option, -} - -struct RuntimeMountContext { - id: u32, - target: EventTarget, - mode: RuntimeDeliveryMode, -} - -#[derive(Clone, Debug)] -#[allow(dead_code)] -struct RuntimeMountOptions { - options: MountOptions, - mode: RuntimeDeliveryMode, -} - -thread_local! { - static RUNTIME_MOUNT_CONTEXT: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; - static RUNTIME_MOUNT_OPTIONS: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; - static NEXT_MOUNT_ID: Cell = const { Cell::new(1) }; -} - -fn next_mount_id() -> u32 { - NEXT_MOUNT_ID.with(|cell| { - let next = cell.get(); - cell.set(next.saturating_add(1).max(1)); - next - }) -} - -fn set_runtime_mount_context(context: Option) { - RUNTIME_MOUNT_CONTEXT.with(|cell| { - *cell.borrow_mut() = context; - }); -} - -fn runtime_mount_context() -> Option<(u32, EventTarget, RuntimeDeliveryMode)> { - RUNTIME_MOUNT_CONTEXT.with(|cell| { - cell.borrow() - .as_ref() - .map(|context| (context.id, context.target.clone(), context.mode)) - }) -} - -fn runtime_delivery_mode() -> RuntimeDeliveryMode { - resolve_runtime_delivery_mode( - runtime_mount_context().map(|(_, _, mode)| mode), - url_requests_embedded_mode(), - ) -} - -fn set_runtime_mount_options(options: Option) { - RUNTIME_MOUNT_OPTIONS.with(|cell| { - *cell.borrow_mut() = options; - }); -} - -fn runtime_editor_instance_id(mount_id: Option) -> String { - mount_id - .filter(|value| *value > 0) - .map(|value| format!("mnote-leptos-tiptap-spike-{value}")) - .unwrap_or_else(|| "mnote-leptos-tiptap-spike-standalone".to_string()) -} - fn editor_block_by_id(block_id: &str) -> Option { find_mnote_block_anchor(block_id) } @@ -728,7 +642,7 @@ pub fn unmount(mount_id: u32) -> Result<(), JsValue> { if let Some(handle) = take_unmount_handle(mount_id) { if runtime_mount_context().map(|(id, _, _)| id) == Some(mount_id) { set_runtime_mount_context(None); - set_runtime_mount_options(None); + set_runtime_mount_options(None, RuntimeDeliveryMode::Embedded); } drop(handle); Ok(()) @@ -737,35 +651,6 @@ pub fn unmount(mount_id: u32) -> Result<(), JsValue> { } } -fn mount_app_into(target: HtmlElement, options: MountOptions, mode: RuntimeDeliveryMode) -> u32 { - let mount_id = next_mount_id(); - let context_target: EventTarget = target.clone().into(); - let mount_options = RuntimeMountOptions { - options: options.clone(), - mode, - }; - - set_runtime_mount_options(Some(mount_options.clone())); - set_runtime_mount_context(Some(RuntimeMountContext { - id: mount_id, - target: context_target.clone(), - mode, - })); - - let handle = mount_to(target, move || { - view! { - - } - }); - - let command_listener = build_command_listener(mount_id); - let _ = context_target - .add_event_listener_with_callback(COMMAND_EVENT, command_listener.as_ref().unchecked_ref()); - - register_unmount_handle(mount_id, context_target, command_listener, handle); - mount_id -} - fn default_title() -> String { "Leptos Tiptap 主编辑器 P0".to_string() } @@ -818,28 +703,6 @@ fn runtime_persisted_identity( persisted_document_identity(document_id.get_untracked(), workspace_id.get_untracked()) } -fn url_requests_embedded_mode() -> bool { - window() - .and_then(|win| win.location().search().ok()) - .map(|search| search.contains("embedded=1")) - .unwrap_or(false) -} - -fn resolve_runtime_delivery_mode( - explicit_mode: Option, - url_embedded: bool, -) -> RuntimeDeliveryMode { - if let Some(mode) = explicit_mode { - return mode; - } - - if url_embedded { - RuntimeDeliveryMode::Embedded - } else { - RuntimeDeliveryMode::Standalone - } -} - fn is_embedded_mode() -> bool { runtime_delivery_mode() == RuntimeDeliveryMode::Embedded } @@ -4386,13 +4249,6 @@ fn App(mount_options: MountOptions) -> impl IntoView { } } -fn initial_content() -> TiptapContent { - TiptapContent::json(serde_json::json!({ - "type": "doc", - "content": [] - })) -} - pub fn standalone_main() { console_error_panic_hook::set_once(); mount_to_body(|| {