重构基线

This commit is contained in:
Agent Board
2026-06-25 21:08:17 +08:00
parent cc4f975466
commit dabaf03bd7
42 changed files with 7720 additions and 138 deletions
+113 -1
View File
@@ -8,7 +8,6 @@ pub(crate) mod evidence;
mod gateway;
mod health;
mod hermes;
mod ui_debug;
mod hermes_client;
mod hermes_tools;
mod kernel;
@@ -25,6 +24,8 @@ mod mindmap_shell;
pub(crate) mod navigation_recent;
mod onlyoffice;
pub(crate) mod onlyoffice_bridge;
mod page_ai_board;
mod page_ai_opencode;
mod page_ai_workflow;
mod query_support;
mod resource_trash;
@@ -36,6 +37,7 @@ mod sse;
mod stream_support;
mod tree;
mod tree_view_state;
mod ui_debug;
pub(crate) mod ui_preferences;
pub(crate) mod web_shell;
mod ws;
@@ -162,6 +164,10 @@ pub fn build_router(state: AppState) -> Router {
)
.route("/pdf-preview", get(web_shell::pdf_preview_page))
.route("/api/pdfjs/{*asset_path}", get(web_shell::pdfjs_asset))
.route(
"/api/mnote-browser-runtime/mnote-ui-runtime.js",
get(web_shell::mnote_ui_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/resource-open-runtime.js",
get(web_shell::resource_open_runtime_asset),
@@ -379,6 +385,112 @@ pub fn build_router(state: AppState) -> Router {
"/api/ai/agent-profiles",
get(hermes_client::list_agent_profiles),
)
.route(
"/api/page-ai/opencode/status",
get(page_ai_opencode::status),
)
.route(
"/api/page-ai/opencode/session",
post(page_ai_opencode::bind_session),
)
.route(
"/api/page-ai/opencode/sessions",
get(page_ai_opencode::sessions),
)
.route("/api/page-ai/opencode/abort", post(page_ai_opencode::abort))
.route("/api/page-ai/opencode/todo", get(page_ai_opencode::todo))
.route("/api/page-ai/opencode/diff", get(page_ai_opencode::diff))
.route(
"/api/page-ai/opencode/messages",
get(page_ai_opencode::messages),
)
.route(
"/api/page-ai/opencode/prompt",
post(page_ai_opencode::prompt),
)
.route(
"/api/page-ai/opencode/permissions",
get(page_ai_opencode::permissions),
)
.route(
"/api/page-ai/opencode/permission/reply",
post(page_ai_opencode::reply_permission),
)
.route(
"/api/page-ai/opencode/events",
get(page_ai_opencode::events),
)
.route("/page-ai/opencode", any(page_ai_opencode::proxy_root))
.route(
"/page-ai/opencode/assets/{*path}",
any(page_ai_opencode::proxy_assets),
)
.route(
"/page-ai/opencode/favicon-96x96-v3.png",
any(page_ai_opencode::proxy_assets),
)
.route(
"/page-ai/opencode/favicon-v3.svg",
any(page_ai_opencode::proxy_assets),
)
.route(
"/page-ai/opencode/favicon-v3.ico",
any(page_ai_opencode::proxy_assets),
)
.route(
"/page-ai/opencode/apple-touch-icon-v3.png",
any(page_ai_opencode::proxy_assets),
)
.route(
"/page-ai/opencode/site.webmanifest",
any(page_ai_opencode::proxy_assets),
)
.route(
"/page-ai/opencode/social-share.png",
any(page_ai_opencode::proxy_assets),
)
.route("/page-ai/opencode/{*path}", any(page_ai_opencode::proxy))
.route("/assets/{*path}", any(page_ai_opencode::proxy_assets))
.route("/global/{*path}", any(page_ai_opencode::proxy_assets))
.route("/favicon-96x96-v3.png", any(page_ai_opencode::proxy_current_path))
.route("/favicon-v3.svg", any(page_ai_opencode::proxy_current_path))
.route("/favicon-v3.ico", any(page_ai_opencode::proxy_current_path))
.route("/apple-touch-icon-v3.png", any(page_ai_opencode::proxy_current_path))
.route("/site.webmanifest", any(page_ai_opencode::proxy_current_path))
.route("/social-share.png", any(page_ai_opencode::proxy_current_path))
.route("/provider", any(page_ai_opencode::proxy_current_path))
.route("/path", any(page_ai_opencode::proxy_current_path))
.route("/project", any(page_ai_opencode::proxy_current_path))
.route("/project/{*path}", any(page_ai_opencode::proxy_current_path))
.route("/lsp", any(page_ai_opencode::proxy_current_path))
.route("/command", any(page_ai_opencode::proxy_current_path))
.route("/mcp", any(page_ai_opencode::proxy_current_path))
.route("/agent", any(page_ai_opencode::proxy_current_path))
.route("/config", any(page_ai_opencode::proxy_current_path))
.route("/vcs", any(page_ai_opencode::proxy_current_path))
.route("/permission", any(page_ai_opencode::proxy_current_path))
.route("/question", any(page_ai_opencode::proxy_current_path))
.route("/event", any(page_ai_opencode::proxy_current_path))
.route("/session", any(page_ai_opencode::proxy_current_path))
.route("/session/{*path}", any(page_ai_opencode::proxy_current_path))
.route("/new-session", any(page_ai_opencode::proxy_current_path))
.route("/{opencode_dir}/session", any(page_ai_opencode::proxy_current_path))
.route("/{opencode_dir}/session/{*path}", any(page_ai_opencode::proxy_current_path))
.route("/api/page-ai/board/status", get(page_ai_board::status))
.route("/api/page-ai/board/workers", get(page_ai_board::workers))
.route(
"/api/page-ai/board/workflows",
get(page_ai_board::workflows),
)
.route("/api/page-ai/board/runs", post(page_ai_board::create_run))
.route(
"/api/page-ai/board/runs/{run_id}",
get(page_ai_board::get_run),
)
.route(
"/api/page-ai/board/runs/{run_id}/cancel",
post(page_ai_board::cancel_run),
)
.route(
"/api/sidebar/shortcuts",
get(sidebar_shortcuts::list_shortcuts).post(sidebar_shortcuts::upsert_shortcut),
@@ -0,0 +1,334 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use axum::extract::{Path, State};
use axum::{Extension, Json};
use reqwest::Method;
use serde_json::{json, Value};
use std::time::Duration;
const DEFAULT_BOARD_BASE_URL: &str = "http://127.0.0.1:3901/api";
const DEFAULT_BOARD_PROJECT_ID: &str = "51067826-50c7-4869-a8cd-5496f08ca8e6";
const DEFAULT_PAGE_AI_WORKFLOW_ID: &str = "builtin-mnote-page-ai-chat";
const DEFAULT_PAGE_AI_WORKER_PRESET_ID: &str = "mnote-page-ai-zcode";
const DEFAULT_PAGE_AI_MODEL_OVERRIDE: &str = "zcode-default";
fn page_ai_worker_options() -> Value {
json!([{
"id": DEFAULT_PAGE_AI_WORKER_PRESET_ID,
"workerPresetId": DEFAULT_PAGE_AI_WORKER_PRESET_ID,
"name": "MNote 页面 AI · ZCode",
"surface": "mnote-page-ai",
"role": "developer",
"agentType": "zcode",
"capabilities": ["text", "repo-edit", "terminal", "mnote-capability-envelope", "local-markdown-edit"],
"modelOptions": page_ai_model_options(),
}])
}
fn page_ai_workflow_options() -> Value {
json!([{
"id": DEFAULT_PAGE_AI_WORKFLOW_ID,
"workflowId": DEFAULT_PAGE_AI_WORKFLOW_ID,
"name": "MNote 页面 AI",
"surface": "mnote-page-ai",
"stages": ["answer"],
}])
}
fn page_ai_model_options() -> Value {
json!([
{ "id": "zcode-default", "label": "默认", "default": true },
{ "id": "zcode-fast", "label": "快速" },
{ "id": "zcode-strong", "label": "强力" },
])
}
fn validate_page_ai_route(
context: &RequestContext,
workflow_id: &str,
worker_preset_id: &str,
model_override: &str,
) -> Result<(), WebError> {
if workflow_id != DEFAULT_PAGE_AI_WORKFLOW_ID
|| worker_preset_id != DEFAULT_PAGE_AI_WORKER_PRESET_ID
{
return Err(WebError::bad_request_code(
"page_ai_board_route_not_allowed",
"Page AI 只能使用 mnote-page-ai 白名单 worker/workflow",
)
.with_context(context));
}
let allowed_models = ["zcode-default", "zcode-fast", "zcode-strong"];
if !allowed_models.contains(&model_override) {
return Err(WebError::bad_request_code(
"page_ai_board_model_not_allowed",
"Page AI 只能使用当前 MNote worker 允许的模型档位",
)
.with_context(context));
}
Ok(())
}
fn ensure_authenticated(context: &RequestContext) -> Result<(), WebError> {
let has_actor = context.auth.actor_id.trim() != "anonymous";
if has_actor || context.auth.authorization.is_some() || context.auth.cookie_header.is_some() {
return Ok(());
}
Err(WebError::new(
axum::http::StatusCode::UNAUTHORIZED,
"page_ai_board_unauthorized",
"页面 AI Agent Board bridge 需要登录后访问",
)
.with_context(context))
}
fn board_base_url() -> String {
std::env::var("MNOTE_AGENT_BOARD_API_BASE")
.or_else(|_| {
std::env::var("MNOTE_AGENT_BOARD_BASE_URL")
.map(|value| format!("{}/api", value.trim_end_matches('/')))
})
.unwrap_or_else(|_| DEFAULT_BOARD_BASE_URL.to_string())
.trim_end_matches('/')
.to_string()
}
fn default_project_id() -> String {
std::env::var("MNOTE_AGENT_BOARD_PROJECT_ID")
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| DEFAULT_BOARD_PROJECT_ID.to_string())
}
async fn board_request(
context: &RequestContext,
method: Method,
path: &str,
body: Option<Value>,
) -> Result<Value, WebError> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build()
.map_err(|error| {
WebError::internal(format!("Agent Board client 构造失败: {error}"))
.with_context(context)
})?;
let url = format!("{}{}", board_base_url(), path);
let mut request = client
.request(method, &url)
.header("accept", "application/json");
if let Some(body) = body {
request = request.json(&body);
}
let response = request.send().await.map_err(|error| {
WebError::bad_gateway_code(
"page_ai_board_unreachable",
format!("无法连接 Agent Board: {error}"),
)
.with_context(context)
})?;
let status = response.status();
let payload = response.json::<Value>().await.unwrap_or_else(|_| json!({}));
if !status.is_success() {
return Err(WebError::bad_gateway_code(
"page_ai_board_error",
format!("Agent Board 返回 HTTP {status}: {payload}"),
)
.with_context(context)
.with_details(payload));
}
Ok(payload)
}
pub async fn status(
Extension(context): Extension<RequestContext>,
) -> Result<Json<Value>, WebError> {
ensure_authenticated(&context)?;
let payload = board_request(&context, Method::GET, "/health", None).await?;
Ok(Json(json!({
"ok": true,
"schema": "mnote.page_ai_board_status.v1",
"baseUrl": board_base_url(),
"projectId": default_project_id(),
"board": payload,
})))
}
pub async fn workers(
Extension(context): Extension<RequestContext>,
) -> Result<Json<Value>, WebError> {
ensure_authenticated(&context)?;
let project_id = default_project_id();
let payload = board_request(
&context,
Method::GET,
&format!("/workers/catalog?projectId={project_id}"),
None,
)
.await
.unwrap_or_else(|_| json!(null));
Ok(Json(json!({
"ok": true,
"schema": "agent_board.page_ai_route.v2",
"surface": "mnote-page-ai",
"projectId": project_id,
"workerPresetId": DEFAULT_PAGE_AI_WORKER_PRESET_ID,
"workerName": "MNote 页面 AI · ZCode",
"allowedWorkerPresetIds": [DEFAULT_PAGE_AI_WORKER_PRESET_ID],
"modelOverride": DEFAULT_PAGE_AI_MODEL_OVERRIDE,
"modelOptions": page_ai_model_options(),
"workers": page_ai_worker_options(),
"boardCatalog": payload,
})))
}
pub async fn workflows(
Extension(context): Extension<RequestContext>,
) -> Result<Json<Value>, WebError> {
ensure_authenticated(&context)?;
let project_id = default_project_id();
let payload = board_request(
&context,
Method::GET,
&format!("/workflow-presets?projectId={project_id}"),
None,
)
.await
.unwrap_or_else(|_| json!(null));
Ok(Json(json!({
"ok": true,
"schema": "agent_board.page_ai_route.v2",
"surface": "mnote-page-ai",
"projectId": project_id,
"workflowId": DEFAULT_PAGE_AI_WORKFLOW_ID,
"workflowName": "MNote 页面 AI",
"allowedWorkflowIds": [DEFAULT_PAGE_AI_WORKFLOW_ID],
"requiresConfirmation": false,
"requires": {
"filesystem": true,
"write": false,
"browser": false,
"vision": false,
},
"stages": ["answer"],
"workflows": page_ai_workflow_options(),
"boardCatalog": payload,
})))
}
pub async fn create_run(
State(_state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(mut body): Json<Value>,
) -> Result<Json<Value>, WebError> {
ensure_authenticated(&context)?;
let project_id = body
.get("projectId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.unwrap_or_else(default_project_id);
let envelope = body.get("envelope").cloned().unwrap_or_else(|| json!({}));
let user_message = body
.get("message")
.and_then(Value::as_str)
.unwrap_or("请处理当前页面任务")
.trim();
let workflow_id = body
.get("workflowId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_PAGE_AI_WORKFLOW_ID)
.to_string();
let worker_preset_id = body
.get("workerPresetId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_PAGE_AI_WORKER_PRESET_ID)
.to_string();
let model_override = body
.get("modelOverride")
.and_then(Value::as_str)
.or_else(|| envelope.get("modelOverride").and_then(Value::as_str))
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_PAGE_AI_MODEL_OVERRIDE)
.to_string();
validate_page_ai_route(&context, &workflow_id, &worker_preset_id, &model_override)?;
let board_message = format!(
"你正在处理 MNote Page AI 发来的任务。你的最终回复会直接显示在页面 AI 对话里。\n\n用户请求:\n{user_message}\n\nMNote Page AI envelope\n```json\n{}\n```\n\n要求:\n1. 只读问答要像普通页面 AI 一样直接回答用户,不要输出 Board 任务报告。\n2. 如果任务要求编辑页面,只修改 envelope.primaryTarget 指向的真实文件,不要调用 MNote 内部页面写入接口。\n3. 你的源头最终回答必须是自然语言 final answer;同时在结构化 receipt.finalAnswer/changedFiles/verification/remaining 中写入运行记录。\n4. 为兼容旧运行器,<task-summary> 可以包含 ## FINAL_ANSWER 段,但不要把 Completed/Comments/Remaining 当作用户主回答。",
serde_json::to_string_pretty(&envelope).unwrap_or_else(|_| "{}".to_string())
);
body["projectId"] = Value::String(project_id.clone());
body["message"] = Value::String(board_message);
body["envelope"] = envelope;
body["surface"] = Value::String("mnote-page-ai".into());
body["workflowId"] = Value::String(workflow_id.clone());
body["workerPresetId"] = Value::String(worker_preset_id.clone());
body["modelOverride"] = Value::String(model_override.clone());
if body.get("autoRun").is_none() {
body["autoRun"] = Value::Bool(true);
}
let payload = board_request(&context, Method::POST, "/workflow-runs", Some(body)).await?;
Ok(Json(json!({
"ok": true,
"schema": "mnote.page_ai_board_run.v1",
"surface": "mnote-page-ai",
"projectId": project_id,
"workflowId": workflow_id,
"workerPresetId": worker_preset_id,
"modelOverride": model_override,
"board": payload,
})))
}
pub async fn get_run(
Extension(context): Extension<RequestContext>,
Path(run_id): Path<String>,
) -> Result<Json<Value>, WebError> {
ensure_authenticated(&context)?;
let details = board_request(
&context,
Method::GET,
&format!("/workflow-runs/{run_id}"),
None,
)
.await?;
let receipt = board_request(
&context,
Method::GET,
&format!("/workflow-runs/{run_id}/receipt"),
None,
)
.await
.unwrap_or_else(|_| json!(null));
Ok(Json(json!({
"ok": true,
"schema": "mnote.page_ai_board_run_status.v1",
"runId": run_id,
"board": details,
"receipt": receipt,
})))
}
pub async fn cancel_run(
Extension(context): Extension<RequestContext>,
Path(run_id): Path<String>,
) -> Result<Json<Value>, WebError> {
ensure_authenticated(&context)?;
let payload = board_request(
&context,
Method::POST,
&format!("/workflow-runs/{run_id}/cancel"),
Some(json!({})),
)
.await?;
Ok(Json(
json!({ "ok": true, "runId": run_id, "board": payload }),
))
}
File diff suppressed because it is too large Load Diff
+3 -4
View File
@@ -19,10 +19,9 @@ pub async fn components_shell(
Extension(context): Extension<RequestContext>,
) -> Result<Response, WebError> {
let workspace_id = "default";
let sidebar_tree_html =
load_sidebar_tree_html(state.config(), &context, workspace_id, None)
.await
.unwrap_or_default();
let sidebar_tree_html = load_sidebar_tree_html(state.config(), &context, workspace_id, None)
.await
.unwrap_or_default();
let body_content = crate::ssr::render_view(leptos::view! {
<UiDebugComponentsPage
@@ -2428,6 +2428,20 @@ pub async fn resource_open_runtime_asset() -> Response {
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn mnote_ui_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/mnote-ui-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(browser_runtime_js_body(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn local_upload_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/local-upload-runtime.js");
Response::builder()