chore: checkpoint turso and ai runtime work

This commit is contained in:
Agent Board
2026-07-03 23:20:16 +08:00
parent a75b3d11f9
commit 36d027a4a1
67 changed files with 4224 additions and 914 deletions
@@ -1469,7 +1469,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
const isKnowledgeRagSourceEntry = (entry) => {
if (!entry || String(entry.sourceKind || '').trim() !== 'local_folder') return false;
if (!String(entry.rootUri || '').trim() || !String(entry.path || '').trim()) return false;
return entry.kind === 'image' || entry.kind === 'pdf';
return ['image', 'pdf', 'markdown', 'text', 'code'].includes(entry.kind);
};
const knowledgeRagProvider = () => {
@@ -1487,11 +1487,11 @@ export const createResourceTabRuntime = (dependencies = {}) => {
const statusNode = entry.panel.querySelector('[data-mnote-knowledge-rag-status-text]');
if (statusNode instanceof HTMLElement) {
statusNode.textContent = message || (
normalizedStatus === 'done' ? '资料库已索引'
: normalizedStatus === 'running' ? '资料库索引中'
: normalizedStatus === 'failed' ? '资料库索引失败'
: normalizedStatus === 'stale' ? '资料库需更新'
: '资料库未索引'
normalizedStatus === 'done' ? '知识库已索引'
: normalizedStatus === 'running' ? '知识库索引中'
: normalizedStatus === 'failed' ? '知识库索引失败'
: normalizedStatus === 'stale' ? '知识库需更新'
: '知识库未索引'
);
}
const openButton = entry.panel.querySelector('[data-mnote-knowledge-rag-action="open"]');
@@ -1523,7 +1523,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
artifactRootRelativePath: '',
provider: knowledgeRagProvider(),
status,
stageLabel: status === 'done' ? '资料库已索引' : status === 'failed' ? '资料库需重建' : '资料库索引中',
stageLabel: status === 'done' ? '知识库已索引' : status === 'failed' ? '知识库需重建' : '知识库索引中',
stale: registryEntry.stale === true,
updatedAtMs: Number(registryEntry.updatedAtMs || registryEntry.updated_at_ms || Date.now()),
finishedAtMs: status === 'done' ? Number(registryEntry.indexedAtMs || registryEntry.indexed_at_ms || Date.now()) : null,
@@ -1750,7 +1750,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
.filter((node) => node instanceof HTMLButtonElement);
taskToggles.forEach((toggle) => {
const opensSettings = toggle.getAttribute('data-mnote-action') === 'open-knowledge-rag-settings';
const label = runningCount > 0 ? `${runningCount}资料库索引处理中,打开资料库设置` : (jobs.length > 0 ? `${jobs.length}资料库任务,打开资料库设置` : '资料库设置');
const label = runningCount > 0 ? `${runningCount}知识库索引处理中,打开知识库设置` : (jobs.length > 0 ? `${jobs.length}知识库任务,打开知识库设置` : '知识库设置');
const taskLabel = runningCount > 0 ? `${runningCount} 个后台任务正在运行` : (jobs.length > 0 ? `${jobs.length} 个后台任务` : '后台任务');
toggle.setAttribute('title', opensSettings ? label : taskLabel);
toggle.setAttribute('aria-label', opensSettings ? label : taskLabel);
@@ -1811,7 +1811,7 @@ export const createResourceTabRuntime = (dependencies = {}) => {
row.setAttribute('data-mnote-knowledge-rag-task-row', String(job.sourceRootRelativePath || ''));
row.setAttribute('data-mnote-knowledge-rag-task-status', String(job.status || ''));
row.setAttribute('data-mnote-knowledge-rag-task-category', knowledgeRagTaskCategory(job));
const title = String(job.sourceRootRelativePath || '').split('/').filter(Boolean).pop() || '资料库索引';
const title = String(job.sourceRootRelativePath || '').split('/').filter(Boolean).pop() || '知识库索引';
const category = knowledgeRagTaskCategory(job);
const progress = knowledgeRagTaskProgress(job);
row.innerHTML = '<div class="mnote-knowledge-rag-task-main"><div class="mnote-knowledge-rag-task-title-line"><strong></strong><em></em></div><span></span><div class="mnote-knowledge-rag-task-progress" role="progressbar"><i></i></div></div><div class="mnote-knowledge-rag-task-actions"><button type="button" data-mnote-knowledge-rag-task-open>打开</button><button type="button" data-mnote-knowledge-rag-task-retry>重试</button><button type="button" data-mnote-knowledge-rag-task-clear>清除</button><button type="button" data-mnote-knowledge-rag-task-delete>删除</button></div>';
@@ -1909,9 +1909,9 @@ export const createResourceTabRuntime = (dependencies = {}) => {
const entryRootUri = String(entry.rootUri || '').trim();
if (entryRootUri) knowledgeRagTaskState.rootUri = entryRootUri;
const startedAt = Date.now();
const pendingJob = knowledgeRagTaskSnapshotFromEntry(entry, 'running', knowledgeRagProvider(), '资料库索引中', startedAt);
const pendingJob = knowledgeRagTaskSnapshotFromEntry(entry, 'running', knowledgeRagProvider(), '知识库索引中', startedAt);
pendingJob.taskKind = 'knowledge_rag';
setKnowledgeRagStatus(entry, 'running', '资料库索引中', pendingJob);
setKnowledgeRagStatus(entry, 'running', '知识库索引中', pendingJob);
updateKnowledgeRagTaskState(pendingJob);
const workspaceId = String(entry.workspaceId || currentWebShellWorkspaceId() || '').trim();
const body = {
@@ -1945,12 +1945,12 @@ export const createResourceTabRuntime = (dependencies = {}) => {
const job = knowledgeRagJobFromRegistryEntry(registryEntry, entryRootUri) || {
...pendingJob,
status: payload.retryRequired ? 'running' : 'done',
stageLabel: payload.retryRequired ? '知识库忙碌,等待重试' : '资料库已提交索引',
stageLabel: payload.retryRequired ? '知识库忙碌,等待重试' : '知识库已提交索引',
updatedAtMs: Date.now(),
finishedAtMs: payload.retryRequired ? null : Date.now(),
};
const displayStatus = knowledgeRagDisplayStatus(job, 'done');
setKnowledgeRagStatus(entry, displayStatus, displayStatus === 'stale' ? '资料库需更新' : statusTextForKnowledgeRagTask(job), job);
setKnowledgeRagStatus(entry, displayStatus, displayStatus === 'stale' ? '知识库需更新' : statusTextForKnowledgeRagTask(job), job);
if (job) updateKnowledgeRagTaskState(job);
window.dispatchEvent(new CustomEvent('mnote:knowledge-rag-source-updated', {
detail: { rootUri: entryRootUri, workspaceId, sourceRootRelativePath: String(entry.path || '').trim(), result: payload }
@@ -2003,6 +2003,25 @@ export const createResourceTabRuntime = (dependencies = {}) => {
const workspaceId = currentWebShellWorkspaceId();
const seen = new Set();
const candidates = [];
const rawDocumentId = String(documentId || '').trim();
const markdownPath = rawDocumentId.startsWith('local-md:') ? localMarkdownRelativePathFromDocumentId(rawDocumentId) : '';
if (markdownPath) {
seen.add(markdownPath);
const title = markdownPath.split('/').filter(Boolean).pop() || markdownPath;
candidates.push({
sourceKind: 'local_folder',
rootUri,
path: markdownPath,
kind: 'markdown',
title,
fileName: title,
documentId,
ownerDocumentId: documentId,
workspaceId,
objectIdentity: `local-md:${markdownPath}`,
assetId: `local-md:${markdownPath}`,
});
}
document.querySelectorAll(`.document-pane[data-pane-role="${role}"] .ProseMirror img`).forEach((image) => {
if (!(image instanceof HTMLImageElement)) return;
const sourcePath = localFileOpenPathFromTiptapHref(image.getAttribute('src') || image.src || '');
@@ -2146,13 +2165,13 @@ export const createResourceTabRuntime = (dependencies = {}) => {
});
});
}
setKnowledgeRagStatus(entry, 'idle', '资料库未索引', null);
setKnowledgeRagStatus(entry, 'idle', '知识库未索引', null);
ensureKnowledgeRagTaskDock();
void loadKnowledgeRagTasks(entry.rootUri).catch(() => undefined);
void readKnowledgeRagStatus(entry).then((job) => {
if (!job) return;
const displayStatus = knowledgeRagDisplayStatus(job, 'done');
setKnowledgeRagStatus(entry, displayStatus, displayStatus === 'stale' ? '资料库需更新' : statusTextForKnowledgeRagTask(job), job);
setKnowledgeRagStatus(entry, displayStatus, displayStatus === 'stale' ? '知识库需更新' : statusTextForKnowledgeRagTask(job), job);
updateKnowledgeRagTaskState(job);
}).catch(() => undefined);
};
@@ -151,6 +151,10 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
relativePath: String(item.relativePath || item.relative_path || item.path || '').trim(),
documentId: String(item.documentId || item.document_id || '').trim(),
eventKind: String(item.eventKind || item.event_kind || item.changeType || item.change_type || '').trim(),
selfWriteEcho: Boolean(item.selfWriteEcho || item.self_write_echo),
fileVersion: String(item.fileVersion || item.file_version || item.observedFileVersion || item.observed_file_version || '').trim(),
observedFileVersion: String(item.observedFileVersion || item.observed_file_version || '').trim(),
bufferFileVersion: String(item.bufferFileVersion || item.buffer_file_version || '').trim(),
};
}).filter(Boolean);
}
@@ -1377,11 +1381,17 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
const relativePath = typeof item.relativePath === 'string' ? item.relativePath.trim() : '';
if (targetSession.sessionKind === 'resource') {
if (!relativePath || relativePath !== resourceWatchPath(targetSession)) return;
if (targetSession.saving) {
if (item.selfWriteEcho === true || targetSession.saving && shouldSuppressLocalFolderSelfChange(targetSession.documentId, String(item.eventKind || ''))) {
targetSession.externalChangePending = false;
targetSession.lastSelfSaveSignalAt = Date.now();
return;
}
if (targetSession.hasExternalConflict || sessionHasRecentLocalInput(targetSession) || targetSession.dirty || targetSession.saveTimer || targetSession.saving) {
targetSession.lastExternalChangeSignalAt = Date.now();
targetSession.externalChangePending = true;
markSessionExternalConflict(targetSession, '当前本地资源文件已在外部更新,请刷新或保存前先处理冲突');
return;
}
targetSession.lastExternalChangeSignalAt = Date.now();
targetSession.externalChangePending = true;
scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-resource-watch');
@@ -1394,14 +1404,30 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|| (!documentId && relativePath && relativeTarget && relativePath === relativeTarget)
);
if (!targetsCurrentDocument) return;
if (item.selfWriteEcho === true) {
targetSession.externalChangePending = false;
targetSession.lastSelfSaveSignalAt = Date.now();
if (item.fileVersion) {
targetSession.fileVersion = item.fileVersion;
targetSession.conflictDetectionKey = item.fileVersion;
targetSession.lastExternalConflictDetectionKey = item.fileVersion;
}
return;
}
if (shouldSuppressLocalFolderSelfChange(targetSession.documentId, eventKind)) {
targetSession.externalChangePending = false;
targetSession.lastSelfSaveSignalAt = Date.now();
return;
}
if (targetSession.saving) {
targetSession.externalChangePending = false;
targetSession.lastSelfSaveSignalAt = Date.now();
if (targetSession.hasExternalConflict || sessionHasRecentLocalInput(targetSession) || targetSession.dirty || targetSession.saveTimer || targetSession.saving) {
targetSession.lastExternalChangeSignalAt = Date.now();
targetSession.externalChangePending = true;
markSessionExternalConflict(targetSession, conflictMessageFromEnvelope(targetSession, {
message: externalConflictMessage,
dirtyState: targetSession.dirty ? 'Dirty' : (targetSession.saving ? 'Saving' : 'Clean'),
fileVersion: item.fileVersion || null,
externalActor: 'external-editor',
}));
return;
}
targetSession.lastExternalChangeSignalAt = Date.now();
@@ -7,8 +7,16 @@
var connections = new Map();
var sidebarRefreshQueues = new Map();
var fileChangeQueues = new Map();
var lastSource = '';
var lastReason = '';
var lastFileChangeDiagnostics = {
source: '',
changedCount: 0,
droppedCount: 0,
lastReaction: '',
lastBatchSchema: ''
};
function root() {
return document.documentElement;
@@ -21,6 +29,11 @@
root().setAttribute('data-mnote-local-folder-event-bus-connections', String(connections.size));
root().setAttribute('data-mnote-local-folder-event-bus-last-source', lastSource);
root().setAttribute('data-mnote-local-folder-event-bus-last-reason', lastReason);
root().setAttribute('data-mnote-file-change-service', 'ready');
root().setAttribute('data-mnote-file-change-service-last-source', lastFileChangeDiagnostics.source || lastSource);
root().setAttribute('data-mnote-file-change-service-last-count', String(lastFileChangeDiagnostics.changedCount || 0));
root().setAttribute('data-mnote-file-change-service-dropped-count', String(lastFileChangeDiagnostics.droppedCount || 0));
root().setAttribute('data-mnote-file-change-service-last-reaction', lastFileChangeDiagnostics.lastReaction || '');
}
function normalizeRootUri(rootUri) {
@@ -53,6 +66,375 @@
return [String(value)].filter(Boolean);
}
function safeNow() {
return Date.now ? Date.now() : new Date().getTime();
}
function decodeFileUriPath(rootUri) {
var value = String(rootUri || '').trim();
if (!value) return '';
try {
var url = new URL(value);
if (url.protocol !== 'file:') return '';
return decodeURIComponent(url.pathname || '').replace(/\/+$/, '');
} catch (_) {
return '';
}
}
function normalizeSlashes(value) {
return String(value || '').trim().replace(/\\/g, '/');
}
function rootRelativePath(rawPath, rootUri) {
var value = normalizeSlashes(rawPath).replace(/^file:\/\//, '');
if (!value) return '';
if (/^[A-Za-z]:\//.test(value) || value.charAt(0) === '/') {
var rootPath = normalizeSlashes(decodeFileUriPath(rootUri));
if (rootPath && (value === rootPath || value.indexOf(rootPath + '/') === 0)) {
return value.slice(rootPath.length).replace(/^\/+/, '');
}
return '';
}
return value.replace(/^\/+/, '');
}
function localMarkdownDocumentId(relativePath) {
var normalized = normalizeSlashes(relativePath).replace(/^\/+/, '');
if (!/\.md(?:own)?$/i.test(normalized)) return '';
return 'local-md:' + normalized.replace(/\//g, '~2F');
}
function normalizeChangeType(item, fallback) {
var raw = String(
(item && (item.changeType || item.change_type || item.eventKind || item.event_kind || item.kind))
|| fallback
|| 'modified'
).trim();
if (/remove|delete/i.test(raw)) return 'deleted';
if (/create/i.test(raw)) return 'created';
if (/rename|name/i.test(raw)) return 'renamed';
return 'modified';
}
function resourceKindForPath(relativePath) {
return /\.md(?:own)?$/i.test(String(relativePath || '')) ? 'markdown' : 'resource';
}
function normalizeFileChangeItem(item, context) {
var source = String((context && context.source) || (item && item.source) || 'watcher').trim() || 'watcher';
var rootUri = normalizeRootUri((context && context.rootUri) || (item && item.rootUri));
var workspaceId = normalizeWorkspaceId((context && context.workspaceId) || (item && item.workspaceId));
var rawPath = typeof item === 'string'
? item
: String(item && (
item.relativePath
|| item.relative_path
|| item.path
|| item.sourcePath
|| item.source_path
|| item.filePath
|| item.file_path
) || '');
var relativePath = rootRelativePath(rawPath, rootUri);
if (!relativePath) {
return { dropped: true, reason: 'outside_root_or_empty_path', rawPath: rawPath };
}
var eventKind = String(item && (item.eventKind || item.event_kind || item.kind || item.changeType || item.change_type) || '').trim();
var documentId = String(item && (item.documentId || item.document_id) || '').trim() || localMarkdownDocumentId(relativePath);
var observedFileVersion = String(item && (item.observedFileVersion || item.observed_file_version || '') || '').trim();
var bufferFileVersion = String(item && (item.bufferFileVersion || item.buffer_file_version || '') || '').trim();
var fileVersion = String(item && (item.fileVersion || item.file_version || '') || '').trim()
|| observedFileVersion
|| bufferFileVersion
|| String(item && item.revision || (context && context.revision) || '').trim();
return {
schema: 'mnote.file_change.v1',
rootUri: rootUri,
workspaceId: workspaceId,
relativePath: relativePath,
documentId: documentId,
resourceKind: String(item && (item.resourceKind || item.resource_kind) || resourceKindForPath(relativePath)),
changeType: normalizeChangeType(item, eventKind),
eventKind: eventKind,
fileVersion: fileVersion,
source: source,
selfWriteEcho: Boolean(item && (item.selfWriteEcho || item.self_write_echo)),
observedFileVersion: observedFileVersion,
bufferFileVersion: bufferFileVersion,
lastWriteIntentId: String(item && (item.lastWriteIntentId || item.last_write_intent_id || '') || '').trim(),
lastSaveOperationId: String(item && (item.lastSaveOperationId || item.last_save_operation_id || '') || '').trim(),
createdAt: Number(item && (item.createdAt || item.created_at) || safeNow())
};
}
function standardBatchToCompatPayload(batch) {
return {
schema: 'mnote.local_folder.watch_batch.v1',
kind: 'watch_batch',
sourceKind: 'local_folder',
source: batch.source,
rootUri: batch.rootUri,
workspaceId: batch.workspaceId,
revision: batch.revision,
changedPaths: batch.changes.map(function(change) {
return {
relativePath: change.relativePath,
documentId: change.documentId,
kind: change.eventKind || change.changeType,
eventKind: change.eventKind || change.changeType,
changeType: change.changeType,
revision: change.fileVersion || batch.revision,
fileVersion: change.fileVersion,
observedFileVersion: change.observedFileVersion,
bufferFileVersion: change.bufferFileVersion,
lastWriteIntentId: change.lastWriteIntentId,
lastSaveOperationId: change.lastSaveOperationId,
selfWriteEcho: change.selfWriteEcho,
source: change.source
};
}),
affectedParents: batch.affectedParents,
eventKinds: Array.from(new Set(batch.changes.map(function(change) {
return change.eventKind || change.changeType || '';
}).filter(Boolean))),
fallbackResync: batch.resyncRequired === true
};
}
function normalizeFileChangeBatch(entry, event, payload, meta) {
var revision = revisionOf(payload, event);
var source = String((meta && meta.source) || payload.source || payload.sourceKind || 'watcher_sse').trim() || 'watcher_sse';
var reason = String((meta && meta.reason) || payload.reason || 'watch_batch').trim() || 'watch_batch';
var rootUri = normalizeRootUri((entry && entry.rootUri) || payload.rootUri);
var workspaceId = normalizeWorkspaceId((entry && entry.workspaceId) || payload.workspaceId);
var raw = []
.concat(Array.isArray(payload.changedPaths) ? payload.changedPaths : [])
.concat(Array.isArray(payload.changed_paths) ? payload.changed_paths : [])
.concat(Array.isArray(payload.paths) ? payload.paths : [])
.concat(Array.isArray(payload.events) ? payload.events : []);
if (!raw.length && payload.relativePath) raw.push(payload);
var dropped = [];
var changes = [];
raw.forEach(function(item) {
var normalized = normalizeFileChangeItem(item, {
source: source,
rootUri: rootUri,
workspaceId: workspaceId,
revision: revision
});
if (normalized && normalized.dropped) {
dropped.push(normalized);
} else if (normalized) {
changes.push(normalized);
}
});
var affectedParents = collectAffectedParents(payload).map(function(parent) {
return { relativePath: parent, reason: reason };
});
changes.forEach(function(change) {
var parent = change.relativePath.indexOf('/') >= 0 ? change.relativePath.split('/').slice(0, -1).join('/') : '';
if (!affectedParents.some(function(item) { return item.relativePath === parent; })) {
affectedParents.push({ relativePath: parent, reason: reason });
}
});
return {
schema: 'mnote.file_change_batch.v1',
rootUri: rootUri,
workspaceId: workspaceId,
source: source,
reason: reason,
changes: changes,
affectedParents: affectedParents,
revision: revision,
dropped: dropped,
resyncRequired: payload.fallbackResync === true
|| payload.requiresResync === true
|| payload.resyncRequired === true
|| payload.resync_required === true,
rawPayload: payload,
bootstrap: entry && entry.bootstrap || null
};
}
function mergeFileChange(existing, change) {
if (!existing) return change;
return Object.assign({}, existing, change, {
source: change.source || existing.source,
selfWriteEcho: change.selfWriteEcho === true ? true : (change.selfWriteEcho === false ? false : existing.selfWriteEcho),
observedFileVersion: change.observedFileVersion || existing.observedFileVersion,
bufferFileVersion: change.bufferFileVersion || existing.bufferFileVersion,
fileVersion: change.fileVersion || existing.fileVersion,
createdAt: Math.max(Number(existing.createdAt || 0), Number(change.createdAt || 0))
});
}
function fileChangeQueueKey(batch) {
return [batch.rootUri || '', batch.workspaceId || 'default'].join('#');
}
function enqueueFileChangeBatch(batch) {
if (!batch || !batch.rootUri) return;
var key = fileChangeQueueKey(batch);
var queue = fileChangeQueues.get(key);
if (!queue) {
queue = {
rootUri: batch.rootUri,
workspaceId: batch.workspaceId,
sources: new Set(),
reasons: new Set(),
revisions: new Set(),
changes: new Map(),
affectedParents: new Map(),
dropped: [],
resyncRequired: false,
bootstrap: batch.bootstrap || null,
timer: 0
};
fileChangeQueues.set(key, queue);
}
if (batch.source) queue.sources.add(batch.source);
if (batch.reason) queue.reasons.add(batch.reason);
if (batch.revision) queue.revisions.add(String(batch.revision));
queue.dropped = queue.dropped.concat(batch.dropped || []);
queue.resyncRequired = queue.resyncRequired || batch.resyncRequired === true;
(batch.changes || []).forEach(function(change) {
var changeKey = [change.rootUri || batch.rootUri, change.relativePath, change.fileVersion || ''].join('#');
queue.changes.set(changeKey, mergeFileChange(queue.changes.get(changeKey), change));
});
(batch.affectedParents || []).forEach(function(parent) {
var relativePath = String(parent && parent.relativePath || parent || '');
queue.affectedParents.set(relativePath, {
relativePath: relativePath,
reason: String(parent && parent.reason || batch.reason || 'file-change')
});
});
if (!queue.timer) {
queue.timer = window.setTimeout(function() {
flushFileChangeQueue(key);
}, 80);
}
}
function currentDocumentSessionState(change) {
var snapshot = null;
try {
snapshot = window.__mnoteDebugDocumentSessions
&& window.__mnoteDebugDocumentSessions.snapshot
&& window.__mnoteDebugDocumentSessions.snapshot();
} catch (_) {
snapshot = null;
}
root().setAttribute('data-mnote-file-change-service-session-api', snapshot ? 'available' : 'unavailable');
var sessions = Array.isArray(snapshot && snapshot.sessions) ? snapshot.sessions : [];
return sessions.find(function(session) {
if (!session || session.sourceKind !== 'local_folder') return false;
if (change.rootUri && session.rootUri && change.rootUri !== session.rootUri) return false;
if (change.documentId && session.documentId === change.documentId) return true;
return false;
}) || null;
}
function reactionTypeForChange(change) {
if (change.selfWriteEcho) return 'ignore_self_write_echo';
if (change.resourceKind !== 'markdown') return 'refresh_resource_tab';
var session = currentDocumentSessionState(change);
if (session && /dirty|external/i.test(String(session.dirtyState || session.status || ''))) {
return 'external_conflict_current_document';
}
if (session) return 'refresh_current_document';
if (change.changeType === 'created' || change.changeType === 'deleted' || change.changeType === 'renamed') {
return 'refresh_page_tree_projection';
}
return 'refresh_filetree_parents';
}
function emitFileChangeReactions(detail) {
var reactionTypes = new Set();
(detail.fileChangeBatch.changes || []).forEach(function(change) {
var type = reactionTypeForChange(change);
reactionTypes.add(type);
emit('mnote:file-change-reaction', {
schema: 'mnote.file_change_reaction.v1',
type: type,
rootUri: detail.rootUri,
workspaceId: detail.workspaceId,
source: detail.source,
change: change,
batch: detail.fileChangeBatch
});
});
if (detail.affectedParents && detail.affectedParents.length) {
reactionTypes.add('refresh_filetree_parents');
}
lastFileChangeDiagnostics.lastReaction = Array.from(reactionTypes).join(',') || '';
root().setAttribute('data-mnote-file-change-service-last-reaction', lastFileChangeDiagnostics.lastReaction);
return reactionTypes;
}
function flushFileChangeQueue(key) {
var queue = fileChangeQueues.get(key);
if (!queue) return;
fileChangeQueues.delete(key);
var sources = Array.from(queue.sources);
var reasons = Array.from(queue.reasons);
var revision = Array.from(queue.revisions).pop() || null;
var batch = {
schema: 'mnote.file_change_batch.v1',
rootUri: queue.rootUri,
workspaceId: queue.workspaceId,
source: sources.join(',') || 'watcher',
reason: reasons.join(',') || 'watch_batch',
changes: Array.from(queue.changes.values()),
affectedParents: Array.from(queue.affectedParents.values()),
revision: revision,
dropped: queue.dropped,
resyncRequired: queue.resyncRequired
};
var compatPayload = standardBatchToCompatPayload(batch);
var detail = {
schema: 'mnote.local_folder.event_bus.watch_batch.v1',
source: batch.source,
reason: batch.reason,
rootUri: batch.rootUri,
workspaceId: batch.workspaceId,
revision: batch.revision,
payload: compatPayload,
bootstrap: queue.bootstrap || null,
changedPaths: compatPayload.changedPaths,
affectedParents: batch.affectedParents,
fileChangeBatch: batch,
resyncRequired: batch.resyncRequired,
viaEventBus: true
};
lastFileChangeDiagnostics = {
source: batch.source,
changedCount: batch.changes.length,
droppedCount: batch.dropped.length,
lastReaction: '',
lastBatchSchema: batch.schema
};
setDiagnostics(batch.source, batch.reason);
root().setAttribute('data-mnote-tree-live-revision', String(batch.revision || ''));
root().setAttribute('data-mnote-file-change-service-last-schema', batch.schema);
emit('mnote:file-change-batch', batch);
emit('mnote:local-folder:watch-batch', detail);
emit('tree:local-folder-watch-batch', detail);
var reactionTypes = emitFileChangeReactions(detail);
if (batch.affectedParents.length > 0 || reactionTypes.has('refresh_filetree_parents')) {
emit('mnote:local-folder:filetree-parent-changed', detail);
}
if (batch.changes.length > 0) {
emit('mnote:local-folder:document-changed', detail);
emit('mnote:local-folder:resource-changed', detail);
emit('mnote:local-folder:knowledge-rag-source-updated', detail);
}
queueSidebarRefresh(detail);
if (batch.resyncRequired) {
emit('mnote:local-folder:resync-required', detail);
}
}
function pathArrayOf(value) {
if (!value) return [];
var list = Array.isArray(value) ? value : [value];
@@ -191,47 +573,7 @@
}
function dispatchWatchBatch(entry, event, payload, meta) {
var revision = revisionOf(payload, event);
var changedPaths = collectChangedPaths(payload);
var affectedParents = collectAffectedParents(payload);
var source = String((meta && meta.source) || payload.source || 'watcher_sse').trim() || 'watcher_sse';
var reason = String((meta && meta.reason) || payload.reason || 'watch_batch').trim() || 'watch_batch';
var resyncRequired = payload.fallbackResync === true
|| payload.requiresResync === true
|| payload.resyncRequired === true
|| payload.resync_required === true;
var detail = {
schema: 'mnote.local_folder.event_bus.watch_batch.v1',
source: source,
reason: reason,
rootUri: entry.rootUri,
workspaceId: entry.workspaceId,
revision: revision,
payload: payload,
bootstrap: entry.bootstrap || null,
changedPaths: collectChangedPathItems(payload, reason),
affectedParents: pathItems(affectedParents, reason),
resyncRequired: resyncRequired,
viaEventBus: true
};
setDiagnostics(source, reason);
root().setAttribute('data-mnote-tree-live-revision', String(revision || ''));
emit('mnote:local-folder:watch-batch', detail);
emit('tree:local-folder-watch-batch', detail);
if (affectedParents.length > 0) {
emit('mnote:local-folder:filetree-parent-changed', detail);
}
if (changedPaths.length > 0) {
emit('mnote:local-folder:document-changed', detail);
emit('mnote:local-folder:resource-changed', detail);
emit('mnote:local-folder:knowledge-rag-source-updated', detail);
}
queueSidebarRefresh(detail);
if (resyncRequired) {
emit('mnote:local-folder:resync-required', detail);
}
enqueueFileChangeBatch(normalizeFileChangeBatch(entry, event, payload, meta));
}
function closeEntry(entry) {
@@ -326,6 +668,32 @@
});
}
function emitChangedFiles(detail) {
var payload = detail && detail.payload && typeof detail.payload === 'object' ? detail.payload : {};
var changedFiles = []
.concat(Array.isArray(detail && detail.changedFiles) ? detail.changedFiles : [])
.concat(Array.isArray(payload.changedFiles) ? payload.changedFiles : [])
.concat(Array.isArray(payload.changed_files) ? payload.changed_files : []);
if (!changedFiles.length) return;
var rootUri = normalizeRootUri((detail && detail.rootUri) || payload.rootUri);
var workspaceId = normalizeWorkspaceId((detail && detail.workspaceId) || payload.workspaceId);
emitSyntheticWatchBatch({
source: (detail && detail.source) || payload.source || 'changed_files_adapter',
reason: (detail && detail.reason) || payload.reason || 'changed_files_adapter',
rootUri: rootUri,
workspaceId: workspaceId,
payload: {
schema: 'mnote.local_folder.watch_batch.v1',
source: (detail && detail.source) || payload.source || 'changed_files_adapter',
rootUri: rootUri,
workspaceId: workspaceId,
revision: (detail && detail.revision) || payload.revision || null,
changedPaths: changedFiles,
affectedParents: Array.isArray(detail && detail.affectedParents) ? detail.affectedParents : []
}
});
}
function closeAll() {
Array.from(connections.values()).forEach(function(entry) {
closeEntry(entry);
@@ -337,6 +705,7 @@
window.__mnoteLocalFolderEventBus = {
startLocalFolderWatcher: startLocalFolderWatcher,
emitSyntheticWatchBatch: emitSyntheticWatchBatch,
emitChangedFiles: emitChangedFiles,
closeAll: closeAll,
flushSidebarRefresh: function(rootUri) {
flushSidebarRefresh(normalizeRootUri(rootUri));
@@ -346,7 +715,8 @@
return {
connections: connections.size,
lastSource: lastSource,
lastReason: lastReason
lastReason: lastReason,
fileChange: Object.assign({}, lastFileChangeDiagnostics)
};
}
};
@@ -415,7 +415,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
|| String(detail && (detail.localRelativePath || detail.path || detail.title) || '').trim();
var rootUri = knowledgeRagRootUri(detail, trigger);
if (!sourceRootRelativePath || !rootUri) {
throw new Error('缺少资料库来源或 rootUri');
throw new Error('缺少知识库来源或 rootUri');
}
var workspaceId = String(detail && detail.workspaceId || resolveWorkspaceId(document.body) || '').trim();
document.documentElement.setAttribute('data-mnote-knowledge-rag-menu-status', 'running');
@@ -426,12 +426,14 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
body: JSON.stringify({
workspaceId: workspaceId,
rootUri: rootUri,
sources: [{ sourcePath: sourceRootRelativePath }]
sources: [{ sourcePath: sourceRootRelativePath }],
knowledgeBaseId: String(detail && detail.kbId || '').trim() || undefined,
providerKnowledgeBaseId: String(detail && (detail.providerKbId || detail.kbId) || '').trim() || undefined
})
});
var payload = await response.json().catch(function() { return null; });
if (!response.ok || !payload || payload.ok === false) {
var message = payload && payload.message ? payload.message : '资料库索引失败';
var message = payload && payload.message ? payload.message : '知识库索引失败';
document.documentElement.setAttribute('data-mnote-knowledge-rag-menu-status', 'failed');
throw new Error(message);
}
@@ -450,12 +452,30 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
recordFileTreeActionStatus('pending', detail);
void ingestKnowledgeRagForDetail(detail, trigger).then(function() {
recordFileTreeActionStatus('done', detail);
if (window.mnote && window.mnote.toast) {
window.mnote.toast('已加入知识库', { kind: 'success' });
}
}).catch(function(error) {
var msg = error && error.message ? error.message : '知识库索引失败';
recordFileTreeActionStatus('failed', Object.assign({}, detail, { fallback: 'alert' }));
window.alert(error && error.message ? error.message : '资料库索引失败');
if (window.mnote && window.mnote.toast) {
window.mnote.toast(msg, { kind: 'error' });
} else {
window.alert(msg);
}
});
return;
}
if (action === 'open-knowledge-page') {
var knowledgeUrl = '/knowledge';
if (typeof window.open === 'function') {
window.open(knowledgeUrl, '_blank', 'noopener,noreferrer');
} else {
window.location.href = knowledgeUrl;
}
return;
}
if (detail.contextKind === 'attachment') {
if (action === 'copy-link') {
void copyTreeContextValue(detail.href || '', 'attachment-copy-link');
@@ -683,6 +703,148 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
menu.appendChild(sep);
return;
}
// Submenu root: render parent button with child flyout
if (item.children && Array.isArray(item.children) && item.children.length > 0) {
if (typeof injectKnowledgeSubmenuStyles === 'function') injectKnowledgeSubmenuStyles();
var rootBtn = document.createElement('button');
rootBtn.type = 'button';
rootBtn.className = 'mnote-tree-context-menu__item mnote-tree-context-menu__item--submenu-root';
rootBtn.setAttribute('role', 'menuitem');
rootBtn.setAttribute('data-action', item.action || 'knowledge-rag-root');
rootBtn.setAttribute('data-command-id', item.commandId || item.action || 'knowledge-rag-root');
if (item.when) rootBtn.setAttribute('data-command-when', item.when);
if (item.danger || item.destructive) rootBtn.setAttribute('data-destructive', 'true');
if (item.requiresApproval) rootBtn.setAttribute('data-requires-approval', 'true');
rootBtn.disabled = item.disabled === true;
if (item.title) rootBtn.title = item.title;
if (item.disabled === true && item.title) rootBtn.setAttribute('data-disabled-reason', item.title);
var rootIcon = document.createElement('span');
rootIcon.className = 'material-symbols-outlined mnote-tree-context-menu__icon';
rootIcon.setAttribute('aria-hidden', 'true');
rootIcon.setAttribute('data-icon', item.icon || 'radio_button_unchecked');
var rootLabel = document.createElement('span');
rootLabel.className = 'mnote-tree-context-menu__label';
rootLabel.textContent = item.label;
var arrow = document.createElement('span');
arrow.className = 'mnote-tree-context-menu__submenu-arrow';
arrow.textContent = '▶';
arrow.setAttribute('aria-hidden', 'true');
rootBtn.appendChild(rootIcon);
rootBtn.appendChild(rootLabel);
rootBtn.appendChild(arrow);
var submenu = document.createElement('div');
submenu.className = 'mnote-tree-context-menu__submenu';
submenu.setAttribute('data-testid', 'mnote-knowledge-submenu');
submenu.setAttribute('role', 'menu');
function renderSubmenuChildren(children) {
submenu.textContent = '';
children.forEach(function(child) {
if (child.separator) {
var childSep = document.createElement('div');
childSep.className = 'mnote-tree-context-menu__separator';
childSep.setAttribute('role', 'separator');
submenu.appendChild(childSep);
return;
}
var childBtn = document.createElement('button');
childBtn.type = 'button';
childBtn.className = child.danger ? 'mnote-tree-context-menu__item mnote-tree-context-menu__item--danger' : 'mnote-tree-context-menu__item';
if (child.heading) childBtn.className += ' mnote-tree-context-menu__item--heading';
childBtn.setAttribute('role', 'menuitem');
childBtn.setAttribute('data-action', child.action || '');
childBtn.setAttribute('data-command-id', child.commandId || child.action || '');
if (child.when) childBtn.setAttribute('data-command-when', child.when);
childBtn.disabled = child.disabled === true || child.heading === true;
if (child.title) childBtn.title = child.title;
if (child.disabled === true && child.title) childBtn.setAttribute('data-disabled-reason', child.title);
var childIcon = document.createElement('span');
childIcon.className = 'material-symbols-outlined mnote-tree-context-menu__icon';
childIcon.setAttribute('aria-hidden', 'true');
childIcon.setAttribute('data-icon', child.icon || 'radio_button_unchecked');
var childLabel = document.createElement('span');
childLabel.className = 'mnote-tree-context-menu__label';
childLabel.textContent = child.label;
childBtn.appendChild(childIcon);
childBtn.appendChild(childLabel);
if (child.shortcut) {
var childShortcut = document.createElement('span');
childShortcut.className = 'mnote-tree-context-menu__shortcut';
childShortcut.textContent = child.shortcut;
childBtn.appendChild(childShortcut);
}
var childDetail = child.kbId ? Object.assign({}, detail, { kbId: child.kbId, providerKbId: child.providerKbId || child.kbId }) : detail;
(function(act, actDetail, actTrigger) {
childBtn.addEventListener('click', function(event) {
event.preventDefault();
event.stopPropagation();
if (!act || childBtn.disabled) return;
handleTreeContextMenuAction(act, actDetail, actTrigger);
});
})(child.action, childDetail, trigger);
submenu.appendChild(childBtn);
});
}
renderSubmenuChildren(item.children);
rootBtn.appendChild(submenu);
var submenuShown = false;
var submenuHideTimer = null;
var lastSubmenuPointerY = 0;
function knowledgeSubmenuPosition(pointerY) {
var btnRect = rootBtn.getBoundingClientRect();
submenu.style.display = 'block';
submenu.style.visibility = 'hidden';
var smRect = submenu.getBoundingClientRect();
var leftPos = btnRect.right + 4;
var anchorY = Number(pointerY || lastSubmenuPointerY || 0);
var topPos = anchorY > 0 ? anchorY - 18 : btnRect.top;
if (leftPos + smRect.width > window.innerWidth - 8) {
leftPos = Math.max(8, btnRect.left - smRect.width - 4);
}
if (topPos + smRect.height > window.innerHeight - 8) {
topPos = Math.max(8, window.innerHeight - smRect.height - 8);
}
submenu.style.left = leftPos + 'px';
submenu.style.top = topPos + 'px';
submenu.style.visibility = 'visible';
}
function knowledgeSubmenuShow(pointerY) {
if (Number(pointerY || 0) > 0) lastSubmenuPointerY = pointerY;
if (submenuHideTimer) { window.clearTimeout(submenuHideTimer); submenuHideTimer = null; }
if (!submenuShown) { knowledgeSubmenuPosition(pointerY); submenuShown = true; rootBtn.classList.add('mnote-tree-context-menu__item--submenu-open'); }
}
function knowledgeSubmenuScheduleHide() {
if (submenuHideTimer) window.clearTimeout(submenuHideTimer);
submenuHideTimer = window.setTimeout(function() {
if (submenuShown) { submenu.style.display = 'none'; submenuShown = false; rootBtn.classList.remove('mnote-tree-context-menu__item--submenu-open'); }
submenuHideTimer = null;
}, 150);
}
rootBtn.addEventListener('mouseenter', function(event) { if (!rootBtn.disabled) knowledgeSubmenuShow(event.clientY); });
rootBtn.addEventListener('mousemove', function(event) {
if (rootBtn.disabled || !submenuShown) return;
lastSubmenuPointerY = event.clientY;
});
rootBtn.addEventListener('mouseleave', function(event) {
if (event.relatedTarget && (submenu.contains(event.relatedTarget) || rootBtn.contains(event.relatedTarget))) return;
knowledgeSubmenuScheduleHide();
});
submenu.addEventListener('mouseenter', function() {
if (submenuHideTimer) { window.clearTimeout(submenuHideTimer); submenuHideTimer = null; }
});
submenu.addEventListener('mouseleave', function(event) {
if (event.relatedTarget && (rootBtn.contains(event.relatedTarget) || submenu.contains(event.relatedTarget))) return;
knowledgeSubmenuScheduleHide();
});
if (typeof item.loadChildren === 'function') {
item.loadChildren().then(function(children) {
if (!Array.isArray(children) || children.length === 0) return;
renderSubmenuChildren(children);
if (submenuShown) knowledgeSubmenuPosition(lastSubmenuPointerY);
}).catch(function() {});
}
menu.appendChild(rootBtn);
return;
}
var button = document.createElement('button');
button.type = 'button';
button.className = item.danger ? 'mnote-tree-context-menu__item mnote-tree-context-menu__item--danger' : 'mnote-tree-context-menu__item';
@@ -718,6 +880,106 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
menu.appendChild(button);
}
// ── 知识库二级菜单 CSS 注入(仅一次) ──
var __mnoteKnowledgeSubmenuStylesInjected = false;
function injectKnowledgeSubmenuStyles() {
if (__mnoteKnowledgeSubmenuStylesInjected) return;
__mnoteKnowledgeSubmenuStylesInjected = true;
var style = document.createElement('style');
style.id = 'mnote-knowledge-submenu-styles';
style.textContent = '.mnote-tree-context-menu__item--submenu-root{position:relative}.mnote-tree-context-menu__submenu{position:fixed;z-index:calc(var(--wolai-z-context-menu,9999) + 1);width:240px;min-width:220px;max-width:min(340px,calc(100vw - 16px));padding:6px;border:1px solid rgba(27,28,28,.08);border-radius:6px;background:#FFF;box-shadow:0 8px 20px rgba(27,28,28,.12);backdrop-filter:blur(24px);color:var(--atelier-text);display:none}.mnote-tree-context-menu__submenu-arrow{margin-left:auto;font-size:11px;color:#6D6A65;padding-left:12px}.mnote-tree-context-menu__item--submenu-open{background:#F4F3F3}.mnote-tree-context-menu__item--heading{font-size:12px;color:#6D6A65;background:transparent!important;cursor:default}';
document.head.appendChild(style);
}
// ── 知识库列表获取:只读 MNote 后端 registry/status,避免前端直连 WeKnora service key。 ──
function fetchKnowledgeBaseList(detail) {
var rootUri = knowledgeRagRootUri(detail, null);
if (!rootUri) return Promise.resolve([]);
var workspaceId = String(detail && detail.workspaceId || resolveWorkspaceId(document.body) || '').trim();
var sourcePath = knowledgeRagSourceRelativePath(detail);
var statusUrl = '/api/knowledge-rag/status?rootUri=' + encodeURIComponent(rootUri);
if (workspaceId) statusUrl += '&workspaceId=' + encodeURIComponent(workspaceId);
return fetch(statusUrl, { credentials: 'include', headers: { accept: 'application/json' } })
.then(function(response) { return response.ok ? response.json() : null; })
.then(function(payload) {
// 构建 knowledgeBase → 已索引 sourcePath 快速查找
var indexedByKb = {};
var registry = payload && payload.registry;
if (registry && Array.isArray(registry.entries) && sourcePath) {
registry.entries.forEach(function(entry) {
var entryKbId = String(entry.providerKnowledgeBaseId || entry.provider_kb_id || '').trim();
var entryPath = String(entry.sourceRootRelativePath || entry.source_root_relative_path || '').trim();
if (entryKbId && entryPath && entry.indexedAtMs && entryPath === sourcePath) {
if (!indexedByKb[entryKbId]) indexedByKb[entryKbId] = {};
indexedByKb[entryKbId][entryPath] = true;
}
});
}
var bases = payload && payload.knowledgeBases && Array.isArray(payload.knowledgeBases.bases)
? payload.knowledgeBases.bases
: [];
return bases.filter(function(base) {
return base && base.canWrite !== false && (base.providerKbId || base.provider_kb_id || base.baseId || base.base_id);
}).map(function(base) {
var providerKbId = String(base.providerKbId || base.provider_kb_id || '').trim();
var baseId = String(base.baseId || base.base_id || providerKbId).trim();
var kbId = providerKbId || baseId;
return {
id: baseId,
providerKbId: kbId,
name: String(base.name || baseId || providerKbId || '未命名知识库').trim(),
sourceCount: Number(base.sourceCount || base.source_count || 0) || 0,
sourceAlreadyIndexed: Boolean(sourcePath && indexedByKb[kbId] && indexedByKb[kbId][sourcePath])
};
});
}).catch(function() { return []; });
}
function buildKnowledgeRagSubmenuChildren(detail, writableBases) {
var children = [
{ action: 'knowledge-rag-index', icon: 'travel_explore', label: '索引到默认知识库', when: '!workspace.readonly' },
{ separator: true },
{ heading: true, icon: 'history', label: '最近使用知识库' }
];
if (writableBases && writableBases.length > 0) {
writableBases.slice(0, 3).forEach(function(base) {
var indexed = base.sourceAlreadyIndexed;
children.push({
action: 'knowledge-rag-index',
icon: indexed ? 'check_circle' : 'database',
label: (indexed ? '✓ ' : '') + base.name,
kbId: base.id,
providerKbId: base.providerKbId,
title: indexed ? '重新索引' : (base.sourceCount ? ('已有 ' + base.sourceCount + ' 个来源') : '加入此知识库'),
when: '!workspace.readonly'
});
});
} else {
children.push({ action: '', icon: 'database', label: '暂无可写知识库', disabled: true });
}
children.push({ heading: true, icon: 'folder_managed', label: '当前 workspace 知识库' });
(writableBases || []).slice(3).forEach(function(base) {
var indexed = base.sourceAlreadyIndexed;
children.push({
action: 'knowledge-rag-index',
icon: indexed ? 'check_circle' : 'database',
label: (indexed ? '✓ ' : '') + base.name,
kbId: base.id,
providerKbId: base.providerKbId,
title: indexed ? '重新索引' : (base.sourceCount ? ('已有 ' + base.sourceCount + ' 个来源') : '加入此知识库'),
when: '!workspace.readonly'
});
});
children.push(
{ separator: true },
{ action: 'open-knowledge-page', icon: 'add', label: '新建知识库...' },
{ action: 'open-knowledge-page', icon: 'settings', label: '管理知识库...' }
);
return children;
}
// ── CommandContext 启用态辅助:与 Rust core-protocol CommandContext 保持同一口径 ──
function currentOpenEditorsSnapshotForCommandContext() {
@@ -979,7 +1241,13 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true, destructive: true, requiresApproval: true, when: '!workspace.readonly && !editor.dirty' }
];
if (currentSourceKind() === 'local_folder' && detail.rowKind !== 'index' && knowledgeRagSupported) {
var ragItem = { action: 'knowledge-rag-index', icon: 'travel_explore', label: '加入资料库索引', when: '!workspace.readonly' };
var ragChildren = buildKnowledgeRagSubmenuChildren(detail, []);
var ragItem = { action: 'knowledge-rag-root', icon: 'travel_explore', label: '加入知识库', children: ragChildren, when: '!workspace.readonly' };
ragItem.loadChildren = function() {
return fetchKnowledgeBaseList(detail).then(function(bases) {
return buildKnowledgeRagSubmenuChildren(detail, bases);
});
};
var insertAt = isAttachment ? 11 : isAsset ? 3 : 3;
if (insertAt >= 0) items.splice(insertAt, 0, ragItem);
}
@@ -1274,6 +1274,10 @@ export function createSidebarPageAiRuntime(context) {
window.addEventListener('message', function(event) {
if (event.origin !== window.location.origin) return;
var message = event.data && typeof event.data === 'object' ? event.data : null;
if (message && message.source === 'openhub-ai' && message.type === 'mnote:minimize-openhub') {
closePageAiDrawer();
return;
}
if (message && message.source === 'openhub-ai' && message.type === 'mnote:get-active-tab-address') {
var addressPayload = pageAiCurrentActiveTabAddressPayload();
var requestPayload = message.payload && typeof message.payload === 'object' ? message.payload : {};
@@ -1822,29 +1826,21 @@ export function createSidebarPageAiRuntime(context) {
var workspaceId = String(options && options.workspaceId || resolveWorkspaceId(document.body) || '').trim();
var isMarkdown = Boolean(documentId);
if (isMarkdown) {
if (window.__mnoteLocalFolderEventBus && typeof window.__mnoteLocalFolderEventBus.emitSyntheticWatchBatch === 'function') {
window.__mnoteLocalFolderEventBus.emitSyntheticWatchBatch({
if (window.__mnoteLocalFolderEventBus && typeof window.__mnoteLocalFolderEventBus.emitChangedFiles === 'function') {
window.__mnoteLocalFolderEventBus.emitChangedFiles({
source: 'openhub_changed_file_bridge',
reason: 'openhub_changed_file',
rootUri: rootUri,
workspaceId: workspaceId,
payload: {
schema: 'mnote.local_folder.watch_batch.v1',
source: 'openhub_changed_file_bridge',
rootUri: rootUri,
changedPaths: [{ relativePath: targetPath, documentId: documentId, changeType: 'modified' }],
affectedParents: [{ relativePath: pageAiParentRelativePath(targetPath), reason: 'openhub_changed_file' }]
}
});
}
if (typeof window.__mnoteDocumentPaneRuntime.refreshPrimaryDocument === 'function') {
void window.__mnoteDocumentPaneRuntime.refreshPrimaryDocument({
documentId: documentId,
workspaceId: workspaceId,
source: 'openhub-changed-file-bridge'
changedFiles: [{ relativePath: targetPath, documentId: documentId, changeType: 'modified' }],
affectedParents: [{ relativePath: pageAiParentRelativePath(targetPath), reason: 'openhub_changed_file' }]
});
}
if (typeof window.__mnoteDocumentPaneRuntime.openPrimaryDocument === 'function') {
if (documentId === String(currentDocumentId() || '').trim()) {
document.documentElement.setAttribute('data-mnote-page-ai-openhub-document-pane-refresh', targetPath);
return true;
}
void window.__mnoteDocumentPaneRuntime.openPrimaryDocument({
documentId: documentId,
workspaceId: workspaceId,
@@ -1861,12 +1857,18 @@ export function createSidebarPageAiRuntime(context) {
}
function pageAiRefreshOpencodeCurrentPage(reason) {
if (window.__mnoteDocumentPaneRuntime && typeof window.__mnoteDocumentPaneRuntime.refreshPrimaryDocument === 'function') {
if (reason) {
void window.__mnoteDocumentPaneRuntime.refreshPrimaryDocument({ reason: reason });
} else {
void window.__mnoteDocumentPaneRuntime.refreshPrimaryDocument({ reason: 'page-ai-opencode-host' });
}
var rootUri = String(currentRootUri() || '').trim();
var targetPath = String(pageAiOpencodePrimaryPath() || '').trim();
var documentId = String(currentDocumentId() || pageAiDocumentIdFromRootRelativePath(targetPath) || '').trim();
if (rootUri && targetPath && documentId && window.__mnoteLocalFolderEventBus && typeof window.__mnoteLocalFolderEventBus.emitChangedFiles === 'function') {
window.__mnoteLocalFolderEventBus.emitChangedFiles({
source: 'opencode_changed_files_adapter',
reason: reason || 'page-ai-opencode-host',
rootUri: rootUri,
workspaceId: resolveWorkspaceId(document.body),
changedFiles: [{ relativePath: targetPath, documentId: documentId, changeType: 'modified' }],
affectedParents: [{ relativePath: pageAiParentRelativePath(targetPath), reason: reason || 'page-ai-opencode-host' }]
});
return true;
}
return false;
@@ -3168,22 +3170,25 @@ export function createSidebarPageAiRuntime(context) {
affectedParents: affectedParents
};
document.documentElement.setAttribute('data-mnote-page-ai-receipt-filetree-refresh', 'true');
if (window.__mnoteLocalFolderEventBus && typeof window.__mnoteLocalFolderEventBus.emitSyntheticWatchBatch === 'function') {
window.__mnoteLocalFolderEventBus.emitSyntheticWatchBatch({
if (window.__mnoteLocalFolderEventBus && typeof window.__mnoteLocalFolderEventBus.emitChangedFiles === 'function') {
window.__mnoteLocalFolderEventBus.emitChangedFiles({
source: 'synthetic_page_ai_receipt',
reason: 'agent_run_receipt',
rootUri: rootUri,
workspaceId: resolveWorkspaceId(document.body),
changedFiles: changedPaths,
affectedParents: affectedParents,
payload: syntheticPayload
});
} else {
document.documentElement.setAttribute('data-mnote-page-ai-receipt-fallback-direct', 'true');
window.dispatchEvent(new CustomEvent('tree:local-folder-watch-batch', {
detail: { payload: syntheticPayload }
}));
}
}
}
if (refresh.touchesCurrentFile === true) {
if (refresh.touchesCurrentFile === true && !files.length) {
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', {
@@ -700,29 +700,29 @@ export function createSidebarPageSettingsRuntime(context) {
function createKnowledgeRagSettingsPanelHtmlV2() {
return '' +
'<div class="wolai-page-settings-panel mnote-settings-panel mnote-knowledge-rag-settings-panel mnote-weknora-kb-page" role="dialog" aria-modal="false" aria-label="WeKnora 知识库" data-testid="mnote-weknora-knowledge-settings-panel" data-knowledge-rag-default-provider="weknora" data-knowledge-rag-legacy-fallback-provider="lightrag" data-knowledge-rag-source-registry="mnote" data-knowledge-rag-provider-index="weknora-kb-chunk-index" data-mnote-weknora-page-replacement="true">' +
'<div class="wolai-page-settings-panel mnote-settings-panel mnote-knowledge-rag-settings-panel mnote-weknora-kb-page" role="dialog" aria-modal="false" aria-label="LightRAG 知识库" data-testid="mnote-weknora-knowledge-settings-panel" data-knowledge-rag-default-provider="lightrag" data-knowledge-rag-legacy-fallback-provider="lightrag" data-knowledge-rag-source-registry="mnote" data-knowledge-rag-provider-index="lightrag-source-registry" data-mnote-weknora-page-replacement="false">' +
'<div class="mnote-weknora-kb-page-header">' +
'<div class="mnote-weknora-kb-page-title">' +
'<span class="mnote-weknora-kb-page-eyebrow">WeKnora</span>' +
'<span class="mnote-weknora-kb-page-eyebrow">LightRAG</span>' +
'<strong>知识库</strong>' +
'<span>由 MNote 工作区授权文件驱动</span>' +
'<span>由 MNote source registry 与 LightRAG 索引驱动</span>' +
'</div>' +
'<div class="mnote-weknora-kb-page-actions">' +
'<button type="button" data-knowledge-rag-action="refresh" title="刷新"><span class="material-symbols-outlined" data-icon="refresh" aria-hidden="true"></span></button>' +
'<button type="button" data-knowledge-rag-action="open-dashboard" title="打开知识库" aria-label="打开知识库"><span class="material-symbols-outlined" data-icon="open_in_new" aria-hidden="true"></span></button>' +
'<button type="button" class="mnote-settings-panel-close" data-settings-action="close" aria-label="关闭 WeKnora 知识库"><span class="material-symbols-outlined" data-icon="close" aria-hidden="true"></span></button>' +
'<button type="button" class="mnote-settings-panel-close" data-settings-action="close" aria-label="关闭知识库"><span class="material-symbols-outlined" data-icon="close" aria-hidden="true"></span></button>' +
'</div>' +
'</div>' +
'<div class="mnote-weknora-kb-page-status" data-testid="mnote-knowledge-rag-status"></div>' +
'<div class="mnote-weknora-kb-page-body">' +
'<aside class="mnote-weknora-kb-list-pane" data-testid="mnote-weknora-kb-list-pane">' +
'<div class="mnote-weknora-kb-list-head">' +
'<div><strong>知识库列表</strong><span data-testid="mnote-weknora-kb-list-count">0</span></div>' +
'<button type="button" data-knowledge-rag-action="focus-create-kb" title="新建知识库"><span class="material-symbols-outlined" data-icon="create_new_folder" aria-hidden="true"></span></button>' +
'<div><strong>资料库</strong><span data-testid="mnote-weknora-kb-list-count">0</span></div>' +
'<button type="button" data-knowledge-rag-action="focus-create-kb" title="LightRAG 当前使用单资料库" hidden><span class="material-symbols-outlined" data-icon="create_new_folder" aria-hidden="true"></span></button>' +
'</div>' +
'<div class="mnote-weknora-kb-card-wrap" data-testid="mnote-weknora-kb-card-list"></div>' +
'<section class="mnote-weknora-kb-create-card" data-testid="mnote-weknora-kb-controls">' +
'<div class="mnote-weknora-kb-create-title"><span class="material-symbols-outlined" data-icon="folder_add" aria-hidden="true"></span><strong>新建知识库</strong></div>' +
'<section class="mnote-weknora-kb-create-card" data-testid="mnote-weknora-kb-controls" hidden>' +
'<div class="mnote-weknora-kb-create-title"><span class="material-symbols-outlined" data-icon="folder_add" aria-hidden="true"></span><strong>新建逻辑资料库</strong></div>' +
'<input type="text" class="wolai-page-settings-index-path" data-testid="mnote-weknora-create-kb-name" data-knowledge-rag-create-kb-name="true" placeholder="知识库名称" spellcheck="false" />' +
'<input type="text" class="wolai-page-settings-index-path" data-testid="mnote-weknora-create-kb-description" data-knowledge-rag-create-kb-description="true" placeholder="描述,可留空" spellcheck="false" />' +
'<button type="button" class="wolai-page-settings-index-add" data-knowledge-rag-action="create-kb">创建知识库</button>' +
@@ -732,19 +732,19 @@ export function createSidebarPageSettingsRuntime(context) {
'<section class="mnote-weknora-kb-detail-pane" data-testid="mnote-weknora-kb-detail-pane">' +
'<div class="mnote-weknora-kb-detail-hero" data-testid="mnote-weknora-kb-detail-hero">' +
'<div>' +
'<nav class="mnote-weknora-kb-breadcrumb" data-testid="mnote-weknora-kb-breadcrumb" aria-label="知识库面包屑"><span>知识库</span><span>/</span><strong data-testid="mnote-weknora-kb-breadcrumb-current">WeKnora 知识库</strong></nav>' +
'<span class="mnote-weknora-kb-type-pill"><span class="material-symbols-outlined" data-icon="folder" aria-hidden="true"></span>Document KB</span>' +
'<h2 data-testid="mnote-weknora-kb-detail-title">WeKnora 知识库</h2>' +
'<p data-testid="mnote-weknora-kb-detail-description">MNote 负责工作区授权和 source registryWeKnora 负责解析、chunk、检索和引用。</p>' +
'<nav class="mnote-weknora-kb-breadcrumb" data-testid="mnote-weknora-kb-breadcrumb" aria-label="知识库面包屑"><span>知识库</span><span>/</span><strong data-testid="mnote-weknora-kb-breadcrumb-current">LightRAG 资料库</strong></nav>' +
'<span class="mnote-weknora-kb-type-pill"><span class="material-symbols-outlined" data-icon="folder" aria-hidden="true"></span>Source Registry</span>' +
'<h2 data-testid="mnote-weknora-kb-detail-title">LightRAG 资料库</h2>' +
'<p data-testid="mnote-weknora-kb-detail-description">MNote 管理本地来源、引用定位和权限边界,LightRAG 负责索引与召回。</p>' +
'</div>' +
'<select class="mnote-kb-params-select" data-testid="mnote-weknora-kb-select" data-knowledge-rag-kb-select="true"><option value="">加载中...</option></select>' +
'</div>' +
'<div class="mnote-knowledge-rag-meta" data-testid="mnote-knowledge-rag-meta"></div>' +
'<div class="mnote-knowledge-rag-detail-tabs" role="tablist" aria-label="WeKnora 知识库详情">' +
'<button type="button" class="mnote-knowledge-rag-detail-tab is-active" role="tab" aria-selected="true" data-kb-rag-tab="sources" data-testid="mnote-weknora-tab-documents">Documents</button>' +
'<div class="mnote-knowledge-rag-detail-tabs" role="tablist" aria-label="LightRAG 知识库详情">' +
'<button type="button" class="mnote-knowledge-rag-detail-tab is-active" role="tab" aria-selected="true" data-kb-rag-tab="sources" data-testid="mnote-weknora-tab-documents">来源</button>' +
'<button type="button" class="mnote-knowledge-rag-detail-tab" role="tab" aria-selected="false" data-kb-rag-tab="wiki" data-testid="mnote-weknora-tab-wiki">Wiki</button>' +
'<button type="button" class="mnote-knowledge-rag-detail-tab" role="tab" aria-selected="false" data-kb-rag-tab="graph" data-testid="mnote-weknora-tab-graph">Graph</button>' +
'<button type="button" class="mnote-knowledge-rag-detail-tab" role="tab" aria-selected="false" data-kb-rag-tab="search">Search</button>' +
'<button type="button" class="mnote-knowledge-rag-detail-tab" role="tab" aria-selected="false" data-kb-rag-tab="search">检索</button>' +
'</div>' +
'<div class="mnote-knowledge-rag-detail-panel" data-kb-rag-panel="sources">' +
'<div class="mnote-weknora-doc-layout" data-testid="mnote-weknora-document-management">' +
@@ -755,7 +755,7 @@ export function createSidebarPageSettingsRuntime(context) {
'<button type="button" data-knowledge-rag-action="filter-sources" data-knowledge-rag-filter="indexed"><span class="material-symbols-outlined" data-icon="check_circle" aria-hidden="true"></span>已索引</button>' +
'<button type="button" data-knowledge-rag-action="filter-sources" data-knowledge-rag-filter="indexing"><span class="material-symbols-outlined" data-icon="sync" aria-hidden="true"></span>处理中</button>' +
'</div>' +
'<div class="mnote-weknora-doc-sidebar-head"><strong>标签</strong><span>WeKnora views</span></div>' +
'<div class="mnote-weknora-doc-sidebar-head"><strong>标签</strong><span>MNote views</span></div>' +
'<div class="mnote-weknora-doc-tags" data-testid="mnote-weknora-doc-tags"><span>PDF</span><span>Markdown</span><span>Folder</span><span>Citation</span></div>' +
'</aside>' +
'<section class="mnote-weknora-doc-main">' +
@@ -767,12 +767,13 @@ export function createSidebarPageSettingsRuntime(context) {
'</div>' +
'<button type="button" data-knowledge-rag-action="use-filetree-selection"><span class="material-symbols-outlined" data-icon="account_tree" aria-hidden="true"></span>添加文件夹</button>' +
'<button type="button" data-knowledge-rag-action="add-source"><span class="material-symbols-outlined" data-icon="upload_file" aria-hidden="true"></span>添加文档</button>' +
'<button type="button" data-knowledge-rag-action="ingest-active"><span class="material-symbols-outlined" data-icon="note_add" aria-hidden="true"></span>加入当前页</button>' +
'</div>' +
'<div class="mnote-knowledge-rag-source-inputs" data-testid="mnote-knowledge-rag-source-inputs">' +
knowledgeRagSourceInputRow('') +
'</div>' +
'<div class="mnote-weknora-doc-actions">' +
'<button type="button" data-knowledge-rag-action="ingest">上传/索引到 WeKnora</button>' +
'<button type="button" data-knowledge-rag-action="ingest">索引到 LightRAG</button>' +
'<button type="button" data-knowledge-rag-action="prune">清除失效记录</button>' +
'</div>' +
'<div class="mnote-knowledge-rag-source-filters" data-testid="mnote-knowledge-rag-source-filters"></div>' +
@@ -783,13 +784,13 @@ export function createSidebarPageSettingsRuntime(context) {
'<div class="mnote-knowledge-rag-detail-panel" data-kb-rag-panel="wiki" hidden>' +
'<div class="mnote-weknora-placeholder-panel" data-testid="mnote-weknora-wiki-panel">' +
'<strong>Wiki</strong>' +
'<span>从 WeKnora 文档解析与 chunk 结果生成知识页入口;当前保留 MNote open-reference 边界。</span>' +
'<span>后续可基于 LightRAG 召回结果生成知识页;当前 MNote open-reference 边界为准。</span>' +
'</div>' +
'</div>' +
'<div class="mnote-knowledge-rag-detail-panel" data-kb-rag-panel="graph" hidden>' +
'<div class="mnote-weknora-placeholder-panel" data-testid="mnote-weknora-graph-panel">' +
'<strong>Graph</strong>' +
'<span>展示 provider 侧文档关系与引用入口,不迁移 WeKnora tenant 或文件真相。</span>' +
'<span>展示 LightRAG 图谱和文档关系入口,本地文件真相仍由 MNote source registry 管理。</span>' +
'</div>' +
'</div>' +
'<div class="mnote-knowledge-rag-detail-panel" data-kb-rag-panel="search" hidden>' +
@@ -798,7 +799,7 @@ export function createSidebarPageSettingsRuntime(context) {
'<button type="button" class="wolai-page-settings-index-add" data-kb-rag-action="search">搜索</button>' +
'</div>' +
'<div class="mnote-kb-search-results" data-kb-rag-search-results="true">' +
'<span class="mnote-extensions-loading">输入关键词后用 WeKnora hybrid 检索</span>' +
'<span class="mnote-extensions-loading">输入关键词后用 LightRAG hybrid 检索</span>' +
'</div>' +
'</div>' +
'<div class="mnote-knowledge-rag-detail-panel" data-kb-rag-panel="params" hidden>' +
@@ -806,7 +807,7 @@ export function createSidebarPageSettingsRuntime(context) {
'<div class="mnote-kb-params-row">' +
'<label class="mnote-kb-params-label">Provider 检索策略</label>' +
'<select class="mnote-kb-params-select" data-kb-rag-param="mode">' +
'<option value="hybrid" selected>WeKnora hybrid</option>' +
'<option value="hybrid" selected>LightRAG hybrid</option>' +
'<option value="vector">向量召回</option>' +
'<option value="keyword">关键词召回</option>' +
'<option value="rerank">Hybrid + rerank</option>' +
@@ -1214,11 +1215,11 @@ export function createSidebarPageSettingsRuntime(context) {
}
function knowledgeRagCurrentFilter() {
return pageUiState.knowledgeRagSourceFilter || 'attention';
return pageUiState.knowledgeRagSourceFilter || 'all';
}
function setKnowledgeRagSourceFilter(filter) {
pageUiState.knowledgeRagSourceFilter = String(filter || 'attention').trim() || 'attention';
pageUiState.knowledgeRagSourceFilter = String(filter || 'all').trim() || 'all';
renderKnowledgeRagSettings();
}
@@ -1226,7 +1227,8 @@ export function createSidebarPageSettingsRuntime(context) {
var deleted = entry && entry.deletedAtMs;
var stale = entry && entry.stale;
var providerStatus = knowledgeRagEntryProviderStatus(entry);
var failed = providerStatus === 'failed';
var providerMissing = providerStatus === 'provider_missing';
var failed = providerStatus === 'failed' || providerMissing;
var deleting = providerStatus === 'delete_submitted';
var deletedDone = providerStatus === 'delete_completed';
var docId = knowledgeRagEntryProviderDocId(entry);
@@ -1239,14 +1241,14 @@ export function createSidebarPageSettingsRuntime(context) {
function knowledgeRagProviderName(status) {
var config = status && status.providerConfig && typeof status.providerConfig === 'object' ? status.providerConfig : {};
return String(status && status.provider || config.active || 'weknora').trim() || 'weknora';
return String(status && status.provider || config.active || 'lightrag_legacy').trim() || 'lightrag_legacy';
}
function knowledgeRagProviderLabel(status) {
var providerName = knowledgeRagProviderName(status);
if (providerName === 'weknora') return 'WeKnora';
if (providerName === 'lightrag') return 'LightRAG legacy';
if (providerName === 'lightrag_legacy') return 'LightRAG legacy';
if (providerName === 'lightrag') return 'LightRAG';
if (providerName === 'lightrag_legacy') return 'LightRAG';
return providerName;
}
@@ -1405,14 +1407,25 @@ export function createSidebarPageSettingsRuntime(context) {
seen[providerKbId] = true;
output.push({
providerKbId: providerKbId,
name: 'WeKnora KB ' + providerKbId.slice(0, 8),
description: '由 WeKnora provider 配置注入的知识库',
name: 'Provider KB ' + providerKbId.slice(0, 8),
description: '由知识库 provider 配置注入的资料库',
sourceCount: 0,
chunkCount: 0,
updatedAt: '',
status: 'configured'
});
});
if (!output.length && ['lightrag', 'lightrag_legacy'].includes(knowledgeRagProviderName(status || {}))) {
output.push({
providerKbId: 'lightrag-default',
name: '默认资料库',
description: 'LightRAG 单工作区索引,由 MNote source registry 划分范围',
sourceCount: ((status && status.registry && Array.isArray(status.registry.entries)) ? status.registry.entries.length : 0),
chunkCount: 0,
updatedAt: '',
status: 'active'
});
}
return output;
}
@@ -1432,6 +1445,7 @@ export function createSidebarPageSettingsRuntime(context) {
function knowledgeRagEntriesForKb(entries, providerKnowledgeBaseId) {
var selected = String(providerKnowledgeBaseId || '').trim();
if (selected === 'lightrag-default') return Array.isArray(entries) ? entries : [];
return (Array.isArray(entries) ? entries : []).filter(function(entry) {
return selected && knowledgeRagEntryProviderKnowledgeBaseId(entry) === selected;
});
@@ -1439,6 +1453,7 @@ export function createSidebarPageSettingsRuntime(context) {
function knowledgeRagProviderDocsForKb(providerDocs, providerKnowledgeBaseId) {
var selected = String(providerKnowledgeBaseId || '').trim();
if (selected === 'lightrag-default') return Array.isArray(providerDocs) ? providerDocs : [];
return (Array.isArray(providerDocs) ? providerDocs : []).filter(function(doc) {
var id = String(doc && (doc.knowledgeBaseId || doc.knowledge_base_id || doc.providerKnowledgeBaseId || doc.provider_knowledge_base_id) || '').trim();
return selected && id === selected;
@@ -1482,17 +1497,18 @@ export function createSidebarPageSettingsRuntime(context) {
if (!(listNode instanceof HTMLElement)) return;
var bases = knowledgeRagKnowledgeBases(status || {});
var selected = selectedKnowledgeRagProviderKnowledgeBaseId(status || {});
var providerName = knowledgeRagProviderName(status || {});
if (countNode instanceof HTMLElement) countNode.textContent = String(bases.length);
if (!bases.length) {
listNode.innerHTML = '<div class="wolai-page-settings-index-empty">还没有 WeKnora 知识库</div>';
listNode.innerHTML = '<div class="wolai-page-settings-index-empty">还没有知识库来源</div>';
return;
}
listNode.innerHTML = bases.map(function(base) {
var stats = knowledgeRagBaseStats(base, entries, providerDocs);
var isActive = base.providerKbId === selected;
var status = String(base.status || 'active');
var baseStatus = String(base.status || 'active');
return '' +
'<button type="button" class="mnote-weknora-kb-card' + (isActive ? ' is-active' : '') + '" data-knowledge-rag-action="select-kb" data-knowledge-rag-kb-id="' + escapeHtml(base.providerKbId) + '" data-knowledge-rag-kb-provider="weknora" data-knowledge-rag-processing-count="' + escapeHtml(String(stats.processingCount)) + '">' +
'<button type="button" class="mnote-weknora-kb-card' + (isActive ? ' is-active' : '') + '" data-knowledge-rag-action="select-kb" data-knowledge-rag-kb-id="' + escapeHtml(base.providerKbId) + '" data-knowledge-rag-kb-provider="' + escapeHtml(providerName) + '" data-knowledge-rag-processing-count="' + escapeHtml(String(stats.processingCount)) + '">' +
'<span class="mnote-weknora-kb-card-star"><span class="material-symbols-outlined" data-icon="folder" aria-hidden="true"></span></span>' +
'<span class="mnote-weknora-kb-card-main">' +
'<strong title="' + escapeHtml(base.name) + '">' + escapeHtml(base.name) + '</strong>' +
@@ -1503,7 +1519,7 @@ export function createSidebarPageSettingsRuntime(context) {
(stats.processingCount ? '<i class="is-processing"><span class="material-symbols-outlined" data-icon="sync" aria-hidden="true"></span>' + escapeHtml(String(stats.processingCount)) + '</i>' : '') +
'</span>' +
'</span>' +
'<span class="mnote-weknora-kb-card-status" data-status="' + escapeHtml(status) + '">' + escapeHtml(status) + '</span>' +
'<span class="mnote-weknora-kb-card-status" data-status="' + escapeHtml(baseStatus) + '">' + escapeHtml(baseStatus) + '</span>' +
'</button>';
}).join('');
}
@@ -1518,7 +1534,7 @@ export function createSidebarPageSettingsRuntime(context) {
var base = bases.find(function(item) { return item.providerKbId === selected; }) || bases[0] || null;
if (!base) {
titleNode.textContent = '未配置知识库';
descNode.textContent = '创建 WeKnora 知识库后,可在这里管理 MNote 授权的本地文件和引用。';
descNode.textContent = '加入资料来源后,可在这里管理 MNote 授权的本地文件和引用。';
if (breadcrumbNode instanceof HTMLElement) breadcrumbNode.textContent = '未配置';
return;
}
@@ -1562,7 +1578,7 @@ export function createSidebarPageSettingsRuntime(context) {
var activeCount = Object.keys(activeKbIds).length || kbIds.length;
var processingCount = knowledgeRagInFlightCount(entries, providerDocs);
return '' +
'<div class="mnote-weknora-kb-summary-card" data-testid="mnote-weknora-kb-summary" data-knowledge-rag-kb-id="' + escapeHtml(kbIds[0] || '') + '" data-knowledge-rag-kb-ids="' + escapeHtml(kbIds.join(',')) + '" data-knowledge-rag-kb-provider="weknora" data-knowledge-rag-kb-status="configured">' +
'<div class="mnote-weknora-kb-summary-card" data-testid="mnote-weknora-kb-summary" data-knowledge-rag-kb-id="' + escapeHtml(kbIds[0] || '') + '" data-knowledge-rag-kb-ids="' + escapeHtml(kbIds.join(',')) + '" data-knowledge-rag-kb-provider="' + escapeHtml(knowledgeRagProviderName(status || {})) + '" data-knowledge-rag-kb-status="configured">' +
'<div><span>知识库</span><strong>' + escapeHtml(activeCount ? String(activeCount) + ' 个' : '未配置') + '</strong></div>' +
'<div><span>文档</span><strong>' + escapeHtml(String((entries || []).length)) + '</strong></div>' +
'<div><span>Provider docs</span><strong>' + escapeHtml(String((providerDocs || []).length)) + '</strong></div>' +
@@ -1873,6 +1889,7 @@ export function createSidebarPageSettingsRuntime(context) {
return;
}
var pipelineProgress = knowledgeRagPipelineProgress(status || {});
var providerName = knowledgeRagProviderName(status || {});
var providerDocs = knowledgeRagProviderDocuments(status || {});
var providerDocsById = new Map();
var providerDocsByPath = new Map();
@@ -1895,7 +1912,7 @@ export function createSidebarPageSettingsRuntime(context) {
var knowledgeId = knowledgeRagProviderKnowledgeId(doc);
var providerTitle = providerPath || providerId || 'Provider 文档';
var chunkCount = knowledgeRagProviderChunkCount(doc);
var providerMeta = 'provider=weknora · 未映射到 MNote source registry · ' + docStatus
var providerMeta = 'provider=' + providerName + ' · 未映射到 MNote source registry · ' + docStatus
+ (providerId ? ' · provider id ' + providerId : '')
+ (chunkCount ? ' · chunk ' + chunkCount : '')
+ (providerPath ? '' : ' · 定位降级');
@@ -1903,7 +1920,7 @@ export function createSidebarPageSettingsRuntime(context) {
providerProgress = knowledgeRagIndeterminateProgress(status || {}, doc, docStatus);
}
return '' +
'<div class="mnote-knowledge-rag-source-row" data-knowledge-rag-source-row="true" data-knowledge-rag-source-kind="provider" data-knowledge-rag-provider="weknora" data-knowledge-rag-provider-id="' + escapeHtml(providerId) + '" data-knowledge-rag-knowledge-id="' + escapeHtml(knowledgeId) + '" data-knowledge-rag-chunk-count="' + escapeHtml(String(chunkCount || 0)) + '" data-knowledge-rag-processing-status="' + escapeHtml(docStatus) + '" data-knowledge-rag-open-reference-mode="provider-reference-fallback" data-knowledge-rag-source-path="' + escapeHtml(providerPath || providerId) + '">' +
'<div class="mnote-knowledge-rag-source-row" data-knowledge-rag-source-row="true" data-knowledge-rag-source-kind="provider" data-knowledge-rag-provider="' + escapeHtml(providerName) + '" data-knowledge-rag-provider-id="' + escapeHtml(providerId) + '" data-knowledge-rag-knowledge-id="' + escapeHtml(knowledgeId) + '" data-knowledge-rag-chunk-count="' + escapeHtml(String(chunkCount || 0)) + '" data-knowledge-rag-processing-status="' + escapeHtml(docStatus) + '" data-knowledge-rag-open-reference-mode="provider-reference-fallback" data-knowledge-rag-source-path="' + escapeHtml(providerPath || providerId) + '">' +
'<span class="wolai-page-settings-index-status-dot" data-index-status="' + (failedDoc ? 'fault' : 'indexed') + '" title="' + escapeHtml(docStatus || 'Provider 文档') + '"></span>' +
'<div class="mnote-knowledge-rag-source-main">' +
'<strong title="' + escapeHtml(providerTitle) + '">' + escapeHtml(providerTitle) + '</strong>' +
@@ -1918,7 +1935,8 @@ export function createSidebarPageSettingsRuntime(context) {
var deleted = entry && entry.deletedAtMs;
var stale = entry && entry.stale;
var providerStatus = knowledgeRagEntryProviderStatus(entry);
var failed = providerStatus === 'failed';
var providerMissing = providerStatus === 'provider_missing';
var failed = providerStatus === 'failed' || providerMissing;
var deleting = providerStatus === 'delete_submitted';
var deletedDone = providerStatus === 'delete_completed';
var docId = knowledgeRagEntryProviderDocId(entry);
@@ -1930,7 +1948,8 @@ export function createSidebarPageSettingsRuntime(context) {
: deleting ? '删除已提交'
: deleted ? '已删除'
: stale ? '已过期'
: failed ? '索引失败'
: providerMissing ? 'Provider 索引缺失'
: failed ? '索引失败'
: indexed ? '已索引'
: (docId || providerStatus ? '正在索引' : '待提交');
var providerDoc = (docId && providerDocsById.get(docId)) || providerDocsByPath.get(String(entry && entry.lightRagFilePath || '').trim()) || null;
@@ -1939,7 +1958,7 @@ export function createSidebarPageSettingsRuntime(context) {
var kbId = knowledgeRagEntryProviderKnowledgeBaseId(entry);
var metaParts = [
statusLabel,
'provider=weknora',
'provider=' + providerName,
'MNote source registry',
kbId ? 'kb ' + kbId : '',
providerIds.length ? 'provider ids ' + providerIds.join(', ') : '',
@@ -1955,7 +1974,7 @@ export function createSidebarPageSettingsRuntime(context) {
entryProgress = knowledgeRagIndeterminateProgress(status || {}, providerDoc, providerStatus);
}
return '' +
'<div class="mnote-knowledge-rag-source-row" data-knowledge-rag-source-row="true" data-knowledge-rag-provider="weknora" data-knowledge-rag-provider-ids="' + escapeHtml(providerIds.join(',')) + '" data-knowledge-rag-kb-id="' + escapeHtml(kbId) + '" data-knowledge-rag-knowledge-id="' + escapeHtml(knowledgeId) + '" data-knowledge-rag-chunk-count="' + escapeHtml(String(chunkCount || 0)) + '" data-knowledge-rag-processing-status="' + escapeHtml(providerStatus || statusLabel) + '" data-knowledge-rag-open-reference-mode="source-registry-reference" data-knowledge-rag-source-path="' + escapeHtml(sourcePath) + '">' +
'<div class="mnote-knowledge-rag-source-row" data-knowledge-rag-source-row="true" data-knowledge-rag-provider="' + escapeHtml(providerName) + '" data-knowledge-rag-provider-ids="' + escapeHtml(providerIds.join(',')) + '" data-knowledge-rag-kb-id="' + escapeHtml(kbId) + '" data-knowledge-rag-knowledge-id="' + escapeHtml(knowledgeId) + '" data-knowledge-rag-chunk-count="' + escapeHtml(String(chunkCount || 0)) + '" data-knowledge-rag-processing-status="' + escapeHtml(providerStatus || statusLabel) + '" data-knowledge-rag-open-reference-mode="source-registry-reference" data-knowledge-rag-source-path="' + escapeHtml(sourcePath) + '">' +
'<span class="wolai-page-settings-index-status-dot" data-index-status="' + kind + '" title="' + escapeHtml(statusLabel) + '"></span>' +
'<div class="mnote-knowledge-rag-source-main">' +
'<strong title="' + escapeHtml(title) + '">' + escapeHtml(title || '未命名来源') + '</strong>' +
@@ -1980,7 +1999,7 @@ export function createSidebarPageSettingsRuntime(context) {
if (!(statusNode instanceof HTMLElement)) return;
if (!knowledgeRagIsAvailable()) {
statusNode.textContent = '资料库问答仅在本地工作区可用';
statusNode.textContent = '知识库仅在本地工作区可用';
if (metaNode instanceof HTMLElement) metaNode.innerHTML = '';
renderKnowledgeRagSourceFilters(popover, []);
renderKnowledgeRagSources(popover, [], {});
@@ -2013,7 +2032,7 @@ export function createSidebarPageSettingsRuntime(context) {
if (metaNode instanceof HTMLElement) {
metaNode.setAttribute('data-knowledge-rag-active-provider', knowledgeRagProviderName(status));
metaNode.setAttribute('data-knowledge-rag-legacy-fallback-provider', 'lightrag');
metaNode.setAttribute('data-knowledge-rag-reference-boundary', 'mnote-source-registry-weknora-kb-chunk-index');
metaNode.setAttribute('data-knowledge-rag-reference-boundary', 'mnote-source-registry-lightrag-sidecar');
}
var healthStatus = String(healthBody.status || '');
var healthy = health.ok === true && (!healthStatus || healthStatus === 'healthy' || healthStatus === 'ok');
@@ -2054,7 +2073,7 @@ export function createSidebarPageSettingsRuntime(context) {
metaNode.innerHTML = '' +
knowledgeRagKbSummaryHtml(status, entries, providerDocs) +
'<div><span>Provider</span><code>' + escapeHtml(providerLabel) + '</code></div>' +
'<div><span>Boundary</span><code>MNote source registry / WeKnora KB chunk index</code></div>' +
'<div><span>Boundary</span><code>MNote source registry / LightRAG sidecar locator</code></div>' +
'<div><span>Dashboard</span><code>' + escapeHtml(dashboardUrl || '未配置') + '</code></div>' +
'<div><span>Model</span><code>' + escapeHtml(llmModel || '未上报') + (vlmModel ? ' / VLM ' + escapeHtml(vlmModel) : '') + '</code></div>' +
'<div><span>Rerank</span><code>' + escapeHtml(rerankLabel + (rerankBinding ? ' · ' + rerankBinding : '') + (rerankModel ? ' · ' + rerankModel : '')) + '</code></div>' +
@@ -2067,8 +2086,12 @@ export function createSidebarPageSettingsRuntime(context) {
function publicKnowledgeRagDashboardUrl(status) {
var config = status && status.providerConfig && typeof status.providerConfig === 'object' ? status.providerConfig : {};
var active = knowledgeRagProviderName(status || {});
var weknora = config.weknora && typeof config.weknora === 'object' ? config.weknora : {};
var url = String((weknora && weknora.dashboardUrl) || (status && (status.dashboardUrl || status.endpoint)) || '').trim();
var lightrag = config.lightrag && typeof config.lightrag === 'object' ? config.lightrag : {};
var url = active === 'weknora'
? String((weknora && weknora.dashboardUrl) || (status && (status.dashboardUrl || status.endpoint)) || '').trim()
: String((status && (status.dashboardUrl || status.endpoint)) || (lightrag && lightrag.dashboardUrl) || '').trim();
if (!url) return '';
try {
var parsed = new URL(url, window.location.href);
@@ -2496,10 +2519,12 @@ export function createSidebarPageSettingsRuntime(context) {
}
function openKnowledgeRagDashboard() {
var status = currentKnowledgeRagSummary().status || {};
var url = publicKnowledgeRagDashboardUrl(status);
if (!url) return;
window.open(url, '_blank', 'noopener');
var url = publicKnowledgeRagDashboardUrl(currentKnowledgeRagSummary().status || {});
if (url && typeof window.open === 'function') {
window.open(url, '_blank', 'noopener,noreferrer');
return;
}
if (url) window.location.href = url;
}
// --- 知识库详情 tab 切换 ---
@@ -2612,7 +2637,7 @@ export function createSidebarPageSettingsRuntime(context) {
var providerKnowledgeId = String(providerIds.knowledgeId || r.providerKnowledgeId || '').trim();
var providerChunkId = String(providerIds.chunkId || r.providerChunkId || r.chunkId || '').trim();
var preview = String(content).substring(0, 200);
html += '<div class="mnote-kb-search-result-item" data-kb-rag-search-result="true" data-knowledge-rag-provider="weknora" data-knowledge-rag-kb-id="' + escapeHtml(providerKnowledgeBaseId) + '" data-knowledge-rag-knowledge-id="' + escapeHtml(providerKnowledgeId) + '" data-knowledge-rag-chunk-id="' + escapeHtml(providerChunkId) + '" data-knowledge-rag-source-path="' + escapeHtml(String(source)) + '">' +
html += '<div class="mnote-kb-search-result-item" data-kb-rag-search-result="true" data-knowledge-rag-provider="' + escapeHtml(knowledgeRagProviderName(currentKnowledgeRagSummary().status || {})) + '" data-knowledge-rag-kb-id="' + escapeHtml(providerKnowledgeBaseId) + '" data-knowledge-rag-knowledge-id="' + escapeHtml(providerKnowledgeId) + '" data-knowledge-rag-chunk-id="' + escapeHtml(providerChunkId) + '" data-knowledge-rag-source-path="' + escapeHtml(String(source)) + '">' +
'<div class="mnote-kb-search-result-header">' +
'<span class="mnote-kb-search-result-index">#' + (i + 1) + '</span>' +
'<span class="mnote-kb-search-result-score" data-kb-rag-score-label="provider-ranking-score" data-kb-rag-score-is-similarity-percent="false">provider 排序分: ' + score + '</span>' +
@@ -2809,14 +2834,20 @@ export function createSidebarPageSettingsRuntime(context) {
if (!resp.ok) throw new Error('HTTP ' + resp.status);
var data = await resp.json();
_knowledgeRagExtensionsCache = data;
var indexedCount = (data.files && data.files.length) || data.total_files || 0;
var statusText = data.ready ? '就绪' : '未就绪';
var statusClass = data.ready ? 'mnote-status-ready' : 'mnote-status-pending';
var docs = knowledgeRagProviderDocuments(data);
var registry = data && data.registry && typeof data.registry === 'object' ? data.registry : {};
var entries = Array.isArray(registry.entries) ? registry.entries : [];
var indexedCount = entries.length || docs.length || data.total_files || 0;
var health = data && data.health && typeof data.health === 'object' ? data.health : {};
var body = health.health && typeof health.health === 'object' ? health.health : {};
var ready = data && data.ok === true && health.ok === true && (!body.status || body.status === 'healthy' || body.status === 'ok');
var statusText = ready ? '就绪' : '未就绪';
var statusClass = ready ? 'mnote-status-ready' : 'mnote-status-pending';
var lastIngest = data.last_ingest_time || '';
statusEl.innerHTML = '' +
'<div class="mnote-extensions-item">' +
'<div class="mnote-extensions-item-row"><span class="mnote-extensions-label">知识库 provider 状态</span><span class="' + statusClass + '">' + escapeHtml(String(statusText)) + '</span></div>' +
'<div class="mnote-extensions-item-row"><span class="mnote-extensions-label">已索引文件</span><span>' + indexedCount + ' 个</span></div>' +
'<div class="mnote-extensions-item-row"><span class="mnote-extensions-label">已索引来源</span><span>' + indexedCount + ' 个</span></div>' +
(lastIngest ? '<div class="mnote-extensions-item-row"><span class="mnote-extensions-label">最近更新</span><span>' + escapeHtml(String(lastIngest)) + '</span></div>' : '') +
'<div class="mnote-extensions-item-actions">' +
'<button type="button" class="wolai-page-settings-action" data-knowledge-rag-action="manage">管理知识库...</button>' +
@@ -3166,9 +3197,15 @@ export function createSidebarPageSettingsRuntime(context) {
var resp = await fetch('/api/knowledge-rag/status');
if (!resp.ok) throw new Error('HTTP ' + resp.status);
var data = await resp.json();
var count = (data.files && data.files.length) || data.total_files || 0;
var statusText = data.ready ? '就绪' : '未就绪';
el.textContent = count + ' 文件 / ' + statusText;
var docs = knowledgeRagProviderDocuments(data);
var registry = data && data.registry && typeof data.registry === 'object' ? data.registry : {};
var entries = Array.isArray(registry.entries) ? registry.entries : [];
var health = data && data.health && typeof data.health === 'object' ? data.health : {};
var body = health.health && typeof health.health === 'object' ? health.health : {};
var ready = data && data.ok === true && health.ok === true && (!body.status || body.status === 'healthy' || body.status === 'ok');
var count = entries.length || docs.length || data.total_files || 0;
var statusText = ready ? '就绪' : '未就绪';
el.textContent = count + ' 来源 / ' + statusText;
} catch (_) {
el.textContent = '不可用';
}
@@ -2157,12 +2157,12 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
'<div class="wolai-search-options-left">' +
'<span class="wolai-search-switch-control"><span>仅匹配标题</span><button type="button" class="wolai-search-switch" data-search-switch="title" role="switch" aria-checked="false" aria-label="仅匹配标题"></button></span>' +
'<span class="wolai-search-switch-control"><span>精确匹配</span><button type="button" class="wolai-search-switch" data-search-switch="exact" role="switch" aria-checked="false" aria-label="精确匹配"></button></span>' +
'<span class="wolai-search-sort-control"><span>资料库模式</span><select class="wolai-search-sort-value" data-search-knowledge-mode aria-label="资料库检索模式"><option value="exact" selected>关键词</option><option value="mix">综合</option><option value="hybrid">图谱混合</option><option value="naive">向量</option><option value="local">实体</option><option value="global">关系</option></select></span>' +
'<span class="wolai-search-sort-control"><span>知识库模式</span><select class="wolai-search-sort-value" data-search-knowledge-mode aria-label="知识库检索模式"><option value="exact" selected>关键词</option><option value="mix">综合</option><option value="hybrid">图谱混合</option><option value="naive">向量</option><option value="local">实体</option><option value="global">关系</option></select></span>' +
'<span class="wolai-search-sort-control"><span>按编辑时间</span><button type="button" class="wolai-search-sort-value" data-search-sort="updated" aria-label="按编辑时间范围">所有</button></span>' +
'<span class="wolai-search-sort-control"><span>按创建时间</span><button type="button" class="wolai-search-sort-value" data-search-sort="created" aria-label="按创建时间范围">所有</button></span>' +
'</div>' +
'<div class="wolai-search-options-right">' +
'<span class="wolai-search-switch-control"><span>全盘资料库</span><button type="button" class="wolai-search-switch is-on" data-search-switch="knowledge" role="switch" aria-checked="true" aria-label="全盘资料库检索"></button></span>' +
'<span class="wolai-search-switch-control"><span>全盘知识库</span><button type="button" class="wolai-search-switch is-on" data-search-switch="knowledge" role="switch" aria-checked="true" aria-label="全盘知识库检索"></button></span>' +
'<span class="wolai-search-switch-control"><span>页面内搜索</span><button type="button" class="wolai-search-switch" data-search-switch="page" role="switch" aria-checked="false" aria-label="页面内搜索"></button></span>' +
'<span class="wolai-search-switch-control"><span>折叠同来源</span><button type="button" class="wolai-search-switch is-on" data-search-switch="collapseSource" role="switch" aria-checked="true" aria-label="默认折叠相同来源结果"></button></span>' +
'</div>' +
@@ -2703,7 +2703,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
var knowledgeMode = searchSwitchValue(overlay, 'knowledge');
if (knowledgeMode && searchNonWhitespaceCharCount(query) < 2) {
activeSearchRequestId += 1;
meta.innerHTML = '<span>资料库检索</span><span>请输入至少 2 个字再搜索</span>';
meta.innerHTML = '<span>知识库检索</span><span>请输入至少 2 个字再搜索</span>';
results.innerHTML = '<div class="wolai-search-empty" data-search-empty="true">请输入至少 2 个字再搜索</div>';
window.__mnoteSearchResults = [];
saveSearchUiState(overlay);
@@ -2746,7 +2746,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
var payload = await response.json();
if (requestId !== activeSearchRequestId) return;
var items = Array.isArray(payload.results) ? payload.results : [];
var ownerLabel = knowledgeMode ? ('资料库检索 · ' + knowledgeSearchModeLabel(payload.retrievalMode || knowledgeRequestMode)) : '工作区搜索';
var ownerLabel = knowledgeMode ? ('知识库检索 · ' + knowledgeSearchModeLabel(payload.retrievalMode || knowledgeRequestMode)) : '工作区搜索';
meta.innerHTML = '<span>' + ownerLabel + ' · 共 ' + items.length + ' 条匹配结果</span><span>Ctrl + Enter 新窗口打开 / Alt + Enter 右侧边栏打开</span>';
if (!items.length) {
results.innerHTML = '<div class="wolai-search-empty" data-search-empty="true">没有搜索结果</div>';
@@ -2988,6 +2988,13 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
var knowledgeRagSettingsTrigger = closestAction(e.target, '[data-mnote-action="open-knowledge-rag-settings"]');
if (knowledgeRagSettingsTrigger) {
e.preventDefault();
window.location.href = '/knowledge';
return;
}
var knowledgeRagDebugTrigger = closestAction(e.target, '[data-mnote-action="open-knowledge-rag-debug-settings"]');
if (knowledgeRagDebugTrigger) {
e.preventDefault();
openKnowledgeRagSettingsPopover();
return;
@@ -3087,6 +3094,10 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
void ingestKnowledgeRagSources();
return;
}
if (knowledgeRagActionName === 'ingest-active') {
window.dispatchEvent(new CustomEvent('mnote:knowledge-rag-settings-action', { detail: { action: 'run-active' } }));
return;
}
if (knowledgeRagActionName === 'reindex-source') {
void ingestSingleKnowledgeRagSource(knowledgeRagAction.getAttribute('data-knowledge-rag-source-path') || '');
return;
@@ -3866,5 +3877,10 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
if (isPageSettingsOpen()) renderPageSettingsPopover();
});
restoreSidebarTreeTab();
if (window.location.pathname === '/debug/knowledge-rag' || window.location.pathname === '/knowledge') {
window.setTimeout(function() {
openKnowledgeRagSettingsPopover();
}, 0);
}
startLocalFolderSidebarWatch();
})();
+7 -23
View File
@@ -8,7 +8,9 @@ use axum::extract::Request;
use axum::middleware::Next;
use axum::response::Response;
use axum::Router;
use control_plane::{ControlPlaneStore, SqliteControlPlaneStore};
use control_plane::ControlPlaneStore;
#[cfg(test)]
use control_plane::SqliteControlPlaneStore;
#[cfg(not(test))]
use control_plane::{TursoControlPlaneConfig, TursoControlPlaneMode, TursoControlPlaneStore};
use std::env;
@@ -201,7 +203,7 @@ impl AppState {
#[cfg(test)]
pub(crate) fn open_control_plane_store() -> Arc<dyn ControlPlaneStore> {
Arc::new(SqliteControlPlaneStore::in_memory().expect("初始化测试 SQLite 控制面"))
Arc::new(SqliteControlPlaneStore::in_memory().expect("初始化测试 control-plane"))
}
#[cfg(not(test))]
@@ -210,9 +212,8 @@ pub(crate) fn open_control_plane_store() -> Arc<dyn ControlPlaneStore> {
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "sqlite".to_string());
.unwrap_or_else(|| "libsql-local".to_string());
match backend.as_str() {
"sqlite" => open_sqlite_control_plane_store(),
"libsql-local" | "turso-local" | "turso" => open_turso_local_control_plane_store(),
"turso-remote" => open_turso_remote_control_plane_store(),
"turso-local-replica" | "turso-remote-replica" => {
@@ -220,33 +221,16 @@ pub(crate) fn open_control_plane_store() -> Arc<dyn ControlPlaneStore> {
}
"turso-synced" => open_turso_synced_control_plane_store(),
other => panic!(
"不支持的控制面后端 {other},支持 sqlite/libsql-local/turso-remote/turso-local-replica/turso-synced"
"不支持的控制面后端 {other}mnote-web 运行时只支持 libsql-local/turso-remote/turso-local-replica/turso-syncedSQLite 仅保留给 control-plane-admin 迁移/导出和测试"
),
}
}
#[cfg(not(test))]
fn open_sqlite_control_plane_store() -> Arc<dyn ControlPlaneStore> {
let db_path = env::var("MNOTE_CONTROL_PLANE_DB_PATH")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| "/mnt/Data1T/Mnote_data/control-plane/control-plane.db".to_string());
if let Some(parent) = std::path::Path::new(&db_path).parent() {
fs::create_dir_all(parent).expect("创建 SQLite 控制面目录");
}
Arc::new(SqliteControlPlaneStore::open(&db_path).expect("初始化 SQLite 控制面"))
}
#[cfg(not(test))]
fn open_turso_local_control_plane_store() -> Arc<dyn ControlPlaneStore> {
let db_path = env::var("MNOTE_TURSO_LOCAL_PATH")
.ok()
.filter(|value| !value.trim().is_empty())
.or_else(|| {
env::var("MNOTE_CONTROL_PLANE_DB_PATH")
.ok()
.filter(|value| !value.trim().is_empty())
})
.unwrap_or_else(|| {
"/mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db".to_string()
});
@@ -412,7 +396,7 @@ mod tests {
}
#[test]
fn app_state_initializes_sqlite_control_plane_store() {
fn app_state_initializes_control_plane_store_for_tests() {
let state = AppState::new(test_config());
state
@@ -70,7 +70,7 @@ pub async fn query(
let body = serde_json::from_value::<KnowledgeRagQueryRequest>(args).map_err(|error| {
WebError::bad_request_code(
"mnote_knowledge_rag_query_payload_invalid",
format!("资料库问答参数无效: {error}"),
format!("知识库问答参数无效: {error}"),
)
.with_context(context)
})?;
@@ -95,7 +95,7 @@ pub async fn open_reference(
serde_json::from_value::<KnowledgeRagOpenReferenceRequest>(args).map_err(|error| {
WebError::bad_request_code(
"mnote_knowledge_rag_open_reference_payload_invalid",
format!("资料库引用打开参数无效: {error}"),
format!("知识库引用打开参数无效: {error}"),
)
.with_context(context)
})?;
@@ -119,7 +119,7 @@ pub async fn section_context(
serde_json::from_value::<KnowledgeRagSectionContextRequest>(args).map_err(|error| {
WebError::bad_request_code(
"mnote_knowledge_rag_section_context_payload_invalid",
format!("资料库章节上下文参数无效: {error}"),
format!("知识库章节上下文参数无效: {error}"),
)
.with_context(context)
})?;
@@ -57,10 +57,6 @@ fn doc_tools() -> Vec<Value> {
fn knowledge_rag_tools() -> Vec<Value> {
vec![
knowledge_rag_status_tool(),
weknora_search_tool(),
weknora_list_sources_tool(),
weknora_get_source_status_tool(),
weknora_open_reference_tool(),
knowledge_rag_query_tool(),
knowledge_rag_section_context_tool(),
knowledge_rag_open_reference_tool(),
@@ -359,7 +355,7 @@ fn knowledge_rag_status_tool() -> Value {
}
json!({
"name": "mnote.knowledge_rag.status",
"description": "查看当前资料库 provider 状态、dashboard 地址、source registry 和同步状态。默认 provider 是 WeKnoraLightRAG 仅作为 legacy fallback。",
"description": "查看当前知识库 provider 状态、dashboard 地址、source registry 和同步状态。默认 provider 是 RAGFlowWeKnora/LightRAG 仅作为 legacy fallback。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",
@@ -371,114 +367,6 @@ fn knowledge_rag_status_tool() -> Value {
})
}
fn weknora_scope_properties() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
map.insert("rootUri".into(), json!({ "type": "string" }));
map.insert("scope".into(), json!({ "type": "object" }));
map.insert(
"allowlist".into(),
json!({ "type": "array", "items": { "type": "string" } }),
);
map.insert("allowedRoots".into(), json!({ "type": "array" }));
map.insert("aiAccessScope".into(), json!({ "type": "object" }));
map.insert(
"sourcePaths".into(),
json!({ "type": "array", "items": { "type": "string" } }),
);
}
properties
}
fn weknora_search_tool() -> Value {
let mut properties = weknora_scope_properties();
if let Value::Object(map) = &mut properties {
map.insert("query".into(), json!({ "type": "string" }));
map.insert("topK".into(), json!({ "type": "integer", "default": 10 }));
map.insert(
"includeChunkContent".into(),
json!({ "type": "boolean", "default": true }),
);
}
json!({
"name": "mnote.weknora.search",
"description": "只读调用 WeKnora 检索。必须带 rootUri 和 scope/allowlist,返回 provider ids、MNote source registry 映射以及 locatorDegraded;不要把 WeKnora filename/chunk id 当成本地 path。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"required": ["workspaceId", "rootUri", "query"],
"properties": properties
}
})
}
fn weknora_list_sources_tool() -> Value {
json!({
"name": "mnote.weknora.list_sources",
"description": "只读列出 MNote source registry 与 WeKnora provider 状态。必须带 rootUri 和 scope/allowlist。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"required": ["workspaceId", "rootUri"],
"properties": weknora_scope_properties()
}
})
}
fn weknora_get_source_status_tool() -> Value {
let mut properties = weknora_scope_properties();
if let Value::Object(map) = &mut properties {
map.insert("sourcePath".into(), json!({ "type": "string" }));
map.insert("providerKnowledgeId".into(), json!({ "type": "string" }));
map.insert("providerSourceId".into(), json!({ "type": "string" }));
}
json!({
"name": "mnote.weknora.get_source_status",
"description": "只读查询某个 MNote source 或 WeKnora provider knowledge id 的映射状态。必须带 rootUri 和 scope/allowlist。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"required": ["workspaceId", "rootUri"],
"properties": properties
}
})
}
fn weknora_open_reference_tool() -> Value {
let mut properties = weknora_scope_properties();
if let Value::Object(map) = &mut properties {
map.insert("reference".into(), json!({ "type": "object" }));
map.insert(
"providerKnowledgeBaseId".into(),
json!({ "type": "string" }),
);
map.insert("providerKnowledgeId".into(), json!({ "type": "string" }));
map.insert("providerChunkId".into(), json!({ "type": "string" }));
}
json!({
"name": "mnote.weknora.open_reference",
"description": "只读把 WeKnora reference 映射为 MNote open action。provider ids 独立返回;未命中 source registry 时必须视为 locatorDegraded。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",
"annotations": tool_annotations(true, false, true, false),
"inputSchema": {
"type": "object",
"required": ["workspaceId", "rootUri", "reference"],
"properties": properties
}
})
}
fn knowledge_rag_query_tool() -> Value {
let mut properties = base_identity_properties();
if let Value::Object(map) = &mut properties {
@@ -517,7 +405,7 @@ fn knowledge_rag_query_tool() -> Value {
}
json!({
"name": "mnote.knowledge_rag.query",
"description": "向当前资料库 provider 提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的 references/citations。默认 provider 是 WeKnoraLightRAG 仅 legacy fallback。用户要求链接、来源、引用或证据时优先调用;返回的 citationMarkdown 由 MNote 前端自动追加成可点击来源定位,agent 不应在最终回答中手写 /documents、mnote:// 或搜索引擎包装链接。",
"description": "向当前知识库 provider 提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的 references/citations。默认 provider 是 RAGFlowWeKnora/LightRAG 仅 legacy fallback。用户要求链接、来源、引用或证据时优先调用;返回的 citationMarkdown 由 MNote 前端自动追加成可点击来源定位,agent 不应在最终回答中手写 /documents、mnote:// 或搜索引擎包装链接。",
"schemaVersion": TOOL_SCHEMA_VERSION,
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
"status": "available",
@@ -37,8 +37,8 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
},
MnoteCapabilityPack {
id: "mnote-knowledge-rag",
title: "资料库问答",
description: "通过当前知识库 provider 检索多本书、论文、PDF 和附件资料库,并返回可回跳来源;默认 provider 是 WeKnora",
title: "知识库问答",
description: "通过当前知识库 provider 检索多本书、论文、PDF 和附件,并返回可回跳来源;默认 provider 是 RAGFlow",
category: "knowledge",
agent_ids: &["hermes", "reasonix"],
read_only: true,
@@ -46,10 +46,6 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
tool_names: &[
"mnote.context.snapshot",
"mnote.context.resolve_target",
"mnote.weknora.search",
"mnote.weknora.list_sources",
"mnote.weknora.get_source_status",
"mnote.weknora.open_reference",
"mnote.knowledge_rag.status",
"mnote.knowledge_rag.query",
"mnote.knowledge_rag.section_context",
+24 -24
View File
@@ -134,10 +134,10 @@ pub async fn auth_api(
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web"));
}
if action == "auth:signOut" {
return Ok(build_sqlite_sign_out_response(&state, &context));
return Ok(build_control_plane_sign_out_response(&state, &context));
}
handle_sqlite_auth_action(&state, &context, &payload).await
handle_control_plane_auth_action(&state, &context, &payload).await
}
pub async fn auth_entry(
@@ -2070,7 +2070,7 @@ fn has_real_auth_context(state: &AppState, context: &RequestContext) -> bool {
|| extract_cookie_value(context, COOKIE_MNOTE_WEB_CONVEX_TOKEN).is_some()
}
async fn handle_sqlite_auth_action(
async fn handle_control_plane_auth_action(
state: &AppState,
context: &RequestContext,
payload: &serde_json::Value,
@@ -2131,7 +2131,7 @@ async fn handle_sqlite_auth_action(
role: None,
password_hash: None,
})
.map_err(|error| sqlite_auth_error(context, error))?;
.map_err(|error| control_plane_auth_error(context, error))?;
state
.control_plane()
.create_password_identity(CreatePasswordIdentityInput {
@@ -2140,11 +2140,11 @@ async fn handle_sqlite_auth_action(
username: user.username.clone(),
password: password.clone(),
})
.map_err(|error| sqlite_auth_error(context, error))?;
.map_err(|error| control_plane_auth_error(context, error))?;
state
.control_plane()
.ensure_default_workspace(&user.id)
.map_err(|error| sqlite_auth_error(context, error))?;
.map_err(|error| control_plane_auth_error(context, error))?;
state
.control_plane()
.authenticate_password(AuthenticatePasswordInput {
@@ -2156,7 +2156,7 @@ async fn handle_sqlite_auth_action(
ip_hash: None,
expires_at: None,
})
.map_err(|error| sqlite_auth_error(context, error))?
.map_err(|error| control_plane_auth_error(context, error))?
} else {
let account = params
.get("account")
@@ -2181,7 +2181,7 @@ async fn handle_sqlite_auth_action(
ip_hash: None,
expires_at: None,
})
.map_err(|error| sqlite_auth_error(context, error))?
.map_err(|error| control_plane_auth_error(context, error))?
};
let audit_action = if flow == "signUp" {
@@ -2202,7 +2202,7 @@ async fn handle_sqlite_auth_action(
.unwrap_or_else(|_| "{}".to_string()),
});
let provider_sync_results = if flow == "signUp" {
let provider_sync_results = if matches!(flow, "signUp" | "signIn") {
let directory_grants = state
.control_plane()
.list_directory_grants_for_actor(&resolved.user.id)
@@ -2233,13 +2233,13 @@ async fn handle_sqlite_auth_action(
None
};
let mut response = build_sqlite_auth_response(
let mut response = build_control_plane_auth_response(
context,
&session_token,
&resolved.user.id,
resolved.user.email.as_deref().unwrap_or_default(),
&resolved.user.display_name,
&effective_sqlite_auth_actor_type(&resolved.user.id, &resolved.user.role),
&effective_control_plane_auth_actor_type(&resolved.user.id, &resolved.user.role),
);
if let Some(results) = provider_sync_results {
let all_ok = results.iter().all(|item| item.ok);
@@ -2253,7 +2253,7 @@ async fn handle_sqlite_auth_action(
Ok(response)
}
fn sqlite_auth_error(
fn control_plane_auth_error(
context: &RequestContext,
error: control_plane::ControlPlaneError,
) -> WebError {
@@ -2273,7 +2273,7 @@ fn sqlite_auth_error(
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
}
fn build_sqlite_auth_response(
fn build_control_plane_auth_response(
context: &RequestContext,
session_token: &str,
user_id: &str,
@@ -2286,7 +2286,7 @@ fn build_sqlite_auth_response(
"userId": user_id,
"email": email,
"name": name,
"authMode": "sqliteSession",
"authMode": "controlPlaneSession",
"actorType": actor_type
}))
.into_response();
@@ -2307,7 +2307,7 @@ fn build_sqlite_auth_response(
response
}
fn effective_sqlite_auth_actor_type(user_id: &str, stored_role: &str) -> String {
fn effective_control_plane_auth_actor_type(user_id: &str, stored_role: &str) -> String {
let role = stored_role.trim();
let fallback_role = if role.is_empty() { "user" } else { role };
if crate::routes::local_folder_source::is_local_access_policy_admin_actor(
@@ -2320,7 +2320,7 @@ fn effective_sqlite_auth_actor_type(user_id: &str, stored_role: &str) -> String
}
}
fn build_sqlite_sign_out_response(state: &AppState, context: &RequestContext) -> Response {
fn build_control_plane_sign_out_response(state: &AppState, context: &RequestContext) -> Response {
if let Some(raw_token) = extract_cookie_value(context, COOKIE_MNOTE_SESSION) {
let token_hash = session_token_hash(&raw_token);
if let Ok(Some(resolved)) = state.control_plane().get_session_by_token_hash(&token_hash) {
@@ -3062,7 +3062,7 @@ mod tests {
}
#[tokio::test]
async fn root_entry_uses_sqlite_session_display_name_for_workspace_label() {
async fn root_entry_uses_control_plane_session_display_name_for_workspace_label() {
use control_plane::{session_token_hash, CreateSessionInput, UpsertUserInput};
let app_state = AppState::new(AppConfig {
@@ -3095,7 +3095,7 @@ mod tests {
role: None,
password_hash: None,
})
.expect("upsert sqlite user");
.expect("upsert control-plane user");
app_state
.control_plane()
.create_session(CreateSessionInput {
@@ -3106,7 +3106,7 @@ mod tests {
ip_hash: None,
expires_at: None,
})
.expect("create sqlite session");
.expect("create control-plane session");
let response = build_app(app_state)
.oneshot(
@@ -3745,7 +3745,7 @@ mod tests {
}
#[tokio::test]
async fn auth_api_signup_sets_sqlite_session_cookie_when_compat_disabled() {
async fn auth_api_signup_sets_control_plane_session_cookie_when_compat_disabled() {
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
@@ -3786,11 +3786,11 @@ mod tests {
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["userId"], "new-user");
assert_eq!(payload["email"], "new-user@example.com");
assert_eq!(payload["authMode"], "sqliteSession");
assert_eq!(payload["authMode"], "controlPlaneSession");
}
#[tokio::test]
async fn auth_api_signin_accepts_sqlite_username_after_signup() {
async fn auth_api_signin_accepts_control_plane_username_after_signup() {
let app = app_with_config("http://127.0.0.1:3100".into(), false);
let signup = app
.clone()
@@ -3829,7 +3829,7 @@ mod tests {
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["userId"], "mnote-e2e");
assert_eq!(payload["email"], "mnote.e2e@example.com");
assert_eq!(payload["authMode"], "sqliteSession");
assert_eq!(payload["authMode"], "controlPlaneSession");
}
#[tokio::test]
@@ -3865,7 +3865,7 @@ mod tests {
}
#[tokio::test]
async fn auth_api_signout_clears_sqlite_session_cookie() {
async fn auth_api_signout_clears_control_plane_session_cookie() {
let response = app_with_config("http://127.0.0.1:3100".into(), false)
.oneshot(
Request::builder()
@@ -34,7 +34,7 @@ use tracing::{info, warn};
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_HERMES_CLIENT_OWNER: &str = "x-mnote-hermes-client-owner";
const ACP_RUNTIME_SQLITE_STORE: &str = "sqlite_acp_runtime_store";
const ACP_RUNTIME_CONTROL_PLANE_STORE: &str = "control_plane_acp_runtime_store";
const ACP_ABORT_NOTIFICATION_TIMEOUT_MS: u64 = 2_500;
const LOCAL_SHARE_GRANTS_JSON: &str = "/mnt/Data1T/Mnote_data/control-plane/share-grants.json";
const ENV_LOCAL_SHARE_GRANTS_FILE: &str = "MNOTE_SHARE_GRANTS_FILE";
@@ -493,7 +493,7 @@ pub async fn search_sessions(
Json(json!({
"ok": true,
"traceId": context.trace.trace_id,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
"results": results
})),
));
@@ -590,24 +590,24 @@ async fn list_acp_sessions(
.map(String::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let sqlite_runs = state
let control_plane_runs = state
.control_plane()
.list_ai_runtime_runs(&user_id, workspace_id, document_id, session_id, limit)
.map_err(|error| {
WebError::internal(format!("SQLite ACP session 列表读取失败: {error}"))
.with_context(context)
})?;
if !sqlite_runs.is_empty() {
if !control_plane_runs.is_empty() {
return Ok((
StatusCode::OK,
stamp_client_headers(),
Json(json!({
"ok": true,
"traceId": context.trace.trace_id,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"sessionStorage": "sqlite_control_plane",
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
"sessionStorage": "control_plane",
"legacyPersistence": "local_ai_session_jsonl",
"sessions": sqlite_runs.iter().map(ai_runtime_run_to_json).collect::<Vec<_>>()
"sessions": control_plane_runs.iter().map(ai_runtime_run_to_json).collect::<Vec<_>>()
})),
));
}
@@ -669,7 +669,7 @@ async fn list_acp_sessions(
Json(json!({
"ok": true,
"traceId": context.trace.trace_id,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
"sessions": runs.iter().map(ai_runtime_run_to_json).collect::<Vec<_>>()
})),
));
@@ -897,8 +897,8 @@ pub async fn create_session(
"profile": profile,
"title": payload.title.unwrap_or_else(|| "当前页问答".into()),
"traceId": trace_id,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"sessionStorage": "sqlite_control_plane",
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
"sessionStorage": "control_plane",
"legacyPersistence": "local_ai_session_jsonl",
"legacySessionStorage": session_storage,
"permissionLevel": permission_level,
@@ -973,8 +973,8 @@ pub async fn create_session(
"providerKind": "api-chat",
"title": payload.title.unwrap_or_else(|| "当前页问答".into()),
"traceId": trace_id,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"sessionStorage": "sqlite_control_plane"
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
"sessionStorage": "control_plane"
})),
));
}
@@ -1004,7 +1004,7 @@ pub async fn create_session(
&runtime_payload,
)
.await?;
persistence = ACP_RUNTIME_SQLITE_STORE;
persistence = ACP_RUNTIME_CONTROL_PLANE_STORE;
}
Ok((
StatusCode::OK,
@@ -1450,7 +1450,7 @@ pub async fn toggle_skill(
return Ok((
StatusCode::OK,
stamp_client_headers(),
Json(json!({"ok": true, "skillKind": "mnote_builtin", "configScope": "user_sqlite"})),
Json(json!({"ok": true, "skillKind": "mnote_builtin", "configScope": "user_control_plane"})),
));
}
let access =
@@ -2220,7 +2220,7 @@ pub async fn toggle_capability(
"id": capability_id,
"enabled": enabled,
"profile": profile,
"configScope": "user_sqlite+profile_tool_policy"
"configScope": "user_control_plane+profile_tool_policy"
})),
))
}
@@ -2324,7 +2324,7 @@ pub async fn resume_session(
payload["resumeSource"] = payload
.get("persistence")
.cloned()
.unwrap_or_else(|| Value::String(ACP_RUNTIME_SQLITE_STORE.into()));
.unwrap_or_else(|| Value::String(ACP_RUNTIME_CONTROL_PLANE_STORE.into()));
return Ok((result.0, result.1, Json(payload)));
}
get_session(
@@ -2422,7 +2422,7 @@ pub async fn delete_session(
stamp_client_headers(),
Json(json!({
"ok": true,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
"result": {
"ok": true,
"sessionId": session_id,
@@ -2494,7 +2494,7 @@ pub async fn rename_session(
stamp_client_headers(),
Json(json!({
"ok": true,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
"result": {
"ok": true,
"sessionId": session_id,
@@ -2579,7 +2579,7 @@ pub async fn auto_title_session(
stamp_client_headers(),
Json(json!({
"ok": true,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
"result": {
"ok": true,
"sessionId": session_id,
@@ -2720,8 +2720,8 @@ async fn get_acp_session(
Json(json!({
"ok": true,
"traceId": context.trace.trace_id,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"sessionStorage": "sqlite_control_plane",
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
"sessionStorage": "control_plane",
"legacyPersistence": "local_ai_session_jsonl",
"sessionId": session_id,
"session": {
@@ -2804,7 +2804,7 @@ async fn get_acp_session(
Json(json!({
"ok": true,
"traceId": context.trace.trace_id,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
"sessionId": session_id,
"session": {
"sessionId": session_id,
@@ -2983,7 +2983,7 @@ pub async fn create_run(
"persistence": persistence_result
.get("persistence")
.and_then(Value::as_str)
.unwrap_or(ACP_RUNTIME_SQLITE_STORE),
.unwrap_or(ACP_RUNTIME_CONTROL_PLANE_STORE),
"sessionStorage": persistence_result
.get("sessionStorage")
.cloned()
@@ -3050,7 +3050,7 @@ pub async fn create_run(
"persistence": persistence_result
.get("persistence")
.and_then(Value::as_str)
.unwrap_or(ACP_RUNTIME_SQLITE_STORE),
.unwrap_or(ACP_RUNTIME_CONTROL_PLANE_STORE),
"sessionStorage": persistence_result
.get("sessionStorage")
.cloned()
@@ -4997,7 +4997,7 @@ fn stamp_mnote_builtin_skill_payload_policy(
Value::Bool(ai_preference_bool(state, &actor_id, &key, true)?);
skill["builtin"] = Value::Bool(true);
skill["configurable"] = Value::Bool(true);
skill["configScope"] = Value::String("user_sqlite".to_string());
skill["configScope"] = Value::String("user_control_plane".to_string());
skill["skillKind"] = Value::String("mnote_builtin".to_string());
}
}
@@ -5411,7 +5411,7 @@ fn mnote_capabilities_payload(
"toggleable": skill.get("toggleable").cloned().unwrap_or_else(|| json!(true)),
"builtin": true,
"configurable": true,
"configScope": "user_sqlite+profile_tool_policy",
"configScope": "user_control_plane+profile_tool_policy",
"skillKind": "mnote_capability",
"source": "mnote",
"origin": "builtin",
@@ -6319,7 +6319,7 @@ fn mnote_builtin_skills_payload(agent_id: Option<&str>) -> Value {
"toggleable": true,
"builtin": true,
"configurable": true,
"configScope": "user_sqlite",
"configScope": "user_control_plane",
"skillKind": "mnote_builtin",
"source": "mnote",
"origin": "builtin",
@@ -6853,7 +6853,7 @@ fn enforce_local_ai_run_access(
payload["allowedRoots"] = json!([{
"rootUri": root_uri,
"permission": if chat_only { "read" } else { "write" },
"source": "sqlite_directory_grant",
"source": "control_plane_directory_grant",
"grantIds": access.grant_ids
}]);
Ok(())
@@ -9903,7 +9903,7 @@ fn ai_runtime_run_to_json(record: &control_plane::AiRuntimeRunRecord) -> Value {
"payload": payload,
"createdAt": record.created_at,
"updatedAt": record.updated_at,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
})
}
@@ -10078,7 +10078,7 @@ fn ai_runtime_event_to_json(record: &control_plane::AiRuntimeEventRecord) -> Val
"eventType": record.event_type,
"payload": payload,
"createdAt": record.created_at,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
})
}
@@ -10210,7 +10210,7 @@ fn page_ai_run_to_journal_json(record: &control_plane::AiRuntimeRunRecord) -> Va
"payload": payload,
"createdAt": record.created_at,
"updatedAt": record.updated_at,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
})
}
@@ -10587,7 +10587,7 @@ fn page_ai_journal_event_to_json(record: &control_plane::AiRuntimeJournalEventRe
"acpRuntime": event.acp_runtime,
"payload": payload,
"createdAt": event.created_at,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
})
}
@@ -10615,7 +10615,7 @@ fn page_ai_synthetic_terminal_event_to_json(
"reason": "terminal_status_without_terminal_event"
},
"createdAt": run.updated_at.clone(),
"persistence": ACP_RUNTIME_SQLITE_STORE,
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
"synthetic": true,
})
}
@@ -11645,8 +11645,8 @@ async fn persist_acp_runtime_run(
})?;
Ok(json!({
"ok": true,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"sessionStorage": "sqlite_control_plane",
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
"sessionStorage": "control_plane",
"legacyPersistence": legacy_persistence,
"legacySessionStorage": legacy_session_storage,
"sessionId": record.session_id,
@@ -11824,8 +11824,8 @@ async fn persist_acp_runtime_event(
}
Ok(json!({
"ok": true,
"persistence": ACP_RUNTIME_SQLITE_STORE,
"sessionStorage": "sqlite_control_plane",
"persistence": ACP_RUNTIME_CONTROL_PLANE_STORE,
"sessionStorage": "control_plane",
"legacyPersistence": legacy_persistence,
"legacySessionStorage": legacy_session_storage,
"sessionId": registration.session_id,
@@ -12636,7 +12636,7 @@ mod tests {
}
#[tokio::test]
async fn page_ai_agent_profiles_are_sqlite_user_scoped() {
async fn page_ai_agent_profiles_are_control_plane_user_scoped() {
let response = app()
.oneshot(
Request::builder()
@@ -12743,7 +12743,7 @@ mod tests {
.find(|skill| skill["id"] == "mnote-current-page")
.expect("current page skill");
assert_eq!(current_page["enabled"], false);
assert_eq!(current_page["configScope"], "user_sqlite");
assert_eq!(current_page["configScope"], "user_control_plane");
let mindmap_skill = payload["categories"]
.as_array()
.expect("categories")
@@ -14830,7 +14830,7 @@ mod tests {
}
#[tokio::test]
async fn api_chat_create_session_persists_sqlite_without_acp_session() {
async fn api_chat_create_session_persists_control_plane_without_acp_session() {
let _runtime_guard = runtime_lock().lock().expect("runtime lock");
clear_runtime_registry();
clear_acp_run_payloads();
@@ -14861,8 +14861,8 @@ mod tests {
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
assert_eq!(payload["sessionStorage"], "sqlite_control_plane");
assert_eq!(payload["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
assert_eq!(payload["sessionStorage"], "control_plane");
assert_eq!(payload["providerKind"], "api-chat");
assert_eq!(payload["profile"], "api-gpt-chat");
}
@@ -14998,7 +14998,7 @@ mod tests {
}
#[tokio::test]
async fn api_chat_session_detail_restores_messages_from_sqlite_events() {
async fn api_chat_session_detail_restores_messages_from_control_plane_events() {
let _env_guard = env_lock().lock().expect("env lock");
let _runtime_guard = runtime_lock().lock().expect("runtime lock");
clear_runtime_registry();
@@ -15855,12 +15855,12 @@ mod tests {
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["ok"], true);
assert_eq!(payload["sessionId"], "mnote_doc_1_trace_1");
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
assert_eq!(payload["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
assert!(payload.get("messages").is_none());
}
#[tokio::test]
async fn hermes_client_local_acp_session_create_writes_sqlite_and_private_jsonl() {
async fn hermes_client_local_acp_session_create_writes_control_plane_and_private_jsonl() {
let root = std::env::temp_dir().join(format!(
"mnote-local-ai-session-private-{}",
std::process::id()
@@ -15931,8 +15931,8 @@ mod tests {
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
assert_eq!(payload["sessionStorage"], "sqlite_control_plane");
assert_eq!(payload["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
assert_eq!(payload["sessionStorage"], "control_plane");
assert_eq!(payload["legacyPersistence"], "local_ai_session_jsonl");
assert_eq!(payload["legacySessionStorage"], "local_private");
let session_id = payload["sessionId"].as_str().expect("session id");
@@ -15965,8 +15965,8 @@ mod tests {
.await
.expect("list body");
let list_payload: Value = serde_json::from_slice(&list_body).expect("list json");
assert_eq!(list_payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
assert_eq!(list_payload["sessionStorage"], "sqlite_control_plane");
assert_eq!(list_payload["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
assert_eq!(list_payload["sessionStorage"], "control_plane");
assert_eq!(list_payload["sessions"][0]["sessionId"], session_id);
let detail_uri = format!(
@@ -15991,7 +15991,7 @@ mod tests {
.await
.expect("detail body");
let detail_payload: Value = serde_json::from_slice(&detail_body).expect("detail json");
assert_eq!(detail_payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
assert_eq!(detail_payload["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
assert_eq!(
detail_payload["session"]["runs"][0]["sessionId"],
session_id
@@ -16001,7 +16001,7 @@ mod tests {
}
#[tokio::test]
async fn hermes_client_local_acp_run_writes_sqlite_and_private_jsonl_without_convex() {
async fn hermes_client_local_acp_run_writes_control_plane_and_private_jsonl_without_convex() {
let root =
std::env::temp_dir().join(format!("mnote-local-ai-run-private-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
@@ -16069,8 +16069,8 @@ mod tests {
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
assert_eq!(payload["sessionStorage"], "sqlite_control_plane");
assert_eq!(payload["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
assert_eq!(payload["sessionStorage"], "control_plane");
assert_eq!(payload["legacyPersistence"], "local_ai_session_jsonl");
assert_eq!(payload["legacySessionStorage"], "local_private");
let jsonl_path = root
@@ -16085,9 +16085,9 @@ mod tests {
}
#[tokio::test]
async fn hermes_client_local_acp_session_create_accepts_sqlite_directory_grant() {
async fn hermes_client_local_acp_session_create_accepts_control_plane_directory_grant() {
let root = std::env::temp_dir().join(format!(
"mnote-local-ai-session-sqlite-grant-{}",
"mnote-local-ai-session-control-plane-grant-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
@@ -16136,7 +16136,7 @@ mod tests {
"documentId": "local-md:README.md",
"sourceKind": "local_folder",
"rootUri": root_uri,
"traceId": "trace_sqlite_grant_session_1",
"traceId": "trace_control_plane_grant_session_1",
"profile": "reasonix",
"title": "授权本地会话"
})
@@ -16152,8 +16152,8 @@ mod tests {
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
assert_eq!(payload["sessionStorage"], "sqlite_control_plane");
assert_eq!(payload["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
assert_eq!(payload["sessionStorage"], "control_plane");
assert_eq!(payload["legacyPersistence"], "local_ai_session_jsonl");
let _ = fs::remove_dir_all(&root);
@@ -16234,7 +16234,7 @@ mod tests {
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["sessionStorage"], "sqlite_control_plane");
assert_eq!(payload["sessionStorage"], "control_plane");
assert_eq!(payload["legacySessionStorage"], "local_shared");
let session_id = payload["sessionId"].as_str().expect("session id");
let jsonl_path = root
@@ -16435,7 +16435,7 @@ mod tests {
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
assert_eq!(payload["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
assert_eq!(
captured_body.lock().expect("captured convex body").clone(),
@@ -16444,7 +16444,7 @@ mod tests {
}
#[tokio::test]
async fn hermes_client_acp_run_registers_scoped_runtime_record_in_sqlite() {
async fn hermes_client_acp_run_registers_scoped_runtime_record_in_control_plane() {
let _guard = runtime_lock().lock().expect("runtime lock");
clear_runtime_registry();
clear_run_queue();
@@ -16482,7 +16482,7 @@ mod tests {
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
assert_eq!(payload["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
let response = router
.oneshot(
@@ -16500,7 +16500,7 @@ mod tests {
.await
.expect("list body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
assert_eq!(payload["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
let run = &payload["sessions"][0];
assert_eq!(run["sessionId"], "sess_1");
assert_eq!(run["runId"], "run_trace_1");
@@ -16753,7 +16753,7 @@ mod tests {
}
#[tokio::test]
async fn hermes_client_acp_session_rename_uses_sqlite_store() {
async fn hermes_client_acp_session_rename_uses_control_plane_store() {
let response = build_app(seeded_acp_state())
.oneshot(
Request::builder()
@@ -16772,13 +16772,13 @@ mod tests {
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["ok"], true);
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
assert_eq!(payload["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
assert_eq!(payload["result"]["title"], "新标题");
assert_eq!(payload["result"]["runs"][0]["sessionId"], "sess_1");
}
#[tokio::test]
async fn hermes_client_acp_session_export_uses_sqlite_store() {
async fn hermes_client_acp_session_export_uses_control_plane_store() {
let response = build_app(seeded_acp_state())
.oneshot(
Request::builder()
@@ -16796,7 +16796,7 @@ mod tests {
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["ok"], true);
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
assert_eq!(payload["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
assert_eq!(
payload["export"]["schema"],
"mnote.page_ai_session_export.v1"
@@ -16818,7 +16818,7 @@ mod tests {
}
#[tokio::test]
async fn hermes_client_acp_session_delete_and_auto_title_use_sqlite_store() {
async fn hermes_client_acp_session_delete_and_auto_title_use_control_plane_store() {
let router = build_app(seeded_acp_state());
let response = router
@@ -16840,7 +16840,7 @@ mod tests {
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
assert_eq!(payload["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
assert_eq!(payload["result"]["title"], "自动标题");
let response = router
@@ -16859,7 +16859,7 @@ mod tests {
.await
.expect("delete body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE);
assert_eq!(payload["persistence"], ACP_RUNTIME_CONTROL_PLANE_STORE);
assert_eq!(payload["result"]["deleted"], 1);
}
@@ -17687,7 +17687,7 @@ mod tests {
"rootUri": "file:///mnt/Data1T/Mnote_data/users/user_1/我的空间",
"permission": "write",
"recursive": true,
"source": "sqlite_directory_grant",
"source": "control_plane_directory_grant",
"absolutePath": "/mnt/Data1T/Mnote_data/users/user_1/我的空间"
}],
"editorTarget": {
@@ -18069,7 +18069,7 @@ mod tests {
}
#[test]
fn local_ai_run_access_rewrites_allowed_roots_from_sqlite_grant() {
fn local_ai_run_access_rewrites_allowed_roots_from_control_plane_grant() {
let state = test_state();
state
.control_plane()
@@ -18107,7 +18107,7 @@ mod tests {
let mut payload = json!({
"workspaceId": "local-ws:alice:test",
"documentId": "local-md:README.md",
"sessionId": "sess_sqlite_roots",
"sessionId": "sess_control_plane_roots",
"sourceKind": "local_folder",
"rootUri": "file:///tmp/mnote-ai-allowed",
"allowedRoots": [{"rootUri": "file:///tmp/evil", "permission": "write", "source": "client"}]
@@ -18124,7 +18124,7 @@ mod tests {
assert_eq!(payload["allowedRoots"][0]["permission"], "write");
assert_eq!(
payload["allowedRoots"][0]["source"],
"sqlite_directory_grant"
"control_plane_directory_grant"
);
assert!(payload["allowedRoots"][0]["grantIds"]
.as_array()
@@ -18134,7 +18134,7 @@ mod tests {
}
#[test]
fn local_ai_run_access_rejects_other_user_without_sqlite_grant() {
fn local_ai_run_access_rejects_other_user_without_control_plane_grant() {
let state = test_state();
state
.control_plane()
@@ -368,21 +368,20 @@ pub(crate) async fn execute_mnote_tool_call(
Err(WebError::new(
StatusCode::GONE,
"mnote_evidence_tools_retired",
"旧 docs/evidence/LiteParse tools 已退役;请使用 mnote.weknora.search/open_reference 或兼容 mnote.knowledge_rag.query/open_reference",
"旧 docs/evidence/LiteParse tools 已退役;请使用兼容 mnote.knowledge_rag.query/open_reference",
)
.with_context(&context))
}
"mnote.knowledge_rag.status" => knowledge_rag::status(&state, &context, &input).await,
"mnote.weknora.search" => knowledge_rag::search(&state, &context, &input).await,
"mnote.weknora.list_sources" => {
knowledge_rag::list_sources(&state, &context, &input).await
}
"mnote.weknora.get_source_status" => {
knowledge_rag::get_source_status(&state, &context, &input).await
}
"mnote.weknora.open_reference" => {
knowledge_rag::open_reference(&state, &context, &input).await
}
"mnote.weknora.search"
| "mnote.weknora.list_sources"
| "mnote.weknora.get_source_status"
| "mnote.weknora.open_reference" => Err(WebError::new(
StatusCode::GONE,
"mnote_weknora_tools_retired",
"WeKnora 专用工具已从默认 agent manifest 移除;请改用 provider-neutral mnote.knowledge_rag.*,或显式启动 legacy WeKnora provider 调试。",
)
.with_context(&context)),
"mnote.knowledge_rag.query" => knowledge_rag::query(&state, &context, &input).await,
"mnote.knowledge_rag.section_context" => {
knowledge_rag::section_context(&state, &context, &input).await
@@ -738,10 +737,6 @@ fn is_read_tool(tool_name: &str) -> bool {
| "mnote.evidence.read"
| "mnote.evidence.open"
| "mnote.knowledge_rag.status"
| "mnote.weknora.search"
| "mnote.weknora.list_sources"
| "mnote.weknora.get_source_status"
| "mnote.weknora.open_reference"
| "mnote.knowledge_rag.query"
| "mnote.knowledge_rag.section_context"
| "mnote.knowledge_rag.open_reference"
@@ -775,10 +770,6 @@ fn is_evidence_receipt_tool(tool_name: &str) -> bool {
| "mnote.evidence.read"
| "mnote.evidence.open"
| "mnote.knowledge_rag.status"
| "mnote.weknora.search"
| "mnote.weknora.list_sources"
| "mnote.weknora.get_source_status"
| "mnote.weknora.open_reference"
| "mnote.knowledge_rag.query"
| "mnote.knowledge_rag.section_context"
| "mnote.knowledge_rag.open_reference"
File diff suppressed because it is too large Load Diff
@@ -264,11 +264,14 @@ fn build_local_folder_watch_batch_payload(
root_uri.hash(&mut hasher);
workspace_id.hash(&mut hasher);
for payload in watcher_payloads {
let relative_path = payload
let Some(relative_path) = payload
.get("relativePath")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
.filter(|value| !value.is_empty())
else {
continue;
};
let event_kind = payload
.get("eventKind")
.and_then(Value::as_str)
@@ -286,7 +289,36 @@ fn build_local_folder_watch_batch_payload(
changed_paths.push(json!({
"relativePath": relative_path,
"kind": event_kind,
"eventKind": event_kind,
"changeType": event_kind,
"revision": event_revision,
"documentId": payload.get("documentId").cloned().unwrap_or(Value::Null),
"sourceKind": payload.get("sourceKind").cloned().unwrap_or(Value::Null),
"observedFileVersion": payload
.get("observedFileVersion")
.cloned()
.unwrap_or(Value::Null),
"bufferFileVersion": payload
.get("bufferFileVersion")
.cloned()
.unwrap_or(Value::Null),
"fileVersion": payload
.get("observedFileVersion")
.cloned()
.or_else(|| payload.get("bufferFileVersion").cloned())
.unwrap_or(Value::Null),
"lastWriteIntentId": payload
.get("lastWriteIntentId")
.cloned()
.unwrap_or(Value::Null),
"lastSaveOperationId": payload
.get("lastSaveOperationId")
.cloned()
.unwrap_or(Value::Null),
"selfWriteEcho": payload
.get("selfWriteEcho")
.and_then(Value::as_bool)
.unwrap_or(false),
}));
}
if seen_kinds.insert(event_kind.to_string()) {
@@ -655,7 +687,13 @@ mod tests {
vec![
json!({
"relativePath": "docs/README.md",
"documentId": "local-md:docs~2FREADME.md",
"eventKind": "Modify(Data)",
"observedFileVersion": "sha256:observed",
"bufferFileVersion": "sha256:buffer",
"lastWriteIntentId": "write-1",
"lastSaveOperationId": "save-1",
"selfWriteEcho": true,
}),
json!({
"relativePath": "docs/New.md",
@@ -671,6 +709,15 @@ mod tests {
assert_eq!(payload["watchRevision"]["scope"], "changed_paths");
assert_eq!(payload["watchRevision"]["entryCount"], 2);
assert_eq!(payload["changedPaths"].as_array().map(Vec::len), Some(2));
let first_path = &payload["changedPaths"][0];
assert_eq!(first_path["documentId"], "local-md:docs~2FREADME.md");
assert_eq!(first_path["eventKind"], "Modify(Data)");
assert_eq!(first_path["observedFileVersion"], "sha256:observed");
assert_eq!(first_path["bufferFileVersion"], "sha256:buffer");
assert_eq!(first_path["fileVersion"], "sha256:observed");
assert_eq!(first_path["lastWriteIntentId"], "write-1");
assert_eq!(first_path["lastSaveOperationId"], "save-1");
assert_eq!(first_path["selfWriteEcho"], true);
assert!(
payload["affectedParents"]
.as_array()
@@ -658,7 +658,7 @@ pub(crate) fn ensure_local_workspace_access_with_state(
}
let actor_id = context.auth.actor_id.trim();
let canonical_root_uri = file_uri_for_path(&canonical_root);
let sqlite_access = state
let control_plane_access = state
.control_plane()
.resolve_access(actor_id, &canonical_root_uri)
.unwrap_or_else(|error| {
@@ -675,7 +675,7 @@ pub(crate) fn ensure_local_workspace_access_with_state(
grant_ids: Vec::new(),
}
});
if local_access_permission_allows(&sqlite_access.permission, mode) {
if local_access_permission_allows(&control_plane_access.permission, mode) {
return Ok(canonical_root);
}
ensure_local_workspace_access_for_actor_with_mode(
@@ -785,22 +785,8 @@ pub(crate) fn control_plane_status_display() -> String {
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| "sqlite".to_string());
.unwrap_or_else(|| "libsql-local".to_string());
match backend.as_str() {
"sqlite" => {
let db_path = std::env::var("MNOTE_CONTROL_PLANE_DB_PATH")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| {
default_local_workspace_base_dir()
.join("control-plane")
.join("control-plane.db")
.display()
.to_string()
});
format!("backend=sqlite; db={db_path}")
}
"libsql-local" | "turso-local" | "turso" => {
let db_path = std::env::var("MNOTE_TURSO_LOCAL_PATH")
.ok()
@@ -850,6 +836,7 @@ pub(crate) fn control_plane_status_display() -> String {
});
format!("backend=turso-synced; local={db_path}; remote=env:MNOTE_TURSO_DATABASE_URL")
}
"sqlite" => "backend=sqlite; unsupported-runtime; use control-plane-admin only".to_string(),
other => format!("backend={other}; unsupported"),
}
}
@@ -1211,7 +1198,7 @@ fn append_control_plane_outbox_event(state: &AppState, event_type: &str, payload
let _ = state.stream_delta_tx.send(delta);
}
fn list_sqlite_share_links_for_context(
fn list_control_plane_share_links_for_context(
state: &AppState,
context: &RequestContext,
query: ShareLinkListQuery,
@@ -1227,15 +1214,15 @@ fn list_sqlite_share_links_for_context(
let links = state
.control_plane()
.list_share_links(workspace_id)
.map_err(|error| WebError::internal(format!("SQLite 分享链接读取失败: {error}")))?;
.map_err(|error| WebError::internal(format!("control-plane 分享链接读取失败: {error}")))?;
Ok(json!({
"ok": true,
"controlPlane": "sqlite",
"controlPlane": "control-plane",
"links": links.iter().map(share_link_payload).collect::<Vec<_>>(),
}))
}
fn create_sqlite_share_link_for_context(
fn create_control_plane_share_link_for_context(
state: &AppState,
context: &RequestContext,
request: LocalShareLinkRequest,
@@ -1263,7 +1250,7 @@ fn create_sqlite_share_link_for_context(
created_by: context.auth.actor_id.trim().to_string(),
expires_at: request.expires_at,
})
.map_err(|error| WebError::internal(format!("SQLite 分享链接写入失败: {error}")))?;
.map_err(|error| WebError::internal(format!("control-plane 分享链接写入失败: {error}")))?;
let _ = state.control_plane().append_audit(AppendAuditInput {
actor_user_id: Some(context.auth.actor_id.trim().to_string()),
action: "control.share.created".to_string(),
@@ -1286,13 +1273,13 @@ fn create_sqlite_share_link_for_context(
);
Ok(json!({
"ok": true,
"controlPlane": "sqlite",
"controlPlane": "control-plane",
"token": created.token,
"link": share_link_payload(&created.link),
}))
}
fn revoke_sqlite_share_link_for_context(
fn revoke_control_plane_share_link_for_context(
state: &AppState,
context: &RequestContext,
link_id: &str,
@@ -1312,7 +1299,7 @@ fn revoke_sqlite_share_link_for_context(
control_plane::ControlPlaneError::NotFound(message) => {
WebError::new(StatusCode::NOT_FOUND, "share_link_not_found", message)
}
other => WebError::internal(format!("SQLite 分享链接撤销失败: {other}")),
other => WebError::internal(format!("control-plane 分享链接撤销失败: {other}")),
})?;
let _ = state.control_plane().append_audit(AppendAuditInput {
actor_user_id: Some(context.auth.actor_id.trim().to_string()),
@@ -1330,7 +1317,7 @@ fn revoke_sqlite_share_link_for_context(
);
Ok(json!({
"ok": true,
"controlPlane": "sqlite",
"controlPlane": "control-plane",
"revokedLinkId": link_id,
}))
}
@@ -1879,7 +1866,7 @@ fn is_default_workspace_auto_grant(grant: &DirectoryGrantRecord) -> bool {
&& grant.root_uri.ends_with("/workspaces/my-space")
}
fn sqlite_access_policy_payload(
fn control_plane_access_policy_payload(
state: &AppState,
context: &RequestContext,
) -> Result<Value, WebError> {
@@ -1887,14 +1874,14 @@ fn sqlite_access_policy_payload(
let grants = state
.control_plane()
.list_directory_grants()
.map_err(|error| WebError::internal(format!("SQLite 控制面授权列表读取失败: {error}")))?;
.map_err(|error| WebError::internal(format!("control-plane授权列表读取失败: {error}")))?;
let grant_values = grants
.iter()
.map(control_plane_grant_payload)
.collect::<Vec<_>>();
Ok(json!({
"ok": true,
"controlPlane": "sqlite",
"controlPlane": "control-plane",
"policyPath": local_access_policy_path().display().to_string(),
"policy": {
"admins": policy.admins,
@@ -1907,7 +1894,7 @@ fn sqlite_access_policy_payload(
}))
}
fn sqlite_user_access_policy_payload(
fn control_plane_user_access_policy_payload(
state: &AppState,
context: &RequestContext,
) -> Result<Value, WebError> {
@@ -1922,7 +1909,7 @@ fn sqlite_user_access_policy_payload(
let grants = state
.control_plane()
.list_directory_grants()
.map_err(|error| WebError::internal(format!("SQLite 控制面授权列表读取失败: {error}")))?;
.map_err(|error| WebError::internal(format!("control-plane授权列表读取失败: {error}")))?;
let grant_values = grants
.iter()
.filter(|grant| {
@@ -1933,7 +1920,7 @@ fn sqlite_user_access_policy_payload(
.collect::<Vec<_>>();
Ok(json!({
"ok": true,
"controlPlane": "sqlite",
"controlPlane": "control-plane",
"policy": {
"grants": grant_values,
},
@@ -1956,7 +1943,7 @@ fn validate_local_access_root_for_context(
}))
}
fn add_sqlite_user_access_grant_for_context(
fn add_control_plane_user_access_grant_for_context(
state: &AppState,
context: &RequestContext,
mut request: LocalAccessGrantRequest,
@@ -1973,19 +1960,19 @@ fn add_sqlite_user_access_grant_for_context(
ensure_path_inside_owned_local_workspace(actor_id, &canonical)?;
request.root_uri = file_uri_for_path(&canonical);
request.root_path = canonical.display().to_string();
add_sqlite_local_access_grant_for_context_inner(state, context, request, "user")
add_control_plane_local_access_grant_for_context_inner(state, context, request, "user")
}
fn add_sqlite_local_access_grant_for_context(
fn add_control_plane_local_access_grant_for_context(
state: &AppState,
context: &RequestContext,
request: LocalAccessGrantRequest,
) -> Result<Value, WebError> {
let _policy = require_local_access_policy_admin(context)?;
add_sqlite_local_access_grant_for_context_inner(state, context, request, "admin")
add_control_plane_local_access_grant_for_context_inner(state, context, request, "admin")
}
fn add_sqlite_local_access_grant_for_context_inner(
fn add_control_plane_local_access_grant_for_context_inner(
state: &AppState,
context: &RequestContext,
request: LocalAccessGrantRequest,
@@ -2010,7 +1997,7 @@ fn add_sqlite_local_access_grant_for_context_inner(
root_uri: Some(root_uri.clone()),
include_revoked: false,
})
.map_err(|error| WebError::internal(format!("SQLite 控制面授权查重失败: {error}")))?;
.map_err(|error| WebError::internal(format!("control-plane授权查重失败: {error}")))?;
if duplicates.iter().any(|grant| {
grant.permission.trim() == permission
&& grant.recursive == request.recursive
@@ -2035,7 +2022,7 @@ fn add_sqlite_local_access_grant_for_context_inner(
source: source.to_string(),
created_by: Some(context.auth.actor_id.trim().to_string()),
})
.map_err(|error| WebError::internal(format!("SQLite 控制面授权写入失败: {error}")))?;
.map_err(|error| WebError::internal(format!("control-plane授权写入失败: {error}")))?;
let _ = state.control_plane().append_audit(AppendAuditInput {
actor_user_id: Some(context.auth.actor_id.trim().to_string()),
action: "control.grant.created".to_string(),
@@ -2058,14 +2045,14 @@ fn add_sqlite_local_access_grant_for_context_inner(
let grants = state
.control_plane()
.list_directory_grants()
.map_err(|error| WebError::internal(format!("SQLite 控制面授权列表读取失败: {error}")))?;
.map_err(|error| WebError::internal(format!("control-plane授权列表读取失败: {error}")))?;
let grant_values = grants
.iter()
.map(control_plane_grant_payload)
.collect::<Vec<_>>();
Ok(json!({
"ok": true,
"controlPlane": "sqlite",
"controlPlane": "control-plane",
"policyPath": local_access_policy_path().display().to_string(),
"grant": control_plane_grant_payload(&grant),
"policy": {
@@ -2074,7 +2061,7 @@ fn add_sqlite_local_access_grant_for_context_inner(
}))
}
fn delete_sqlite_user_access_grant_for_context(
fn delete_control_plane_user_access_grant_for_context(
state: &AppState,
context: &RequestContext,
grant_id: &str,
@@ -2102,7 +2089,7 @@ fn delete_sqlite_user_access_grant_for_context(
root_uri: None,
include_revoked: false,
})
.map_err(|error| WebError::internal(format!("SQLite 控制面授权查找失败: {error}")))?;
.map_err(|error| WebError::internal(format!("control-plane授权查找失败: {error}")))?;
let grant = grants.first().ok_or_else(|| {
WebError::new(
StatusCode::NOT_FOUND,
@@ -2134,7 +2121,7 @@ fn delete_sqlite_user_access_grant_for_context(
"local_access_policy_grant_not_found",
message,
),
other => WebError::internal(format!("SQLite 控制面授权撤销失败: {other}")),
other => WebError::internal(format!("control-plane授权撤销失败: {other}")),
})?;
let _ = state.control_plane().append_audit(AppendAuditInput {
actor_user_id: Some(actor_id.to_string()),
@@ -2152,14 +2139,14 @@ fn delete_sqlite_user_access_grant_for_context(
);
Ok(json!({
"ok": true,
"controlPlane": "sqlite",
"controlPlane": "control-plane",
"deletedGrantId": grant_id,
"revokedUserId": revoked_user_id,
"policy": {
"grants": state
.control_plane()
.list_directory_grants()
.map_err(|error| WebError::internal(format!("SQLite 控制面授权列表读取失败: {error}")))?
.map_err(|error| WebError::internal(format!("control-plane授权列表读取失败: {error}")))?
.iter()
.filter(|grant| grant.created_by.as_deref().map(str::trim) == Some(actor_id))
.map(control_plane_grant_payload)
@@ -2168,7 +2155,7 @@ fn delete_sqlite_user_access_grant_for_context(
}))
}
fn delete_sqlite_local_access_grant_for_context(
fn delete_control_plane_local_access_grant_for_context(
state: &AppState,
context: &RequestContext,
grant_id: &str,
@@ -2189,7 +2176,7 @@ fn delete_sqlite_local_access_grant_for_context(
root_uri: None,
include_revoked: false,
})
.map_err(|error| WebError::internal(format!("SQLite 控制面授权查找失败: {error}")))?;
.map_err(|error| WebError::internal(format!("control-plane授权查找失败: {error}")))?;
if grants
.first()
.map(is_default_workspace_auto_grant)
@@ -2211,7 +2198,7 @@ fn delete_sqlite_local_access_grant_for_context(
"local_access_policy_grant_not_found",
message,
),
other => WebError::internal(format!("SQLite 控制面授权撤销失败: {other}")),
other => WebError::internal(format!("control-plane授权撤销失败: {other}")),
})?;
let _ = state.control_plane().append_audit(AppendAuditInput {
actor_user_id: Some(context.auth.actor_id.trim().to_string()),
@@ -2229,7 +2216,7 @@ fn delete_sqlite_local_access_grant_for_context(
);
Ok(json!({
"ok": true,
"controlPlane": "sqlite",
"controlPlane": "control-plane",
"policyPath": local_access_policy_path().display().to_string(),
"deletedGrantId": grant_id,
"revokedUserId": revoked_user_id,
@@ -2237,7 +2224,7 @@ fn delete_sqlite_local_access_grant_for_context(
"grants": state
.control_plane()
.list_directory_grants()
.map_err(|error| WebError::internal(format!("SQLite 控制面授权列表读取失败: {error}")))?
.map_err(|error| WebError::internal(format!("control-plane授权列表读取失败: {error}")))?
.iter()
.map(control_plane_grant_payload)
.collect::<Vec<_>>(),
@@ -2420,7 +2407,7 @@ pub async fn get_local_access_policy(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let payload = sqlite_access_policy_payload(&state, &context)
let payload = control_plane_access_policy_payload(&state, &context)
.map_err(|error| error.with_context(&context))?;
Ok((StatusCode::OK, Json(payload)))
}
@@ -2429,7 +2416,7 @@ pub async fn get_user_access_policy(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let payload = sqlite_user_access_policy_payload(&state, &context)
let payload = control_plane_user_access_policy_payload(&state, &context)
.map_err(|error| error.with_context(&context))?;
Ok((StatusCode::OK, Json(payload)))
}
@@ -2448,7 +2435,7 @@ pub async fn create_local_access_grant(
Extension(context): Extension<RequestContext>,
Json(request): Json<LocalAccessGrantRequest>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let payload = add_sqlite_local_access_grant_for_context(&state, &context, request)
let payload = add_control_plane_local_access_grant_for_context(&state, &context, request)
.map_err(|error| error.with_context(&context))?;
if let Some(user_id) = payload
.pointer("/grant/userId")
@@ -2466,7 +2453,7 @@ pub async fn create_user_access_grant(
Extension(context): Extension<RequestContext>,
Json(request): Json<LocalAccessGrantRequest>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let payload = add_sqlite_user_access_grant_for_context(&state, &context, request)
let payload = add_control_plane_user_access_grant_for_context(&state, &context, request)
.map_err(|error| error.with_context(&context))?;
if let Some(user_id) = payload
.pointer("/grant/userId")
@@ -2484,7 +2471,7 @@ pub async fn delete_local_access_grant(
Extension(context): Extension<RequestContext>,
AxumPath(grant_id): AxumPath<String>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let payload = delete_sqlite_local_access_grant_for_context(&state, &context, &grant_id)
let payload = delete_control_plane_local_access_grant_for_context(&state, &context, &grant_id)
.map_err(|error| error.with_context(&context))?;
if let Some(user_id) = payload
.get("revokedUserId")
@@ -2502,7 +2489,7 @@ pub async fn delete_user_access_grant(
Extension(context): Extension<RequestContext>,
AxumPath(grant_id): AxumPath<String>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let payload = delete_sqlite_user_access_grant_for_context(&state, &context, &grant_id)
let payload = delete_control_plane_user_access_grant_for_context(&state, &context, &grant_id)
.map_err(|error| error.with_context(&context))?;
if let Some(user_id) = payload
.get("revokedUserId")
@@ -2536,7 +2523,7 @@ pub async fn get_share_links(
Extension(context): Extension<RequestContext>,
Query(query): Query<ShareLinkListQuery>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let payload = list_sqlite_share_links_for_context(&state, &context, query)
let payload = list_control_plane_share_links_for_context(&state, &context, query)
.map_err(|error| error.with_context(&context))?;
Ok((StatusCode::OK, Json(payload)))
}
@@ -2546,7 +2533,7 @@ pub async fn create_share_link(
Extension(context): Extension<RequestContext>,
Json(request): Json<LocalShareLinkRequest>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let payload = create_sqlite_share_link_for_context(&state, &context, request)
let payload = create_control_plane_share_link_for_context(&state, &context, request)
.map_err(|error| error.with_context(&context))?;
Ok((StatusCode::OK, Json(payload)))
}
@@ -2556,7 +2543,7 @@ pub async fn delete_share_link(
Extension(context): Extension<RequestContext>,
AxumPath(link_id): AxumPath<String>,
) -> Result<(StatusCode, Json<Value>), WebError> {
let payload = revoke_sqlite_share_link_for_context(&state, &context, &link_id)
let payload = revoke_control_plane_share_link_for_context(&state, &context, &link_id)
.map_err(|error| error.with_context(&context))?;
Ok((StatusCode::OK, Json(payload)))
}
@@ -9578,7 +9565,7 @@ fn markdown_page_subtree(document_id: &str, title: &str, content: &Value) -> Val
#[cfg(test)]
mod tests {
use super::{
add_sqlite_local_access_grant_for_context,
add_control_plane_local_access_grant_for_context,
create_default_local_workspace_for_actor_at_base, create_local_access_grant,
create_share_grant, create_share_link, create_user_access_grant, create_user_share_grant,
delete_share_link, delete_user_access_grant, editor_blocks_to_markdown_for_file,
@@ -11054,7 +11041,7 @@ fn main() {}
.expect("created grant id")
.to_string();
assert_eq!(created["grant"]["permission"], "read");
assert_eq!(created["controlPlane"], "sqlite");
assert_eq!(created["controlPlane"], "control-plane");
let created_events = state
.control_plane()
.drain_outbox(10)
@@ -11080,7 +11067,7 @@ fn main() {}
.expect_err("created read grant cannot write");
assert_eq!(write_error.status(), StatusCode::FORBIDDEN);
let duplicate_error = add_sqlite_local_access_grant_for_context(
let duplicate_error = add_control_plane_local_access_grant_for_context(
&state,
&request_context("admin_1", "user"),
LocalAccessGrantRequest {
@@ -11262,7 +11249,7 @@ fn main() {}
}
#[tokio::test]
async fn user_access_policy_grant_uses_sqlite_directory_grants_for_owned_folder() {
async fn user_access_policy_grant_uses_control_plane_directory_grants_for_owned_folder() {
let _guard = env_lock().lock().expect("env lock");
let owned_root = temp_root("mnote-user-access-policy-owned-root");
let outside_root = temp_root("mnote-user-access-policy-outside-root");
@@ -11304,7 +11291,7 @@ fn main() {}
.as_str()
.expect("grant id")
.to_string();
assert_eq!(created["controlPlane"], "sqlite");
assert_eq!(created["controlPlane"], "control-plane");
assert_eq!(created["grant"]["userId"], "user_target");
assert_eq!(created["grant"]["createdBy"], "user_owner");
assert_eq!(created["grant"]["permission"], "write");
@@ -11324,7 +11311,7 @@ fn main() {}
)
.await
.expect("owner can list self-created directory grants");
assert_eq!(listed["controlPlane"], "sqlite");
assert_eq!(listed["controlPlane"], "control-plane");
assert_eq!(listed["grants"][0]["id"], grant_id);
assert_eq!(listed["grants"][0]["permission"], "write");
@@ -11334,7 +11321,7 @@ fn main() {}
)
.await
.expect("target can list incoming directory grants");
assert_eq!(target_listed["controlPlane"], "sqlite");
assert_eq!(target_listed["controlPlane"], "control-plane");
assert_eq!(target_listed["grants"][0]["id"], grant_id);
assert_eq!(target_listed["grants"][0]["userId"], "user_target");
@@ -11452,7 +11439,7 @@ fn main() {}
}
#[tokio::test]
async fn share_link_api_creates_lists_and_revokes_sqlite_record() {
async fn share_link_api_creates_lists_and_revokes_control_plane_record() {
let _guard = env_lock().lock().expect("env lock");
let state = test_state();
std::env::set_var("MNOTE_ADMIN_USER_IDS", "admin_1");
@@ -11488,7 +11475,7 @@ fn main() {}
.await
.expect("admin can create share link");
let link_id = created["link"]["id"].as_str().expect("link id").to_string();
assert_eq!(created["controlPlane"], "sqlite");
assert_eq!(created["controlPlane"], "control-plane");
assert_eq!(created["token"], "visible-token");
assert!(created["link"].get("tokenHash").is_none());
assert_eq!(created["link"]["permission"], "read");
@@ -11507,7 +11494,7 @@ fn main() {}
let stored = state
.control_plane()
.list_share_links(&workspace.id)
.expect("list sqlite share links");
.expect("list control-plane share links");
assert_eq!(stored.len(), 1);
assert_eq!(stored[0].id, link_id);
assert_ne!(stored[0].token_hash, "visible-token");
@@ -12691,30 +12678,30 @@ fn main() {}
}
#[tokio::test]
async fn local_file_open_allows_sqlite_directory_read_grant_without_json_policy() {
async fn local_file_open_allows_control_plane_directory_read_grant_without_json_policy() {
let _guard = env_lock().lock().expect("env lock");
let root = temp_root("mnote-local-file-open-sqlite-read-grant-root");
let policy_root = temp_root("mnote-local-file-open-sqlite-read-grant-config");
let root = temp_root("mnote-local-file-open-control-plane-read-grant-root");
let policy_root = temp_root("mnote-local-file-open-control-plane-read-grant-config");
let policy_file = policy_root.join("missing-access-policy.json");
let state = test_state();
let root_uri = format!("file://{}", root.display());
std::fs::write(root.join("README.txt"), "hello sqlite").expect("write file");
std::fs::write(root.join("README.txt"), "hello control plane").expect("write file");
std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &policy_file);
state
.control_plane()
.upsert_user(UpsertUserInput {
id: Some("sqlite_reader".into()),
email: Some("sqlite-reader@example.com".into()),
username: "sqlite_reader".into(),
display_name: "sqlite_reader".into(),
id: Some("control_plane_reader".into()),
email: Some("control-plane-reader@example.com".into()),
username: "control_plane_reader".into(),
display_name: "control_plane_reader".into(),
role: None,
password_hash: None,
})
.expect("upsert sqlite reader");
.expect("upsert control-plane reader");
state
.control_plane()
.grant_directory_access(DirectoryGrantInput {
user_id: "sqlite_reader".into(),
user_id: "control_plane_reader".into(),
workspace_id: None,
root_uri: root_uri.clone(),
root_path: root
@@ -12728,9 +12715,9 @@ fn main() {}
source: "test".into(),
created_by: None,
})
.expect("grant sqlite read");
.expect("grant control-plane read");
let context = request_context("sqlite_reader", "user");
let context = request_context("control_plane_reader", "user");
let (_, _, bytes) = open_local_file(
State(state),
Extension(context),
@@ -12741,8 +12728,8 @@ fn main() {}
}),
)
.await
.expect("sqlite read grant can open local file");
assert_eq!(bytes, b"hello sqlite");
.expect("control-plane read grant can open local file");
assert_eq!(bytes, b"hello control plane");
assert!(!policy_file.exists(), "SQLite grant 不应写旧 JSON policy");
std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE");
@@ -12751,32 +12738,32 @@ fn main() {}
}
#[tokio::test]
async fn local_file_open_allows_legacy_sqlite_directory_grant_with_path_root_uri() {
async fn local_file_open_allows_legacy_control_plane_directory_grant_with_path_root_uri() {
let _guard = env_lock().lock().expect("env lock");
let root = temp_root("mnote-local-file-open-legacy-sqlite-grant-root");
let policy_root = temp_root("mnote-local-file-open-legacy-sqlite-grant-config");
let root = temp_root("mnote-local-file-open-legacy-control-plane-grant-root");
let policy_root = temp_root("mnote-local-file-open-legacy-control-plane-grant-config");
let policy_file = policy_root.join("missing-access-policy.json");
let state = test_state();
let canonical_root = root.canonicalize().expect("canonical root");
let canonical_root_path = canonical_root.display().to_string();
let root_uri = format!("file://{canonical_root_path}");
std::fs::write(root.join("README.txt"), "hello legacy sqlite").expect("write file");
std::fs::write(root.join("README.txt"), "hello legacy control plane").expect("write file");
std::env::set_var("MNOTE_LOCAL_ACCESS_POLICY_FILE", &policy_file);
state
.control_plane()
.upsert_user(UpsertUserInput {
id: Some("legacy_sqlite_reader".into()),
email: Some("legacy-sqlite-reader@example.com".into()),
username: "legacy_sqlite_reader".into(),
display_name: "legacy_sqlite_reader".into(),
id: Some("legacy_control_plane_reader".into()),
email: Some("legacy-control-plane-reader@example.com".into()),
username: "legacy_control_plane_reader".into(),
display_name: "legacy_control_plane_reader".into(),
role: None,
password_hash: None,
})
.expect("upsert sqlite reader");
.expect("upsert control-plane reader");
state
.control_plane()
.grant_directory_access(DirectoryGrantInput {
user_id: "legacy_sqlite_reader".into(),
user_id: "legacy_control_plane_reader".into(),
workspace_id: None,
root_uri: canonical_root_path.clone(),
root_path: canonical_root_path,
@@ -12786,9 +12773,9 @@ fn main() {}
source: "legacy-test".into(),
created_by: None,
})
.expect("grant legacy sqlite read");
.expect("grant legacy control-plane read");
let context = request_context("legacy_sqlite_reader", "user");
let context = request_context("legacy_control_plane_reader", "user");
let (_, _, bytes) = open_local_file(
State(state),
Extension(context),
@@ -12799,8 +12786,8 @@ fn main() {}
}),
)
.await
.expect("legacy sqlite read grant can open local file");
assert_eq!(bytes, b"hello legacy sqlite");
.expect("legacy control-plane read grant can open local file");
assert_eq!(bytes, b"hello legacy control plane");
assert!(!policy_file.exists(), "SQLite grant 不应写旧 JSON policy");
std::env::remove_var("MNOTE_LOCAL_ACCESS_POLICY_FILE");
@@ -12809,10 +12796,10 @@ fn main() {}
}
#[tokio::test]
async fn local_resource_write_allows_sqlite_directory_write_grant_without_json_policy() {
async fn local_resource_write_allows_control_plane_directory_write_grant_without_json_policy() {
let _guard = env_lock().lock().expect("env lock");
let root = temp_root("mnote-local-resource-write-sqlite-grant-root");
let policy_root = temp_root("mnote-local-resource-write-sqlite-grant-config");
let root = temp_root("mnote-local-resource-write-control-plane-grant-root");
let policy_root = temp_root("mnote-local-resource-write-control-plane-grant-config");
let policy_file = policy_root.join("missing-access-policy.json");
let state = test_state();
let root_uri = format!("file://{}", root.display());
@@ -12822,18 +12809,18 @@ fn main() {}
state
.control_plane()
.upsert_user(UpsertUserInput {
id: Some("sqlite_writer".into()),
email: Some("sqlite-writer@example.com".into()),
username: "sqlite_writer".into(),
display_name: "sqlite_writer".into(),
id: Some("control_plane_writer".into()),
email: Some("control-plane-writer@example.com".into()),
username: "control_plane_writer".into(),
display_name: "control_plane_writer".into(),
role: None,
password_hash: None,
})
.expect("upsert sqlite writer");
.expect("upsert control-plane writer");
state
.control_plane()
.grant_directory_access(DirectoryGrantInput {
user_id: "sqlite_writer".into(),
user_id: "control_plane_writer".into(),
workspace_id: None,
root_uri: root_uri.clone(),
root_path: root
@@ -12847,9 +12834,9 @@ fn main() {}
source: "test".into(),
created_by: None,
})
.expect("grant sqlite write");
.expect("grant control-plane write");
let context = request_context("sqlite_writer", "user");
let context = request_context("control_plane_writer", "user");
let (_, _, payload) = write_local_resource(
State(state),
Extension(context),
@@ -12868,7 +12855,7 @@ fn main() {}
}),
)
.await
.expect("sqlite write grant can write local resource");
.expect("control-plane write grant can write local resource");
assert_eq!(payload["ok"], true);
let written = std::fs::read_to_string(root.join("README.md")).expect("read file");
assert!(written.contains("new content"));
@@ -9,12 +9,15 @@ use control_plane::{ControlPlaneStore, UpsertUserInput, UpsertUserUiPreferenceIn
use core_protocol::EvidenceSearchMatchInfo;
#[cfg(test)]
use core_protocol::{EvidenceLocator, EvidenceSearchResult};
#[cfg(test)]
use core_protocol::{
ParsedResourceArtifact, ResourceSourceMap, SourceMapBlock, PARSED_RESOURCE_ARTIFACT_SCHEMA,
};
#[cfg(test)]
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
#[cfg(test)]
use std::collections::BTreeSet;
use std::fs;
use std::path::{Component, Path, PathBuf};
@@ -28,6 +31,7 @@ const LOCAL_INDEX_SETTINGS_SCOPE_KIND: &str = "localIndex";
const LOCAL_INDEX_SOURCE_KIND: &str = "local_folder";
const DEFAULT_INDEX_SCHEDULE_MODE: &str = "daily";
const DEFAULT_INDEX_SCHEDULE_TIME: &str = "02:00";
#[cfg(test)]
const EVIDENCE_SQLITE_SCHEMA_VERSION: u32 = 1;
fn local_index_scope_id(root_path: &Path) -> String {
@@ -460,7 +464,6 @@ fn local_index_status_for_settings(
.join(".mnote")
.join("index")
.join("search-index.json");
let evidence_path = evidence_sqlite_path(root_path);
let mut document_count = 0usize;
let mut resource_count = 0usize;
let mut built_at = Value::Null;
@@ -478,21 +481,15 @@ fn local_index_status_for_settings(
cache_matches_settings = settings.include_paths.is_empty();
local_index_schedule_is_due(&settings, 0)
};
let evidence_block_count = if evidence_path.exists() {
count_evidence_blocks(&evidence_path).unwrap_or(0)
} else {
0
};
Ok(json!({
"schema": "mnote.local_index.status.v1",
"rootUri": root_uri,
"workspaceId": workspace_id,
"settings": settings,
"indexPath": ".mnote/index/search-index.json",
"evidenceIndexPath": ".mnote/index/evidence.sqlite",
"settingsPath": ".mnote/index/local-index-settings.json",
"indexExists": index_path.exists(),
"evidenceIndexExists": evidence_path.exists(),
"evidenceIndexRetired": true,
"cacheMatchesSettings": cache_matches_settings,
"scheduledDue": scheduled_due,
"builtAt": built_at,
@@ -502,7 +499,6 @@ fn local_index_status_for_settings(
.unwrap_or_default(),
"documentCount": document_count,
"resourceCount": resource_count,
"evidenceBlockCount": evidence_block_count,
}))
}
@@ -1233,6 +1229,7 @@ pub(crate) fn refresh_local_search_index_for_path_with_settings(
index.built_at = now_ms();
write_local_search_index_json(root_path, &index)?;
let _ = included;
#[cfg(test)]
remove_evidence_sqlite_index_for_path(root_path, &index, &relative_path)?;
return Ok(json!({
"version": index.version,
@@ -1283,6 +1280,7 @@ pub(crate) fn refresh_local_search_index_for_path_with_settings(
.sort_by(|left, right| left.path.cmp(&right.path));
index.built_at = now_ms();
write_local_search_index_json(root_path, &index)?;
#[cfg(test)]
if included {
refresh_evidence_sqlite_index_for_path(root_path, &index, &relative_path)?;
} else {
@@ -1518,6 +1516,7 @@ fn load_or_rebuild_local_search_index_with_settings(
settings,
);
}
#[cfg(test)]
ensure_evidence_sqlite_index(root_path, &index)?;
Ok(index)
}
@@ -2108,7 +2107,11 @@ fn index_resource_file(root_path: &Path, path: &Path) -> Result<LocalSearchResou
fn write_local_search_index(root_path: &Path, index: &LocalSearchIndex) -> Result<(), WebError> {
write_local_search_index_json(root_path, index)?;
write_evidence_sqlite_index(root_path, index)
#[cfg(test)]
{
write_evidence_sqlite_index(root_path, index)?;
}
Ok(())
}
fn write_local_search_index_json(
@@ -2138,15 +2141,32 @@ fn clear_local_search_index(root_path: &Path) -> Result<(), WebError> {
.join(".mnote")
.join("index")
.join("search-index.json");
let evidence_path = evidence_sqlite_path(root_path);
for path in [index_path, evidence_path] {
match fs::remove_file(&path) {
match fs::remove_file(&index_path) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(WebError::bad_request_code(
"local_search_index_delete_failed",
format!(
"无法删除本地搜索索引文件 {}: {error}",
index_path.display()
),
));
}
}
#[cfg(test)]
{
let evidence_path = evidence_sqlite_path(root_path);
match fs::remove_file(&evidence_path) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(WebError::bad_request_code(
"local_search_index_delete_failed",
format!("无法删除本地搜索索引文件 {}: {error}", path.display()),
format!(
"无法删除本地 evidence 测试索引文件 {}: {error}",
evidence_path.display()
),
));
}
}
@@ -2154,6 +2174,7 @@ fn clear_local_search_index(root_path: &Path) -> Result<(), WebError> {
Ok(())
}
#[cfg(test)]
fn ensure_evidence_sqlite_index(
root_path: &Path,
index: &LocalSearchIndex,
@@ -2165,6 +2186,7 @@ fn ensure_evidence_sqlite_index(
write_evidence_sqlite_index(root_path, index)
}
#[cfg(test)]
fn write_evidence_sqlite_index(root_path: &Path, index: &LocalSearchIndex) -> Result<(), WebError> {
let Some(parent) = evidence_sqlite_path(root_path)
.parent()
@@ -2220,6 +2242,7 @@ fn write_evidence_sqlite_index(root_path: &Path, index: &LocalSearchIndex) -> Re
tx.commit().map_err(sqlite_error)
}
#[cfg(test)]
fn create_evidence_schema(connection: &Connection) -> Result<(), WebError> {
connection
.execute_batch(
@@ -2279,6 +2302,7 @@ CREATE TABLE IF NOT EXISTS evidence_edge(
.map_err(sqlite_error)
}
#[cfg(test)]
fn refresh_evidence_sqlite_index_for_path(
root_path: &Path,
index: &LocalSearchIndex,
@@ -2338,6 +2362,7 @@ fn refresh_evidence_sqlite_index_for_path(
tx.commit().map_err(sqlite_error)
}
#[cfg(test)]
fn remove_evidence_sqlite_index_for_path(
root_path: &Path,
index: &LocalSearchIndex,
@@ -2365,6 +2390,7 @@ fn remove_evidence_sqlite_index_for_path(
tx.commit().map_err(sqlite_error)
}
#[cfg(test)]
fn affected_evidence_resource_ids(index: &LocalSearchIndex, relative_path: &str) -> Vec<String> {
let mut ids = Vec::new();
let document_id = format!("local-md:{}", encode_local_id_segment(relative_path));
@@ -2393,6 +2419,7 @@ fn affected_evidence_resource_ids(index: &LocalSearchIndex, relative_path: &str)
ids
}
#[cfg(test)]
fn existing_evidence_resource_ids_for_path(
connection: &Connection,
relative_path: &str,
@@ -2413,6 +2440,7 @@ fn existing_evidence_resource_ids_for_path(
Ok(resource_ids)
}
#[cfg(test)]
fn delete_evidence_resource_projection(
connection: &Connection,
resource_id: &str,
@@ -2466,6 +2494,7 @@ fn delete_evidence_resource_projection(
.map_err(sqlite_error)
}
#[cfg(test)]
fn insert_document_evidence(
connection: &Connection,
index: &LocalSearchIndex,
@@ -2521,6 +2550,7 @@ fn insert_document_evidence(
Ok(())
}
#[cfg(test)]
fn markdown_parsed_artifact(document: &LocalSearchDocument) -> ParsedResourceArtifact {
ParsedResourceArtifact {
schema: PARSED_RESOURCE_ARTIFACT_SCHEMA.into(),
@@ -2536,6 +2566,7 @@ fn markdown_parsed_artifact(document: &LocalSearchDocument) -> ParsedResourceArt
}
}
#[cfg(test)]
fn insert_evidence_resource_from_artifact(
connection: &Connection,
resource_id: &str,
@@ -2555,6 +2586,7 @@ fn insert_evidence_resource_from_artifact(
)
}
#[cfg(test)]
fn insert_resource_evidence(
connection: &Connection,
root_path: &Path,
@@ -2606,6 +2638,7 @@ fn insert_resource_evidence(
Ok(())
}
#[cfg(test)]
fn parse_resource_evidence_artifact(
_root_path: &Path,
_index: &LocalSearchIndex,
@@ -2614,10 +2647,12 @@ fn parse_resource_evidence_artifact(
Ok(None)
}
#[cfg(test)]
fn parsed_resource_id(resource: &LocalSearchResource) -> String {
format!("{}#parse", resource.resource_id)
}
#[cfg(test)]
fn insert_source_map_artifact_evidence(
connection: &Connection,
index: &LocalSearchIndex,
@@ -2682,6 +2717,7 @@ fn insert_source_map_artifact_evidence(
Ok(())
}
#[cfg(test)]
fn insert_source_map_evidence_section(
connection: &Connection,
resource_id: &str,
@@ -2709,6 +2745,7 @@ fn insert_source_map_evidence_section(
.map_err(sqlite_error)
}
#[cfg(test)]
fn source_map_section_path_for_block(
source_map: &ResourceSourceMap,
block_id: &str,
@@ -2721,6 +2758,7 @@ fn source_map_section_path_for_block(
.unwrap_or_default()
}
#[cfg(test)]
fn source_map_block_locator(
index: &LocalSearchIndex,
artifact: &ParsedResourceArtifact,
@@ -2766,6 +2804,7 @@ fn source_map_block_locator(
})
}
#[cfg(test)]
fn empty_source_map_block(text: &str) -> SourceMapBlock {
SourceMapBlock {
id: "artifact".into(),
@@ -2776,6 +2815,7 @@ fn empty_source_map_block(text: &str) -> SourceMapBlock {
}
}
#[cfg(test)]
fn insert_evidence_resource(
connection: &Connection,
resource_id: &str,
@@ -2807,6 +2847,7 @@ fn insert_evidence_resource(
.map_err(sqlite_error)
}
#[cfg(test)]
fn insert_evidence_block(
connection: &Connection,
block_id: &str,
@@ -2825,6 +2866,7 @@ fn insert_evidence_block(
)
}
#[cfg(test)]
fn insert_evidence_block_with_metadata(
connection: &Connection,
block_id: &str,
@@ -2871,6 +2913,7 @@ fn insert_evidence_block_with_metadata(
}
#[derive(Debug, Clone)]
#[cfg(test)]
struct MarkdownEvidenceBlock {
block_id: String,
text: String,
@@ -2879,6 +2922,7 @@ struct MarkdownEvidenceBlock {
}
#[derive(Debug, Clone)]
#[cfg(test)]
struct MarkdownEvidenceSection {
section_id: String,
title: String,
@@ -2887,6 +2931,7 @@ struct MarkdownEvidenceSection {
parent_section_id: Option<String>,
}
#[cfg(test)]
fn markdown_evidence_blocks(document: &LocalSearchDocument) -> Vec<MarkdownEvidenceBlock> {
let mut headings: Vec<String> = Vec::new();
let mut blocks = Vec::new();
@@ -2910,6 +2955,7 @@ fn markdown_evidence_blocks(document: &LocalSearchDocument) -> Vec<MarkdownEvide
blocks
}
#[cfg(test)]
fn markdown_evidence_sections(document: &LocalSearchDocument) -> Vec<MarkdownEvidenceSection> {
let mut headings: Vec<String> = Vec::new();
let mut sections = Vec::new();
@@ -2945,6 +2991,7 @@ fn markdown_evidence_sections(document: &LocalSearchDocument) -> Vec<MarkdownEvi
sections
}
#[cfg(test)]
fn markdown_heading(line: &str) -> Option<(usize, String)> {
let trimmed = line.trim_start();
let marker_count = trimmed.chars().take_while(|value| *value == '#').count();
@@ -2958,6 +3005,7 @@ fn markdown_heading(line: &str) -> Option<(usize, String)> {
Some((marker_count, rest.trim_matches('#').trim().to_string()))
}
#[cfg(test)]
fn insert_markdown_evidence_section(
connection: &Connection,
resource_id: &str,
@@ -2985,6 +3033,7 @@ fn insert_markdown_evidence_section(
.map_err(sqlite_error)
}
#[cfg(test)]
fn insert_document_graph_edges(
connection: &Connection,
document: &LocalSearchDocument,
@@ -3029,6 +3078,7 @@ fn insert_document_graph_edges(
Ok(())
}
#[cfg(test)]
fn first_markdown_evidence_block_id(document: &LocalSearchDocument) -> String {
document
.raw_text
@@ -3039,6 +3089,7 @@ fn first_markdown_evidence_block_id(document: &LocalSearchDocument) -> String {
.unwrap_or_else(|| format!("{}#line1", document.document_id))
}
#[cfg(test)]
fn insert_evidence_edge(
connection: &Connection,
from_id: &str,
@@ -3073,6 +3124,7 @@ fn insert_evidence_edge(
.map_err(sqlite_error)
}
#[cfg(test)]
fn markdown_reference_target_id(owner_path: &str, target: &str) -> String {
let target = target.trim();
if target.starts_with("local-md:") {
@@ -3087,6 +3139,7 @@ fn markdown_reference_target_id(owner_path: &str, target: &str) -> String {
format!("markdown-title:{}", encode_local_id_segment(target))
}
#[cfg(test)]
fn resource_reference_target_id(owner_path: &str, target: &str) -> String {
format!(
"local-resource:{}",
@@ -3095,11 +3148,13 @@ fn resource_reference_target_id(owner_path: &str, target: &str) -> String {
}
#[derive(Debug, Clone)]
#[cfg(test)]
struct MarkdownMentionEdge {
target_id: String,
source_block_id: String,
}
#[cfg(test)]
fn markdown_mention_edges(document: &LocalSearchDocument) -> Vec<MarkdownMentionEdge> {
let mut seen = std::collections::BTreeSet::new();
let mut mentions = Vec::new();
@@ -3118,6 +3173,7 @@ fn markdown_mention_edges(document: &LocalSearchDocument) -> Vec<MarkdownMention
mentions
}
#[cfg(test)]
fn extract_line_mentions(line: &str) -> Vec<String> {
let chars = line.chars().collect::<Vec<_>>();
let mut mentions = Vec::new();
@@ -3151,6 +3207,7 @@ fn extract_line_mentions(line: &str) -> Vec<String> {
mentions
}
#[cfg(test)]
fn markdown_locator(
index: &LocalSearchIndex,
document_id: &str,
@@ -3177,6 +3234,7 @@ fn markdown_locator(
})
}
#[cfg(test)]
fn evidence_sqlite_path(root_path: &Path) -> PathBuf {
root_path
.join(".mnote")
@@ -3184,6 +3242,8 @@ fn evidence_sqlite_path(root_path: &Path) -> PathBuf {
.join("evidence.sqlite")
}
#[cfg(test)]
#[allow(dead_code)]
fn count_evidence_blocks(path: &Path) -> Result<u64, WebError> {
let connection = Connection::open(path).map_err(|error| {
WebError::bad_request_code(
@@ -3198,6 +3258,7 @@ fn count_evidence_blocks(path: &Path) -> Result<u64, WebError> {
.map_err(sqlite_error)
}
#[cfg(test)]
#[allow(dead_code)]
#[derive(Debug, Clone, Default)]
pub(crate) struct LocalEvidenceSourceStatuses {
@@ -3205,6 +3266,7 @@ pub(crate) struct LocalEvidenceSourceStatuses {
pub(crate) failed_paths: BTreeSet<String>,
}
#[cfg(test)]
#[allow(dead_code)]
pub(crate) fn local_evidence_source_statuses(
root_path: &Path,
@@ -3251,6 +3313,7 @@ pub(crate) fn local_evidence_source_statuses(
Ok(statuses)
}
#[cfg(test)]
fn evidence_resource_kind_for_type(resource_type: &str) -> &'static str {
match resource_type {
"mindmap" => "mindmap",
@@ -3262,6 +3325,7 @@ fn evidence_resource_kind_for_type(resource_type: &str) -> &'static str {
}
}
#[cfg(test)]
fn evidence_resource_kind_for_path(path: &str) -> &'static str {
match Path::new(path)
.extension()
@@ -3315,6 +3379,7 @@ fn is_local_evidence_sidecar_relative_path(relative_path: &str) -> bool {
&& (file_name.ends_with(".parse.md") || file_name.ends_with(".source-map.json"))
}
#[cfg(test)]
fn normalize_local_reference_path(owner_path: &str, target: &str) -> String {
let target = target
.split('#')
@@ -3333,6 +3398,7 @@ fn normalize_local_reference_path(owner_path: &str, target: &str) -> String {
normalize_path_components(owner_parent.join(target))
}
#[cfg(test)]
fn normalize_path_components(path: PathBuf) -> String {
let mut parts = Vec::new();
for component in path.components() {
@@ -3348,6 +3414,7 @@ fn normalize_path_components(path: PathBuf) -> String {
parts.join("/")
}
#[cfg(test)]
fn sqlite_error(error: rusqlite::Error) -> WebError {
WebError::bad_request_code(
"evidence_index_sqlite_error",
+3
View File
@@ -86,6 +86,9 @@ pub fn build_router(state: AppState) -> Router {
)
.route("/auth", get(gateway::auth_entry).post(gateway::auth_entry))
.route("/search", get(search::shell))
.route("/knowledge", get(gateway::root_entry))
.route("/knowledge/{*path}", get(gateway::root_entry))
.route("/debug/knowledge-rag", get(gateway::root_entry))
.route("/api/evidence/search", post(evidence::search))
.route("/api/evidence/read", post(evidence::read))
.route("/api/evidence/open", post(evidence::open))
@@ -9,6 +9,7 @@ use axum::response::Response;
use axum::{Extension, Json};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use control_plane::session_token_hash;
use futures_util::TryStreamExt;
use serde::Deserialize;
use serde_json::{json, Value};
use std::collections::hash_map::DefaultHasher;
@@ -364,6 +365,29 @@ fn mnote_user_id(state: &AppState, context: &RequestContext) -> String {
"anonymous".to_string()
}
fn build_allowed_roots(state: &AppState, user_id: &str) -> Vec<Value> {
state
.control_plane()
.list_directory_grants_for_actor(user_id)
.unwrap_or_default()
.into_iter()
.filter(|grant| grant.status.trim() == "active")
.filter(|grant| !grant.root_uri.trim().is_empty())
.map(|grant| {
json!({
"grantId": grant.id,
"workspaceId": grant.workspace_id,
"rootUri": grant.root_uri,
"rootPath": grant.root_path,
"permission": grant.permission,
"recursive": grant.recursive,
"capabilities": serde_json::from_str::<Vec<String>>(&grant.capabilities_json).unwrap_or_default(),
"source": grant.source,
})
})
.collect()
}
fn text_or_default(value: Option<&str>, fallback: &str) -> String {
value
.map(str::trim)
@@ -378,6 +402,56 @@ fn file_path_from_root_uri(root_uri: &str) -> Option<PathBuf> {
(!file_path.trim().is_empty()).then(|| PathBuf::from(file_path))
}
fn default_openhub_workspace_root_uri(user_id: &str) -> String {
format!("file:///mnt/Data1T/Mnote_data/users/{user_id}/workspaces/my-space")
}
fn preferred_openhub_root_uri(root: &Value) -> Option<String> {
root.get("rootUri")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| value.starts_with("file://"))
.map(ToOwned::to_owned)
.or_else(|| {
root.get("rootPath")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| format!("file://{value}"))
})
.or_else(|| {
root.get("rootUri")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
})
}
fn default_allowed_roots_for_scope(default_allowed_roots: &[Value], root_uri: &str) -> Value {
let root_uri = root_uri.trim();
let matched_root = default_allowed_roots
.iter()
.find(|root| {
preferred_openhub_root_uri(root)
.as_deref()
.is_some_and(|candidate| candidate == root_uri)
})
.cloned();
if let Some(root) = matched_root {
return json!([root]);
}
json!([{
"rootUri": root_uri,
"permission": "write",
"recursive": true,
"capabilities": ["ai", "read", "write"],
"source": "mnote_openhub_default_root",
}])
}
fn artifact_index_store_path(root_uri: Option<&str>) -> PathBuf {
if let Ok(path) = std::env::var("MNOTE_OPENHUB_ARTIFACT_INDEX_PATH") {
let path = path.trim();
@@ -554,6 +628,12 @@ fn build_scope(
request: &OpenHubBootstrapRequest,
) -> Value {
let mnote_user_id = mnote_user_id(state, context);
let mnote_display_name = crate::routes::gateway::current_actor_display_name(state, context);
let default_allowed_roots = build_allowed_roots(state, &mnote_user_id);
let default_root_uri = default_allowed_roots
.iter()
.find_map(preferred_openhub_root_uri)
.unwrap_or_else(|| default_openhub_workspace_root_uri(&mnote_user_id));
let workspace_id = text_or_default(
request
.workspace_id
@@ -561,7 +641,7 @@ fn build_scope(
.or(context.workspace.workspace_id.as_deref()),
"default-workspace",
);
let root_uri = text_or_default(request.root_uri.as_deref(), "");
let root_uri = text_or_default(request.root_uri.as_deref(), &default_root_uri);
let page_resource_id = text_or_default(
request
.page_id
@@ -583,13 +663,14 @@ fn build_scope(
"pageAbsolutePath": request.page_absolute_path.as_deref(),
"pageTitle": request.page_title.as_deref(),
});
let allowed_roots = request
.allowed_roots
.as_ref()
.cloned()
.unwrap_or_else(|| default_allowed_roots_for_scope(&default_allowed_roots, &root_uri));
let weknora_seed = format!(
"{openhub_user_key}\n{openhub_workspace_key}\n{}",
request
.allowed_roots
.as_ref()
.map(Value::to_string)
.unwrap_or_else(|| "[]".to_string())
allowed_roots.to_string()
);
let weknora_tool_scope = stable_hash("weknora_tool", &weknora_seed);
json!({
@@ -597,6 +678,8 @@ fn build_scope(
"authTruth": "mnote_session",
"rejectsFrontendAuthTruth": ["openhub_jwt", "openhub_localStorage", "openhub_login_page"],
"mnoteUserId": mnote_user_id,
"mnoteActorId": context.auth.actor_id,
"mnoteDisplayName": mnote_display_name,
"openhubUserKey": openhub_user_key,
"openhub_user_key": openhub_user_key,
"openhubWorkspaceKey": openhub_workspace_key,
@@ -612,7 +695,7 @@ fn build_scope(
"weknoraToolScope": weknora_tool_scope,
"weknora_tool_scope": weknora_tool_scope,
"workspaceScope": root_scope,
"allowedRoots": request.allowed_roots.as_ref().cloned().unwrap_or_else(|| json!([])),
"allowedRoots": allowed_roots,
"selectionPresent": request.selection.as_deref().map(str::trim).is_some_and(|value| !value.is_empty()),
})
}
@@ -683,6 +766,12 @@ fn add_mnote_scope_headers(
if let Some(value) = header_string(scope.get("openhub_user_key")) {
request = request.header(HeaderName::from_static("x-mnote-user-key"), value);
}
if let Some(value) = header_string(scope.get("mnoteUserId")) {
request = request.header(HeaderName::from_static("x-mnote-user-id"), value);
}
if let Some(value) = header_string(scope.get("mnoteDisplayName")) {
request = request.header(HeaderName::from_static("x-mnote-display-name"), value);
}
if let Some(value) = header_string(scope.get("workspace_key")) {
request = request.header(HeaderName::from_static("x-mnote-workspace-key"), value);
}
@@ -754,7 +843,7 @@ pub async fn status(
let openhub_fastapi = probe_service(
"openhub_fastapi",
openhub_base.clone(),
"/global/health",
"/api/health",
openhub_configured,
)
.await;
@@ -1095,6 +1184,22 @@ fn response_with_status(status: StatusCode, body: String) -> Response {
.unwrap_or_else(|_| Response::new(Body::from("openhub proxy response build failed")))
}
fn is_streaming_response(headers: &HeaderMap) -> bool {
let content_type = headers
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("")
.to_ascii_lowercase();
content_type.contains("text/event-stream")
|| content_type.contains("application/x-ndjson")
|| headers
.get(header::CACHE_CONTROL)
.and_then(|value| value.to_str().ok())
.unwrap_or("")
.to_ascii_lowercase()
.contains("no-cache")
}
pub async fn ai_proxy(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
@@ -1116,7 +1221,7 @@ pub async fn ai_proxy(
let upstream_method =
reqwest::Method::from_bytes(method.as_str().as_bytes()).unwrap_or(reqwest::Method::GET);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.connect_timeout(Duration::from_secs(10))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|error| {
@@ -1162,6 +1267,28 @@ pub async fn ai_proxy(
})?;
let status = upstream.status();
let headers = upstream.headers().clone();
if is_streaming_response(&headers) {
let mut builder = Response::builder().status(status);
let response_headers = strip_hop_headers(&headers);
for (name, value) in response_headers.iter() {
builder = builder.header(name, value);
}
builder = builder
.header(header::CACHE_CONTROL, "no-cache, no-transform")
.header("x-accel-buffering", "no");
let stream = upstream.bytes_stream().map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::Other,
format!("OpenHub AI proxy 流式响应读取失败:{error}"),
)
});
return Ok(builder.body(Body::from_stream(stream)).unwrap_or_else(|_| {
response_with_status(
StatusCode::BAD_GATEWAY,
"OpenHub AI proxy 流式响应构造失败".to_string(),
)
}));
}
let content_type = headers
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
+6 -6
View File
@@ -82,7 +82,7 @@ fn build_session_response(state: &AppState, context: RequestContext) -> SessionR
email: resolved.user.email.unwrap_or_default(),
name: resolved.user.display_name,
actor_type: effective_actor_type_for_user(&resolved.user.id, &resolved.user.role),
auth_mode: "sqliteSession",
auth_mode: "controlPlaneSession",
request_id: context.trace.request_id,
trace_id: context.trace.trace_id,
};
@@ -321,7 +321,7 @@ mod tests {
}
#[tokio::test]
async fn session_prefers_sqlite_cookie_identity_over_dev_fallback() {
async fn session_prefers_control_plane_cookie_identity_over_dev_fallback() {
let state = AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
@@ -386,11 +386,11 @@ mod tests {
assert_eq!(payload["email"], "shujuan@163.com");
assert_eq!(payload["name"], "shujuan");
assert_eq!(payload["actorType"], "user");
assert_eq!(payload["authMode"], "sqliteSession");
assert_eq!(payload["authMode"], "controlPlaneSession");
}
#[tokio::test]
async fn session_returns_admin_for_sqlite_admin_user() {
async fn session_returns_admin_for_control_plane_admin_user() {
let state = AppState::new(AppConfig {
service_name: "mnote-web".into(),
service_version: "0.1.0".into(),
@@ -452,7 +452,7 @@ mod tests {
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["userId"], "liaibo");
assert_eq!(payload["actorType"], "admin");
assert_eq!(payload["authMode"], "sqliteSession");
assert_eq!(payload["authMode"], "controlPlaneSession");
}
#[tokio::test]
@@ -535,7 +535,7 @@ mod tests {
let payload: serde_json::Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["userId"], "liaibo");
assert_eq!(payload["actorType"], "admin");
assert_eq!(payload["authMode"], "sqliteSession");
assert_eq!(payload["authMode"], "controlPlaneSession");
}
#[tokio::test]
@@ -79,7 +79,7 @@ pub(crate) async fn get_tree_view_state(
Some(&scope.source_kind),
)
.map_err(|error| {
WebError::internal(format!("SQLite tree view state 读取失败: {error}"))
WebError::internal(format!("control-plane tree view state 读取失败: {error}"))
.with_context(&context)
})?;
let Some(record) = find_tree_view_state_record(&preferences, &scope) else {
@@ -92,14 +92,14 @@ pub(crate) async fn get_tree_view_state(
)));
};
let parsed = serde_json::from_str::<Value>(&record.value_json).map_err(|error| {
WebError::internal(format!("SQLite tree view state JSON 无效: {error}"))
WebError::internal(format!("control-plane tree view state JSON 无效: {error}"))
.with_context(&context)
})?;
let normalized = normalize_state(&context, &scope, parsed)?;
Ok(Json(response_payload(
&actor_id,
&scope,
"sqlite",
"control-plane",
Some(record.revision),
normalized,
)))
@@ -143,7 +143,7 @@ pub(crate) async fn put_tree_view_state(
Ok(Json(response_payload(
&actor_id,
&scope,
"sqlite",
"control-plane",
Some(record.revision),
normalized,
)))
@@ -3287,7 +3287,7 @@ fn annotate_local_attachment_refs_authorization(
.control_plane()
.resolve_access(&context.auth.actor_id, resolved_uri)
.map_err(|error| {
WebError::internal(format!("SQLite 控制面附件授权解析失败: {error}"))
WebError::internal(format!("control-plane 附件授权解析失败: {error}"))
})?;
object.insert(
"authorized".to_string(),
@@ -3816,7 +3816,7 @@ mod tests {
role: None,
password_hash: None,
})
.expect("upsert sqlite reader");
.expect("upsert control-plane reader");
state
.control_plane()
.grant_directory_access(DirectoryGrantInput {
@@ -3834,7 +3834,7 @@ mod tests {
source: "unit-test".into(),
created_by: None,
})
.expect("grant sqlite read");
.expect("grant control-plane read");
}
fn app_with_unreachable_convex_without_fixture() -> axum::Router {
@@ -4706,7 +4706,7 @@ mod tests {
}
#[tokio::test]
async fn page_aggregate_endpoint_allows_sqlite_granted_local_folder_read_access() {
async fn page_aggregate_endpoint_allows_control_plane_granted_local_folder_read_access() {
let root = std::env::temp_dir().join(format!(
"mnote-local-page-aggregate-grant-{}",
std::process::id()
@@ -4753,7 +4753,7 @@ mod tests {
Some(&root_uri),
)
.await
.expect("sqlite read grant can open local page aggregate");
.expect("control-plane read grant can open local page aggregate");
let _ = std::fs::remove_dir_all(&root);
@@ -4766,7 +4766,7 @@ mod tests {
}
#[tokio::test]
async fn local_page_aggregate_marks_attachment_refs_authorization_from_sqlite_grants() {
async fn local_page_aggregate_marks_attachment_refs_authorization_from_control_plane_grants() {
let root = temp_root("mnote-local-page-aggregate-attachment-auth-root");
let allowed_external_root = temp_root("mnote-local-page-aggregate-attachment-auth-allowed");
let denied_external_root = temp_root("mnote-local-page-aggregate-attachment-auth-denied");
@@ -4935,7 +4935,7 @@ mod tests {
}
#[tokio::test]
async fn local_folder_page_aggregate_prefers_sqlite_ui_preference_over_default_title_header() {
async fn local_folder_page_aggregate_prefers_control_plane_ui_preference_over_default_title_header() {
let root = std::env::temp_dir().join(format!(
"mnote-local-page-aggregate-ui-pref-{}",
std::process::id()
@@ -5118,7 +5118,7 @@ mod tests {
.await
.expect("body");
let get_payload: Value = serde_json::from_slice(&get_body).expect("json");
assert_eq!(get_payload["result"]["source"], "sqlite");
assert_eq!(get_payload["result"]["source"], "control-plane");
assert_eq!(
get_payload["result"]["state"]["expandedRelativePaths"][0],
"design/05-editor-mainline"
+2 -2
View File
@@ -20,7 +20,7 @@ pub fn AuthPage() -> impl IntoView {
<h1 id="mnote-auth-title">"账号登录"</h1>
<p>"登录后进入你的工作区"</p>
</div>
<form class="mnote-auth-form" method="post" action="/api/auth" data-auth-mode="sqlite-session">
<form class="mnote-auth-form" method="post" action="/api/auth" data-auth-mode="control-plane-session">
<input type="hidden" name="action" value="auth:signIn" />
<input type="hidden" name="provider" value="password" />
<input type="hidden" name="flow" value="signIn" data-auth-flow />
@@ -95,7 +95,7 @@ const AUTH_SCRIPT: &str = r#"
(function () {
var root = document.querySelector('[data-testid="mnote-auth-page"]');
if (!root) return;
var form = root.querySelector('form[data-auth-mode="sqlite-session"]');
var form = root.querySelector('form[data-auth-mode="control-plane-session"]');
var flowInput = root.querySelector('[data-auth-flow]');
var accountInput = root.querySelector('#account');
var emailInput = root.querySelector('#email');
+13 -6
View File
@@ -192,7 +192,6 @@ pub fn PageLayout(
<span class="wolai-public-pill" data-testid="wolai-public-state" hidden></span>
<button type="button" class="wolai-icon-button" title="星标置顶" aria-label="星标置顶" data-state="closed" aria-haspopup="true" aria-expanded="false" data-mnote-action="toggle-sidebar-shortcut" data-mnote-shortcut-kind="page"><span class="material-symbols-outlined" data-icon="star" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="演示" aria-label="演示"><span class="material-symbols-outlined" data-icon="slideshow" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="资料库问答" aria-label="资料库问答" data-testid="mnote-knowledge-rag-settings-toggle" data-mnote-action="open-knowledge-rag-settings"><span class="material-symbols-outlined" data-icon="travel_explore" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="评论" aria-label="评论"><span class="material-symbols-outlined" data-icon="comment" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="分享" aria-label="分享"><span class="material-symbols-outlined" data-icon="share" aria-hidden="true"></span></button>
<button type="button" class="wolai-icon-button" title="成员" aria-label="成员"><span class="material-symbols-outlined" data-icon="person_add" aria-hidden="true"></span></button>
@@ -204,6 +203,7 @@ pub fn PageLayout(
{children()}
</article>
<div class="wolai-floating-actions" aria-label="浮动操作">
<a href="/knowledge" data-testid="mnote-floating-knowledge" class="wolai-floating-button wolai-floating-button--knowledge" aria-label="知识库" title="打开知识库" data-mnote-action="open-knowledge-host"><span class="material-symbols-outlined material-symbols-filled" data-icon="travel_explore" aria-hidden="true"></span></a>
<button type="button" data-testid="wolai-floating-ai" class="wolai-floating-button wolai-floating-button--ai" aria-label="AI 助手" data-state="closed" aria-haspopup="dialog" aria-expanded="false" title="点击 进入空间智能问答" data-mnote-action="open-page-ai"><span class="material-symbols-outlined material-symbols-filled" data-icon="auto_awesome" aria-hidden="true"></span></button>
</div>
</div>
@@ -547,8 +547,15 @@ mod tests {
assert!(!html.contains(r#"data-testid="mnote-local-index-settings-toggle""#));
assert!(!html.contains(r#"data-mnote-action="open-index-settings""#));
assert!(html.contains(r#"data-testid="mnote-knowledge-rag-settings-toggle""#));
assert!(html.contains(r#"data-mnote-action="open-knowledge-rag-settings""#));
assert!(!html.contains(r#"data-testid="mnote-knowledge-rag-settings-toggle""#));
assert!(html.contains(r#"data-testid="mnote-floating-knowledge""#));
assert!(html.contains(r#"href="/knowledge""#));
assert!(html.contains(r#"data-mnote-action="open-knowledge-host""#));
assert!(
html.find(r#"data-testid="mnote-floating-knowledge""#)
< html.find(r#"data-testid="wolai-floating-ai""#)
);
assert!(!html.contains(r#"data-mnote-action="open-knowledge-rag-settings""#));
assert!(html.contains(r#"data-icon="travel_explore""#));
assert!(crate::ssr::styles::MNOTE_CSS.contains(r#"data-icon="travel_explore""#));
assert!(!html.contains(r#"data-testid="mnote-local-ocr-task-toggle""#));
@@ -595,9 +602,9 @@ mod tests {
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-knowledge-rag-input-status-label"));
assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("localOcrAutoEnabled"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-mnote-local-ocr-auto-retired"));
assert!(
SIDEBAR_TREE_RUNTIME_JS.contains("[data-mnote-action=\"open-knowledge-rag-settings\"]")
);
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("window.location.href = '/knowledge'"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("open-knowledge-rag-debug-settings"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("/debug/knowledge-rag"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("[data-knowledge-rag-action]"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("useKnowledgeRagFileTreeSelection"));
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("reindex-source"));
@@ -2340,9 +2340,11 @@
}
.wolai-floating-actions {
position: fixed;
display: flex;
right: 24px;
bottom: 44px;
flex-direction: column-reverse;
flex-direction: column;
gap: 16px;
z-index: var(--wolai-z-dock);
}
@@ -2366,7 +2368,8 @@
font-size: 18px;
}
.wolai-floating-button--ai {
.wolai-floating-button--ai,
.wolai-floating-button--knowledge {
width: 40px;
height: 40px;
border: 1px solid rgba(27, 28, 28, 0.1);
@@ -2376,6 +2379,10 @@
font-size: 18px;
}
.wolai-floating-button--knowledge {
text-decoration: none;
}
.document-shell[data-page-wide-layout="true"] {
width: min(100%, 980px);
}
@@ -2614,7 +2621,7 @@
.mnote-weknora-kb-page-body {
display: grid;
grid-template-columns: minmax(260px, 320px) minmax(0, 1fr);
min-height: 620px;
min-height: min(620px, calc(100vh - 190px));
max-height: calc(100vh - 150px);
}
@@ -2869,6 +2876,39 @@
line-height: 18px;
}
.mnote-knowledge-rag-detail-tabs {
display: inline-flex;
width: fit-content;
max-width: 100%;
gap: 3px;
padding: 3px;
border: 1px solid #E5E7EB;
border-radius: 8px;
background: #FFFFFF;
}
.mnote-knowledge-rag-detail-tab {
min-height: 28px;
border: 0;
border-radius: 6px;
background: transparent;
color: #4B5563;
cursor: pointer;
font-size: 12px;
line-height: 18px;
padding: 0 10px;
}
.mnote-knowledge-rag-detail-tab.is-active {
background: #EAF7EA;
color: #166534;
font-weight: 600;
}
.mnote-knowledge-rag-detail-panel[hidden] {
display: none;
}
.mnote-weknora-doc-toolbar,
.mnote-weknora-doc-actions {
display: flex;
@@ -3063,21 +3103,52 @@
}
.mnote-knowledge-rag-meta {
display: flex;
flex-direction: column;
gap: 5px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 8px;
}
.mnote-knowledge-rag-meta div {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 8px;
align-items: baseline;
color: #8B8782;
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
padding: 8px 10px;
border: 1px solid #E5E7EB;
border-radius: 8px;
background: #FFFFFF;
color: #6B7280;
font-size: 12px;
line-height: 18px;
}
.mnote-knowledge-rag-meta .mnote-weknora-kb-summary-card {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0;
padding: 0;
overflow: hidden;
}
.mnote-knowledge-rag-meta .mnote-weknora-kb-summary-card div {
border: 0;
border-right: 1px solid #E5E7EB;
border-radius: 0;
background: transparent;
padding: 10px 12px;
}
.mnote-knowledge-rag-meta .mnote-weknora-kb-summary-card div:last-child {
border-right: 0;
}
.mnote-knowledge-rag-meta strong {
color: #111827;
font-size: 15px;
line-height: 22px;
}
.mnote-knowledge-rag-meta code {
min-width: 0;
overflow: hidden;
@@ -3140,6 +3211,15 @@
gap: 8px;
}
.mnote-knowledge-rag-source-row {
padding: 10px 12px;
border-bottom: 1px solid #EEF0F2;
}
.mnote-knowledge-rag-source-row:last-child {
border-bottom: 0;
}
.mnote-knowledge-rag-source-input-row {
grid-template-columns: minmax(0, 1fr) 56px 28px;
}
@@ -3249,6 +3329,7 @@
.mnote-knowledge-rag-source-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-end;
}
@@ -3274,6 +3355,23 @@
opacity: 0.45;
}
@media (max-width: 900px) {
.mnote-weknora-kb-page-body,
.mnote-weknora-kb-detail-hero,
.mnote-weknora-doc-layout {
grid-template-columns: 1fr;
}
.mnote-weknora-kb-list-pane {
border-right: 0;
border-bottom: 1px solid #E5E7EB;
}
.mnote-knowledge-rag-meta .mnote-weknora-kb-summary-card {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.wolai-page-settings-tabs {
display: flex;
gap: 6px;