chore: retire openhub and pi ts runtime
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
# 7-66 Page AI Pi TS / OpenHub 软删除收口 Checklist v1
|
||||
|
||||
状态:done
|
||||
Owner:07-ai / mnote-web
|
||||
日期:2026-07-12
|
||||
|
||||
## 1. 背景
|
||||
|
||||
Pi TS 与 OpenHub 已不再作为当前 Page AI 主链。当前主线收口为 MNote 托管的 Pi Rust Page AI,OpenHub 与 Pi TS 仅作为历史对照保留到 `recycle/20260712-pi-ts-openhub-retirement/`。
|
||||
|
||||
## 2. Checklist
|
||||
|
||||
- [x] 移除 `page_ai_openhub` active route,并把原 route 源码软归档到 recycle。
|
||||
- [x] 移除 `/page-ai/openhub/*` 与 `/api/page-ai/openhub/*` active router 入口。
|
||||
- [x] 移除 Pi TS runtime fallback、`MNOTE_PAGE_AI_PI_TS_BIN` 与 `PI_LAB_RUNTIME_IMPL_TS`。
|
||||
- [x] AI 管理页不再展示 OpenHub Admin 入口或兼容卡片。
|
||||
- [x] `dev:hot` / `desktop:hot` 不再默认启动或 health check OpenHub。
|
||||
- [x] OpenHub 专项 smoke 与融合设计稿软归档到 recycle。
|
||||
- [x] Pi Lab 静态 / 浏览器 smoke 改为验证 Pi Rust 独立 drawer 与退役 host 隔离。
|
||||
- [x] 跑完静态、Rust、浏览器与 CodeGraph 验证后移入 `done/`。
|
||||
|
||||
## 3. 验收命令
|
||||
|
||||
```bash
|
||||
node scripts/task-pi-lab-static-smoke.js
|
||||
node scripts/task-ai-management-control-plane-static-smoke.js
|
||||
node scripts/task-dev-hot-plan-test.js
|
||||
node --test scripts/desktop-hot.test.js
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web page_ai_pi -- --nocapture
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web ai_admin -- --nocapture
|
||||
git diff --check
|
||||
codegraph sync .
|
||||
codegraph status .
|
||||
```
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
> 更新时间:2026-06-28
|
||||
>
|
||||
> 当前产品口径:`VSCode 简化版工作区 + tiptap 的 Markdown 前端编辑器 + OpenHub/opencode agent + WeKnora 知识库融合 + simplemindmap/office 插件 + Wolai 主题 Web 壳 + 鉴权控制面`。
|
||||
> 当前产品口径:`VSCode 简化版工作区 + tiptap 的 Markdown 前端编辑器 + Pi Rust Page AI + opencode/native agent 边界 + LightRAG 知识库融合 + simplemindmap/office 插件 + Wolai 主题 Web 壳 + 鉴权控制面`。
|
||||
>
|
||||
> 当前上位主线:local-first MVP 已初步完成;local-first workspace 是默认数据形态,本地 `.md` 是页面正文真相,Rust kernel / projection / command 持有语义,Rust SQLite control-plane 持有默认 auth、membership、share grants、sync state、AI policy、Page AI runtime session 控制面;Convex / 服务端只作为历史迁移源、显式 cloud source、compat 和 sync replica。入口设计见:
|
||||
> `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/done/2-2-local-first-workspace-convex-control-plane-v1.md`
|
||||
|
||||
@@ -2160,18 +2160,31 @@ impl ControlPlaneStore for SqliteControlPlaneStore {
|
||||
|
||||
let conn = self.lock_conn()?;
|
||||
let now = now_text();
|
||||
let existing = conn
|
||||
.query_row(
|
||||
let existing = if let Some(workspace_id) = input.workspace_id.as_deref() {
|
||||
conn.query_row(
|
||||
"SELECT id, user_id, workspace_id, allowed_roots_json, model_policy_json, quota_json, created_at, updated_at, revision
|
||||
FROM ai_policies
|
||||
WHERE (?1 IS NULL OR user_id = ?1)
|
||||
AND (?2 IS NULL OR workspace_id = ?2)
|
||||
WHERE workspace_id = ?1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1",
|
||||
params![input.user_id, input.workspace_id],
|
||||
params![workspace_id],
|
||||
row_to_ai_policy,
|
||||
)
|
||||
.optional()?;
|
||||
.optional()?
|
||||
} else if let Some(user_id) = input.user_id.as_deref() {
|
||||
conn.query_row(
|
||||
"SELECT id, user_id, workspace_id, allowed_roots_json, model_policy_json, quota_json, created_at, updated_at, revision
|
||||
FROM ai_policies
|
||||
WHERE user_id = ?1 AND workspace_id IS NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1",
|
||||
params![user_id],
|
||||
row_to_ai_policy,
|
||||
)
|
||||
.optional()?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(existing) = existing {
|
||||
let revision = existing.revision + 1;
|
||||
@@ -4067,6 +4080,52 @@ mod tests {
|
||||
assert_eq!(user_only.id, user_policy.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_policy_user_upsert_does_not_overwrite_workspace_policy() {
|
||||
let store = store();
|
||||
create_user(&store, "ai_user");
|
||||
let workspace = store
|
||||
.ensure_default_workspace("ai_user")
|
||||
.expect("default workspace");
|
||||
|
||||
let workspace_policy = store
|
||||
.upsert_ai_policy(UpsertAiPolicyInput {
|
||||
id: None,
|
||||
user_id: Some("ai_user".to_string()),
|
||||
workspace_id: Some(workspace.id.clone()),
|
||||
allowed_roots_json: "[\"file:///workspace-root\"]".to_string(),
|
||||
model_policy_json: "{\"default\":\"workspace\"}".to_string(),
|
||||
quota_json: "{}".to_string(),
|
||||
})
|
||||
.expect("workspace policy");
|
||||
|
||||
let user_policy = store
|
||||
.upsert_ai_policy(UpsertAiPolicyInput {
|
||||
id: None,
|
||||
user_id: Some("ai_user".to_string()),
|
||||
workspace_id: None,
|
||||
allowed_roots_json: "[\"file:///user-root\"]".to_string(),
|
||||
model_policy_json: "{\"default\":\"user\"}".to_string(),
|
||||
quota_json: "{}".to_string(),
|
||||
})
|
||||
.expect("user policy");
|
||||
assert_ne!(user_policy.id, workspace_policy.id);
|
||||
|
||||
let user_only = store
|
||||
.get_ai_policy("ai_user", None)
|
||||
.expect("get user policy")
|
||||
.expect("user policy exists");
|
||||
assert_eq!(user_only.id, user_policy.id);
|
||||
assert!(user_only.allowed_roots_json.contains("user-root"));
|
||||
|
||||
let workspace_only = store
|
||||
.get_ai_policy("ai_user", Some(&workspace.id))
|
||||
.expect("get workspace policy")
|
||||
.expect("workspace policy exists");
|
||||
assert_eq!(workspace_only.id, workspace_policy.id);
|
||||
assert!(workspace_only.allowed_roots_json.contains("workspace-root"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_state_upsert_tracks_cursor_status_and_revision() {
|
||||
let store = store();
|
||||
|
||||
@@ -2684,18 +2684,31 @@ impl ControlPlaneStore for TursoControlPlaneStore {
|
||||
|
||||
let conn = self.lock_conn()?;
|
||||
let now = now_text();
|
||||
let existing = conn
|
||||
.query_row(
|
||||
let existing = if let Some(workspace_id) = input.workspace_id.as_deref() {
|
||||
conn.query_row(
|
||||
"SELECT id, user_id, workspace_id, allowed_roots_json, model_policy_json, quota_json, created_at, updated_at, revision
|
||||
FROM ai_policies
|
||||
WHERE (?1 IS NULL OR user_id = ?1)
|
||||
AND (?2 IS NULL OR workspace_id = ?2)
|
||||
WHERE workspace_id = ?1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1",
|
||||
params![input.user_id, input.workspace_id],
|
||||
params![workspace_id],
|
||||
row_to_ai_policy,
|
||||
)
|
||||
.optional()?;
|
||||
.optional()?
|
||||
} else if let Some(user_id) = input.user_id.as_deref() {
|
||||
conn.query_row(
|
||||
"SELECT id, user_id, workspace_id, allowed_roots_json, model_policy_json, quota_json, created_at, updated_at, revision
|
||||
FROM ai_policies
|
||||
WHERE user_id = ?1 AND workspace_id IS NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1",
|
||||
params![user_id],
|
||||
row_to_ai_policy,
|
||||
)
|
||||
.optional()?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(existing) = existing {
|
||||
let revision = existing.revision + 1;
|
||||
@@ -4593,6 +4606,52 @@ mod tests {
|
||||
assert_eq!(user_only.id, user_policy.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_policy_user_upsert_does_not_overwrite_workspace_policy() {
|
||||
let store = store();
|
||||
create_user(&store, "ai_user");
|
||||
let workspace = store
|
||||
.ensure_default_workspace("ai_user")
|
||||
.expect("default workspace");
|
||||
|
||||
let workspace_policy = store
|
||||
.upsert_ai_policy(UpsertAiPolicyInput {
|
||||
id: None,
|
||||
user_id: Some("ai_user".to_string()),
|
||||
workspace_id: Some(workspace.id.clone()),
|
||||
allowed_roots_json: "[\"file:///workspace-root\"]".to_string(),
|
||||
model_policy_json: "{\"default\":\"workspace\"}".to_string(),
|
||||
quota_json: "{}".to_string(),
|
||||
})
|
||||
.expect("workspace policy");
|
||||
|
||||
let user_policy = store
|
||||
.upsert_ai_policy(UpsertAiPolicyInput {
|
||||
id: None,
|
||||
user_id: Some("ai_user".to_string()),
|
||||
workspace_id: None,
|
||||
allowed_roots_json: "[\"file:///user-root\"]".to_string(),
|
||||
model_policy_json: "{\"default\":\"user\"}".to_string(),
|
||||
quota_json: "{}".to_string(),
|
||||
})
|
||||
.expect("user policy");
|
||||
assert_ne!(user_policy.id, workspace_policy.id);
|
||||
|
||||
let user_only = store
|
||||
.get_ai_policy("ai_user", None)
|
||||
.expect("get user policy")
|
||||
.expect("user policy exists");
|
||||
assert_eq!(user_only.id, user_policy.id);
|
||||
assert!(user_only.allowed_roots_json.contains("user-root"));
|
||||
|
||||
let workspace_only = store
|
||||
.get_ai_policy("ai_user", Some(&workspace.id))
|
||||
.expect("get workspace policy")
|
||||
.expect("workspace policy exists");
|
||||
assert_eq!(workspace_only.id, workspace_policy.id);
|
||||
assert!(workspace_only.allowed_roots_json.contains("workspace-root"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_state_upsert_tracks_cursor_status_and_revision() {
|
||||
let store = store();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// == Pi Lab Page AI Runtime ==
|
||||
// MNote-native adapter for Pi-first Page AI Lab.
|
||||
// Renders the MNote-owned Pi Rust Page AI surface. OpenHub is kept as a
|
||||
// compatibility/admin boundary during migration, not as the default chat owner.
|
||||
// Renders the MNote-owned Pi Rust Page AI surface. Retired hosts stay in
|
||||
// recycle; this runtime must stay independent.
|
||||
// NO setInterval polling.
|
||||
//
|
||||
// UI note: @earendil-works/pi-web-ui@0.75.3 was checked as the mature upstream UI.
|
||||
|
||||
@@ -199,12 +199,6 @@ export function createSidebarPageAiRuntime(context) {
|
||||
pageAiOpencodeEventSource: null,
|
||||
pageAiOpencodeEventSessionId: '',
|
||||
pageAiOpencodeEventTimer: 0,
|
||||
pageAiOpenHubStatus: null,
|
||||
pageAiOpenHubStatusError: '',
|
||||
pageAiOpenHubBootstrap: null,
|
||||
pageAiOpenHubIframeUrl: '',
|
||||
pageAiOpenHubScopeSummary: '',
|
||||
pageAiOpenHubFallbackReason: '',
|
||||
pageAiSessionSearchQuery: '',
|
||||
pageAiSessionSearchResults: [],
|
||||
pageAiSessionSearchTimer: 0,
|
||||
@@ -892,10 +886,6 @@ export function createSidebarPageAiRuntime(context) {
|
||||
}
|
||||
}
|
||||
|
||||
function pageAiOpenHubHostEnabled() {
|
||||
return true;
|
||||
}
|
||||
|
||||
function pageAiOpencodeStatusLabel(status) {
|
||||
var node = status && typeof status === 'object' ? status : {};
|
||||
var state = String(node.status || node.state || node.runtimeStatus || '').trim();
|
||||
@@ -1151,116 +1141,6 @@ export function createSidebarPageAiRuntime(context) {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function pageAiOpenHubScopeSummary(scope) {
|
||||
var node = scope && typeof scope === 'object' ? scope : {};
|
||||
var workspace = node.workspaceScope && typeof node.workspaceScope === 'object' ? node.workspaceScope : {};
|
||||
var items = [];
|
||||
if (node.openhubUserKey || node.openhub_user_key) items.push('openhub_user_key=' + String(node.openhubUserKey || node.openhub_user_key));
|
||||
if (node.openhubWorkspaceKey || node.workspace_key) items.push('workspace_key=' + String(node.openhubWorkspaceKey || node.workspace_key));
|
||||
if (workspace.workspaceId) items.push('workspace=' + String(workspace.workspaceId));
|
||||
if (workspace.rootUri) items.push('rootUri=' + String(workspace.rootUri));
|
||||
if (node.openhubSessionScope || node.session_scope) items.push('session_scope=' + String(node.openhubSessionScope || node.session_scope));
|
||||
if (node.skillScope || node.skill_scope) items.push('skill_scope=' + String(node.skillScope || node.skill_scope));
|
||||
if (node.mcpScope) items.push('mcp_scope=' + String(node.mcpScope));
|
||||
if (node.toolPermissionScope || node.tool_permission_scope) items.push('tool_permission_scope=' + String(node.toolPermissionScope || node.tool_permission_scope));
|
||||
if (node.weknoraToolScope || node.weknora_tool_scope) items.push('weknora_tool_scope=' + String(node.weknoraToolScope || node.weknora_tool_scope));
|
||||
return items.join('\n');
|
||||
}
|
||||
|
||||
function pageAiOpenHubStatusText(status) {
|
||||
var node = status && typeof status === 'object' ? status : {};
|
||||
if (node.ok === true || String(node.status || '').trim() === 'ready') return '已连接 OpenHub';
|
||||
if (String(node.status || '').trim() === 'degraded') return 'OpenHub 连接受限';
|
||||
return '正在连接 OpenHub';
|
||||
}
|
||||
|
||||
function pageAiRenderOpenHubHostChrome() {
|
||||
var drawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
|
||||
if (!(drawer instanceof HTMLElement) || drawer.getAttribute('data-page-ai-openhub-host') !== 'true') return;
|
||||
var bootstrap = pageUiState.pageAiOpenHubBootstrap && typeof pageUiState.pageAiOpenHubBootstrap === 'object' ? pageUiState.pageAiOpenHubBootstrap : {};
|
||||
var scope = bootstrap.scope && typeof bootstrap.scope === 'object' ? bootstrap.scope : {};
|
||||
var status = pageUiState.pageAiOpenHubStatus && typeof pageUiState.pageAiOpenHubStatus === 'object' ? pageUiState.pageAiOpenHubStatus : {};
|
||||
var statusNode = drawer.querySelector('[data-page-ai-openhub-runtime-status]');
|
||||
if (statusNode instanceof HTMLElement) {
|
||||
statusNode.textContent = pageUiState.pageAiOpenHubStatusError || pageAiOpenHubStatusText(status);
|
||||
statusNode.setAttribute('data-state', pageUiState.pageAiOpenHubStatusError ? 'error' : (status.ok ? 'ok' : 'degraded'));
|
||||
}
|
||||
var userNode = drawer.querySelector('[data-page-ai-openhub-user-key]');
|
||||
if (userNode instanceof HTMLElement) userNode.textContent = String(scope.openhubUserKey || scope.openhub_user_key || '等待 bootstrap');
|
||||
var authNode = drawer.querySelector('[data-page-ai-openhub-auth-truth]');
|
||||
if (authNode instanceof HTMLElement) authNode.textContent = '登录真相:MNote mnote_session;拒绝 OpenHub JWT/localStorage';
|
||||
var workspaceNode = drawer.querySelector('[data-page-ai-openhub-workspace-scope]');
|
||||
if (workspaceNode instanceof HTMLElement) workspaceNode.textContent = pageAiOpenHubScopeSummary(scope) || '等待 workspace/rootUri/session/tool scope';
|
||||
var guardNode = drawer.querySelector('[data-page-ai-openhub-route-guard]');
|
||||
if (guardNode instanceof HTMLElement) guardNode.textContent = '非 AI 路由 guard:login/admin/file/knowledge 由 MNote 接管';
|
||||
var fallbackNode = drawer.querySelector('[data-page-ai-openhub-fallback]');
|
||||
if (fallbackNode instanceof HTMLElement) {
|
||||
fallbackNode.textContent = pageUiState.pageAiOpenHubFallbackReason || 'OpenHub 不可用时提示用户刷新或检查服务,不再提供 opencode 页面回退';
|
||||
}
|
||||
var iframe = drawer.querySelector('[data-page-ai-openhub-iframe]');
|
||||
if (iframe instanceof HTMLIFrameElement) {
|
||||
var nextUrl = String(pageUiState.pageAiOpenHubIframeUrl || bootstrap.openhubIframeUrl || '/page-ai/openhub/ai').trim();
|
||||
if (nextUrl && iframe.getAttribute('src') !== nextUrl) iframe.setAttribute('src', nextUrl);
|
||||
iframe.hidden = !nextUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function pageAiEnsureOpenHubHostDrawer() {
|
||||
var drawer = ensurePageAiDrawer();
|
||||
pageAiInstallMNoteOpenFileBridge();
|
||||
if (drawer.getAttribute('data-page-ai-openhub-host') !== 'true') {
|
||||
drawer.setAttribute('data-page-ai-openhub-host', 'true');
|
||||
drawer.removeAttribute('data-page-ai-opencode-host');
|
||||
drawer.setAttribute('data-mnote-acp-runtime', 'openhub-opencode');
|
||||
drawer.innerHTML = '' +
|
||||
'<div class="wolai-page-ai-resize-handle" data-page-ai-resize-handle title="拖动调整页面 AI 宽度" aria-hidden="true"></div>' +
|
||||
'<div class="wolai-page-ai-panel wolai-page-ai-opencode-panel" role="dialog" aria-modal="false" aria-label="OpenHub AI">' +
|
||||
'<details class="wolai-page-ai-openhub-diagnostics" data-page-ai-openhub-bootstrap-copy hidden aria-hidden="true">' +
|
||||
'<summary>诊断</summary>' +
|
||||
'<div class="wolai-page-ai-opencode-row"><span>用户隔离</span><code data-page-ai-openhub-user-key>等待 bootstrap</code></div>' +
|
||||
'<div class="wolai-page-ai-opencode-row"><span>认证边界</span><strong data-page-ai-openhub-auth-truth>登录真相:MNote mnote_session</strong></div>' +
|
||||
'<pre class="wolai-page-ai-opencode-empty" data-page-ai-openhub-workspace-scope>等待 workspace/rootUri/session/tool scope</pre>' +
|
||||
'<div class="wolai-page-ai-opencode-badges"><span data-page-ai-openhub-route-guard>非 AI 路由 guard:login/admin/file/knowledge</span><span data-page-ai-openhub-fallback>OpenHub 不可用时提示刷新或检查服务</span></div>' +
|
||||
'</details>' +
|
||||
'<div class="wolai-page-ai-opencode-frame-wrap" data-page-ai-openhub-frame-wrap>' +
|
||||
'<iframe class="wolai-page-ai-opencode-iframe" data-page-ai-openhub-iframe title="OpenHub AI 面板" allow="clipboard-read; clipboard-write" referrerpolicy="same-origin" src="about:blank"></iframe>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
pageAiRenderOpenHubHostChrome();
|
||||
void pageAiBootstrapOpenHubHost();
|
||||
return drawer;
|
||||
}
|
||||
|
||||
async function pageAiBootstrapOpenHubHost() {
|
||||
var context = pageAiBuildOpencodeContextPayload();
|
||||
try {
|
||||
var statusResponse = await fetch('/api/page-ai/openhub/status', { headers: { accept: 'application/json' }, cache: 'no-store' });
|
||||
pageUiState.pageAiOpenHubStatus = await statusResponse.json().catch(function() { return null; }) || {};
|
||||
var response = await fetch('/api/page-ai/openhub/bootstrap', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
body: JSON.stringify(context)
|
||||
});
|
||||
var payload = await response.json().catch(function() { return null; });
|
||||
if (!response.ok) throw new Error(payload && (payload.error || payload.message) ? String(payload.error || payload.message) : 'HTTP ' + response.status);
|
||||
pageUiState.pageAiOpenHubBootstrap = payload || {};
|
||||
pageUiState.pageAiOpenHubStatusError = '';
|
||||
pageUiState.pageAiOpenHubIframeUrl = String(payload && payload.openhubIframeUrl || '/page-ai/openhub/ai').trim();
|
||||
var statusFallback = pageUiState.pageAiOpenHubStatus && pageUiState.pageAiOpenHubStatus.fallback ? pageUiState.pageAiOpenHubStatus.fallback : null;
|
||||
pageUiState.pageAiOpenHubFallbackReason = payload && payload.fallback
|
||||
? String(payload.fallback.reason || '')
|
||||
: (statusFallback ? String(statusFallback.reason || '') : '');
|
||||
pageUiState.pageAiOpenHubScopeSummary = pageAiOpenHubScopeSummary(payload && payload.scope);
|
||||
pageAiRenderOpenHubHostChrome();
|
||||
} catch (error) {
|
||||
pageUiState.pageAiOpenHubStatusError = error instanceof Error ? error.message : String(error);
|
||||
pageUiState.pageAiOpenHubFallbackReason = 'OpenHub bootstrap 不可用,请刷新或检查 OpenHub 服务';
|
||||
pageAiRenderOpenHubHostChrome();
|
||||
}
|
||||
}
|
||||
|
||||
function pageAiPostOpencodeBridgeMessage(type, payload) {
|
||||
var iframe = document.querySelector('[data-page-ai-opencode-iframe]');
|
||||
if (!(iframe instanceof HTMLIFrameElement) || !iframe.contentWindow) return false;
|
||||
@@ -1268,45 +1148,6 @@ export function createSidebarPageAiRuntime(context) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function pageAiInstallMNoteOpenFileBridge() {
|
||||
if (pageUiState.pageAiMNoteOpenFileBridgeInstalled) return;
|
||||
pageUiState.pageAiMNoteOpenFileBridgeInstalled = true;
|
||||
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 : {};
|
||||
var kind = String(requestPayload.kind || message.kind || 'tab').trim();
|
||||
var value = kind === 'folder' ? addressPayload.folderUrl : addressPayload.tabUrl;
|
||||
if (event.source && typeof event.source.postMessage === 'function') {
|
||||
event.source.postMessage({
|
||||
source: 'mnote-page-ai',
|
||||
type: 'mnote:active-tab-address',
|
||||
requestId: message.requestId || '',
|
||||
payload: Object.assign({}, addressPayload, {
|
||||
kind: kind === 'folder' ? 'folder' : 'tab',
|
||||
value: value
|
||||
})
|
||||
}, event.origin);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!message || (message.type !== 'mnote:open-file' && message.type !== 'mnote:open-reference')) return;
|
||||
if (message.source !== 'openhub-diff' && message.source !== 'openhub-changed-files' && message.source !== 'openhub-citation') return;
|
||||
var payload = message.payload && typeof message.payload === 'object' ? message.payload : {};
|
||||
pageAiOpenOpencodeChangedFile(message.path || payload.path || payload.href || '', {
|
||||
rootUri: String(message.rootUri || payload.rootUri || payload.root_uri || currentRootUri() || '').trim(),
|
||||
workspaceId: String(message.workspaceId || payload.workspaceId || payload.workspace_id || resolveWorkspaceId(document.body) || '').trim(),
|
||||
documentId: String(message.documentId || payload.documentId || payload.document_id || '').trim()
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function pageAiInstallOpencodeBridge() {
|
||||
if (pageUiState.pageAiOpencodeBridgeInstalled) return;
|
||||
pageUiState.pageAiOpencodeBridgeInstalled = true;
|
||||
@@ -1540,10 +1381,8 @@ export function createSidebarPageAiRuntime(context) {
|
||||
|
||||
function pageAiEnsureOpencodeHostDrawer() {
|
||||
var drawer = ensurePageAiDrawer();
|
||||
pageAiInstallMNoteOpenFileBridge();
|
||||
if (drawer.getAttribute('data-page-ai-opencode-host') !== 'true') {
|
||||
drawer.setAttribute('data-page-ai-opencode-host', 'true');
|
||||
drawer.removeAttribute('data-page-ai-openhub-host');
|
||||
drawer.setAttribute('data-mnote-acp-runtime', 'opencode');
|
||||
drawer.innerHTML = '' +
|
||||
'<div class="wolai-page-ai-resize-handle" data-page-ai-resize-handle title="拖动调整页面 AI 宽度" aria-hidden="true"></div>' +
|
||||
@@ -1828,17 +1667,17 @@ export function createSidebarPageAiRuntime(context) {
|
||||
if (isMarkdown) {
|
||||
if (window.__mnoteLocalFolderEventBus && typeof window.__mnoteLocalFolderEventBus.emitChangedFiles === 'function') {
|
||||
window.__mnoteLocalFolderEventBus.emitChangedFiles({
|
||||
source: 'openhub_changed_file_bridge',
|
||||
reason: 'openhub_changed_file',
|
||||
source: 'opencode_changed_file_bridge',
|
||||
reason: 'opencode_changed_file',
|
||||
rootUri: rootUri,
|
||||
workspaceId: workspaceId,
|
||||
changedFiles: [{ relativePath: targetPath, documentId: documentId, changeType: 'modified' }],
|
||||
affectedParents: [{ relativePath: pageAiParentRelativePath(targetPath), reason: 'openhub_changed_file' }]
|
||||
affectedParents: [{ relativePath: pageAiParentRelativePath(targetPath), reason: 'opencode_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);
|
||||
document.documentElement.setAttribute('data-mnote-page-ai-opencode-document-pane-refresh', targetPath);
|
||||
return true;
|
||||
}
|
||||
void window.__mnoteDocumentPaneRuntime.openPrimaryDocument({
|
||||
@@ -1847,7 +1686,7 @@ export function createSidebarPageAiRuntime(context) {
|
||||
sourceKind: currentSourceKind() || 'local_folder',
|
||||
rootUri: rootUri
|
||||
});
|
||||
document.documentElement.setAttribute('data-mnote-page-ai-openhub-document-pane-refresh', targetPath);
|
||||
document.documentElement.setAttribute('data-mnote-page-ai-opencode-document-pane-refresh', targetPath);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -3872,20 +3711,6 @@ export function createSidebarPageAiRuntime(context) {
|
||||
|
||||
|
||||
function openPageAiDrawer() {
|
||||
if (pageAiOpenHubHostEnabled()) {
|
||||
pageAiEnsureContextRefState();
|
||||
void pageAiLoadAllowedRoots().then(function() {
|
||||
pageAiRenderOpenHubHostChrome();
|
||||
}).catch(function() {
|
||||
pageAiRenderOpenHubHostChrome();
|
||||
});
|
||||
var openhubDrawer = pageAiEnsureOpenHubHostDrawer();
|
||||
pageAiApplyDrawerWidth(openhubDrawer);
|
||||
openhubDrawer.hidden = false;
|
||||
pageUiState.pageAiOpen = true;
|
||||
updatePageAiTriggerState();
|
||||
return;
|
||||
}
|
||||
if (pageAiOpencodeHostEnabled()) {
|
||||
pageAiEnsureContextRefState();
|
||||
void pageAiLoadAllowedRoots().then(function() {
|
||||
@@ -5095,9 +4920,6 @@ export function createSidebarPageAiRuntime(context) {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (action === 'openhub-refresh-bootstrap') {
|
||||
void pageAiBootstrapOpenHubHost();
|
||||
}
|
||||
if (action === 'cancel-queued-run') {
|
||||
void pageAiCancelQueuedRun(pageAiAction.getAttribute('data-page-ai-queue-id'));
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
use crate::error::WebError;
|
||||
use control_plane::{ControlPlaneStore, DirectoryGrantRecord, UserRecord};
|
||||
use control_plane::UserRecord;
|
||||
use reqwest::header::{HeaderMap, HeaderValue};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::env;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::time::Duration;
|
||||
|
||||
const DEFAULT_OPENHUB_BASE_URL: &str = "http://127.0.0.1:18080";
|
||||
const DEFAULT_WEKNORA_ENDPOINT: &str = "http://127.0.0.1:8080/api/v1";
|
||||
const PROVIDER_IDENTITY_SYNC_TIMEOUT_MS: u64 = 1_500;
|
||||
|
||||
@@ -31,13 +28,6 @@ fn clean_url(value: String) -> Option<String> {
|
||||
(!trimmed.is_empty()).then_some(trimmed)
|
||||
}
|
||||
|
||||
fn openhub_base_url() -> String {
|
||||
env::var("MNOTE_OPENHUB_BASE_URL")
|
||||
.ok()
|
||||
.and_then(clean_url)
|
||||
.unwrap_or_else(|| DEFAULT_OPENHUB_BASE_URL.to_string())
|
||||
}
|
||||
|
||||
fn weknora_endpoint() -> String {
|
||||
env::var("MNOTE_WEKNORA_ENDPOINT")
|
||||
.or_else(|_| env::var("WEKNORA_ENDPOINT"))
|
||||
@@ -67,12 +57,6 @@ fn internal_headers() -> HeaderMap {
|
||||
headers
|
||||
}
|
||||
|
||||
fn stable_openhub_user_id(user_id: &str) -> i64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
format!("mnote-openhub-user:{user_id}").hash(&mut hasher);
|
||||
(hasher.finish() % 1_900_000_000) as i64 + 100_000_000
|
||||
}
|
||||
|
||||
fn fallback_email(user: &UserRecord) -> String {
|
||||
user.email
|
||||
.as_deref()
|
||||
@@ -82,13 +66,6 @@ fn fallback_email(user: &UserRecord) -> String {
|
||||
.unwrap_or_else(|| format!("{}@mnote.local", user.username))
|
||||
}
|
||||
|
||||
fn default_workspace_path(user: &UserRecord) -> String {
|
||||
format!(
|
||||
"/mnt/Data1T/Mnote_data/users/{}/workspaces/my-space",
|
||||
user.id
|
||||
)
|
||||
}
|
||||
|
||||
fn sync_disabled() -> bool {
|
||||
!env_flag("MNOTE_PROVIDER_IDENTITY_SYNC", true)
|
||||
}
|
||||
@@ -96,7 +73,6 @@ fn sync_disabled() -> bool {
|
||||
pub async fn sync_provider_identities(
|
||||
user: &UserRecord,
|
||||
password: &str,
|
||||
directory_grants: &[DirectoryGrantRecord],
|
||||
) -> Vec<ProviderIdentitySyncResult> {
|
||||
if sync_disabled() {
|
||||
return vec![ProviderIdentitySyncResult {
|
||||
@@ -107,78 +83,16 @@ pub async fn sync_provider_identities(
|
||||
}];
|
||||
}
|
||||
|
||||
let sync_openhub = env_flag("MNOTE_PROVIDER_IDENTITY_SYNC_OPENHUB", true);
|
||||
let sync_weknora = env_flag("MNOTE_PROVIDER_IDENTITY_SYNC_WEKNORA", true);
|
||||
|
||||
match (sync_openhub, sync_weknora) {
|
||||
(true, true) => {
|
||||
let (openhub, weknora) = tokio::join!(
|
||||
sync_openhub_identity(user, password, directory_grants),
|
||||
sync_weknora_identity(user, password)
|
||||
);
|
||||
vec![openhub, weknora]
|
||||
}
|
||||
(true, false) => vec![sync_openhub_identity(user, password, directory_grants).await],
|
||||
(false, true) => vec![sync_weknora_identity(user, password).await],
|
||||
(false, false) => vec![ProviderIdentitySyncResult {
|
||||
if sync_weknora {
|
||||
vec![sync_weknora_identity(user, password).await]
|
||||
} else {
|
||||
vec![ProviderIdentitySyncResult {
|
||||
provider: "all",
|
||||
ok: true,
|
||||
message: "provider identity sync disabled".to_string(),
|
||||
provider_user_id: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn sync_openhub_directory_permissions_for_user_id(
|
||||
control_plane: &dyn ControlPlaneStore,
|
||||
user_id: &str,
|
||||
) -> ProviderIdentitySyncResult {
|
||||
if sync_disabled() || !env_flag("MNOTE_PROVIDER_IDENTITY_SYNC_OPENHUB", true) {
|
||||
return ProviderIdentitySyncResult {
|
||||
provider: "openhub",
|
||||
ok: true,
|
||||
message: "provider identity sync disabled".to_string(),
|
||||
provider_user_id: None,
|
||||
};
|
||||
}
|
||||
let user_id = user_id.trim();
|
||||
let provider_user_id = stable_openhub_user_id(user_id);
|
||||
let directory_grants = match control_plane.list_directory_grants_for_actor(user_id) {
|
||||
Ok(grants) => grants,
|
||||
Err(error) => {
|
||||
return ProviderIdentitySyncResult {
|
||||
provider: "openhub",
|
||||
ok: false,
|
||||
message: format!("mnote directory grants read failed: {error}"),
|
||||
provider_user_id: Some(provider_user_id.to_string()),
|
||||
};
|
||||
}
|
||||
};
|
||||
let payload = json!({
|
||||
"mnote_user_id": user_id,
|
||||
"allowedRoots": openhub_allowed_roots_payload(&directory_grants),
|
||||
});
|
||||
match post_json(
|
||||
join_url(
|
||||
&openhub_base_url(),
|
||||
&format!("/api/internal/mnote/users/{provider_user_id}/directory-permissions/sync"),
|
||||
),
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => ProviderIdentitySyncResult {
|
||||
provider: "openhub",
|
||||
ok: true,
|
||||
message: "directory permissions synced".to_string(),
|
||||
provider_user_id: Some(provider_user_id.to_string()),
|
||||
},
|
||||
Err(error) => ProviderIdentitySyncResult {
|
||||
provider: "openhub",
|
||||
ok: false,
|
||||
message: error.message().to_string(),
|
||||
provider_user_id: Some(provider_user_id.to_string()),
|
||||
},
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,74 +124,6 @@ async fn post_json(url: String, body: Value) -> Result<Value, WebError> {
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
async fn sync_openhub_identity(
|
||||
user: &UserRecord,
|
||||
password: &str,
|
||||
directory_grants: &[DirectoryGrantRecord],
|
||||
) -> ProviderIdentitySyncResult {
|
||||
let provider_user_id = stable_openhub_user_id(&user.id);
|
||||
let payload = json!({
|
||||
"provider_user_id": provider_user_id,
|
||||
"mnote_user_id": user.id,
|
||||
"username": user.username,
|
||||
"email": fallback_email(user),
|
||||
"password": password,
|
||||
"workspace_path": default_workspace_path(user),
|
||||
"allowedRoots": openhub_allowed_roots_payload(directory_grants),
|
||||
"disabled": user.status != "active",
|
||||
"is_admin": false,
|
||||
});
|
||||
match post_json(
|
||||
join_url(&openhub_base_url(), "/api/internal/mnote/users/provision"),
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => ProviderIdentitySyncResult {
|
||||
provider: "openhub",
|
||||
ok: true,
|
||||
message: "synced".to_string(),
|
||||
provider_user_id: Some(provider_user_id.to_string()),
|
||||
},
|
||||
Err(error) => ProviderIdentitySyncResult {
|
||||
provider: "openhub",
|
||||
ok: false,
|
||||
message: error.message().to_string(),
|
||||
provider_user_id: Some(provider_user_id.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn openhub_allowed_roots_payload(directory_grants: &[DirectoryGrantRecord]) -> Vec<Value> {
|
||||
directory_grants
|
||||
.iter()
|
||||
.filter(|grant| !is_default_workspace_auto_grant(grant))
|
||||
.filter(|grant| grant.status.trim() == "active")
|
||||
.filter(|grant| !grant.root_path.trim().is_empty())
|
||||
.map(|grant| {
|
||||
json!({
|
||||
"grantId": grant.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 is_default_workspace_auto_grant(grant: &DirectoryGrantRecord) -> bool {
|
||||
grant.source.trim() == "auto"
|
||||
&& grant.permission.trim() == "write"
|
||||
&& grant.recursive
|
||||
&& grant.created_by.as_deref().map(str::trim) == Some(grant.user_id.as_str())
|
||||
&& grant.workspace_id.is_some()
|
||||
&& grant.root_uri.starts_with("local://users/")
|
||||
&& grant.root_uri.ends_with("/workspaces/my-space")
|
||||
}
|
||||
|
||||
async fn sync_weknora_identity(user: &UserRecord, password: &str) -> ProviderIdentitySyncResult {
|
||||
let payload = json!({
|
||||
"mnote_user_id": user.id,
|
||||
|
||||
@@ -2301,16 +2301,7 @@ async fn handle_control_plane_auth_action(
|
||||
});
|
||||
|
||||
let provider_sync_results = if matches!(flow, "signUp" | "signIn") {
|
||||
let directory_grants = state
|
||||
.control_plane()
|
||||
.list_directory_grants_for_actor(&resolved.user.id)
|
||||
.unwrap_or_default();
|
||||
let results = sync_provider_identities(
|
||||
&resolved.user,
|
||||
&password_for_provider_sync,
|
||||
&directory_grants,
|
||||
)
|
||||
.await;
|
||||
let results = sync_provider_identities(&resolved.user, &password_for_provider_sync).await;
|
||||
let _ = state.control_plane().append_audit(AppendAuditInput {
|
||||
actor_user_id: Some(resolved.user.id.clone()),
|
||||
action: "control.auth.provider_identities_synced".to_string(),
|
||||
|
||||
@@ -5,7 +5,6 @@ use crate::page_aggregate::{
|
||||
PageAggregate, PageAggregateSource, PageBody, PageHead, PageIdentity, PageLayout, PageOptions,
|
||||
PagePermissions, PageStats, PageTree,
|
||||
};
|
||||
use crate::provider_identity_sync::sync_openhub_directory_permissions_for_user_id;
|
||||
use crate::routes::local_markdown_parser::{
|
||||
file_stem_title, parse_markdown_attachment_refs, parse_markdown_page, split_frontmatter,
|
||||
};
|
||||
@@ -2442,14 +2441,6 @@ pub async fn create_local_access_grant(
|
||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
||||
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")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
sync_openhub_directory_permissions_for_user_id(state.control_plane(), user_id).await;
|
||||
}
|
||||
Ok((StatusCode::OK, Json(payload)))
|
||||
}
|
||||
|
||||
@@ -2460,14 +2451,6 @@ pub async fn create_user_access_grant(
|
||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
||||
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")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
sync_openhub_directory_permissions_for_user_id(state.control_plane(), user_id).await;
|
||||
}
|
||||
Ok((StatusCode::OK, Json(payload)))
|
||||
}
|
||||
|
||||
@@ -2478,14 +2461,6 @@ pub async fn delete_local_access_grant(
|
||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
||||
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")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
sync_openhub_directory_permissions_for_user_id(state.control_plane(), user_id).await;
|
||||
}
|
||||
Ok((StatusCode::OK, Json(payload)))
|
||||
}
|
||||
|
||||
@@ -2496,14 +2471,6 @@ pub async fn delete_user_access_grant(
|
||||
) -> Result<(StatusCode, Json<Value>), WebError> {
|
||||
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")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
sync_openhub_directory_permissions_for_user_id(state.control_plane(), user_id).await;
|
||||
}
|
||||
Ok((StatusCode::OK, Json(payload)))
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ mod onlyoffice;
|
||||
pub(crate) mod onlyoffice_bridge;
|
||||
mod page_ai_board;
|
||||
mod page_ai_opencode;
|
||||
mod page_ai_openhub;
|
||||
mod page_ai_pi;
|
||||
mod page_ai_workflow;
|
||||
mod query_support;
|
||||
@@ -408,68 +407,6 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/page-ai/opencode/status",
|
||||
get(page_ai_opencode::status),
|
||||
)
|
||||
.route("/api/page-ai/openhub/status", get(page_ai_openhub::status))
|
||||
.route(
|
||||
"/api/page-ai/openhub/bootstrap",
|
||||
post(page_ai_openhub::bootstrap),
|
||||
)
|
||||
.route(
|
||||
"/api/page-ai/openhub/artifact-index",
|
||||
get(page_ai_openhub::artifact_index_get).post(page_ai_openhub::artifact_index_upsert),
|
||||
)
|
||||
.route("/page-ai/openhub/ai", get(page_ai_openhub::ai_shell))
|
||||
.route(
|
||||
"/page-ai/openhub/ai/{*path}",
|
||||
any(page_ai_openhub::ai_proxy),
|
||||
)
|
||||
.route(
|
||||
"/page-ai/openhub/login",
|
||||
any(page_ai_openhub::non_ai_route_guard),
|
||||
)
|
||||
.route(
|
||||
"/page-ai/openhub/login/{*path}",
|
||||
any(page_ai_openhub::non_ai_route_guard),
|
||||
)
|
||||
.route(
|
||||
"/page-ai/openhub/admin",
|
||||
any(page_ai_openhub::non_ai_route_guard),
|
||||
)
|
||||
.route(
|
||||
"/page-ai/openhub/admin/{*path}",
|
||||
any(page_ai_openhub::non_ai_route_guard),
|
||||
)
|
||||
.route(
|
||||
"/page-ai/openhub/file",
|
||||
any(page_ai_openhub::non_ai_route_guard),
|
||||
)
|
||||
.route(
|
||||
"/page-ai/openhub/file/{*path}",
|
||||
any(page_ai_openhub::non_ai_route_guard),
|
||||
)
|
||||
.route(
|
||||
"/page-ai/openhub/files",
|
||||
any(page_ai_openhub::non_ai_route_guard),
|
||||
)
|
||||
.route(
|
||||
"/page-ai/openhub/files/{*path}",
|
||||
any(page_ai_openhub::non_ai_route_guard),
|
||||
)
|
||||
.route(
|
||||
"/page-ai/openhub/knowledge",
|
||||
any(page_ai_openhub::non_ai_route_guard),
|
||||
)
|
||||
.route(
|
||||
"/page-ai/openhub/knowledge/{*path}",
|
||||
any(page_ai_openhub::non_ai_route_guard),
|
||||
)
|
||||
.route(
|
||||
"/page-ai/openhub/git",
|
||||
any(page_ai_openhub::non_ai_route_guard),
|
||||
)
|
||||
.route(
|
||||
"/page-ai/openhub/git/{*path}",
|
||||
any(page_ai_openhub::non_ai_route_guard),
|
||||
)
|
||||
.route(
|
||||
"/api/page-ai/opencode/session",
|
||||
post(page_ai_opencode::bind_session),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Pi-first Page AI Lab — MNote 托管的后端垂直切片。
|
||||
//!
|
||||
//! 该模块默认启用 MNote 托管的 Pi Rust Page AI 后端;OpenHub 仅保留为迁移期兼容 / admin 边界。
|
||||
//! 该模块默认启用 MNote 托管的 Pi Rust Page AI 后端;OpenHub 与 Pi TS 已退役到 recycle 边界。
|
||||
//! Pi 进程通过 RPC subprocess 托管,MNote bridge tools 在 Rust 后端按 allowed roots 执行权限校验。
|
||||
|
||||
use crate::app::AppState;
|
||||
@@ -60,7 +60,6 @@ const PI_LAB_MAX_STARTS_PER_WINDOW: usize = 4;
|
||||
const PI_LAB_MAX_SENDS_PER_WINDOW: usize = 12;
|
||||
const PI_LAB_MAX_TOOLS_PER_WINDOW: usize = 40;
|
||||
const PI_LAB_RUNTIME_IMPL_RUST: &str = "pi-rust";
|
||||
const PI_LAB_RUNTIME_IMPL_TS: &str = "pi-ts";
|
||||
const PI_LAB_MANAGED_BUILTIN_TOOLS: [&str; 8] = [
|
||||
"read",
|
||||
"write",
|
||||
@@ -970,9 +969,6 @@ fn pi_binary() -> String {
|
||||
if let Some(explicit) = env_trimmed("MNOTE_PAGE_AI_PI_BIN") {
|
||||
return explicit;
|
||||
}
|
||||
if pi_runtime_impl() == PI_LAB_RUNTIME_IMPL_TS {
|
||||
return env_trimmed("MNOTE_PAGE_AI_PI_TS_BIN").unwrap_or_else(|| "pi".into());
|
||||
}
|
||||
env_trimmed("MNOTE_PAGE_AI_PI_RUST_BIN")
|
||||
.or_else(first_existing_pi_rust_binary)
|
||||
.unwrap_or_else(|| "pi-rust".into())
|
||||
@@ -983,7 +979,6 @@ fn pi_runtime_impl() -> String {
|
||||
.or_else(|| env_trimmed("MNOTE_PAGE_AI_PI_RUNTIME_IMPL"))
|
||||
.unwrap_or_else(|| PI_LAB_RUNTIME_IMPL_RUST.into());
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"ts" | "typescript" | "pi-ts" | "legacy-ts" => PI_LAB_RUNTIME_IMPL_TS.into(),
|
||||
"rust" | "rs" | "pi-rust" | "pi_agent_rust" | "pi-agent-rust" => {
|
||||
PI_LAB_RUNTIME_IMPL_RUST.into()
|
||||
}
|
||||
@@ -1026,15 +1021,10 @@ fn pi_runtime_binary_available(binary: &str) -> bool {
|
||||
.any(|candidate| candidate.is_file())
|
||||
}
|
||||
|
||||
fn pi_runtime_install_hint(runtime_impl: &str, binary: &str) -> Option<String> {
|
||||
fn pi_runtime_install_hint(_runtime_impl: &str, binary: &str) -> Option<String> {
|
||||
if runtime_mode() == "mock" || pi_runtime_binary_available(binary) {
|
||||
return None;
|
||||
}
|
||||
if runtime_impl == PI_LAB_RUNTIME_IMPL_TS {
|
||||
return Some(format!(
|
||||
"未找到 Pi TS runtime: {binary}。请设置 MNOTE_PAGE_AI_PI_TS_BIN 或 MNOTE_PAGE_AI_PI_BIN。"
|
||||
));
|
||||
}
|
||||
Some(format!(
|
||||
"未找到 Pi Rust runtime: {binary}。请安装到 ~/.local/share/mnote/pi-rust/bin/pi,或设置 MNOTE_PAGE_AI_PI_RUST_BIN / MNOTE_PAGE_AI_PI_BIN。"
|
||||
))
|
||||
|
||||
@@ -1272,7 +1272,6 @@ const AI_ADMIN_SCRIPT: &str = r#"
|
||||
|
||||
function renderServicePanels(piStatus, effectivePayload) {
|
||||
var providers = effectivePayload && Array.isArray(effectivePayload.providers) ? effectivePayload.providers : [];
|
||||
var openhubStatus = '兼容保留';
|
||||
var defaultModel = effectivePayload && (effectivePayload.defaultModel || effectivePayload.default_model || '');
|
||||
var piDefaultModel = piStatus
|
||||
? normalizeModelRef(piStatus.defaultModelProvider || piStatus.default_model_provider, piStatus.defaultModelId || piStatus.default_model_id)
|
||||
@@ -1329,20 +1328,6 @@ const AI_ADMIN_SCRIPT: &str = r#"
|
||||
'</details>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="mnote-ai-admin-admin-card">' +
|
||||
'<div class="mnote-ai-admin-admin-card-head"><h3>OpenHub</h3><span class="mnote-ai-admin-actions-bar">' +
|
||||
renderTag(openhubStatus, 'orange') +
|
||||
'<a href="/page-ai/openhub/admin" class="mnote-ai-admin-policy-link">打开 OpenHub Admin</a>' +
|
||||
'</span></div>' +
|
||||
'<div class="mnote-ai-admin-admin-card-body">' +
|
||||
'<div class="mnote-ai-admin-kv-grid">' +
|
||||
renderKv('定位', '迁移期兼容 / admin 边界,不再作为默认 Page AI chat') +
|
||||
renderKv('MNote 侧入口', '/page-ai/openhub/ai') +
|
||||
renderKv('管理入口', '/page-ai/openhub/admin') +
|
||||
renderKv('后续策略', '只保留一个用户 Page AI 入口,OpenHub 不再扩展重复面板能力') +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
if (channelsPanel) {
|
||||
@@ -1408,7 +1393,6 @@ const AI_ADMIN_SCRIPT: &str = r#"
|
||||
if (healthGrid) {
|
||||
healthGrid.innerHTML = [
|
||||
['Pi Rust Page AI', piRunning ? '运行中' : (piRuntimeAvailable ? '可启动' : '不可用'), 'impl=' + piRuntimeImpl + ' · runtime=' + piRuntime + ' · model=' + piDefaultModel],
|
||||
['OpenHub', '兼容保留', '迁移期 admin / fallback 边界,不再作为默认 Page AI chat'],
|
||||
['LightRAG', '默认', '唯一默认 knowledge provider,Pi 通过 MNote facade 查询'],
|
||||
['Control-plane', effectivePayload && (effectivePayload.sourceOfTruth || effectivePayload.source_of_truth) || 'directory_grants', '授权、策略、receipt 真相层']
|
||||
].map(function(item) {
|
||||
@@ -1605,7 +1589,7 @@ const AI_ADMIN_SCRIPT: &str = r#"
|
||||
'</div>';
|
||||
var body = '';
|
||||
if (activePanel === 'tools') {
|
||||
body = '<div class="mnote-ai-admin-user-section"><div class="mnote-ai-admin-user-section-title"><h3>工具权限</h3><small>按 OpenHub risk_level 分组</small></div>' + (tools || '<div class="mnote-ai-admin-empty">暂无工具</div>') + '</div>';
|
||||
body = '<div class="mnote-ai-admin-user-section"><div class="mnote-ai-admin-user-section-title"><h3>工具权限</h3><small>按 MNote policy 分组</small></div>' + (tools || '<div class="mnote-ai-admin-empty">暂无工具</div>') + '</div>';
|
||||
} else if (activePanel === 'skills') {
|
||||
body = '<div class="mnote-ai-admin-user-section"><div class="mnote-ai-admin-user-section-title"><h3>Skills</h3><small>全局启用后用户可单独降权</small></div>' + (skills || '<div class="mnote-ai-admin-empty">暂无 Skill</div>') + '</div>';
|
||||
} else if (activePanel === 'mcp') {
|
||||
@@ -1621,7 +1605,7 @@ const AI_ADMIN_SCRIPT: &str = r#"
|
||||
'</div>' +
|
||||
(roots || '<div class="mnote-ai-admin-empty">暂无可单独管理的目录权限</div>') + '</div>';
|
||||
} else {
|
||||
body = '<div class="mnote-ai-admin-user-section"><div class="mnote-ai-admin-user-section-title"><h3>模型权限</h3><small>按 provider 分组,参考 OpenHub 用户模型权限</small></div>' + (modelRows || '<div class="mnote-ai-admin-empty">暂无可用模型</div>') +
|
||||
body = '<div class="mnote-ai-admin-user-section"><div class="mnote-ai-admin-user-section-title"><h3>模型权限</h3><small>按 provider/model policy 分组</small></div>' + (modelRows || '<div class="mnote-ai-admin-empty">暂无可用模型</div>') +
|
||||
'<label class="mnote-ai-admin-label">默认模型</label><select class="mnote-ai-admin-select" data-user-default-model>' +
|
||||
catalogModels.filter(function(model) { return allowedIds.has(model.id); }).map(function(model) {
|
||||
return '<option value="' + escapeHtml(model.id) + '"' + (payload.defaultModel === model.id ? ' selected' : '') + '>' + escapeHtml(model.name || model.id) + '</option>';
|
||||
@@ -1719,10 +1703,21 @@ const AI_ADMIN_SCRIPT: &str = r#"
|
||||
var allowedModels = selectedUserSettings && selectedUserSettings.allowedModels
|
||||
? selectedUserSettings.allowedModels.map(function(model) { return model.id; })
|
||||
: [];
|
||||
var catalogModelIds = new Set((selectedUserSettings && selectedUserSettings.catalogModels || []).map(function(model) { return model.id; }).filter(Boolean));
|
||||
if (catalogModelIds.size) {
|
||||
allowedModels = allowedModels.filter(function(modelId) { return catalogModelIds.has(modelId); });
|
||||
}
|
||||
if (modelInputs.length) {
|
||||
allowedModels = Array.from(userSettingsEditor.querySelectorAll('[data-user-model]:checked')).map(function(input) { return input.getAttribute('data-user-model'); });
|
||||
}
|
||||
var defaultSelect = userSettingsEditor.querySelector('[data-user-default-model]');
|
||||
var defaultModel = defaultSelect ? defaultSelect.value : ((selectedUserSettings && selectedUserSettings.defaultModel) || '');
|
||||
if (catalogModelIds.size && defaultModel && !catalogModelIds.has(defaultModel)) {
|
||||
defaultModel = allowedModels[0] || '';
|
||||
}
|
||||
if (defaultModel && allowedModels.indexOf(defaultModel) === -1) {
|
||||
defaultModel = allowedModels[0] || '';
|
||||
}
|
||||
var tools = {};
|
||||
(selectedUserSettings && selectedUserSettings.tools || []).forEach(function(tool) {
|
||||
tools[tool.name] = tool.action;
|
||||
@@ -1752,16 +1747,19 @@ const AI_ADMIN_SCRIPT: &str = r#"
|
||||
piExtensions[input.getAttribute('data-user-pi-extension')] = input.checked;
|
||||
});
|
||||
try {
|
||||
await requestJson('/api/ai-admin/users/' + encodeURIComponent(selectedUserId) + '/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
allowedModels: allowedModels,
|
||||
defaultModel: defaultSelect ? defaultSelect.value : ((selectedUserSettings && selectedUserSettings.defaultModel) || ''),
|
||||
var payload = {
|
||||
tools: tools,
|
||||
skills: skills,
|
||||
mcpServers: mcpServers,
|
||||
piExtensions: piExtensions
|
||||
})
|
||||
};
|
||||
if (modelInputs.length || defaultSelect) {
|
||||
payload.allowedModels = allowedModels;
|
||||
payload.defaultModel = defaultModel;
|
||||
}
|
||||
await requestJson('/api/ai-admin/users/' + encodeURIComponent(selectedUserId) + '/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
showSaveStatus(userSettingsStatus, 'success', '用户 AI 配置已保存');
|
||||
await loadUserSettings(selectedUserId);
|
||||
@@ -1835,7 +1833,7 @@ const AI_ADMIN_SCRIPT: &str = r#"
|
||||
try {
|
||||
piStatus = await requestJson('/api/page-ai/pi/status', { method: 'GET' });
|
||||
} catch (_err) {
|
||||
// Pi Lab 状态失败不阻塞 OpenHub / 管理页主面板。
|
||||
// Pi Lab 状态失败不阻塞管理页主面板。
|
||||
}
|
||||
renderServicePanels(piStatus, payload);
|
||||
}
|
||||
@@ -2518,13 +2516,18 @@ const AI_ADMIN_SCRIPT: &str = r#"
|
||||
}
|
||||
var allowedRaw = val('allowedModels');
|
||||
var failoverRaw = val('failoverChains');
|
||||
var allowedModels = allowedRaw ? allowedRaw.split(',').map(function(s) { return s.trim(); }).filter(Boolean) : [];
|
||||
var defaultModel = val('defaultModel');
|
||||
if (defaultModel && allowedModels.length && allowedModels.indexOf(defaultModel) === -1) {
|
||||
defaultModel = allowedModels[0];
|
||||
}
|
||||
return {
|
||||
id: val('id'),
|
||||
name: val('name'),
|
||||
baseUrl: val('baseUrl'),
|
||||
secretRef: val('secretRef'),
|
||||
allowedModels: allowedRaw ? allowedRaw.split(',').map(function(s) { return s.trim(); }).filter(Boolean) : [],
|
||||
defaultModel: val('defaultModel'),
|
||||
allowedModels: allowedModels,
|
||||
defaultModel: defaultModel,
|
||||
defaultBuildModel: val('defaultBuildModel'),
|
||||
defaultPlanModel: val('defaultPlanModel'),
|
||||
defaultTaskModel: val('defaultTaskModel'),
|
||||
@@ -3029,7 +3032,7 @@ pub fn AiManagementPage(
|
||||
<div class="mnote-ai-admin-section-header">
|
||||
<div>
|
||||
<h2>"用户管理"</h2>
|
||||
<p class="mnote-ai-admin-section-desc">"参考 OpenHub:用户、逐用户模型/工具/目录授权"</p>
|
||||
<p class="mnote-ai-admin-section-desc">"用户、逐用户模型/工具/目录授权"</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mnote-ai-admin-section-body">
|
||||
@@ -3083,9 +3086,8 @@ pub fn AiManagementPage(
|
||||
<div class="mnote-ai-admin-section-header">
|
||||
<div>
|
||||
<h2>"Pi Rust 服务"</h2>
|
||||
<p class="mnote-ai-admin-section-desc">"Pi Rust 是默认 Page AI runtime;OpenHub 仅保留为迁移期兼容 / admin 边界"</p>
|
||||
<p class="mnote-ai-admin-section-desc">"Pi Rust 是默认 Page AI runtime;历史 host 已软归档到 recycle 边界"</p>
|
||||
</div>
|
||||
<a href="/page-ai/openhub/admin" class="mnote-ai-admin-policy-link">"打开 OpenHub Admin"</a>
|
||||
</div>
|
||||
<div class="mnote-ai-admin-section-body">
|
||||
<div data-ai-admin-service-panel>
|
||||
@@ -3263,7 +3265,7 @@ pub fn AiManagementPage(
|
||||
<div class="mnote-ai-admin-section-header">
|
||||
<div>
|
||||
<h2>"渠道管理"</h2>
|
||||
<p class="mnote-ai-admin-section-desc">"OpenHub 的渠道管理在 MNote 中收敛为 provider/model policy"</p>
|
||||
<p class="mnote-ai-admin-section-desc">"渠道管理在 MNote 中收敛为 provider/model policy"</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mnote-ai-admin-section-body">
|
||||
@@ -3308,7 +3310,7 @@ pub fn AiManagementPage(
|
||||
<div class="mnote-ai-admin-section-header">
|
||||
<div>
|
||||
<h2>"系统监控"</h2>
|
||||
<p class="mnote-ai-admin-section-desc">"以 AI 相关服务健康为主,不复制 OpenHub 进程管理"</p>
|
||||
<p class="mnote-ai-admin-section-desc">"以 AI 相关服务健康为主,不复制已退役 host 进程管理"</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mnote-ai-admin-section-body">
|
||||
|
||||
@@ -2221,71 +2221,10 @@ button.wolai-page-ai-history-main span {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wolai-page-ai-drawer[data-page-ai-openhub-host="true"] .wolai-page-ai-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wolai-page-ai-drawer[data-page-ai-openhub-host="true"] .wolai-page-ai-resize-handle {
|
||||
left: -12px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-header {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.wolai-page-ai-openhub-diagnostics {
|
||||
flex: 0 0 auto;
|
||||
max-height: 32px;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid rgba(27, 28, 28, 0.08);
|
||||
background: rgba(247, 247, 245, 0.78);
|
||||
color: rgba(27, 28, 28, 0.58);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-openhub-diagnostics[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.wolai-page-ai-openhub-diagnostics summary {
|
||||
min-height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.wolai-page-ai-openhub-diagnostics summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wolai-page-ai-openhub-diagnostics:not([open]) > :not(summary) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.wolai-page-ai-openhub-diagnostics[open] {
|
||||
max-height: min(36vh, 220px);
|
||||
overflow: auto;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-openhub-diagnostics[open] summary {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-openhub-diagnostics .wolai-page-ai-opencode-row,
|
||||
.wolai-page-ai-openhub-diagnostics .wolai-page-ai-opencode-empty,
|
||||
.wolai-page-ai-openhub-diagnostics .wolai-page-ai-opencode-badges {
|
||||
margin-right: 12px;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-chrome {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
|
||||
@@ -29,7 +29,7 @@ node scripts/task167-local-markdown-title-body-options-no-convex-smoke.js
|
||||
node scripts/task490-runtime-surfaces-smoke.js
|
||||
```
|
||||
|
||||
其中 `task490-runtime-surfaces-smoke.js` 是轻量浏览器 smoke,验证页面设置、Page AI drawer 打开/关闭、Page AI stop-run abort、slash menu、block handle menu 能在当前 leptos-tiptap 文档页工作;它使用 mock run 覆盖停止按钮,不要求真实模型返回,也不替代更重的页面设置、OpenHub/Page AI 或编辑器菜单专项脚本。
|
||||
其中 `task490-runtime-surfaces-smoke.js` 是轻量浏览器 smoke,验证页面设置、Page AI drawer 打开/关闭、Page AI stop-run abort、slash menu、block handle menu 能在当前 leptos-tiptap 文档页工作;它使用 mock run 覆盖停止按钮,不要求真实模型返回,也不替代更重的页面设置、Pi Lab/Page AI 或编辑器菜单专项脚本。
|
||||
|
||||
### 0.1 当前主链优先脚本
|
||||
|
||||
@@ -37,7 +37,7 @@ node scripts/task490-runtime-surfaces-smoke.js
|
||||
- Rust SSR 文档页 / Page Aggregate:local-first 默认优先 `task167-local-markdown-title-body-options-no-convex-smoke.js`;Page Aggregate browser conversion / compat fallback 改动补跑 `task522-page-aggregate-compat-fallback-contract.js`;cloud/control-plane 文档可补跑 `task110-page-title-single-truth-smoke.js`、`task-page-aggregate-body-sync-smoke.js`、`task-page-aggregate-options-sync-smoke.js`、`task-page-aggregate-refresh-persistence-smoke.js`
|
||||
- leptos-tiptap runtime surface:`task490-runtime-surfaces-smoke.js`;需要验证保存回读可补跑 `task121-rust-web-editor-island-hydration-smoke.js`,但它仍使用 `/api/tree/commands create` 准备文档,不作为无 Convex 默认基线;需要验证浮层互斥和更多菜单状态时再跑 `task158-e30-menu-state-smoke.js`
|
||||
- local-first 本地工作区:`task164-desktop-hot-local-folder-main-entry-smoke.js`、`task166-local-first-managed-workspace-no-convex-smoke.js`、`task167-local-markdown-title-body-options-no-convex-smoke.js`、`task436-local-markdown-open-document-external-change-smoke.js`、`task443-local-markdown-asset-upload-smoke.js`、`task451-local-markdown-conflict-resolution-ui-smoke.js`、`task452-local-search-index-browser-smoke.js`、`task453-local-folder-page-ai-changed-files-smoke.js`;WorkspacePath / ObjectIdentity runtime 消费统一改动补跑 `task524-workspace-object-identity-matrix-smoke.js`。`task452` 只覆盖普通本地搜索、settings API、tag/backlink API 和旧 local-index UI 不复活;资料库问答、PDF/Office/image source ingestion 与引用回跳改跑 WeKnora / provider-neutral knowledge-rag 脚本。
|
||||
- WeKnora / OpenHub 资料库:`task781-knowledge-bases-registry-smoke.js`、`task785-weknora-create-kb-folder-ingest-search-smoke.js`、`task787-weknora-default-lightrag-legacy-smoke.js`、`task772-openhub-weknora-kb-ui-smoke.js` 覆盖默认 provider、知识库列表、摄取、搜索、引用桥接与 legacy LightRAG guard。旧 `task529/530/531/534` LightRAG 脚本只作为 legacy fallback / 历史对照,不进入默认回归。旧 `task526-local-folder-ocr-api-smoke.js` / OCR sidecar 链路已退役并软归档;`/api/search/documents` 不作为 WeKnora、LightRAG legacy、LiteParse、OCR sidecar 或 evidence.sqlite 的资料库问答 fallback。
|
||||
- LightRAG / Knowledge RAG 资料库:`task781-knowledge-bases-registry-smoke.js`、`task785-weknora-create-kb-folder-ingest-search-smoke.js`、`task787-weknora-default-lightrag-legacy-smoke.js` 覆盖默认 provider、知识库列表、摄取、搜索、引用桥接与 legacy LightRAG guard。旧 `task529/530/531/534` LightRAG 脚本只作为 legacy fallback / 历史对照,不进入默认回归。旧 OpenHub 专项资料库 smoke 已软归档到 `recycle/20260712-pi-ts-openhub-retirement/`;旧 `task526-local-folder-ocr-api-smoke.js` / OCR sidecar 链路已退役并软归档;`/api/search/documents` 不作为 WeKnora、LightRAG legacy、LiteParse、OCR sidecar 或 evidence.sqlite 的资料库问答 fallback。
|
||||
- local Markdown conflict regression:`task510-local-markdown-conflict-regression-group.js` 串联真实冲突、连续上传、新建页面、外部恢复、多 tab CAS 与上传 409 孤儿策略;可用 `MNOTE_CONFLICT_REGRESSION_TASKS=a.js,b.js` 做定点子集。
|
||||
- tree realtime / live cache:`task446-tree-rename-dual-browser-live-smoke.js`、`task447-tree-move-order-dual-browser-live-smoke.js`、`task448-tree-resync-recovery-dual-browser-smoke.js`、`task449-tree-sse-reconnect-snapshot-recovery-smoke.js`
|
||||
- 资源对象与 mindmap:`task455-local-folder-mindmap-clean-smoke.js`、`task456-resource-object-shell-sync-smoke.js`、`task166-mindmap-phase6-block-smoke.js`、`task167-mindmap-kmind-parity-smoke.js`、`task168-mindmap-put-validator-smoke.js`;Page AI mindmap skill / 资源生成改动补跑 `task503-mindmap-skill-capability-smoke.js`,真实 mindmap resource tab / Page AI target 改动补跑 `task525-page-ai-mindmap-resource-target-smoke.js`
|
||||
@@ -123,7 +123,7 @@ node scripts/task490-runtime-surfaces-smoke.js
|
||||
- `task129-wolai-aline-baseline-smoke.js`
|
||||
- `task130` 到 `task137` 一系列 `wolai-aline-*`
|
||||
- `task160-wolai-page-settings-shell-smoke.js`
|
||||
- 页面 AI 旧 `/api/ai-agent/run` 视觉 smoke 已移入本机 gitignored `recycle/scripts/retired-ai-agent-run-smokes/`;当前页面 AI 验证优先使用 OpenHub / opencode / Page AI smoke 与 `task-page-block-ai-tools-smoke.js`。Hermes 相关 smoke 只作为 legacy guard / 历史对照。
|
||||
- 页面 AI 旧 `/api/ai-agent/run` 视觉 smoke 已移入本机 gitignored `recycle/scripts/retired-ai-agent-run-smokes/`;当前页面 AI 验证优先使用 Pi Lab / opencode / Page AI smoke 与 `task-page-block-ai-tools-smoke.js`。Hermes 相关 smoke 只作为 legacy guard / 历史对照。
|
||||
|
||||
这一层最重要的原则不是“文案命中”,而是:
|
||||
|
||||
@@ -193,7 +193,7 @@ node scripts/task097-homepage-entry-smoke.js
|
||||
- local-first:优先使用 `task164-desktop-hot-local-folder-main-entry-smoke.js`、`task166-local-first-managed-workspace-no-convex-smoke.js`、`task167-local-markdown-title-body-options-no-convex-smoke.js`、`task455-local-folder-mindmap-clean-smoke.js`
|
||||
- 页面设置:`task160`、`task164-page-options-visible-effect-smoke.js`
|
||||
- 双栏:`task165`
|
||||
- AI:优先使用 OpenHub / opencode / Page AI 当前主线 smoke、`task-page-block-ai-tools-smoke.js` 和 `task-hermes-page-ai-retirement-guard.js` 这类 legacy guard;Hermes 页面 AI smoke 只作为历史对照。Page AI history / skills / ChatOnly 相关改动补跑 `task503-mindmap-skill-capability-smoke.js`、`task504-page-ai-history-agent-filter-smoke.js`、`task512-chatonly-doubao-sync-smoke.js`、`task513-chatonly-provider-sync-smoke.js`;OnlyOffice live bridge 改动补跑 `task515-onlyoffice-live-scope-http-smoke.js`、`task516-onlyoffice-bridge-multisession-browser-smoke.js`、`task517-onlyoffice-bridge-plugin-direct-smoke.js`、`task518-onlyoffice-real-iframe-session-scope-smoke.js`,其中 `task516/517` 覆盖 bridge session lifecycle 与 `docKey/pageOrigin` 元数据;Page AI 真实 target picker / Office live session 改动补跑 `task523-page-ai-onlyoffice-real-target-session-smoke.js`;`task155`、`task156`、`task161` 等旧 `/api/ai-agent/run` smoke 只保留在本机 gitignored `recycle/scripts/retired-ai-agent-run-smokes/` 做历史对照
|
||||
- AI:优先使用 Pi Lab / opencode / Page AI 当前主线 smoke、`task-page-block-ai-tools-smoke.js` 和 `task-hermes-page-ai-retirement-guard.js` 这类 legacy guard;Hermes 页面 AI smoke 只作为历史对照。Page AI history / skills / ChatOnly 相关改动补跑 `task503-mindmap-skill-capability-smoke.js`、`task504-page-ai-history-agent-filter-smoke.js`、`task512-chatonly-doubao-sync-smoke.js`、`task513-chatonly-provider-sync-smoke.js`;OnlyOffice live bridge 改动补跑 `task515-onlyoffice-live-scope-http-smoke.js`、`task516-onlyoffice-bridge-multisession-browser-smoke.js`、`task517-onlyoffice-bridge-plugin-direct-smoke.js`、`task518-onlyoffice-real-iframe-session-scope-smoke.js`,其中 `task516/517` 覆盖 bridge session lifecycle 与 `docKey/pageOrigin` 元数据;Page AI 真实 target picker / Office live session 改动补跑 `task523-page-ai-onlyoffice-real-target-session-smoke.js`;`task155`、`task156`、`task161` 等旧 `/api/ai-agent/run` smoke 只保留在本机 gitignored `recycle/scripts/retired-ai-agent-run-smokes/` 做历史对照
|
||||
|
||||
原则:
|
||||
|
||||
@@ -439,7 +439,7 @@ const controlPlaneEnv = buildControlPlaneTestEnv(dataRoot, process.env);
|
||||
- `rust/crates/` 下的 Rust 代码通过 `ControlPlaneStore` trait 访问数据库是正常路径。
|
||||
- `scripts/task-control-plane-admin-libsql-roundtrip-smoke.js` 使用 `cargo run --bin control-plane-admin`,走 Rust admin CLI。
|
||||
- `sqlite` fallback、admin CLI、legacy evidence/local_search 不在脚本纪律约束范围内。
|
||||
- OpenHub 自身 SQLite 不绑定本轮 control-plane Turso 切换;OpenHub 数据库迁移独立安排,不在当前首轮范围内。
|
||||
- 已退役 AI host 自身 SQLite 不绑定本轮 control-plane Turso 切换;历史迁移说明保留在 recycle 归档中。
|
||||
|
||||
### 7.4 违规后果
|
||||
|
||||
|
||||
+4
-158
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 热启动 mnote-web 单入口,以及按需启用的 FastAPI。
|
||||
* 热启动 mnote-web 单入口,以及按需启用的 FastAPI / opencode。
|
||||
* 可使用以下环境变量调整行为:
|
||||
* - ENABLE_BACKEND:设为 "1" or "true" 时启用默认 FastAPI 后端
|
||||
* - BACKEND_CMD:覆盖 FastAPI 启动命令;设置后即视为显式启用后端
|
||||
@@ -9,16 +9,7 @@
|
||||
* - ENABLE_OPENCODE:设为 "1" or "true" 时启用 opencode serve
|
||||
* - OPENCODE_CMD:覆盖 opencode 启动命令;设置后即视为显式启用 opencode
|
||||
* - SKIP_OPENCODE:设为 "1" or "true" 可强制跳过 opencode
|
||||
* - ENABLE_OPENHUB:设为 "1" or "true" 时启用 OpenHub FastAPI
|
||||
* - OPENHUB_CMD / OPENHUB_BACKEND_CMD:覆盖 OpenHub FastAPI 启动命令;设置后即视为显式启用 OpenHub
|
||||
* - OPENHUB_HOST / OPENHUB_BIND_HOST:OpenHub FastAPI 监听地址,默认 0.0.0.0 便于局域网访问
|
||||
* - OPENHUB_PORT / OPENHUB_BACKEND_PORT:OpenHub FastAPI 端口,默认 18080
|
||||
* - SKIP_OPENHUB:设为 "1" or "true" 可强制跳过 OpenHub
|
||||
* - OPENHUB_REDIS_URL / OPENHUB_REDIS_DB:记录 OpenHub Redis 位置;OPENHUB_REDIS_HEALTH_URL 可选做 HTTP health 检查
|
||||
* - OPENHUB_REDIS_HEALTH_URL:可选 Redis health URL;dev-hot 只检查,不释放或结束 OpenHub 相关端口
|
||||
* - OPENHUB_OPENCODE_BASE_URL:OpenHub 侧 opencode serve base URL;默认复用 MNOTE_OPENCODE_BASE_URL
|
||||
* - SKIP_OPENHUB_HEALTH:设为 "1" or "true" 可跳过 OpenHub/Redis/opencode health 预检
|
||||
* - MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE:默认 "1",禁用 OpenHub Git snapshot/restore/revert 写链
|
||||
* - MNOTE_OPENCODE_XDG_ROOT / MNOTE_OPENCODE_HOME:opencode 专用运行目录
|
||||
* - MNOTE_CONTROL_PLANE_BACKEND:控制面后端,默认 libsql-local;可设 turso-remote / turso-local-replica / turso-synced
|
||||
* - MNOTE_TURSO_LOCAL_PATH:libsql-local 本地库路径,默认 /mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db
|
||||
* - MNOTE_PAGE_AI_PI_WARMUP:设为 "1" or "true" 时,在 mnote-web 可用后预启动 Pi Lab runtime / MCP cache
|
||||
@@ -64,7 +55,6 @@ function resolveBackendExecutable(envName, fallbackName) {
|
||||
const pythonBin = resolveBackendExecutable("PYTHON_BIN", "python");
|
||||
const backendPortFromEnv = Number(process.env.BACKEND_PORT || 8000);
|
||||
const opencodePortFromEnv = Number(process.env.OPENCODE_PORT || 4096);
|
||||
const openhubPortFromEnv = Number(process.env.OPENHUB_BACKEND_PORT || process.env.OPENHUB_PORT || 18080);
|
||||
const defaultControlPlaneDir = "/mnt/Data1T/Mnote_data/control-plane";
|
||||
|
||||
function hasCommand(command) {
|
||||
@@ -90,8 +80,8 @@ function buildDefaultBackendCommand(port) {
|
||||
}
|
||||
|
||||
function buildDefaultOpencodeCommand(port) {
|
||||
const opencodeXdgRoot = process.env.OPENHUB_OPENCODE_XDG_ROOT || "/mnt/Data1T/Mnote_data/openhub/opencode-runtime";
|
||||
const opencodeHome = process.env.OPENHUB_OPENCODE_HOME || "/mnt/Data1T/Mnote_data/openhub/opencode-home";
|
||||
const opencodeXdgRoot = process.env.MNOTE_OPENCODE_XDG_ROOT || "/mnt/Data1T/Mnote_data/opencode/runtime";
|
||||
const opencodeHome = process.env.MNOTE_OPENCODE_HOME || "/mnt/Data1T/Mnote_data/opencode/home";
|
||||
const modelEnvNames = [
|
||||
"OPENCODE_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
@@ -124,19 +114,6 @@ function buildDefaultOpencodeCommand(port) {
|
||||
].join(" && ");
|
||||
}
|
||||
|
||||
function buildDefaultOpenHubCommand(port) {
|
||||
const openhubBackendDir = process.env.OPENHUB_BACKEND_DIR || "/mnt/Data1T/Mnote_data/openhub/OpenHub/smart-query-backend";
|
||||
const openhubHost = process.env.OPENHUB_HOST || process.env.OPENHUB_BIND_HOST || "0.0.0.0";
|
||||
const uvicornBin = fs.existsSync(path.join(openhubBackendDir, ".venv", "bin", "uvicorn"))
|
||||
? path.join(openhubBackendDir, ".venv", "bin", "uvicorn")
|
||||
: "uvicorn";
|
||||
const opencodeBaseUrl = process.env.OPENHUB_OPENCODE_BASE_URL || process.env.MNOTE_OPENCODE_BASE_URL || `http://127.0.0.1:${opencodePortFromEnv}`;
|
||||
return [
|
||||
`cd ${JSON.stringify(openhubBackendDir)}`,
|
||||
`OPENCODE_BASE_URL=${JSON.stringify(opencodeBaseUrl)} MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE=${JSON.stringify(process.env.MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE || "1")} exec ${JSON.stringify(uvicornBin)} app.main:app --host ${JSON.stringify(openhubHost)} --port ${port}`,
|
||||
].join(" && ");
|
||||
}
|
||||
|
||||
function isEnabledEnv(value) {
|
||||
const normalized = String(value || "").toLowerCase();
|
||||
return normalized === "1" || normalized === "true";
|
||||
@@ -154,54 +131,6 @@ function shouldStartOpencode(env = process.env) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function shouldStartOpenHub(env = process.env) {
|
||||
if (isEnabledEnv(env.SKIP_OPENHUB)) return false;
|
||||
if (String(env.OPENHUB_BACKEND_CMD || env.OPENHUB_CMD || "").trim()) return true;
|
||||
return isEnabledEnv(env.ENABLE_OPENHUB);
|
||||
}
|
||||
|
||||
function shouldCheckOpenHubHealth(env = process.env) {
|
||||
if (isEnabledEnv(env.SKIP_OPENHUB_HEALTH)) return false;
|
||||
return shouldStartOpenHub(env) || isEnabledEnv(env.CHECK_OPENHUB_HEALTH);
|
||||
}
|
||||
|
||||
function resolveOpenHubHealthPlan(env = process.env) {
|
||||
const openhubPort = Number(env.OPENHUB_BACKEND_PORT || env.OPENHUB_PORT || openhubPortFromEnv);
|
||||
const opencodePort = Number(env.OPENCODE_PORT || opencodePortFromEnv);
|
||||
const baseUrl = String(env.MNOTE_OPENHUB_BASE_URL || env.OPENHUB_BASE_URL || `http://127.0.0.1:${openhubPort}`).replace(/\/+$/, "");
|
||||
const redisHealthUrl = String(env.OPENHUB_REDIS_HEALTH_URL || "").trim();
|
||||
const redisUrl = String(env.OPENHUB_REDIS_URL || "").trim();
|
||||
const redisDb = String(env.OPENHUB_REDIS_DB || "").trim();
|
||||
const opencodeBaseUrl = String(env.OPENHUB_OPENCODE_BASE_URL || env.MNOTE_OPENCODE_BASE_URL || `http://127.0.0.1:${opencodePort}`).replace(/\/+$/, "");
|
||||
const requireHealth = isEnabledEnv(env.REQUIRE_OPENHUB_HEALTH);
|
||||
return {
|
||||
enabled: shouldCheckOpenHubHealth(env),
|
||||
openhub: {
|
||||
label: "OpenHub FastAPI",
|
||||
url: env.OPENHUB_HEALTH_URL || `${baseUrl}/api/health`,
|
||||
required: requireHealth,
|
||||
},
|
||||
redis: {
|
||||
label: "OpenHub Redis",
|
||||
url: redisHealthUrl,
|
||||
redisUrl,
|
||||
redisDb,
|
||||
required: isEnabledEnv(env.REQUIRE_OPENHUB_REDIS_HEALTH),
|
||||
skipped: !redisHealthUrl,
|
||||
},
|
||||
opencode: {
|
||||
label: "opencode",
|
||||
url: env.OPENCODE_HEALTH_URL || `${opencodeBaseUrl}/global/health`,
|
||||
required: requireHealth,
|
||||
},
|
||||
gitSnapshotRestore: {
|
||||
env: "MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE",
|
||||
value: String(env.MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE || "1"),
|
||||
defaultDisabled: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRuntimePlan(env = process.env) {
|
||||
const frontendPort = Number(env.FRONTEND_PORT || 3000);
|
||||
const skipGateway = false;
|
||||
@@ -234,8 +163,6 @@ function resolveRuntimePlan(env = process.env) {
|
||||
MNOTE_WEB_PUBLIC_BIND: env.MNOTE_WEB_PUBLIC_BIND || `127.0.0.1:${publicPort}`,
|
||||
MNOTE_KNOWLEDGE_PROVIDER: env.MNOTE_KNOWLEDGE_PROVIDER || "lightrag_legacy",
|
||||
MNOTE_OPENCODE_BASE_URL: env.MNOTE_OPENCODE_BASE_URL || `http://127.0.0.1:${opencodePortFromEnv}`,
|
||||
MNOTE_OPENHUB_BASE_URL: env.MNOTE_OPENHUB_BASE_URL || `http://127.0.0.1:${openhubPortFromEnv}`,
|
||||
MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE: env.MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE || "1",
|
||||
...controlPlaneEnv,
|
||||
},
|
||||
};
|
||||
@@ -279,18 +206,6 @@ const tasks = [
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(shouldStartOpenHub(process.env)
|
||||
? [
|
||||
{
|
||||
name: "openhub",
|
||||
command:
|
||||
process.env.OPENHUB_BACKEND_CMD ||
|
||||
process.env.OPENHUB_CMD ||
|
||||
buildDefaultOpenHubCommand(openhubPortFromEnv),
|
||||
cwd: rootDir,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
function findTask(name) {
|
||||
@@ -613,32 +528,6 @@ async function ensurePortFree(port, nameForLog) {
|
||||
return false;
|
||||
}
|
||||
|
||||
async function checkHttpHealth(url, label, required) {
|
||||
if (!url) {
|
||||
logPrefix("openhub-health", `${label} health 未配置,已跳过。`);
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 1200);
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: { accept: "application/json,text/plain,*/*" },
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
if (response.ok) {
|
||||
logPrefix("openhub-health", `${label} 可达:${url}`);
|
||||
return true;
|
||||
}
|
||||
logPrefix("openhub-health", `${label} 返回 HTTP ${response.status}:${url}`);
|
||||
return !required;
|
||||
} catch (error) {
|
||||
logPrefix("openhub-health", `${label} 不可达:${url} (${error.message})`);
|
||||
return !required;
|
||||
}
|
||||
}
|
||||
|
||||
async function isHttpHealthy(url) {
|
||||
if (!url) return false;
|
||||
try {
|
||||
@@ -656,24 +545,6 @@ async function isHttpHealthy(url) {
|
||||
}
|
||||
}
|
||||
|
||||
async function checkOpenHubHealth(plan = resolveOpenHubHealthPlan(process.env)) {
|
||||
if (!plan.enabled) {
|
||||
return true;
|
||||
}
|
||||
logPrefix(
|
||||
"openhub-health",
|
||||
`Git snapshot/restore 默认关闭:${plan.gitSnapshotRestore.env}=${plan.gitSnapshotRestore.value || "1"}`,
|
||||
);
|
||||
const checks = [
|
||||
await checkHttpHealth(plan.openhub.url, plan.openhub.label, plan.openhub.required),
|
||||
plan.redis.skipped
|
||||
? (logPrefix("openhub-health", "OpenHub Redis health 未配置,已跳过非破坏性检查。"), true)
|
||||
: await checkHttpHealth(plan.redis.url, plan.redis.label, plan.redis.required),
|
||||
await checkHttpHealth(plan.opencode.url, plan.opencode.label, plan.opencode.required),
|
||||
];
|
||||
return checks.every(Boolean);
|
||||
}
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return {};
|
||||
const content = fs.readFileSync(filePath, "utf8");
|
||||
@@ -854,27 +725,6 @@ async function main() {
|
||||
|
||||
}
|
||||
|
||||
if (shouldStartOpenHub(process.env) && !process.env.OPENHUB_BACKEND_CMD && !process.env.OPENHUB_CMD) {
|
||||
const openhubPortOk = await ensurePortFree(openhubPortFromEnv, "openhub");
|
||||
if (!openhubPortOk) {
|
||||
console.error(`OpenHub 端口 ${openhubPortFromEnv} 无法释放,已中止启动。`);
|
||||
process.exit(1);
|
||||
}
|
||||
const openhubTask = findTask("openhub");
|
||||
if (!openhubTask) {
|
||||
throw new Error("缺少 OpenHub 任务配置");
|
||||
}
|
||||
openhubTask.command = buildDefaultOpenHubCommand(openhubPortFromEnv);
|
||||
} else if (isEnabledEnv(process.env.SKIP_OPENHUB)) {
|
||||
logPrefix("openhub", "已跳过 OpenHub FastAPI(SKIP_OPENHUB=1)。");
|
||||
}
|
||||
|
||||
const healthOk = await checkOpenHubHealth(resolveOpenHubHealthPlan(process.env));
|
||||
if (!healthOk) {
|
||||
console.error("OpenHub health 预检失败,已中止启动。");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
console.error("未配置任何可运行的任务,检查环境变量设置。");
|
||||
process.exit(1);
|
||||
@@ -895,8 +745,6 @@ if (require.main === module) {
|
||||
|
||||
module.exports = {
|
||||
buildDefaultOpencodeCommand,
|
||||
buildDefaultOpenHubCommand,
|
||||
checkOpenHubHealth,
|
||||
collectStaleMnoteWebCargoPids,
|
||||
ensurePortFree,
|
||||
getListeningPidsByPort,
|
||||
@@ -905,12 +753,10 @@ module.exports = {
|
||||
getProcessNameByPid,
|
||||
isHttpHealthy,
|
||||
isPortFree,
|
||||
resolveOpenHubHealthPlan,
|
||||
resolveRuntimePlan,
|
||||
resolveBackendExecutable,
|
||||
schedulePiLabWarmup,
|
||||
shouldStartBackend,
|
||||
shouldStartOpenHub,
|
||||
stopStaleMnoteWebCargoProcesses,
|
||||
terminatePid,
|
||||
};
|
||||
|
||||
@@ -4,16 +4,13 @@ const net = require("node:net");
|
||||
const { test } = require("node:test");
|
||||
const {
|
||||
buildDefaultOpencodeCommand,
|
||||
buildDefaultOpenHubCommand,
|
||||
collectStaleMnoteWebCargoPids,
|
||||
resolveBackendExecutable,
|
||||
ensurePortFree,
|
||||
isHttpHealthy,
|
||||
isPortFree,
|
||||
resolveOpenHubHealthPlan,
|
||||
resolveRuntimePlan,
|
||||
shouldStartBackend,
|
||||
shouldStartOpenHub,
|
||||
stopStaleMnoteWebCargoProcesses,
|
||||
} = require("./desktop-hot.js");
|
||||
|
||||
@@ -145,8 +142,6 @@ test("默认热启动计划只使用 mnote-web 作为 3000 owner", () => {
|
||||
MNOTE_WEB_PUBLIC_BIND: "127.0.0.1:3000",
|
||||
MNOTE_KNOWLEDGE_PROVIDER: "lightrag_legacy",
|
||||
MNOTE_OPENCODE_BASE_URL: "http://127.0.0.1:4096",
|
||||
MNOTE_OPENHUB_BASE_URL: "http://127.0.0.1:18080",
|
||||
MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE: "1",
|
||||
MNOTE_CONTROL_PLANE_BACKEND: "libsql-local",
|
||||
MNOTE_TURSO_LOCAL_PATH: "/mnt/Data1T/Mnote_data/control-plane/control-plane-libsql.db",
|
||||
});
|
||||
@@ -193,50 +188,10 @@ test("默认跳过 FastAPI 后端,只有显式开启时才启动", () => {
|
||||
assert.equal(shouldStartBackend({ ENABLE_BACKEND: "1", SKIP_BACKEND: "1" }), false);
|
||||
});
|
||||
|
||||
test("OpenHub FastAPI 默认跳过,显式开启或命令覆盖时才启动", () => {
|
||||
assert.equal(shouldStartOpenHub({}), false);
|
||||
assert.equal(shouldStartOpenHub({ ENABLE_OPENHUB: "1" }), true);
|
||||
assert.equal(shouldStartOpenHub({ ENABLE_OPENHUB: "true" }), true);
|
||||
assert.equal(shouldStartOpenHub({ OPENHUB_CMD: "custom-openhub" }), true);
|
||||
assert.equal(shouldStartOpenHub({ ENABLE_OPENHUB: "1", SKIP_OPENHUB: "1" }), false);
|
||||
});
|
||||
|
||||
test("OpenHub health plan 包含 FastAPI、Redis、opencode 和默认关闭 Git snapshot/restore", () => {
|
||||
const plan = resolveOpenHubHealthPlan({
|
||||
ENABLE_OPENHUB: "1",
|
||||
REQUIRE_OPENHUB_HEALTH: "1",
|
||||
OPENHUB_PORT: "18081",
|
||||
OPENCODE_PORT: "4097",
|
||||
OPENHUB_REDIS_HEALTH_URL: "http://127.0.0.1:6379/health",
|
||||
});
|
||||
|
||||
assert.equal(plan.enabled, true);
|
||||
assert.equal(plan.openhub.url, "http://127.0.0.1:18081/api/health");
|
||||
assert.equal(plan.openhub.required, true);
|
||||
assert.equal(plan.redis.url, "http://127.0.0.1:6379/health");
|
||||
assert.equal(plan.redis.skipped, false);
|
||||
assert.equal(plan.opencode.url, "http://127.0.0.1:4097/global/health");
|
||||
assert.deepEqual(plan.gitSnapshotRestore, {
|
||||
env: "MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE",
|
||||
value: "1",
|
||||
defaultDisabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("OpenHub 默认命令只启动 FastAPI,不包含 snapshot/restore 写操作", () => {
|
||||
const command = buildDefaultOpenHubCommand(18082);
|
||||
assert.match(command, /app\.main:app/);
|
||||
assert.match(command, /--host "?0\.0\.0\.0"?/);
|
||||
assert.match(command, /--port 18082/);
|
||||
assert.match(command, /\/mnt\/Data1T\/Mnote_data\/openhub\/OpenHub\/smart-query-backend/);
|
||||
assert.match(command, /MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE="1"/);
|
||||
assert.doesNotMatch(command, /git\s+(snapshot|restore|revert)|kill-port/i);
|
||||
});
|
||||
|
||||
test("opencode 默认命令使用 OpenHub 专用运行目录", () => {
|
||||
test("opencode 默认命令使用 MNote 专用运行目录", () => {
|
||||
const command = buildDefaultOpencodeCommand(18085);
|
||||
assert.match(command, /\/mnt\/Data1T\/Mnote_data\/openhub\/opencode-runtime/);
|
||||
assert.match(command, /\/mnt\/Data1T\/Mnote_data\/openhub\/opencode-home/);
|
||||
assert.match(command, /\/mnt\/Data1T\/Mnote_data\/opencode\/runtime/);
|
||||
assert.match(command, /\/mnt\/Data1T\/Mnote_data\/opencode\/home/);
|
||||
assert.match(command, /HOME=/);
|
||||
assert.match(command, /XDG_CONFIG_HOME=/);
|
||||
assert.doesNotMatch(command, /已检测到现有 opencode/);
|
||||
|
||||
@@ -61,19 +61,7 @@ function devHotBindAddr(env = process.env) {
|
||||
|
||||
function buildDevHotEnv(baseEnv = process.env) {
|
||||
const opencodePort = String(baseEnv.OPENCODE_PORT || "4096").trim();
|
||||
const openhubPort = String(baseEnv.OPENHUB_BACKEND_PORT || baseEnv.OPENHUB_PORT || "18080").trim();
|
||||
const opencodeBaseUrl = String(baseEnv.MNOTE_OPENCODE_BASE_URL || `http://127.0.0.1:${opencodePort}`).trim();
|
||||
const openhubBaseUrl = String(baseEnv.MNOTE_OPENHUB_BASE_URL || `http://127.0.0.1:${openhubPort}`).trim();
|
||||
const openhubBackendDir = String(
|
||||
baseEnv.OPENHUB_BACKEND_DIR || "/mnt/Data1T/Mnote_data/openhub/OpenHub/smart-query-backend"
|
||||
).trim();
|
||||
const openhubOpencodeXdgRoot = String(
|
||||
baseEnv.OPENHUB_OPENCODE_XDG_ROOT || "/mnt/Data1T/Mnote_data/openhub/opencode-runtime"
|
||||
).trim();
|
||||
const openhubOpencodeHome = String(
|
||||
baseEnv.OPENHUB_OPENCODE_HOME || "/mnt/Data1T/Mnote_data/openhub/opencode-home"
|
||||
).trim();
|
||||
const skipOpenHub = String(baseEnv.SKIP_OPENHUB || "").trim();
|
||||
const controlPlaneBackend = String(baseEnv.MNOTE_CONTROL_PLANE_BACKEND || "libsql-local").trim() || "libsql-local";
|
||||
if (controlPlaneBackend === "sqlite") {
|
||||
throw new Error("dev:hot 不再支持 SQLite control-plane fallback;请使用 libsql-local/turso-local-replica/turso-remote/turso-synced");
|
||||
@@ -87,16 +75,7 @@ function buildDevHotEnv(baseEnv = process.env) {
|
||||
MNOTE_WEB_BIND: devHotBindAddr(baseEnv),
|
||||
MNOTE_WEB_CMD: String(baseEnv.MNOTE_WEB_CMD || "").trim() || cargoWatchCommand(baseEnv),
|
||||
MNOTE_KNOWLEDGE_PROVIDER: String(baseEnv.MNOTE_KNOWLEDGE_PROVIDER || "lightrag_legacy").trim(),
|
||||
ENABLE_OPENHUB: skipOpenHub ? String(baseEnv.ENABLE_OPENHUB || "") : "1",
|
||||
CHECK_OPENHUB_HEALTH: String(baseEnv.CHECK_OPENHUB_HEALTH || "1"),
|
||||
MNOTE_OPENCODE_BASE_URL: opencodeBaseUrl,
|
||||
OPENHUB_OPENCODE_BASE_URL: String(baseEnv.OPENHUB_OPENCODE_BASE_URL || opencodeBaseUrl).trim(),
|
||||
OPENHUB_BACKEND_DIR: openhubBackendDir,
|
||||
OPENHUB_OPENCODE_XDG_ROOT: openhubOpencodeXdgRoot,
|
||||
OPENHUB_OPENCODE_HOME: openhubOpencodeHome,
|
||||
MNOTE_OPENHUB_BASE_URL: openhubBaseUrl,
|
||||
OPENHUB_HEALTH_URL: String(baseEnv.OPENHUB_HEALTH_URL || `${openhubBaseUrl.replace(/\/+$/, "")}/api/health`).trim(),
|
||||
MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE: String(baseEnv.MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE || "1"),
|
||||
MNOTE_CONTROL_PLANE_BACKEND: controlPlaneBackend,
|
||||
MNOTE_PAGE_AI_PI_WARMUP: String(baseEnv.MNOTE_PAGE_AI_PI_WARMUP ?? "1").trim() || "1",
|
||||
MNOTE_PAGE_AI_PI_WARMUP_SEND: String(baseEnv.MNOTE_PAGE_AI_PI_WARMUP_SEND ?? "0").trim() || "0",
|
||||
|
||||
@@ -97,8 +97,8 @@ async function main() {
|
||||
|
||||
const openHubLink = page.locator('a[href="/page-ai/openhub/admin"]');
|
||||
assert(
|
||||
await openHubLink.count() >= 1,
|
||||
"OpenHub Admin 独立入口必须保留",
|
||||
await openHubLink.count() === 0,
|
||||
"OpenHub Admin 独立入口必须从当前 AI 管理页移除",
|
||||
);
|
||||
|
||||
await page.getByRole("link", { name: "模型配置" }).click();
|
||||
@@ -122,27 +122,37 @@ async function main() {
|
||||
await page.getByRole("button", { name: "保存策略" }).click();
|
||||
await page.locator("[data-ai-admin-tools-save-status]").filter({ hasText: /已保存/ }).waitFor();
|
||||
|
||||
await page.getByRole("link", { name: "技能 / MCP" }).click();
|
||||
await page.getByRole("link", { name: "Skills" }).click();
|
||||
await page.locator("#ai-admin-skills.is-active").waitFor();
|
||||
await page.getByRole("button", { name: "+ 添加 Skill" }).click();
|
||||
const skillRow = page.locator(".mnote-ai-admin-skill-row").last();
|
||||
await skillRow.locator('[name="skillName"]').fill(skillName);
|
||||
await skillRow.locator('[name="skillDescription"]').fill("浏览器验收技能");
|
||||
await skillRow.locator('[name="skillEnabled"]').check();
|
||||
const skillsPanel = page.locator("#ai-admin-skills");
|
||||
await skillsPanel.getByRole("button", { name: "保存 Skills" }).click();
|
||||
await assertEventually(
|
||||
async () => (await skillsPanel.locator("[data-ai-admin-skills-save-status]").textContent()) || "",
|
||||
(text) => text.includes("已保存"),
|
||||
"Skills 配置保存状态未变为已保存",
|
||||
);
|
||||
|
||||
await page.getByRole("link", { name: "MCP" }).click();
|
||||
await page.locator("#ai-admin-mcp.is-active").waitFor();
|
||||
await page.getByRole("button", { name: "+ 添加 MCP" }).click();
|
||||
const mcpRow = page.locator("[data-mcp-idx]").last();
|
||||
await mcpRow.locator("summary").click();
|
||||
await mcpRow.locator('[name="mcpName"]').fill(mcpName);
|
||||
await mcpRow.locator('[name="mcpTransport"]').selectOption("stdio");
|
||||
await mcpRow.locator('[name="mcpCommand"]').fill("scripts/lightrag-native-mcp.sh");
|
||||
await mcpRow.locator('[name="mcpSecretRefs"]').fill("env://LIGHTRAG_API_KEY");
|
||||
await mcpRow.locator('[name="mcpEnabled"]').check();
|
||||
const skillsPanel = page.locator("#ai-admin-skills");
|
||||
await skillsPanel.getByRole("button", { name: "保存配置" }).click();
|
||||
const mcpPanel = page.locator("#ai-admin-mcp");
|
||||
await mcpPanel.getByRole("button", { name: "保存 MCP" }).click();
|
||||
await assertEventually(
|
||||
async () => (await skillsPanel.locator("[data-ai-admin-skills-save-status]").textContent()) || "",
|
||||
async () => (await mcpPanel.locator("[data-ai-admin-mcp-save-status]").textContent()) || "",
|
||||
(text) => text.includes("已保存"),
|
||||
"技能/MCP 配置保存状态未变为已保存",
|
||||
"MCP 配置保存状态未变为已保存",
|
||||
);
|
||||
await page.screenshot({
|
||||
path: path.join(OUTPUT_DIR, "skills-mcp.png"),
|
||||
@@ -155,19 +165,55 @@ async function main() {
|
||||
await targetUser.waitFor();
|
||||
await targetUser.click();
|
||||
await page.locator('[data-ai-admin-user-settings] h3', { hasText: "ai-user" }).waitFor();
|
||||
await page.locator('[data-user-model="omniroute/freefirst-fast"]').evaluate((input) => {
|
||||
const disabledModelId = await page.locator("[data-user-model]").evaluateAll((inputs) => {
|
||||
const checked = inputs
|
||||
.filter((input) => input.checked)
|
||||
.map((input) => input.getAttribute("data-user-model") || "")
|
||||
.filter(Boolean);
|
||||
return checked.length > 1 ? (checked.find((id) => id !== "omniroute/freefirst") || checked[1]) : "";
|
||||
});
|
||||
if (disabledModelId) {
|
||||
await page.locator(`[data-user-model="${disabledModelId}"]`).evaluate((input) => {
|
||||
input.checked = false;
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
await page.locator('[data-action="save-user-settings"]').click();
|
||||
await page.locator("[data-ai-admin-user-save-status]").filter({ hasText: /已保存/ }).waitFor();
|
||||
const saveModelOverride = await Promise.all([
|
||||
page.waitForResponse((response) =>
|
||||
response.url().includes("/api/ai-admin/users/ai-user/settings") &&
|
||||
response.request().method() === "PUT"
|
||||
),
|
||||
page.locator('[data-action="save-user-settings"]').click(),
|
||||
]).then(([response]) => response);
|
||||
assert(
|
||||
saveModelOverride.ok(),
|
||||
`模型降权保存失败: ${saveModelOverride.status()} ${await saveModelOverride.text()}`,
|
||||
);
|
||||
}
|
||||
await page.locator('[data-user-tab="skills"]').click();
|
||||
await page.locator(`[data-user-skill="${skillName}"]`).evaluate((input) => {
|
||||
const disabledSkillId = await page.locator("[data-user-skill]").evaluateAll((inputs) => {
|
||||
const checked = inputs
|
||||
.filter((input) => input.checked)
|
||||
.map((input) => input.getAttribute("data-user-skill") || "")
|
||||
.filter(Boolean);
|
||||
return checked[0] || "";
|
||||
});
|
||||
if (disabledSkillId) {
|
||||
await page.locator(`[data-user-skill="${disabledSkillId}"]`).evaluate((input) => {
|
||||
input.checked = false;
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
await page.locator('[data-action="save-user-settings"]').click();
|
||||
await page.locator("[data-ai-admin-user-save-status]").filter({ hasText: /已保存/ }).waitFor();
|
||||
const saveSkillOverride = await Promise.all([
|
||||
page.waitForResponse((response) =>
|
||||
response.url().includes("/api/ai-admin/users/ai-user/settings") &&
|
||||
response.request().method() === "PUT"
|
||||
),
|
||||
page.locator('[data-action="save-user-settings"]').click(),
|
||||
]).then(([response]) => response);
|
||||
assert(
|
||||
saveSkillOverride.ok(),
|
||||
`Skill 降权保存失败: ${saveSkillOverride.status()} ${await saveSkillOverride.text()}`,
|
||||
);
|
||||
}
|
||||
await page.screenshot({
|
||||
path: path.join(OUTPUT_DIR, "users.png"),
|
||||
fullPage: false,
|
||||
@@ -178,17 +224,23 @@ async function main() {
|
||||
const drawer = document.querySelector("[data-ai-admin-user-drawer]");
|
||||
return !drawer || !drawer.classList.contains("is-open");
|
||||
});
|
||||
await page.getByRole("link", { name: "技能 / MCP" }).click();
|
||||
await page.getByRole("link", { name: "Skills" }).click();
|
||||
await page.reload({ waitUntil: "commit" });
|
||||
await page.locator("#ai-admin-skills.is-active").waitFor();
|
||||
assert.equal(
|
||||
await page.locator(`[name="skillName"][value="${skillName}"]`).count(),
|
||||
1,
|
||||
await assertEventually(
|
||||
async () => page.locator('[name="skillName"]').evaluateAll((inputs) =>
|
||||
inputs.map((input) => input.value || ""),
|
||||
),
|
||||
(values) => values.includes(skillName),
|
||||
"Skill 保存后刷新必须仍存在",
|
||||
);
|
||||
assert.equal(
|
||||
await page.locator(`[name="mcpName"][value="${mcpName}"]`).count(),
|
||||
1,
|
||||
await page.getByRole("link", { name: "MCP" }).click();
|
||||
await page.locator("#ai-admin-mcp.is-active").waitFor();
|
||||
await assertEventually(
|
||||
async () => page.locator('[name="mcpName"]').evaluateAll((inputs) =>
|
||||
inputs.map((input) => input.value || ""),
|
||||
),
|
||||
(values) => values.includes(mcpName),
|
||||
"MCP 保存后刷新必须仍存在",
|
||||
);
|
||||
|
||||
@@ -213,16 +265,20 @@ async function main() {
|
||||
const response = await fetch("/api/ai-admin/users/ai-user/settings", { credentials: "include" });
|
||||
return response.json();
|
||||
});
|
||||
if (disabledModelId) {
|
||||
assert.equal(
|
||||
(userSettings.allowedModels || []).some((model) => model.id === "omniroute/freefirst-fast"),
|
||||
(userSettings.allowedModels || []).some((model) => model.id === disabledModelId),
|
||||
false,
|
||||
"ai-user 刷新后不应包含被禁用模型",
|
||||
`ai-user 刷新后不应包含被禁用模型 ${disabledModelId}`,
|
||||
);
|
||||
}
|
||||
if (disabledSkillId) {
|
||||
assert.equal(
|
||||
(userSettings.skills || []).find((skill) => skill.id === skillName)?.enabled,
|
||||
(userSettings.skills || []).find((skill) => skill.id === disabledSkillId)?.enabled,
|
||||
false,
|
||||
"ai-user 刷新后应保留 Skill 禁用覆盖",
|
||||
`ai-user 刷新后应保留 Skill 禁用覆盖 ${disabledSkillId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const userContext = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
||||
const userResponse = await userContext.request.post(`${BASE}/api/auth`, {
|
||||
@@ -244,16 +300,20 @@ async function main() {
|
||||
const userEffectiveResponse = await userContext.request.get(`${BASE}/api/ai-settings/effective`);
|
||||
assert(userEffectiveResponse.ok(), `ai-user effective 读取失败: ${userEffectiveResponse.status()}`);
|
||||
const userEffective = await userEffectiveResponse.json();
|
||||
if (disabledModelId) {
|
||||
assert.equal(
|
||||
(userEffective.models || []).some((model) => model.id === "omniroute/freefirst-fast"),
|
||||
(userEffective.models || []).some((model) => model.id === disabledModelId),
|
||||
false,
|
||||
"ai-user effective 不应包含被管理员取消的模型",
|
||||
`ai-user effective 不应包含被管理员取消的模型 ${disabledModelId}`,
|
||||
);
|
||||
}
|
||||
if (disabledSkillId) {
|
||||
assert.equal(
|
||||
(userEffective.skills || []).some((skill) => skill.name === skillName),
|
||||
(userEffective.skills || []).some((skill) => skill.id === disabledSkillId || skill.name === disabledSkillId),
|
||||
false,
|
||||
"ai-user effective 不应包含被禁用 Skill",
|
||||
`ai-user effective 不应包含被禁用 Skill ${disabledSkillId}`,
|
||||
);
|
||||
}
|
||||
await userContext.close();
|
||||
|
||||
console.log(JSON.stringify({
|
||||
|
||||
@@ -301,27 +301,16 @@ check(
|
||||
);
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Section 6: Pi Rust 主入口与 OpenHub 兼容边界
|
||||
// Section 6: Pi Rust 主入口与退役边界
|
||||
// --------------------------------------------------------------------------
|
||||
console.log("\n-- 6. Pi Rust 主入口与 OpenHub 兼容边界 --");
|
||||
console.log("\n-- 6. Pi Rust 主入口与退役边界 --");
|
||||
|
||||
check(
|
||||
"OpenHub agent route /page-ai/openhub/ai exists",
|
||||
routesMod.includes('/page-ai/openhub/ai", get(page_ai_openhub::ai_shell)'),
|
||||
"page_ai_openhub::ai_shell"
|
||||
);
|
||||
|
||||
check(
|
||||
"OpenHub admin routes still exist",
|
||||
routesMod.includes('/page-ai/openhub/admin"') &&
|
||||
routesMod.includes("page_ai_openhub::non_ai_route_guard"),
|
||||
"non_ai_route_guard for admin"
|
||||
);
|
||||
|
||||
check(
|
||||
"OpenHub status API still exists",
|
||||
routesMod.includes('/api/page-ai/openhub/status", get(page_ai_openhub::status)'),
|
||||
"GET page_ai_openhub::status"
|
||||
"OpenHub active routes are retired from mnote-web",
|
||||
!routesMod.includes("page_ai_openhub") &&
|
||||
!routesMod.includes("/page-ai/openhub") &&
|
||||
!routesMod.includes("/api/page-ai/openhub"),
|
||||
"OpenHub route module and public routes removed from active router"
|
||||
);
|
||||
|
||||
check(
|
||||
@@ -380,11 +369,11 @@ check(
|
||||
);
|
||||
|
||||
check(
|
||||
"OpenHub is described as compatibility/admin boundary, not default chat",
|
||||
aiAdminRs.includes("迁移期兼容 / admin 边界") &&
|
||||
aiAdminRs.includes("不再作为默认 Page AI chat") &&
|
||||
"AI management no longer exposes OpenHub admin entry",
|
||||
!aiAdminRs.includes("/page-ai/openhub") &&
|
||||
!aiAdminRs.includes("打开 OpenHub Admin") &&
|
||||
!aiAdminRs.includes("OpenHub 仍是默认 Page AI"),
|
||||
"OpenHub remains only as migration compatibility boundary"
|
||||
"OpenHub admin links removed from active AI management UI"
|
||||
);
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -18,16 +18,12 @@ assert.match(env.MNOTE_WEB_CMD, /crates\/mnote-web\/browser/);
|
||||
assert.equal(env.FRONTEND_PORT, "3200");
|
||||
assert.equal(env.MNOTE_WEB_BIND, "0.0.0.0:3200");
|
||||
assert.equal(env.MNOTE_KNOWLEDGE_PROVIDER, "lightrag_legacy");
|
||||
assert.equal(env.ENABLE_OPENHUB, "1");
|
||||
assert.equal(env.CHECK_OPENHUB_HEALTH, "1");
|
||||
assert.equal(env.MNOTE_OPENCODE_BASE_URL, "http://127.0.0.1:4096");
|
||||
assert.equal(env.OPENHUB_OPENCODE_BASE_URL, "http://127.0.0.1:4096");
|
||||
assert.equal(env.OPENHUB_BACKEND_DIR, "/mnt/Data1T/Mnote_data/openhub/OpenHub/smart-query-backend");
|
||||
assert.equal(env.OPENHUB_OPENCODE_XDG_ROOT, "/mnt/Data1T/Mnote_data/openhub/opencode-runtime");
|
||||
assert.equal(env.OPENHUB_OPENCODE_HOME, "/mnt/Data1T/Mnote_data/openhub/opencode-home");
|
||||
assert.equal(env.MNOTE_OPENHUB_BASE_URL, "http://127.0.0.1:18080");
|
||||
assert.equal(env.OPENHUB_HEALTH_URL, "http://127.0.0.1:18080/api/health");
|
||||
assert.equal(env.MNOTE_OPENHUB_DISABLE_GIT_SNAPSHOT_RESTORE, "1");
|
||||
assert.equal(env.ENABLE_OPENHUB, undefined);
|
||||
assert.equal(env.CHECK_OPENHUB_HEALTH, undefined);
|
||||
assert.equal(env.OPENHUB_OPENCODE_BASE_URL, undefined);
|
||||
assert.equal(env.OPENHUB_BACKEND_DIR, undefined);
|
||||
assert.equal(env.MNOTE_OPENHUB_BASE_URL, undefined);
|
||||
assert.equal(env.MNOTE_CONTROL_PLANE_BACKEND, "libsql-local");
|
||||
assert.equal(env.MNOTE_TURSO_LOCAL_PATH, "/mnt/Data1T/Mnote_data/control-plane/control-plane-dev-hot-libsql.db");
|
||||
assert.equal(env.MNOTE_CONTROL_PLANE_DB_PATH, undefined);
|
||||
@@ -44,11 +40,6 @@ const loopbackEnv = buildDevHotEnv({
|
||||
assert.equal(loopbackEnv.MNOTE_WEB_BIND, "0.0.0.0:3300");
|
||||
assert.equal(loopbackEnv.MNOTE_WEB_CMD, "custom");
|
||||
|
||||
const skipEnv = buildDevHotEnv({
|
||||
SKIP_OPENHUB: "1",
|
||||
});
|
||||
assert.equal(skipEnv.ENABLE_OPENHUB, "");
|
||||
|
||||
const fixturesDisabledEnv = buildDevHotEnv({
|
||||
MNOTE_WEB_ALLOW_DEV_FIXTURES: "0",
|
||||
});
|
||||
|
||||
@@ -199,8 +199,8 @@ async function main() {
|
||||
const text = await res.text();
|
||||
if (!text.includes('createSidebarPageAiPiLabRuntime')) return { passed: false, reason: 'missing expected export' };
|
||||
if (!text.includes('data-page-ai-pi-lab-drawer')) return { passed: false, reason: 'missing independent drawer marker' };
|
||||
if (text.includes('attachPanelToDrawer') || text.includes('setOpenHubVisible')) return { passed: false, reason: 'runtime still references OpenHub drawer integration' };
|
||||
if (text.includes('data-page-ai-pi-lab-openhub-tab')) return { passed: false, reason: 'runtime still exposes OpenHub tab inside Pi Lab' };
|
||||
if (text.includes('attachPanelToDrawer') || text.includes('setOpenHubVisible')) return { passed: false, reason: 'runtime still references retired host drawer integration' };
|
||||
if (text.includes('data-page-ai-pi-lab-openhub-tab')) return { passed: false, reason: 'runtime still exposes retired host tab inside Pi Lab' };
|
||||
if (/setInterval\s*\(/.test(text)) return { passed: false, reason: 'runtime still has setInterval call' };
|
||||
if (!text.includes('NO setInterval polling')) return { passed: false, reason: 'missing NO setInterval polling comment' };
|
||||
if (!text.includes('EventSource')) return { passed: false, reason: 'missing EventSource for SSE' };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
// Pi Lab browser smoke
|
||||
// 验证 Pi Lab 默认独立悬浮入口 + MNote-native drawer,不复用 OpenHub drawer/provider tab/iframe。
|
||||
// 验证 Pi Lab 默认独立悬浮入口 + MNote-native drawer,不复用已退役 host drawer/provider tab/iframe。
|
||||
// 需要 mnote-web 已在运行;MNOTE_PAGE_AI_PI_LAB 默认开启,设为 0 时才强制关闭。
|
||||
|
||||
"use strict";
|
||||
@@ -90,16 +90,16 @@ async function main() {
|
||||
console.log(" 3. Pi Lab independent drawer visible");
|
||||
|
||||
const drawerEvidence = await page.evaluate(() => {
|
||||
const openHubDrawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
|
||||
const legacyDrawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
|
||||
const piDrawer = document.querySelector('[data-page-ai-pi-lab="drawer"]');
|
||||
const piPanel = document.querySelector('[data-page-ai-pi-lab="panel"]');
|
||||
const diagnostics = document.querySelector("[data-page-ai-pi-lab-diagnostics]");
|
||||
return {
|
||||
piDrawerVisible: Boolean(piDrawer && getComputedStyle(piDrawer).display !== "none"),
|
||||
panelInPiDrawer: Boolean(piDrawer && piPanel && piDrawer.contains(piPanel)),
|
||||
panelInOpenHubDrawer: Boolean(openHubDrawer && piPanel && openHubDrawer.contains(piPanel)),
|
||||
panelInLegacyDrawer: Boolean(legacyDrawer && piPanel && legacyDrawer.contains(piPanel)),
|
||||
piDrawerHasIframe: Boolean(piDrawer && piDrawer.querySelector("iframe")),
|
||||
openHubTabInPi: Boolean(piDrawer && piDrawer.querySelector("[data-page-ai-pi-lab-openhub-tab]")),
|
||||
retiredHostTabInPi: Boolean(piDrawer && piDrawer.querySelector("[data-page-ai-pi-lab-openhub-tab]")),
|
||||
contextChips: document.querySelectorAll("[data-page-ai-pi-lab-context-strip] [data-page-ai-pi-lab-context]").length,
|
||||
hasCurrentPageContext: Boolean(document.querySelector("[data-page-ai-pi-lab-current-page]")),
|
||||
hasChangedFilesContext: Boolean(document.querySelector("[data-page-ai-pi-lab-changed-files]")),
|
||||
@@ -112,9 +112,9 @@ async function main() {
|
||||
|
||||
assert(drawerEvidence.piDrawerVisible, "Pi Lab independent drawer should be visible");
|
||||
assert(drawerEvidence.panelInPiDrawer, "Pi Lab panel should be mounted inside independent Pi drawer");
|
||||
assert.equal(drawerEvidence.panelInOpenHubDrawer, false, "Pi Lab panel must not be inside OpenHub drawer");
|
||||
assert.equal(drawerEvidence.panelInLegacyDrawer, false, "Pi Lab panel must not be inside retired host drawer");
|
||||
assert.equal(drawerEvidence.piDrawerHasIframe, false, "Pi Lab drawer must not iframe a second app");
|
||||
assert.equal(drawerEvidence.openHubTabInPi, false, "Pi Lab drawer must not expose OpenHub provider tab");
|
||||
assert.equal(drawerEvidence.retiredHostTabInPi, false, "Pi Lab drawer must not expose retired host provider tab");
|
||||
assert(drawerEvidence.contextChips >= 3, `expected context strip chips, got ${drawerEvidence.contextChips}`);
|
||||
assert(drawerEvidence.hasCurrentPageContext, "context strip should retain current page binding");
|
||||
assert(drawerEvidence.hasChangedFilesContext, "context strip should retain changed files count");
|
||||
@@ -298,30 +298,6 @@ async function main() {
|
||||
assert.notEqual(runtimeEvidence.changedFiles, "0", "changed files chip should be non-zero");
|
||||
console.log(" 6. Real Pi Rust send, abort, deny, citation, receipt and diff evidence visible");
|
||||
|
||||
const openHubEvidence = await page.evaluate(async () => {
|
||||
const api = window.__mnoteSidebarPageAiRuntime;
|
||||
if (api && typeof api.openPageAiDrawer === "function") {
|
||||
api.openPageAiDrawer();
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
const openHubDrawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
|
||||
const openHubFrame = document.querySelector("[data-page-ai-openhub-iframe]");
|
||||
const piDrawer = document.querySelector('[data-page-ai-pi-lab="drawer"]');
|
||||
return {
|
||||
openHubApiExists: Boolean(api && typeof api.openPageAiDrawer === "function"),
|
||||
openHubDrawerExists: Boolean(openHubDrawer),
|
||||
openHubHost: openHubDrawer?.getAttribute("data-page-ai-openhub-host") || "",
|
||||
openHubFrameExists: Boolean(openHubFrame),
|
||||
piStillIndependent: Boolean(piDrawer && openHubDrawer && !openHubDrawer.contains(piDrawer)),
|
||||
};
|
||||
});
|
||||
assert(openHubEvidence.openHubApiExists, "OpenHub drawer API should still exist");
|
||||
assert(openHubEvidence.openHubDrawerExists, "OpenHub drawer should still open independently");
|
||||
assert.equal(openHubEvidence.openHubHost, "true", "OpenHub drawer should still be the default host");
|
||||
assert(openHubEvidence.openHubFrameExists, "OpenHub iframe should still exist outside Pi Lab");
|
||||
assert(openHubEvidence.piStillIndependent, "Pi Lab drawer should remain outside OpenHub drawer");
|
||||
console.log(" 7. OpenHub default drawer still works independently");
|
||||
|
||||
fs.mkdirSync(path.dirname(SCREENSHOT), { recursive: true });
|
||||
await page.locator('[data-page-ai-pi-lab="drawer"]').waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
|
||||
await page.screenshot({ path: SCREENSHOT, fullPage: false });
|
||||
|
||||
@@ -350,18 +350,18 @@ async function main() {
|
||||
console.log(" ✅ abort reflected in UI state");
|
||||
|
||||
const domEvidence = await page.evaluate(() => {
|
||||
const openHubDrawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
|
||||
const retiredHostDrawer = document.querySelector('[data-testid="wolai-page-ai-drawer"]');
|
||||
const piDrawer = document.querySelector('[data-page-ai-pi-lab="drawer"]');
|
||||
return {
|
||||
piDrawerVisible: Boolean(piDrawer && getComputedStyle(piDrawer).display !== "none"),
|
||||
piDrawerHasIframe: Boolean(piDrawer && piDrawer.querySelector("iframe")),
|
||||
piInsideOpenHub: Boolean(openHubDrawer && piDrawer && openHubDrawer.contains(piDrawer)),
|
||||
piInsideRetiredHost: Boolean(retiredHostDrawer && piDrawer && retiredHostDrawer.contains(piDrawer)),
|
||||
model: document.querySelector("[data-page-ai-pi-lab-model-chip]")?.textContent || "",
|
||||
};
|
||||
});
|
||||
assert(domEvidence.piDrawerVisible, "Pi drawer should remain visible");
|
||||
assert.equal(domEvidence.piDrawerHasIframe, false, "Pi drawer must not iframe a second app");
|
||||
assert.equal(domEvidence.piInsideOpenHub, false, "Pi drawer must not be inside OpenHub drawer");
|
||||
assert.equal(domEvidence.piInsideRetiredHost, false, "Pi drawer must not be inside retired host drawer");
|
||||
assert(domEvidence.model.includes("omniroute/gpt-5.4-mini"), `model chip mismatch: ${domEvidence.model}`);
|
||||
console.log(" ✅ Pi Lab remains native, independent and non-iframe");
|
||||
|
||||
|
||||
@@ -115,15 +115,15 @@ const checks = [
|
||||
['runtime checks enabled flag via status API but does not hide launcher behind it', runtime.includes('enabled') && runtime.includes('checkStatus')],
|
||||
['runtime renders independent Pi Lab drawer', runtime.includes('data-page-ai-pi-lab-drawer') && runtime.includes('data-page-ai-pi-lab') && runtime.includes('drawer')],
|
||||
['runtime creates drawer through ensureDrawer', runtime.includes('function ensureDrawer') && runtime.includes('setDrawerVisible')],
|
||||
['runtime does NOT mount inside OpenHub drawer', !runtime.includes('attachPanelToDrawer') && !runtime.includes('wolai-page-ai-drawer')],
|
||||
['runtime does NOT toggle OpenHub iframe visibility', !runtime.includes('setOpenHubVisible') && !runtime.includes('data-page-ai-openhub-frame-wrap')],
|
||||
['runtime has no OpenHub provider tab inside Pi Lab', !runtime.includes('data-page-ai-pi-lab-openhub-tab')],
|
||||
['runtime does NOT mount inside retired host drawer', !runtime.includes('attachPanelToDrawer') && !runtime.includes('wolai-page-ai-drawer')],
|
||||
['runtime does NOT toggle retired host iframe visibility', !runtime.includes('setOpenHubVisible') && !runtime.includes('data-page-ai-openhub-frame-wrap')],
|
||||
['runtime has no retired host provider tab inside Pi Lab', !runtime.includes('data-page-ai-pi-lab-openhub-tab')],
|
||||
['runtime has context strip chips', runtime.includes('data-page-ai-pi-lab-context-strip') && runtime.includes('data-page-ai-pi-lab-current-page') && runtime.includes('data-page-ai-pi-lab-allowed-roots') && runtime.includes('data-page-ai-pi-lab-lightrag')],
|
||||
['runtime collapses secondary context/settings like OpenHub chrome', runtime.includes('<details class="wolai-page-ai-pi-lab-settings"') && runtime.includes('<details class="wolai-page-ai-pi-lab-context-strip"')],
|
||||
['runtime collapses secondary context/settings like compact chat chrome', runtime.includes('<details class="wolai-page-ai-pi-lab-settings"') && runtime.includes('<details class="wolai-page-ai-pi-lab-context-strip"')],
|
||||
['runtime settings gear opens AI management page', runtime.includes('data-page-ai-pi-lab-open-settings title="AI 管理"') && runtime.includes("window.location.assign('/user/ai#ai-admin-access')")],
|
||||
['runtime keeps only meaningful top commandbar buttons', runtime.includes('data-page-ai-pi-lab-new') && runtime.includes('data-page-ai-pi-lab-history') && runtime.includes('data-page-ai-pi-lab-btn-clear') && runtime.includes('data-page-ai-pi-lab-open-settings')],
|
||||
['runtime removes no-op top commandbar buttons', !runtime.includes('data-page-ai-pi-lab-open title=') && !runtime.includes('data-page-ai-pi-lab-toggle-artifacts') && !runtime.includes('data-page-ai-pi-lab-clock') && !runtime.includes('data-page-ai-pi-lab-notify')],
|
||||
['runtime has OpenHub-style left history drawer markers', runtime.includes('data-page-ai-pi-lab-history-layer') && runtime.includes('wolai-page-ai-pi-lab-history-scrim') && runtime.includes('历史对话')],
|
||||
['runtime has left history drawer markers', runtime.includes('data-page-ai-pi-lab-history-layer') && runtime.includes('wolai-page-ai-pi-lab-history-scrim') && runtime.includes('历史对话')],
|
||||
['runtime history drawer supports refresh/delete/export/clear', runtime.includes('data-page-ai-pi-lab-history-refresh') && runtime.includes('data-page-ai-pi-lab-history-delete') && runtime.includes('data-page-ai-pi-lab-history-export') && runtime.includes('data-page-ai-pi-lab-history-clear')],
|
||||
['runtime does not expose manual Pi runtime start button', !runtime.includes('data-page-ai-pi-lab-btn-start') && !runtime.includes('启动 Pi runtime') && !runtime.includes('预启动 Pi 会话')],
|
||||
['runtime auto starts current page Pi session when drawer opens', showPiLabBody.includes('checkStatus().then(function ()') && showPiLabBody.includes('startRuntime().then')],
|
||||
@@ -192,7 +192,7 @@ const checks = [
|
||||
['route keeps OmniRoute streaming usage enabled', route.includes('"supportsUsageInStreaming": true') && !route.includes('"supportsUsageInStreaming": false')],
|
||||
['route sends Pi Rust directly to configured OmniRoute base URL', route.includes('"baseUrl": omniroute_base_url()') && !route.includes('omniroute_proxy_chat_completions') && !routesMod.includes('/api/page-ai/pi/omniroute-proxy/')],
|
||||
['route defaults to Pi Rust runtime implementation', route.includes('PI_LAB_RUNTIME_IMPL_RUST') && route.includes('"pi-rust"') && route.includes('MNOTE_PAGE_AI_PI_RUST_BIN')],
|
||||
['route keeps TS Pi as explicit fallback only', route.includes('PI_LAB_RUNTIME_IMPL_TS') && route.includes('MNOTE_PAGE_AI_PI_TS_BIN')],
|
||||
['route removes TS Pi fallback', !route.includes('PI_LAB_RUNTIME_IMPL_TS') && !route.includes('MNOTE_PAGE_AI_PI_TS_BIN') && !route.includes('legacy-ts') && !route.includes('pi-ts')],
|
||||
['route rejects arbitrary cookie_header auth bypass', route.includes('page_ai_pi_lab_invalid_session_cookie') && !route.includes('context.auth.cookie_header.is_some() {\n return Ok') ],
|
||||
['route stores Pi Lab session owner id', route.includes('mnote_user_id') && route.includes('ensure_session_owner')],
|
||||
['route validates session owner on send/abort/tool/events', route.includes('get_session_for_context')],
|
||||
|
||||
@@ -22,7 +22,7 @@ const ROOT_URI = process.env.MNOTE_PI_USER_EXACT_ROOT_URI || `file://${ROOT_PATH
|
||||
const PAGE_PATH = `pi-user-exact-${STAMP}.md`;
|
||||
const MODEL_PROVIDER = process.env.MNOTE_PI_USER_EXACT_MODEL_PROVIDER || "omniroute";
|
||||
const MODEL_ID = process.env.MNOTE_PI_USER_EXACT_MODEL_ID || "gpt-5.4-mini";
|
||||
const USER_PROMPT = "我们当前是从pi ts官方版,切换到了pi agdnt rust版,我希望你全面测试当前的skill/扩展/mcp/工具等是否正常,我当前已经是授权完全访问了。";
|
||||
const USER_PROMPT = "我们当前已经切换到 Pi Rust 官方版,我希望你全面测试当前的 skill/扩展/mcp/工具等是否正常,我当前已经是授权完全访问了。";
|
||||
const CHROMIUM_EXECUTABLE = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE
|
||||
|| (fs.existsSync("/usr/bin/chromium-browser") ? "/usr/bin/chromium-browser" : "")
|
||||
|| (fs.existsSync("/usr/bin/chromium") ? "/usr/bin/chromium" : "")
|
||||
|
||||
Reference in New Issue
Block a user