feat(page-ai): add skill context and agent profile policy

This commit is contained in:
lix-2026
2026-05-29 21:57:29 +08:00
parent 631205ba2f
commit 49a0545148
18 changed files with 3478 additions and 259 deletions
@@ -320,9 +320,11 @@ export function createSidebarPageAiRuntime(context) {
var record = pageAiAgentRecord(next);
pageUiState.pageAiAgentId = next;
pageUiState.pageAiAcpRuntime = record.acpRuntime || 'reasonix';
pageUiState.pageAiAgentPopoverOpen = next === 'hermes';
if (next === 'hermes') pageAiSetActiveProfile(pageAiCurrentProfile());
document.documentElement.setAttribute('data-mnote-page-ai-agent-id', next);
pageAiPersistAiPreference('default_agent_id', next);
void pageAiLoadSkills();
renderPageAiProviderButtons();
renderPageAiControls();
}
@@ -337,7 +339,62 @@ export function createSidebarPageAiRuntime(context) {
renderPageAiControls();
}
function pageAiSetContextPopoverOpen(open) {
pageUiState.pageAiContextPopoverOpen = Boolean(open);
if (open) pageUiState.pageAiAgentPopoverOpen = false;
renderPageAiControls();
}
function pageAiSetAgentPopoverOpen(open) {
pageUiState.pageAiAgentPopoverOpen = Boolean(open);
if (open) pageUiState.pageAiContextPopoverOpen = false;
renderPageAiControls();
}
function pageAiContextRefLabel(kind) {
var record = PAGE_AI_CONTEXT_REF_REGISTRY.find(function(ref) { return ref.id === kind; });
return record ? record.label : kind;
}
function pageAiContextButtonSummary() {
var selected = pageAiEnsureContextRefState();
var labels = PAGE_AI_CONTEXT_REF_REGISTRY
.filter(function(ref) { return selected[ref.id] === true; })
.map(function(ref) { return ref.label; });
if (!labels.length) return '选择上下文';
var head = labels.slice(0, 2).join(' + ');
return labels.length > 2 ? head + ' +' + String(labels.length - 2) : head;
}
function pageAiContextRefDetail(kind) {
var documentId = currentDocumentId();
var relativePath = localMarkdownRelativePathFromPageAiDocumentId(documentId);
if (kind === 'current_page') return relativePath || documentId || '当前页面';
if (kind === 'selection') return currentPageAiSelectedText() ? '当前选区可用' : '当前没有选区';
if (kind === 'active_editor') {
var target = currentPageAiEditorTarget();
return String(target && (target.title || target.documentId) || relativePath || '当前打开资源');
}
if (kind === 'file') return relativePath || '当前文件';
if (kind === 'folder') {
var roots = pageAiBuildAllowedRoots();
if (!roots.length) return '需要授权后可用';
return roots.map(function(root) { return root.rootUri.replace(/^file:\/\//, ''); }).filter(Boolean)[0] || '已授权文件夹';
}
if (kind === 'changed_files') return '本次 run 后可用于追问';
return '';
}
function pageAiContextRefDisabled(kind) {
if (kind === 'selection') return !currentPageAiSelectedText();
return false;
}
function pageAiPersistAiPreference(key, value) {
pageAiPersistRawAiPreference('ai.common.' + key, value);
}
function pageAiPersistRawAiPreference(key, value) {
try {
var workspaceId = resolveWorkspaceId(document.body);
var body = {
@@ -347,7 +404,7 @@ export function createSidebarPageAiRuntime(context) {
documentId: currentDocumentId(),
updates: {}
};
body.updates['ai.common.' + key] = value;
body.updates[key] = value;
void fetch('/api/ui/preferences', {
method: 'PUT',
headers: { 'content-type': 'application/json', accept: 'application/json' },
@@ -369,16 +426,23 @@ export function createSidebarPageAiRuntime(context) {
var preferences = payload.result.aiPreferences && typeof payload.result.aiPreferences === 'object'
? payload.result.aiPreferences
: {};
pageUiState.pageAiSkillPreferences = Object.assign({}, preferences);
var defaultAgent = String(preferences['ai.common.default_agent_id'] || '').trim();
if (defaultAgent) {
var agent = pageAiAgentRecord(defaultAgent);
pageUiState.pageAiAgentId = agent.id;
pageUiState.pageAiAcpRuntime = agent.acpRuntime || pageUiState.pageAiAcpRuntime || 'reasonix';
}
var hermesProfile = String(preferences['ai.agent.hermes.profile_id'] || '').trim();
if (hermesProfile) pageAiSetActiveProfile(hermesProfile);
var selected = preferences['ai.common.context_refs.default_selected'];
if (selected && typeof selected === 'object' && !Array.isArray(selected)) {
pageUiState.pageAiSelectedContextRefs = Object.assign({}, pageAiEnsureContextRefState(), selected);
}
var collapsedSkillGroups = preferences['ai.common.skills.groups.collapsed'];
if (collapsedSkillGroups && typeof collapsedSkillGroups === 'object' && !Array.isArray(collapsedSkillGroups)) {
pageUiState.pageAiCollapsedSkillGroups = Object.assign({}, collapsedSkillGroups);
}
} catch (_) {}
renderPageAiControls();
}
@@ -531,22 +595,18 @@ export function createSidebarPageAiRuntime(context) {
function pageAiPageContextForRefs(pageContext, contextRefs) {
var cloned = pageAiCloneJson(pageContext) || {};
var kinds = pageAiContextKindsFromRefs(contextRefs);
var aiContext = cloned.aiContext && typeof cloned.aiContext === 'object' ? cloned.aiContext : {};
if (!kinds.current_page) {
delete cloned.documentBlocks;
delete aiContext.contextBlocks;
delete aiContext.pageText;
delete aiContext.pageXml;
delete aiContext.truncated;
delete aiContext.warnings;
}
if (!kinds.selection) {
delete aiContext.selectedText;
delete aiContext.selectedBlockIds;
delete aiContext.selectedBlocks;
delete aiContext.allowedTargetBlockIds;
}
delete cloned.documentBlocks;
delete cloned.evidence;
delete aiContext.contextBlocks;
delete aiContext.pageText;
delete aiContext.pageXml;
delete aiContext.truncated;
delete aiContext.warnings;
delete aiContext.selectedText;
delete aiContext.selectedBlockIds;
delete aiContext.selectedBlocks;
delete aiContext.allowedTargetBlockIds;
cloned.aiContext = aiContext;
return cloned;
}
@@ -754,7 +814,7 @@ export function createSidebarPageAiRuntime(context) {
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',
contentAccess: 'mnote.context.read_current_page',
aiContext: aiContext
},
editorTarget: editorTarget,
@@ -1532,13 +1592,17 @@ export function createSidebarPageAiRuntime(context) {
skills: pageAiNormalizeArray(category && category.skills).map(function(skill) {
return {
name: String(skill && skill.name || '').trim(),
id: String(skill && skill.id || skill && skill.name || '').trim(),
title: String(skill && skill.title || skill && skill.name || skill && skill.id || '').trim(),
description: String(skill && skill.description || '').trim(),
enabled: skill && skill.enabled !== false,
toggleable: skill && skill.toggleable !== 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)
modified: Boolean(skill && skill.modified),
category: String(category && category.name || '').trim()
};
})
};
@@ -1546,8 +1610,11 @@ export function createSidebarPageAiRuntime(context) {
archived: archived.map(function(skill) {
return {
name: String(skill && skill.name || '').trim(),
id: String(skill && skill.id || skill && skill.name || '').trim(),
title: String(skill && skill.title || skill && skill.name || skill && skill.id || '').trim(),
description: String(skill && skill.description || '').trim(),
enabled: skill && skill.enabled !== false,
toggleable: skill && skill.toggleable !== false,
source: String(skill && skill.source || 'local').trim() || 'local',
origin: String(skill && skill.origin || '').trim(),
createdBy: String(skill && skill.createdBy || '').trim(),
@@ -1565,8 +1632,11 @@ export function createSidebarPageAiRuntime(context) {
result.push({
category: category.name,
name: skill.name,
id: skill.id || skill.name,
title: skill.title || skill.name,
description: skill.description,
enabled: skill.enabled !== false,
toggleable: skill.toggleable !== false,
source: skill.source || 'local',
origin: skill.origin || '',
createdBy: skill.createdBy || '',
@@ -1666,6 +1736,74 @@ export function createSidebarPageAiRuntime(context) {
}).filter(Boolean).join('\n');
}
function pageAiParentRelativePath(path) {
var normalized = String(path || '').trim().replace(/\\/g, '/').replace(/^\/+/, '');
if (!normalized || normalized.indexOf('/') < 0) return '';
return normalized.split('/').slice(0, -1).join('/');
}
function pageAiChangedFileRelativePath(file) {
return String(file && (file.path || file.relativePath || file.relative_path || file.filePath || file.file_path || '') || '').trim();
}
function pageAiDispatchReceiptRefresh(receipt, changedFiles, fallbackAudit, runId, traceId) {
var refresh = receipt && typeof receipt === 'object' && receipt.refresh && typeof receipt.refresh === 'object'
? receipt.refresh
: {};
var files = pageAiNormalizeArray(
receipt && typeof receipt === 'object' && receipt.changedFiles !== undefined
? receipt.changedFiles
: changedFiles
);
if (files.length) {
var changedPaths = files.map(function(file) {
var relativePath = pageAiChangedFileRelativePath(file);
return {
relativePath: relativePath,
changeType: String(file && (file.changeType || file.change_type || 'modified') || 'modified')
};
}).filter(function(item) { return item.relativePath; });
var affectedParents = changedPaths.map(function(item) {
return { relativePath: pageAiParentRelativePath(item.relativePath), reason: 'agent-run-receipt' };
}).filter(function(item, index, list) {
return index === list.findIndex(function(candidate) { return candidate.relativePath === item.relativePath; });
});
if (changedPaths.length) {
document.documentElement.setAttribute('data-mnote-page-ai-receipt-filetree-refresh', 'true');
window.dispatchEvent(new CustomEvent('tree:local-folder-watch-batch', {
detail: {
payload: {
schema: 'mnote.local_folder.watch_batch.v1',
source: 'agent_run_receipt',
runId: runId,
rootUri: String((receipt && receipt.rootUri) || (fallbackAudit && fallbackAudit.rootUri) || currentRootUri() || ''),
changedPaths: changedPaths,
affectedParents: affectedParents
}
}
}));
}
}
if (refresh.touchesCurrentFile === true) {
var documentId = String(refresh.currentDocumentId || receipt && receipt.documentId || currentDocumentId() || '').trim();
document.documentElement.setAttribute('data-mnote-page-ai-receipt-current-refresh', 'true');
window.dispatchEvent(new CustomEvent('mnote:page-ai-tool-write-completed', {
detail: {
toolName: 'agent.run_receipt',
normalizedToolName: 'agent.run_receipt',
documentId: documentId,
workspaceId: String(receipt && receipt.workspaceId || resolveWorkspaceId(document.body) || ''),
rootUri: String(receipt && receipt.rootUri || fallbackAudit && fallbackAudit.rootUri || currentRootUri() || ''),
changedFiles: files,
receipt: receipt || null,
runId: runId,
traceId: traceId,
toolCallId: runId + ':agent.run_receipt'
}
}));
}
}
function pageAiToolEventDeepFindString(value, keys, depth) {
if (!value || typeof value !== 'object' || depth > 5) return '';
for (var index = 0; index < keys.length; index += 1) {
@@ -1992,9 +2130,11 @@ export function createSidebarPageAiRuntime(context) {
function pageAiFilteredSkillEntries() {
var query = String(pageUiState.pageAiSkillQuery || '').trim().toLowerCase();
return pageAiSkillListEntries().filter(function(skill) {
return pageAiAllSkillEntries().filter(function(skill) {
if (skill.group === 'hermes' && pageAiHideHermesBuiltinSkills(pageAiCurrentProfile()) && skill.builtin === true) return false;
if (!query) return true;
return String(skill.name || '').toLowerCase().indexOf(query) >= 0
return String(skill.name || skill.id || '').toLowerCase().indexOf(query) >= 0
|| String(skill.title || '').toLowerCase().indexOf(query) >= 0
|| String(skill.description || '').toLowerCase().indexOf(query) >= 0
|| String(skill.category || '').toLowerCase().indexOf(query) >= 0
|| pageAiSkillOriginLabel(skill).toLowerCase().indexOf(query) >= 0;
@@ -2018,6 +2158,162 @@ export function createSidebarPageAiRuntime(context) {
return '本地';
}
function pageAiSkillPreferenceKey(group, profile) {
var groupName = String(group || '').trim();
if (groupName === 'mnote') return 'ai.common.skills.mnote.enabled';
if (groupName === 'reasonix') return 'ai.agent.reasonix.skills.enabled';
if (groupName === 'hermes') {
var nextProfile = String(profile || pageAiCurrentProfile() || 'default').trim() || 'default';
return 'ai.agent.hermes.profile.' + nextProfile + '.skills.enabled';
}
return '';
}
function pageAiSkillPreferenceTable(group, profile) {
var key = pageAiSkillPreferenceKey(group, profile);
var preferences = pageUiState.pageAiSkillPreferences || {};
var value = preferences[key];
if (value && typeof value === 'object' && !Array.isArray(value)) return value;
return {};
}
function pageAiHermesHideBuiltinPreferenceKey(profile) {
var nextProfile = String(profile || pageAiCurrentProfile() || 'default').trim() || 'default';
return 'ai.agent.hermes.profile.' + nextProfile + '.skills.hide_builtin';
}
function pageAiHideHermesBuiltinSkills(profile) {
var preferences = pageUiState.pageAiSkillPreferences || {};
return preferences[pageAiHermesHideBuiltinPreferenceKey(profile)] === true;
}
function pageAiSetHideHermesBuiltinSkills(enabled, profile) {
var key = pageAiHermesHideBuiltinPreferenceKey(profile);
var preferences = Object.assign({}, pageUiState.pageAiSkillPreferences || {});
preferences[key] = Boolean(enabled);
pageUiState.pageAiSkillPreferences = preferences;
pageAiPersistRawAiPreference(key, Boolean(enabled));
renderPageAiControls();
}
function pageAiSkillIsBuiltin(skill) {
var origin = String(skill && skill.origin || '').trim();
var source = String(skill && skill.source || '').trim();
return origin === 'builtin' || source === 'builtin';
}
function pageAiToggleableSkillEntries(group, profile) {
var catalog = pageUiState.pageAiSkillCatalogs && pageUiState.pageAiSkillCatalogs[group]
? pageUiState.pageAiSkillCatalogs[group]
: { categories: [], archived: [] };
var overrides = pageAiSkillPreferenceTable(group, profile);
var result = [];
pageAiNormalizeArray(catalog.categories).forEach(function(category) {
pageAiNormalizeArray(category.skills).forEach(function(skill) {
var id = String(skill.id || skill.name || '').trim();
if (!id) return;
result.push({
group: group,
profile: profile || '',
category: category.name,
id: id,
name: skill.name || id,
title: skill.title || skill.name || id,
description: skill.description || '',
enabled: group === 'hermes' ? skill.enabled !== false : overrides[id] !== false,
toggleable: skill.toggleable !== false,
source: skill.source || group,
origin: skill.origin || '',
readOnly: skill.readOnly === true,
builtin: pageAiSkillIsBuiltin(skill),
toolNames: skill.toolNames || [],
requiresContextRefs: skill.requiresContextRefs || []
});
});
});
pageAiNormalizeArray(catalog.archived).forEach(function(skill) {
var id = String(skill.id || skill.name || '').trim();
if (!id) return;
result.push({
group: group,
profile: profile || '',
category: 'archived',
id: id,
name: skill.name || id,
title: skill.title || skill.name || id,
description: skill.description || '',
enabled: group === 'hermes' ? skill.enabled !== false : overrides[id] !== false,
toggleable: skill.toggleable !== false,
source: skill.source || group,
origin: skill.origin || '',
readOnly: skill.readOnly === true,
builtin: pageAiSkillIsBuiltin(skill),
toolNames: skill.toolNames || [],
requiresContextRefs: skill.requiresContextRefs || []
});
});
return result;
}
function pageAiAllSkillEntries() {
return []
.concat(pageAiToggleableSkillEntries('mnote', ''))
.concat(pageAiToggleableSkillEntries('reasonix', ''))
.concat(pageAiToggleableSkillEntries('hermes', pageAiCurrentProfile()));
}
function pageAiSkillGroupCollapsed(group) {
var table = pageUiState.pageAiCollapsedSkillGroups || {};
return table[String(group || '').trim()] === true;
}
function pageAiToggleSkillGroup(group) {
var normalized = String(group || '').trim();
if (!normalized) return;
var table = Object.assign({}, pageUiState.pageAiCollapsedSkillGroups || {});
table[normalized] = table[normalized] !== true;
pageUiState.pageAiCollapsedSkillGroups = table;
pageAiPersistRawAiPreference('ai.common.skills.groups.collapsed', table);
renderPageAiControls();
}
function pageAiSetSkillPreference(group, skillId, enabled, profile) {
var key = pageAiSkillPreferenceKey(group, profile);
if (!key || !skillId) return;
var preferences = Object.assign({}, pageUiState.pageAiSkillPreferences || {});
var current = preferences[key] && typeof preferences[key] === 'object' && !Array.isArray(preferences[key])
? Object.assign({}, preferences[key])
: {};
current[skillId] = Boolean(enabled);
preferences[key] = current;
pageUiState.pageAiSkillPreferences = preferences;
pageAiPersistRawAiPreference(key, current);
}
function pageAiSkillEnabled(skill) {
return skill.enabled !== false;
}
function pageAiLoadSkillCatalog(runtime, profile) {
var params = new URLSearchParams();
params.set('runtime', runtime);
if (runtime === 'mnote') {
params.set('agentId', pageUiState.pageAiAgentId || 'reasonix');
} else if (runtime === 'reasonix') {
params.set('runtime', 'reasonix');
} else if (runtime === 'hermes' && profile) {
params.set('profile', profile);
}
return fetch('/api/hermes/client/skills?' + params.toString(), {
headers: { 'accept': 'application/json' }
}).then(function(response) {
return response.json().catch(function(){ return null; }).then(function(payload) {
if (!response.ok) throw new Error(pageAiErrorMessage(payload, 'skills_failed_' + response.status));
return pageAiNormalizeSkills(payload);
});
});
}
async function pageAiLoadProfiles() {
try {
var response = await fetch('/api/hermes/client/profiles', {
@@ -2050,6 +2346,12 @@ export function createSidebarPageAiRuntime(context) {
var next = String(profileName || '').trim();
if (!next) return;
pageAiSetActiveProfile(next);
pageAiPersistRawAiPreference('ai.agent.hermes.profile_id', next);
pageUiState.pageAiSkills = { categories: [], archived: [] };
pageUiState.pageAiSkillCatalogs = Object.assign({}, pageUiState.pageAiSkillCatalogs || {}, {
hermes: { categories: [], archived: [] }
});
pageUiState.pageAiSkillError = '';
renderPageAiProviderButtons();
renderPageAiControls();
try {
@@ -2121,59 +2423,81 @@ export function createSidebarPageAiRuntime(context) {
}
async function pageAiLoadSkills() {
var requestedProfile = pageAiCurrentProfile();
var loadSeq = (Number(pageUiState.pageAiSkillLoadSeq || 0) || 0) + 1;
pageUiState.pageAiSkillLoadSeq = loadSeq;
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);
var catalogs = await Promise.all([
pageAiLoadSkillCatalog('mnote', ''),
pageAiLoadSkillCatalog('reasonix', ''),
pageAiLoadSkillCatalog('hermes', requestedProfile)
]);
if (pageUiState.pageAiSkillLoadSeq !== loadSeq || pageAiCurrentProfile() !== requestedProfile) return;
pageUiState.pageAiSkillCatalogs = {
mnote: catalogs[0],
reasonix: catalogs[1],
hermes: catalogs[2]
};
pageUiState.pageAiSkills = catalogs[2];
pageUiState.pageAiSkillError = '';
} catch (error) {
if (pageUiState.pageAiSkillLoadSeq !== loadSeq || pageAiCurrentProfile() !== requestedProfile) return;
pageUiState.pageAiSkillError = error instanceof Error ? error.message : String(error);
pageUiState.pageAiSkills = { categories: [], archived: [] };
pageUiState.pageAiSkillCatalogs = pageUiState.pageAiSkillCatalogs || { mnote: { categories: [], archived: [] }, reasonix: { categories: [], archived: [] }, hermes: { categories: [], archived: [] } };
}
renderPageAiControls();
renderPageAiConversation();
}
async function pageAiToggleSkill(skillName, enabled) {
async function pageAiToggleSkill(skillName, enabled, group, profile) {
var name = String(skillName || '').trim();
if (!name) return;
if (String(pageUiState.pageAiAcpRuntime || 'reasonix').trim() === 'reasonix') return;
var skillGroup = String(group || 'hermes').trim() || 'hermes';
var skillProfile = String(profile || pageAiCurrentProfile() || '').trim();
if (skillGroup === 'mnote' || skillGroup === 'reasonix') {
pageAiSetSkillPreference(skillGroup, name, Boolean(enabled), skillProfile);
document.documentElement.setAttribute('data-mnote-page-ai-skill-toggled', skillGroup + ':' + name);
renderPageAiControls();
return;
}
var previous = null;
pageAiSkillListEntries().forEach(function(skill) {
if (skill.name === name && previous == null) previous = skill.enabled !== false;
pageAiToggleableSkillEntries('hermes', pageAiCurrentProfile()).forEach(function(skill) {
if (skill.id === name || skill.name === name) 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(),
profile: skillProfile || 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) {
var hermesCatalog = pageUiState.pageAiSkillCatalogs && pageUiState.pageAiSkillCatalogs.hermes
? pageUiState.pageAiSkillCatalogs.hermes
: pageUiState.pageAiSkills;
pageAiNormalizeArray(hermesCatalog.categories).forEach(function(category) {
pageAiNormalizeArray(category.skills).forEach(function(skill) {
if (skill.name === name) skill.enabled = Boolean(enabled);
if (skill.name === name || skill.id === name) skill.enabled = Boolean(enabled);
});
});
pageUiState.pageAiSkills = hermesCatalog;
pageAiSetSkillPreference('hermes', name, Boolean(enabled), skillProfile || pageAiCurrentProfile());
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) {
var rollbackCatalog = pageUiState.pageAiSkillCatalogs && pageUiState.pageAiSkillCatalogs.hermes
? pageUiState.pageAiSkillCatalogs.hermes
: pageUiState.pageAiSkills;
pageAiNormalizeArray(rollbackCatalog.categories).forEach(function(category) {
pageAiNormalizeArray(category.skills).forEach(function(skill) {
if (skill.name === name) skill.enabled = previous;
if (skill.name === name || skill.id === name) skill.enabled = previous;
});
});
}
@@ -2240,25 +2564,85 @@ export function createSidebarPageAiRuntime(context) {
var drawer = ensurePageAiDrawer();
var isAcp = pageUiState.pageAiAcpRuntime !== '';
var activeAgentId = pageAiCurrentAgentId();
var activeProfile = pageAiRunProfile();
var activeProfile = pageAiCurrentProfile();
drawer.setAttribute('data-page-ai-page', pageUiState.pageAiPage || 'chat');
drawer.setAttribute('data-page-ai-active-agent-id', activeAgentId);
drawer.setAttribute('data-mnote-acp-runtime', pageUiState.pageAiAcpRuntime || 'reasonix');
document.documentElement.setAttribute('data-mnote-page-ai-agent-id', activeAgentId);
var agentSelector = drawer.querySelector('[data-page-ai-agent-selector]');
if (agentSelector instanceof HTMLElement) {
agentSelector.innerHTML = PAGE_AI_AGENT_REGISTRY.map(function(agent) {
var active = agent.id === activeAgentId;
return '<button type="button" class="wolai-page-ai-tab' + (active ? ' is-active' : '') + '" data-page-ai-agent-id="' + escapeHtml(agent.id) + '" aria-pressed="' + (active ? 'true' : 'false') + '">' + escapeHtml(agent.label) + '</button>';
}).join('');
var agentButton = drawer.querySelector('[data-page-ai-agent-button]');
var agentPopoverId = 'mnote-page-ai-agent-popover';
if (agentButton instanceof HTMLElement) {
var activeAgent = pageAiAgentRecord(activeAgentId);
agentButton.textContent = 'AI';
agentButton.setAttribute('aria-expanded', pageUiState.pageAiAgentPopoverOpen ? 'true' : 'false');
agentButton.setAttribute('aria-controls', agentPopoverId);
agentButton.setAttribute('data-page-ai-agent-summary', activeAgent.label);
agentButton.setAttribute('aria-label', 'Agent' + activeAgent.label);
agentButton.setAttribute('title', 'Agent' + activeAgent.label);
}
var contextRefs = drawer.querySelector('[data-page-ai-context-refs]');
if (contextRefs instanceof HTMLElement) {
var agentPopover = drawer.querySelector('[data-page-ai-agent-popover]');
if (agentPopover instanceof HTMLElement) {
agentPopover.id = agentPopoverId;
agentPopover.hidden = !pageUiState.pageAiAgentPopoverOpen;
agentPopover.innerHTML = '' +
'<div class="wolai-page-ai-context-popover-head">' +
'<strong>选择 Agent</strong>' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-action="close-agent-popover">完成</button>' +
'</div>' +
'<div class="wolai-page-ai-agent-option-list">' +
PAGE_AI_AGENT_REGISTRY.map(function(agent) {
var active = agent.id === activeAgentId;
var detail = agent.canWriteFiles ? '可读取并在授权目录内写文件' : '只聊天,不申请文件写权限';
return '' +
'<button type="button" class="wolai-page-ai-agent-option' + (active ? ' is-active' : '') + '" data-page-ai-agent-id="' + escapeHtml(agent.id) + '" aria-pressed="' + (active ? 'true' : 'false') + '">' +
'<span class="wolai-page-ai-agent-option-label">' + escapeHtml(agent.label) + '</span>' +
'<span class="wolai-page-ai-agent-option-detail">' + escapeHtml(detail) + '</span>' +
'</button>';
}).join('') +
'</div>' +
(activeAgentId === 'hermes'
? '<label class="wolai-page-ai-agent-profile"><span>Hermes profile</span><select data-page-ai-profile-select></select></label>'
: '');
}
var contextButton = drawer.querySelector('[data-page-ai-context-button]');
var contextPopoverId = 'mnote-page-ai-context-popover';
if (contextButton instanceof HTMLElement) {
var summary = pageAiContextButtonSummary();
contextButton.textContent = '⇅';
contextButton.setAttribute('aria-expanded', pageUiState.pageAiContextPopoverOpen ? 'true' : 'false');
contextButton.setAttribute('aria-controls', contextPopoverId);
contextButton.setAttribute('data-page-ai-context-summary', summary);
contextButton.setAttribute('aria-label', '上下文:' + summary);
contextButton.setAttribute('title', '上下文:' + summary);
}
var contextPopover = drawer.querySelector('[data-page-ai-context-popover]');
if (contextPopover instanceof HTMLElement) {
var selectedRefs = pageAiEnsureContextRefState();
contextRefs.innerHTML = PAGE_AI_CONTEXT_REF_REGISTRY.map(function(ref) {
var active = selectedRefs[ref.id] !== false && selectedRefs[ref.id] === true;
return '<button type="button" class="wolai-page-ai-ghost' + (active ? ' is-active' : '') + '" data-page-ai-context-ref="' + escapeHtml(ref.id) + '" aria-pressed="' + (active ? 'true' : 'false') + '">' + escapeHtml(ref.label) + '</button>';
}).join('');
contextPopover.id = contextPopoverId;
contextPopover.hidden = !pageUiState.pageAiContextPopoverOpen;
contextPopover.innerHTML = '' +
'<div class="wolai-page-ai-context-popover-head">' +
'<strong>发送给 AI 的上下文</strong>' +
'<button type="button" class="wolai-page-ai-ghost" data-page-ai-action="close-context-popover">完成</button>' +
'</div>' +
'<div class="wolai-page-ai-context-option-list">' +
PAGE_AI_CONTEXT_REF_REGISTRY.map(function(ref) {
var checked = selectedRefs[ref.id] === true;
var disabled = pageAiContextRefDisabled(ref.id);
return '' +
'<label class="wolai-page-ai-context-option' + (disabled ? ' is-disabled' : '') + '">' +
'<input type="checkbox" data-page-ai-context-ref="' + escapeHtml(ref.id) + '"' + (checked ? ' checked' : '') + (disabled ? ' disabled' : '') + ' />' +
'<span class="wolai-page-ai-context-option-main">' +
'<span class="wolai-page-ai-context-option-label">' + escapeHtml(ref.label) + '</span>' +
'<span class="wolai-page-ai-context-option-detail">' + escapeHtml(pageAiContextRefDetail(ref.id)) + '</span>' +
'</span>' +
'</label>';
}).join('') +
'</div>' +
'<div class="wolai-page-ai-context-popover-foot">' +
'<span>授权区域</span>' +
'<span>' + escapeHtml(pageAiBuildAllowedRoots().length ? pageAiBuildAllowedRoots().map(function(root) { return root.permission + ' · ' + root.rootUri.replace(/^file:\/\//, ''); }).join(' / ') : '未选择授权区域') + '</span>' +
'</div>';
}
var allowedRootsNode = drawer.querySelector('[data-page-ai-allowed-roots]');
if (allowedRootsNode instanceof HTMLElement) {
@@ -2424,29 +2808,40 @@ export function createSidebarPageAiRuntime(context) {
if (skillList instanceof HTMLElement) {
var skills = pageAiFilteredSkillEntries();
if (!skills.length) {
var emptyText = String(pageUiState.pageAiAcpRuntime || 'reasonix').trim() === 'reasonix'
? '没有匹配的 Reasonix skill。'
: '没有匹配的 Hermes skill。';
skillList.innerHTML = '<div class="wolai-page-ai-empty">' + escapeHtml(emptyText) + '</div>';
skillList.innerHTML = '<div class="wolai-page-ai-empty">没有匹配的技能。</div>';
} 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';
var groupLabels = { mnote: 'MNote 内置技能', reasonix: 'Reasonix 技能', hermes: 'Hermes 技能' };
skillList.innerHTML = ['mnote', 'reasonix', 'hermes'].map(function(group) {
var groupSkills = skills.filter(function(skill) { return skill.group === group; });
if (!groupSkills.length) return '';
var headingExtra = group === 'hermes' ? ' · ' + pageAiCurrentProfile() : '';
var collapsed = pageAiSkillGroupCollapsed(group);
return '' +
'<div class="wolai-page-ai-skill-row">' +
'<div class="wolai-page-ai-skill-copy">' +
'<div class="wolai-page-ai-skill-main">' +
'<div class="wolai-page-ai-skill-name">' + escapeHtml(skill.name) + '</div>' +
'<div class="wolai-page-ai-skill-source">' + escapeHtml(sourceText) + '</div>' +
'</div>' +
(hasDescription ? '<div class="wolai-page-ai-skill-desc">' + escapeHtml(description) + '</div>' : '') +
'</div>' +
'<button type="button" class="wolai-page-ai-skill-switch' + (skill.enabled !== false ? ' is-on' : '') + '" data-page-ai-skill-toggle="' + escapeHtml(skill.name) + '" aria-pressed="' + (skill.enabled !== false ? 'true' : 'false') + '"' + (canToggle ? '' : ' disabled title="Reasonix skills 当前为只读展示"') + '>' +
'<span></span>' +
'<section class="wolai-page-ai-skill-group' + (collapsed ? ' is-collapsed' : '') + '" data-page-ai-skill-group="' + escapeHtml(group) + '">' +
'<button type="button" class="wolai-page-ai-skill-group-head" data-page-ai-skill-group-toggle="' + escapeHtml(group) + '" aria-expanded="' + (collapsed ? 'false' : 'true') + '">' +
'<span class="wolai-page-ai-skill-group-title"><span class="wolai-page-ai-skill-group-chevron">' + (collapsed ? '' : '⌄') + '</span>' + escapeHtml(groupLabels[group] || group) + '</span>' +
'<span>' + escapeHtml(String(groupSkills.length) + headingExtra) + '</span>' +
'</button>' +
'</div>';
(collapsed ? '' : groupSkills.map(function(skill) {
var sourceText = pageAiSkillOriginLabel(skill) + (skill.modified ? ' · modified' : '');
var description = String(skill.description || '').trim();
var hasDescription = description && description !== '---' && description !== '无描述';
var displayName = String(skill.title || skill.name || skill.id || '').trim();
return '' +
'<div class="wolai-page-ai-skill-row">' +
'<div class="wolai-page-ai-skill-copy">' +
'<div class="wolai-page-ai-skill-main">' +
'<div class="wolai-page-ai-skill-name">' + escapeHtml(displayName) + '</div>' +
'<div class="wolai-page-ai-skill-source">' + escapeHtml(sourceText) + '</div>' +
'</div>' +
(hasDescription ? '<div class="wolai-page-ai-skill-desc">' + escapeHtml(description) + '</div>' : '') +
'</div>' +
'<button type="button" class="wolai-page-ai-skill-switch' + (pageAiSkillEnabled(skill) ? ' is-on' : '') + '" data-page-ai-skill-toggle="' + escapeHtml(skill.id || skill.name) + '" data-page-ai-skill-group="' + escapeHtml(group) + '" data-page-ai-skill-profile="' + escapeHtml(skill.profile || '') + '" aria-pressed="' + (pageAiSkillEnabled(skill) ? 'true' : 'false') + '"' + (skill.toggleable === false ? ' disabled' : '') + '>' +
'<span></span>' +
'</button>' +
'</div>';
}).join('')) +
'</section>';
}).join('');
}
}
@@ -2574,6 +2969,14 @@ export function createSidebarPageAiRuntime(context) {
'</div>' +
'</div>' +
'<div class="wolai-page-ai-header-actions">' +
'<button type="button" class="wolai-page-ai-icon" data-page-ai-tab="skills" aria-label="技能" title="技能">' +
'<svg class="wolai-page-ai-icon-svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false">' +
'<path d="M12 3l1.35 4.15L17.5 8.5l-4.15 1.35L12 14l-1.35-4.15L6.5 8.5l4.15-1.35L12 3z" />' +
'<path d="M18.5 13l.8 2.4 2.2.8-2.2.8-.8 2.4-.8-2.4-2.2-.8 2.2-.8.8-2.4z" />' +
'<path d="M5.5 14l.65 1.85L8 16.5l-1.85.65L5.5 19l-.65-1.85L3 16.5l1.85-.65L5.5 14z" />' +
'</svg>' +
'</button>' +
'<button type="button" class="wolai-page-ai-icon" data-page-ai-action="history" aria-label="历史会话">⌕</button>' +
'<button type="button" class="wolai-page-ai-icon" data-page-ai-tab="agent" aria-label="打开页面 AI 设置">⚙</button>' +
'<button type="button" class="wolai-page-ai-icon" data-page-ai-action="close" aria-label="关闭页面 AI">×</button>' +
'</div>' +
@@ -2581,9 +2984,9 @@ export function createSidebarPageAiRuntime(context) {
'<div class="wolai-page-ai-body">' +
'<section class="wolai-page-ai-panel-section" data-page-ai-panel="chat">' +
'<div class="wolai-page-ai-chat-meta">' +
'<button type="button" class="wolai-page-ai-session-button" data-page-ai-action="history">' +
'<span class="wolai-page-ai-session-summary">' +
'<span data-page-ai-session-status>等待 AI session</span>' +
'</button>' +
'</span>' +
'<span data-page-ai-context-scope-label>当前页</span>' +
'<span data-page-ai-run-status>空闲</span>' +
'</div>' +
@@ -2681,6 +3084,14 @@ export function createSidebarPageAiRuntime(context) {
'<span>搜索技能</span>' +
'<input type="search" data-page-ai-skill-search placeholder="搜索技能…" />' +
'</label>' +
'<label class="wolai-page-ai-profile-select" data-page-ai-skills-hermes-profile>' +
'<span>Hermes profile</span>' +
'<select data-page-ai-profile-select></select>' +
'</label>' +
'<label class="wolai-page-ai-skill-filter-toggle">' +
'<input type="checkbox" data-page-ai-hide-hermes-builtin' + (pageAiHideHermesBuiltinSkills(pageAiCurrentProfile()) ? ' checked' : '') + ' />' +
'<span>隐藏 Hermes 内置</span>' +
'</label>' +
'</div>' +
'<div class="wolai-page-ai-inline-error" data-page-ai-skill-error hidden></div>' +
'<div class="wolai-page-ai-skill-list" data-page-ai-skill-list></div>' +
@@ -2742,13 +3153,17 @@ export function createSidebarPageAiRuntime(context) {
'</div>' +
'<div class="wolai-page-ai-footer">' +
'<div class="wolai-page-ai-composer">' +
'<div class="wolai-page-ai-agent-selector" data-page-ai-agent-selector></div>' +
'<div class="wolai-page-ai-context-refs" data-page-ai-context-refs></div>' +
'<div class="wolai-page-ai-allowed-roots" data-page-ai-allowed-roots></div>' +
'<textarea class="wolai-page-ai-input" data-page-ai-input rows="3" placeholder="输入消息…"></textarea>' +
'<div class="wolai-page-ai-composer-bar">' +
'<button type="button" class="wolai-page-ai-tool-button" data-page-ai-action="new-session" title="新会话"></button>' +
'<button type="button" class="wolai-page-ai-tool-button" data-page-ai-action="history" title="历史会话">⌕</button>' +
'<div class="wolai-page-ai-agent-picker">' +
'<button type="button" class="wolai-page-ai-tool-button wolai-page-ai-agent-button" data-page-ai-agent-button aria-expanded="false" aria-label="AgentReasonix" title="AgentReasonix">AI</button>' +
'<div class="wolai-page-ai-agent-popover" data-page-ai-agent-popover hidden></div>' +
'</div>' +
'<div class="wolai-page-ai-context-picker">' +
'<button type="button" class="wolai-page-ai-tool-button wolai-page-ai-context-button" data-page-ai-context-button aria-expanded="false" aria-label="上下文:当前页 + 打开资源" title="上下文:当前页 + 打开资源">⇅</button>' +
'<div class="wolai-page-ai-context-popover" data-page-ai-context-popover hidden></div>' +
'</div>' +
'<span class="wolai-page-ai-composer-spacer"></span>' +
'<button type="button" class="wolai-page-ai-stop" data-page-ai-action="stop-run" disabled>停止</button>' +
'<button type="button" class="wolai-page-ai-send" data-page-ai-action="send" aria-label="发送">发送</button>' +
@@ -3177,13 +3592,18 @@ export function createSidebarPageAiRuntime(context) {
contextScope: pageUiState.pageAiContextScope,
contextRefs: contextRefs,
allowedRoots: allowedRoots,
skillPreferences: {
mnote: pageAiSkillPreferenceTable('mnote', ''),
reasonix: pageAiSkillPreferenceTable('reasonix', ''),
hermes: pageAiSkillPreferenceTable('hermes', pageAiCurrentProfile())
},
message: prompt,
model: pageAiMnoteToolModel(),
pageContext: requestPageContext,
editorTarget: scopedContext.editorTarget,
runTargetSnapshot: runTargetSnapshot,
selectedBlockId: scopedContext.selectedBlockId,
selectedText: pageAiContextKindsFromRefs(contextRefs).selection ? scopedContext.selectedText : '',
selectedText: '',
traceId: 'page-ai-run-' + Date.now().toString(36)
})
});
@@ -3282,6 +3702,9 @@ export function createSidebarPageAiRuntime(context) {
if (completedSession) completedSession.usage = completed.usage;
}
var agentAudit = completed && completed.agentAudit && typeof completed.agentAudit === 'object' ? completed.agentAudit : null;
var agentRunReceipt = agentAudit && agentAudit.agentRunReceipt && typeof agentAudit.agentRunReceipt === 'object'
? agentAudit.agentRunReceipt
: null;
var changedFiles = pageAiNormalizeArray(agentAudit && agentAudit.changedFiles).map(function(file) {
if (!file || typeof file !== 'object') return file;
return Object.assign({
@@ -3307,26 +3730,10 @@ export function createSidebarPageAiRuntime(context) {
traceId: runTraceId,
auditId: String(agentAudit.eventId || '')
});
}
if (agentRunReceipt || changedFiles.length) {
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'
}
}));
}
pageAiDispatchReceiptRefresh(agentRunReceipt, changedFiles, agentAudit, runId, runTraceId);
} catch (_) {}
}
} catch (_) {}
@@ -3447,9 +3854,13 @@ export function createSidebarPageAiRuntime(context) {
pageAiLoadProfileMemory: (...args) => pageAiLoadProfileMemory(...args),
pageAiLoadSkills: (...args) => pageAiLoadSkills(...args),
pageAiSwitchProfile: (...args) => pageAiSwitchProfile(...args),
pageAiToggleSkillGroup: (...args) => pageAiToggleSkillGroup(...args),
pageAiSetHideHermesBuiltinSkills: (...args) => pageAiSetHideHermesBuiltinSkills(...args),
pageAiSetContextScope: (...args) => pageAiSetContextScope(...args),
pageAiSetAgentId: (...args) => pageAiSetAgentId(...args),
pageAiToggleContextRef: (...args) => pageAiToggleContextRef(...args),
pageAiSetContextPopoverOpen: (...args) => pageAiSetContextPopoverOpen(...args),
pageAiSetAgentPopoverOpen: (...args) => pageAiSetAgentPopoverOpen(...args),
pageAiLoadAllowedRoots: (...args) => pageAiLoadAllowedRoots(...args),
updatePageAiTriggerState: (...args) => updatePageAiTriggerState(...args)
};
@@ -59,8 +59,12 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
pageAiProfileMemoryDrafts: { memory: '', user: '', soul: '' },
pageAiProfileMemoryError: '',
pageAiSkills: { categories: [], archived: [] },
pageAiSkillCatalogs: { mnote: { categories: [], archived: [] }, reasonix: { categories: [], archived: [] }, hermes: { categories: [], archived: [] } },
pageAiSkillPreferences: {},
pageAiCollapsedSkillGroups: {},
pageAiSkillQuery: '',
pageAiSkillError: '',
pageAiSkillLoadSeq: 0,
pageAiSessions: [],
pageAiActiveSessionId: '',
pageAiSessionSearchQuery: '',
@@ -2378,9 +2382,12 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
const pageAiLoadProfileMemory = (...args) => sidebarPageAi.pageAiLoadProfileMemory(...args);
const pageAiLoadSkills = (...args) => sidebarPageAi.pageAiLoadSkills(...args);
const pageAiSwitchProfile = (...args) => sidebarPageAi.pageAiSwitchProfile(...args);
const pageAiToggleSkillGroup = (...args) => sidebarPageAi.pageAiToggleSkillGroup(...args);
const pageAiSetHideHermesBuiltinSkills = (...args) => sidebarPageAi.pageAiSetHideHermesBuiltinSkills(...args);
const pageAiSetContextScope = (...args) => sidebarPageAi.pageAiSetContextScope(...args);
const pageAiSetAgentId = (...args) => sidebarPageAi.pageAiSetAgentId(...args);
const pageAiToggleContextRef = (...args) => sidebarPageAi.pageAiToggleContextRef(...args);
const pageAiSetAgentPopoverOpen = (...args) => sidebarPageAi.pageAiSetAgentPopoverOpen(...args);
const updatePageAiTriggerState = (...args) => sidebarPageAi.updatePageAiTriggerState(...args);
const sidebarAttachmentOpen = createSidebarAttachmentOpenRuntime({
@@ -2593,6 +2600,36 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
return;
}
var pageAiAgentButton = closestAction(e.target, '[data-page-ai-agent-button]');
if (pageAiAgentButton) {
e.preventDefault();
pageAiSetAgentPopoverOpen(pageAiAgentButton.getAttribute('aria-expanded') !== 'true');
return;
}
var pageAiAgentPopoverClose = closestAction(e.target, '[data-page-ai-action="close-agent-popover"]');
if (pageAiAgentPopoverClose) {
e.preventDefault();
pageAiSetAgentPopoverOpen(false);
return;
}
var pageAiContextButton = closestAction(e.target, '[data-page-ai-context-button]');
if (pageAiContextButton) {
e.preventDefault();
sidebarPageAi.pageAiSetContextPopoverOpen(
pageAiContextButton.getAttribute('aria-expanded') !== 'true'
);
return;
}
var pageAiContextPopoverClose = closestAction(e.target, '[data-page-ai-action="close-context-popover"]');
if (pageAiContextPopoverClose) {
e.preventDefault();
sidebarPageAi.pageAiSetContextPopoverOpen(false);
return;
}
var pageAiContextRef = closestAction(e.target, '[data-page-ai-context-ref]');
if (pageAiContextRef) {
e.preventDefault();
@@ -2611,8 +2648,17 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
if (pageAiSkillToggle) {
e.preventDefault();
var skillName = pageAiSkillToggle.getAttribute('data-page-ai-skill-toggle') || '';
var skillGroup = pageAiSkillToggle.getAttribute('data-page-ai-skill-group') || '';
var skillProfile = pageAiSkillToggle.getAttribute('data-page-ai-skill-profile') || '';
var nextEnabled = pageAiSkillToggle.getAttribute('aria-pressed') !== 'true';
void pageAiToggleSkill(skillName, nextEnabled);
void pageAiToggleSkill(skillName, nextEnabled, skillGroup, skillProfile);
return;
}
var pageAiSkillGroupToggle = closestAction(e.target, '[data-page-ai-skill-group-toggle]');
if (pageAiSkillGroupToggle) {
e.preventDefault();
pageAiToggleSkillGroup(pageAiSkillGroupToggle.getAttribute('data-page-ai-skill-group-toggle') || '');
return;
}
@@ -3128,6 +3174,11 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
void pageAiSwitchProfile(pageAiProfileSelect.value);
return;
}
var pageAiHideHermesBuiltin = closestAction(event.target, '[data-page-ai-hide-hermes-builtin]');
if (pageAiHideHermesBuiltin instanceof HTMLInputElement) {
pageAiSetHideHermesBuiltinSkills(pageAiHideHermesBuiltin.checked);
return;
}
var pageAiContextSelect = closestAction(event.target, '[data-page-ai-context-scope]');
if (pageAiContextSelect instanceof HTMLSelectElement) {
pageAiSetContextScope(pageAiContextSelect.value);
@@ -68,6 +68,7 @@ pub struct AcpMnoteToolContext {
pub trace_id: Option<String>,
pub workspace_id: Option<String>,
pub document_id: Option<String>,
pub mnote_capabilities: Option<Value>,
}
/// Handler for session events.
@@ -567,6 +568,7 @@ impl AcpSessionManager {
trace_id: mnote_context.trace_id,
workspace_id: mnote_context.workspace_id,
document_id: mnote_context.document_id,
mnote_capabilities: mnote_context.mnote_capabilities,
};
debug!("ACP session/prompt (session={})", session_id);
+2
View File
@@ -193,6 +193,8 @@ pub struct SessionPromptParams {
pub workspace_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub document_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mnote_capabilities: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -0,0 +1,127 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::{doc, ToolCallInput};
use serde_json::{json, Value};
pub async fn context_snapshot(
_state: &AppState,
_context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let context_refs = input
.arg_value("contextRefs")
.or_else(|| input.arg_value("context_refs"))
.unwrap_or_else(|| json!([]));
Ok(json!({
"ok": true,
"schema": "mnote.context_snapshot.v1",
"runId": input.run_id,
"sessionId": input.session_id,
"workspace": {
"workspaceId": input.effective_workspace_id(),
"sourceKind": input.effective_source_kind(),
"rootUri": input.effective_root_uri()
},
"contextRefs": context_refs,
"primaryTarget": {
"documentId": input.effective_document_id(),
"fileVersion": input.arg_value("fileVersion").or_else(|| input.arg_value("file_version"))
},
"availableReads": {
"currentPage": context_ref_enabled(input, "current_page"),
"selection": context_ref_enabled(input, "selection"),
"folder": context_ref_enabled(input, "folder"),
"changedFiles": context_ref_enabled(input, "changed_files")
}
}))
}
pub async fn resolve_target(
_state: &AppState,
_context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
Ok(json!({
"ok": true,
"schema": "mnote.context_target.v1",
"workspaceId": input.effective_workspace_id(),
"documentId": input.effective_document_id(),
"sourceKind": input.effective_source_kind(),
"rootUri": input.effective_root_uri(),
"relativePath": input.arg_string("relativePath").or_else(|| input.arg_string("relative_path")),
"fileVersion": input.arg_value("fileVersion").or_else(|| input.arg_value("file_version")),
"contextRefs": input.arg_value("contextRefs").or_else(|| input.arg_value("context_refs")).unwrap_or_else(|| json!([]))
}))
}
pub async fn read_current_page(
state: &AppState,
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
if !context_ref_enabled(input, "current_page") {
return Err(WebError::new(
axum::http::StatusCode::FORBIDDEN,
"mnote_context_current_page_not_allowed",
"本次 run 未授权 current_page 上下文",
)
.with_context(context));
}
doc::doc_fetch(state, context, input).await
}
fn context_ref_enabled(input: &ToolCallInput, expected: &str) -> bool {
input
.arg_value("contextRefs")
.or_else(|| input.arg_value("context_refs"))
.and_then(|value| value.as_array().cloned())
.map(|items| {
items.iter().any(|item| {
item.as_str() == Some(expected)
|| item.get("kind").and_then(Value::as_str).map(str::trim) == Some(expected)
})
})
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
fn input_with_refs(context_refs: Value) -> ToolCallInput {
ToolCallInput {
tool_name: "mnote.context.read_current_page".into(),
workspace_id: Some("ws_1".into()),
document_id: Some("doc_1".into()),
source_kind: Some("local_folder".into()),
root_uri: Some("file:///tmp/mnote".into()),
actor_id: Some("user_1".into()),
profile: None,
session_id: Some("sess_1".into()),
run_id: Some("run_1".into()),
tool_call_id: Some("tool_1".into()),
trace_id: Some("trace_1".into()),
idempotency_key: None,
dry_run: None,
capability_scope: Some(vec!["context.read".into()]),
args: Some(json!({ "contextRefs": context_refs })),
}
}
#[test]
fn context_ref_enabled_accepts_string_and_object_refs() {
assert!(context_ref_enabled(
&input_with_refs(json!(["current_page"])),
"current_page"
));
assert!(context_ref_enabled(
&input_with_refs(json!([{ "kind": "current_page" }])),
"current_page"
));
assert!(!context_ref_enabled(
&input_with_refs(json!(["selection"])),
"current_page"
));
}
}
@@ -13,6 +13,10 @@ pub fn manifest() -> Value {
"writeOwner": "rust-runtime-kernel"
},
"tools": [
skill_read_tool(),
context_snapshot_tool(),
context_read_current_page_tool(),
context_resolve_target_tool(),
doc_fetch_tool(),
doc_find_tool(),
block_fetch_tool(),
@@ -37,6 +41,95 @@ pub fn manifest() -> Value {
})
}
fn skill_read_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
map.insert("skillId".into(), json!({ "type": "string" }));
map.insert("agentId".into(), json!({ "type": "string" }));
}
json!({
"name": "mnote.skill.read",
"description": "按需读取 MNote skill 正文。默认 prompt 只列 skill 摘要,正文必须通过本工具懒加载。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["skill.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"required": ["skillId"],
"properties": properties
}
})
}
fn context_snapshot_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
map.insert(
"contextRefs".into(),
json!({ "type": "array", "items": { "type": ["string", "object"] } }),
);
}
json!({
"name": "mnote.context.snapshot",
"description": "返回本次 run 可用的 MNote 上下文摘要,不返回页面正文全文。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["context.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"properties": properties
}
})
}
fn context_read_current_page_tool() -> Value {
let mut tool = doc_fetch_tool();
if let Value::Object(map) = &mut tool {
map.insert(
"name".into(),
Value::String("mnote.context.read_current_page".into()),
);
map.insert(
"description".into(),
Value::String("在 current_page contextRef 被授权时读取当前 Markdown 页面。".into()),
);
map.insert(
"capabilityScope".into(),
json!(["context.read", "page.read"]),
);
}
tool
}
fn context_resolve_target_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
map.insert(
"contextRefs".into(),
json!({ "type": "array", "items": { "type": ["string", "object"] } }),
);
map.insert("relativePath".into(), json!({ "type": "string" }));
map.insert(
"fileVersion".into(),
json!({ "type": ["string", "object"] }),
);
}
json!({
"name": "mnote.context.resolve_target",
"description": "解析当前 Page AI run 的工作区、文档、rootUri、relativePath 与 file version。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["context.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"properties": properties
}
})
}
fn base_identity_properties() -> Value {
json!({
"workspaceId": { "type": "string" },
@@ -1,9 +1,11 @@
pub mod artifact;
pub mod block;
pub mod context_tools;
pub mod doc;
pub mod manifest;
pub mod page;
pub mod resource;
pub mod skill;
use crate::context::RequestContext;
use crate::error::WebError;
@@ -0,0 +1,149 @@
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::ToolCallInput;
use axum::http::StatusCode;
use serde_json::{json, Value};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MnoteSkill {
pub id: &'static str,
pub title: &'static str,
pub description: &'static str,
pub agent_ids: &'static [&'static str],
pub read_only: bool,
pub requires_context_refs: &'static [&'static str],
pub tool_names: &'static [&'static str],
pub content: &'static str,
}
const SKILLS: &[MnoteSkill] = &[
MnoteSkill {
id: "mnote-current-page",
title: "MNote current page",
description: "Read the current MNote Markdown page only when the task needs page content.",
agent_ids: &["hermes", "reasonix"],
read_only: true,
requires_context_refs: &["current_page"],
tool_names: &[
"mnote.context.snapshot",
"mnote.context.resolve_target",
"mnote.context.read_current_page",
],
content: include_str!("../../../../../skills/mnote-current-page/SKILL.md"),
},
MnoteSkill {
id: "mnote-local-file",
title: "MNote local file editing",
description: "Read and patch local Markdown files inside MNote allowed roots.",
agent_ids: &["hermes", "reasonix"],
read_only: false,
requires_context_refs: &["current_page", "file", "folder"],
tool_names: &["mnote.context.snapshot", "mnote.context.resolve_target"],
content: include_str!("../../../../../skills/mnote-local-file/SKILL.md"),
},
MnoteSkill {
id: "mnote-chat-only",
title: "MNote chat only",
description: "Reply conversationally without reading or writing MNote page/file context.",
agent_ids: &["chat_only", "hermes", "reasonix"],
read_only: true,
requires_context_refs: &[],
tool_names: &[],
content: include_str!("../../../../../skills/mnote-chat-only/SKILL.md"),
},
];
pub fn skill_summaries_for_agent(agent_id: Option<&str>) -> Vec<Value> {
SKILLS
.iter()
.filter(|skill| skill_matches_agent(skill, agent_id))
.map(skill_summary)
.collect()
}
pub fn find_skill(skill_id: &str, agent_id: Option<&str>) -> Option<&'static MnoteSkill> {
let requested = skill_id.trim();
SKILLS
.iter()
.find(|skill| skill.id == requested && skill_matches_agent(skill, agent_id))
}
pub async fn skill_read(
context: &RequestContext,
input: &ToolCallInput,
) -> Result<Value, WebError> {
let skill_id = input
.arg_string("skillId")
.or_else(|| input.arg_string("skill_id"))
.ok_or_else(|| {
WebError::bad_request_code("mnote_skill_id_required", "缺少 skillId")
.with_context(context)
})?;
let agent_id = input
.arg_string("agentId")
.or_else(|| input.arg_string("agent_id"));
let skill = find_skill(&skill_id, agent_id.as_deref()).ok_or_else(|| {
WebError::new(
StatusCode::NOT_FOUND,
"mnote_skill_not_found",
"未知或当前 agent 不可用的 MNote skill",
)
.with_context(context)
})?;
Ok(json!({
"ok": true,
"schema": "mnote.skill.v1",
"skill": skill_summary(skill),
"content": skill.content,
"tools": skill.tool_names,
"constraints": {
"allowedRootsRequired": skill.requires_context_refs.contains(&"folder"),
"mustReadBackAfterWrite": !skill.read_only
}
}))
}
fn skill_matches_agent(skill: &MnoteSkill, agent_id: Option<&str>) -> bool {
let Some(agent_id) = agent_id.map(str::trim).filter(|value| !value.is_empty()) else {
return true;
};
skill
.agent_ids
.iter()
.any(|candidate| *candidate == agent_id)
}
fn skill_summary(skill: &MnoteSkill) -> Value {
json!({
"id": skill.id,
"title": skill.title,
"description": skill.description,
"agentIds": skill.agent_ids,
"readOnly": skill.read_only,
"requiresContextRefs": skill.requires_context_refs,
"toolNames": skill.tool_names
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn skill_registry_filters_by_agent() {
let chat_skills = skill_summaries_for_agent(Some("chat_only"));
assert!(chat_skills
.iter()
.any(|skill| skill["id"] == "mnote-chat-only"));
assert!(!chat_skills
.iter()
.any(|skill| skill["id"] == "mnote-local-file"));
}
#[test]
fn skill_lookup_rejects_unknown_or_agent_mismatch() {
assert!(find_skill("mnote-current-page", Some("reasonix")).is_some());
assert!(find_skill("mnote-local-file", Some("chat_only")).is_none());
assert!(find_skill("missing", Some("reasonix")).is_none());
}
}
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,9 @@ use super::hermes_client;
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::{artifact, block, doc, manifest, page, resource, ToolCallInput};
use crate::hermes_tools::{
artifact, block, context_tools, doc, manifest, page, resource, skill, ToolCallInput,
};
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::Json;
@@ -347,6 +349,14 @@ pub(crate) async fn execute_mnote_tool_call(
return Ok(cached);
}
let result = match input.tool_name.as_str() {
"mnote.skill.read" => skill::skill_read(&context, &input).await,
"mnote.context.snapshot" => context_tools::context_snapshot(&state, &context, &input).await,
"mnote.context.read_current_page" => {
context_tools::read_current_page(&state, &context, &input).await
}
"mnote.context.resolve_target" => {
context_tools::resolve_target(&state, &context, &input).await
}
"mnote.doc.fetch" => doc::doc_fetch(&state, &context, &input).await,
"mnote.doc.find" => doc::doc_find(&state, &context, &input).await,
"mnote.doc.plan_update" => doc::plan_update(&state, &context, &input).await,
@@ -538,6 +548,10 @@ fn is_read_tool(tool_name: &str) -> bool {
matches!(
tool_name,
"mnote.page.get"
| "mnote.skill.read"
| "mnote.context.snapshot"
| "mnote.context.read_current_page"
| "mnote.context.resolve_target"
| "mnote.doc.fetch"
| "mnote.doc.find"
| "mnote.block.fetch"
@@ -1359,6 +1373,80 @@ mod tests {
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn hermes_tools_context_read_current_page_reads_local_markdown() {
let root = std::env::temp_dir().join(format!(
"mnote-context-read-current-page-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).expect("root");
fs::write(
root.join("README.md"),
"# 当前页\n\n来自 mnote.context.read_current_page 的正文。\n",
)
.expect("markdown");
let root_uri = format!("file://{}", root.display());
crate::routes::local_folder_source::initialize_local_workspace_for_actor(
"user_1", &root_uri,
)
.expect("workspace");
let response = app()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/hermes/tools/mnote/call")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_1")
.header("x-mnote-actor-type", "user")
.body(Body::from(
json!({
"toolName": "mnote.context.read_current_page",
"workspaceId": "local-ws-context",
"documentId": "local-md:README.md",
"sourceKind": "local_folder",
"rootUri": root_uri,
"profile": "reasonix",
"actorId": "user_1",
"sessionId": "sess_context_read",
"runId": "run_context_read",
"toolCallId": "call_context_read",
"traceId": "trace_context_read",
"args": {
"format": "markdown",
"contextRefs": [{ "kind": "current_page" }],
"aiAccessScope": {
"permissionLevel": "read_write",
"allowedRoots": [{ "rootUri": root_uri, "permission": "write" }],
"allowedResourceIds": ["local-md:README.md"]
}
}
})
.to_string(),
))
.expect("request"),
)
.await
.expect("response");
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(status, StatusCode::OK, "{payload}");
assert_eq!(payload["ok"], true);
assert_eq!(payload["result"]["ok"], true);
assert!(
payload["result"]["content"]
.as_str()
.unwrap_or_default()
.contains("mnote.context.read_current_page"),
"{payload}"
);
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn hermes_tools_mindmap_fetch_reads_authorized_local_resource() {
let root = std::env::temp_dir().join(format!(
+200 -15
View File
@@ -3791,6 +3791,9 @@ body {
.wolai-page-ai-icon {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 6px;
background: transparent;
@@ -3800,6 +3803,13 @@ body {
cursor: pointer;
}
.wolai-page-ai-icon-svg {
width: 16px;
height: 16px;
display: block;
fill: currentColor;
}
.wolai-page-ai-icon:hover,
.wolai-page-ai-icon.is-active {
background: #F3F3F2;
@@ -3878,7 +3888,8 @@ body {
.wolai-page-ai-profile-select,
.wolai-page-ai-context-select,
.wolai-page-ai-skill-search {
.wolai-page-ai-skill-search,
.wolai-page-ai-agent-profile {
display: grid;
gap: 4px;
min-width: 0;
@@ -3886,7 +3897,8 @@ body {
.wolai-page-ai-profile-select select,
.wolai-page-ai-context-select select,
.wolai-page-ai-skill-search input {
.wolai-page-ai-skill-search input,
.wolai-page-ai-agent-profile select {
width: 100%;
min-width: 0;
height: 34px;
@@ -3898,6 +3910,10 @@ body {
font-size: 12px;
}
.wolai-page-ai-agent-profile {
margin-top: 8px;
}
.wolai-page-ai-inline-error {
padding: 8px 10px;
border: 1px solid rgba(198, 80, 80, 0.18);
@@ -3943,23 +3959,15 @@ body {
line-height: 16px;
}
.wolai-page-ai-session-button {
.wolai-page-ai-session-summary {
min-width: 0;
max-width: 190px;
overflow: hidden;
padding: 0;
border: 0;
background: transparent;
color: #5A5A5A;
font: inherit;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.wolai-page-ai-session-button:hover {
color: #1B1C1C;
}
.wolai-page-ai-settings-head {
@@ -4171,10 +4179,29 @@ body {
}
.wolai-page-ai-skills-toolbar {
display: flex;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(124px, 160px) max-content;
align-items: end;
gap: 8px;
}
.wolai-page-ai-skill-filter-toggle {
display: inline-flex;
min-height: 34px;
align-items: center;
gap: 6px;
color: #5A5A5A;
font-size: 12px;
line-height: 18px;
white-space: nowrap;
}
.wolai-page-ai-skill-filter-toggle input {
width: 14px;
height: 14px;
margin: 0;
}
.wolai-page-ai-skill-list {
display: flex;
min-height: 0;
@@ -4184,6 +4211,45 @@ body {
overflow: auto;
}
.wolai-page-ai-skill-group {
display: grid;
gap: 4px;
}
.wolai-page-ai-skill-group-head {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 8px 2px;
border: 0;
border-radius: 6px;
background: transparent;
color: #8B8782;
font-size: 11px;
line-height: 16px;
cursor: pointer;
}
.wolai-page-ai-skill-group-head:hover {
background: #F7F7F6;
color: #5A5A5A;
}
.wolai-page-ai-skill-group-title {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 5px;
}
.wolai-page-ai-skill-group-chevron {
width: 12px;
color: #AAA6A0;
text-align: center;
}
.wolai-page-ai-skill-row {
display: flex;
min-height: 38px;
@@ -4385,6 +4451,8 @@ button.wolai-page-ai-message-text {
}
.wolai-page-ai-footer {
position: relative;
z-index: 20;
display: flex;
flex-direction: column;
gap: 8px;
@@ -4412,16 +4480,16 @@ button.wolai-page-ai-message-text {
}
.wolai-page-ai-composer {
position: relative;
z-index: 20;
display: flex;
flex-direction: column;
overflow: hidden;
overflow: visible;
border: 1px solid rgba(27, 28, 28, 0.12);
border-radius: 10px;
background: #FFF;
}
.wolai-page-ai-agent-selector,
.wolai-page-ai-context-refs,
.wolai-page-ai-allowed-roots {
display: flex;
flex-wrap: wrap;
@@ -4430,6 +4498,123 @@ button.wolai-page-ai-message-text {
padding: 6px 8px 0;
}
.wolai-page-ai-agent-picker,
.wolai-page-ai-context-picker {
position: relative;
flex: 0 0 auto;
}
.wolai-page-ai-agent-button,
.wolai-page-ai-context-button {
font-size: 17px;
line-height: 1;
}
.wolai-page-ai-agent-button[aria-expanded="true"],
.wolai-page-ai-context-button[aria-expanded="true"] {
border-color: rgba(27, 28, 28, 0.32);
background: #F7F6F4;
}
.wolai-page-ai-agent-popover,
.wolai-page-ai-context-popover {
position: absolute;
left: 0;
width: min(320px, calc(100vw - 44px));
bottom: calc(100% + 8px);
z-index: 30;
display: grid;
gap: 10px;
padding: 12px;
border: 1px solid rgba(27, 28, 28, 0.12);
border-radius: 10px;
background: #FFF;
box-shadow: 0 16px 36px rgba(27, 28, 28, 0.16);
}
.wolai-page-ai-agent-popover[hidden],
.wolai-page-ai-context-popover[hidden] {
display: none !important;
}
.wolai-page-ai-context-popover-head,
.wolai-page-ai-context-popover-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: #5A5A5A;
font-size: 12px;
}
.wolai-page-ai-context-option-list {
display: grid;
gap: 8px;
}
.wolai-page-ai-agent-option-list {
display: grid;
gap: 6px;
}
.wolai-page-ai-agent-option {
display: grid;
gap: 2px;
width: 100%;
padding: 8px;
border: 1px solid rgba(27, 28, 28, 0.08);
border-radius: 8px;
background: #FFF;
color: #1B1C1C;
text-align: left;
cursor: pointer;
}
.wolai-page-ai-agent-option.is-active {
border-color: rgba(27, 28, 28, 0.2);
background: #F7F6F4;
}
.wolai-page-ai-agent-option-label {
font-size: 12px;
font-weight: 600;
}
.wolai-page-ai-agent-option-detail {
color: #8B8782;
font-size: 11px;
}
.wolai-page-ai-context-option {
display: grid;
grid-template-columns: 16px minmax(0, 1fr);
gap: 8px;
align-items: start;
color: #1B1C1C;
font-size: 12px;
}
.wolai-page-ai-context-option.is-disabled {
color: #AAA6A0;
}
.wolai-page-ai-context-option-main {
display: grid;
gap: 2px;
min-width: 0;
}
.wolai-page-ai-context-option-label {
font-weight: 500;
}
.wolai-page-ai-context-option-detail {
color: #8B8782;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wolai-page-ai-allowed-roots {
padding-top: 4px;
}