重构基线
This commit is contained in:
@@ -99,10 +99,7 @@ fn mindmap_tools() -> Vec<Value> {
|
||||
}
|
||||
|
||||
fn office_tools() -> Vec<Value> {
|
||||
vec![
|
||||
office_fetch_summary_tool(),
|
||||
office_propose_changes_tool(),
|
||||
]
|
||||
vec![office_fetch_summary_tool(), office_propose_changes_tool()]
|
||||
}
|
||||
|
||||
fn onlyoffice_tools() -> Vec<Value> {
|
||||
@@ -160,8 +157,6 @@ fn artifact_tools() -> Vec<Value> {
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
|
||||
fn annotate_tools_with_capabilities(tools: Vec<Value>) -> Vec<Value> {
|
||||
tools
|
||||
.into_iter()
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -163,6 +163,7 @@ pub fn PageLayout(
|
||||
<div hidden data-testid="mnote-admin-access-policy-template-admin" inner_html={admin_access_policy_template}></div>
|
||||
<div hidden data-testid="mnote-admin-access-policy-template-user" inner_html={user_access_policy_template}></div>
|
||||
<script inner_html={crate::ssr::pages::admin::ADMIN_POLICY_SCRIPT.to_string()}></script>
|
||||
<script type="module" src={browser_runtime_src("mnote-ui-runtime.js")}></script>
|
||||
<script type="module" src={browser_runtime_src("resource-open-runtime.js")}></script>
|
||||
<script type="module" src={browser_runtime_src("local-upload-runtime.js")}></script>
|
||||
<script type="module" src={browser_runtime_src("filetree-runtime.js")}></script>
|
||||
@@ -206,6 +207,7 @@ pub fn PageLayout(
|
||||
<button type="button" data-testid="wolai-floating-ai" class="wolai-floating-button wolai-floating-button--ai" aria-label="AI 助手" data-state="closed" aria-haspopup="dialog" aria-expanded="false" title="点击 进入空间智能问答" data-mnote-action="open-page-ai"><span class="material-symbols-outlined material-symbols-filled" data-icon="auto_awesome" aria-hidden="true"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mnote-portal-root" data-mnote-portal-root="true"></div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -235,6 +237,7 @@ mod tests {
|
||||
// resume/journal contract checks inspect the actual shipped JS.
|
||||
const SIDEBAR_PAGE_AI_RUNTIME_JS: &str =
|
||||
include_str!("../../../browser/sidebar-page-ai-runtime.js");
|
||||
const MNOTE_UI_RUNTIME_JS: &str = include_str!("../../../browser/mnote-ui-runtime.js");
|
||||
const SIDEBAR_PAGE_AI_MARKDOWN_RUNTIME_JS: &str =
|
||||
include_str!("../../../browser/sidebar-page-ai-markdown-runtime.js");
|
||||
const SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS: &str =
|
||||
@@ -418,6 +421,15 @@ mod tests {
|
||||
assert!(!html.contains(r#"data-testid="wolai-public-state">全网公开"#));
|
||||
assert!(html.contains(r#"data-mnote-action="toggle-sidebar-shortcut""#));
|
||||
assert!(html.contains(r#"data-mnote-shortcut-kind="page""#));
|
||||
assert!(html.contains(r#"id="mnote-portal-root""#));
|
||||
assert!(html.contains("/api/mnote-browser-runtime/mnote-ui-runtime.js"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mnote_ui_runtime_exposes_toast_api() {
|
||||
assert!(MNOTE_UI_RUNTIME_JS.contains("window.mnote.toast"));
|
||||
assert!(MNOTE_UI_RUNTIME_JS.contains("data-mnote-toast-region"));
|
||||
assert!(MNOTE_UI_RUNTIME_JS.contains("mnote:toast"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -445,6 +457,10 @@ mod tests {
|
||||
html.contains("/api/mnote-browser-runtime/local-folder-event-bus-runtime.js?devHot="),
|
||||
"dev:hot 下 local-folder event bus URL 必须带 cache buster,避免复用旧连接编排逻辑"
|
||||
);
|
||||
assert!(
|
||||
html.contains("/api/mnote-browser-runtime/mnote-ui-runtime.js?devHot="),
|
||||
"dev:hot 下 UI runtime URL 必须带 cache buster,避免 toast/portal 使用旧版本"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
/// - `styles/base.css`: 基础重置、排版、SVG 图标、滚动条、侧栏/顶栏布局
|
||||
/// - `styles/pages/home.css`, `shells.css`, `auth.css`: 页面级样式
|
||||
/// - `styles/components/main.css`: 核心组件样式(编辑器、侧栏、树、附件等)
|
||||
/// - `styles/components/toast.css`: 全局 toast 与 portal 反馈样式
|
||||
/// - `styles/components/search.css`: 搜索弹窗组件
|
||||
/// - `styles/components/page-ai.css`: Page AI 仪表盘组件
|
||||
/// - `styles/components/ui-debug.css`: UI Debug 组件矩阵
|
||||
@@ -29,6 +30,8 @@ pub const MNOTE_CSS: &str = concat!(
|
||||
"\n",
|
||||
include_str!("styles/components/main.css"),
|
||||
"\n",
|
||||
include_str!("styles/components/toast.css"),
|
||||
"\n",
|
||||
include_str!("styles/components/search.css"),
|
||||
"\n",
|
||||
include_str!("styles/components/page-ai.css"),
|
||||
@@ -140,6 +143,23 @@ mod tests {
|
||||
assert!(MNOTE_CSS.contains(".mnote-tree-context-menu"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mnote_css_contains_ui_foundation_tokens_and_toast() {
|
||||
assert!(MNOTE_CSS.contains("--mnote-btn-primary-bg"));
|
||||
assert!(MNOTE_CSS.contains("--mnote-font-size-base"));
|
||||
assert!(MNOTE_CSS.contains("--wolai-state-focus-ring"));
|
||||
assert!(MNOTE_CSS.contains(".mnote-toast-region"));
|
||||
assert!(MNOTE_CSS.contains(".mnote-toast--success"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mnote_css_does_not_hide_all_closed_state_triggers() {
|
||||
assert!(!MNOTE_CSS.contains("[data-state=\"closed\"] { display: none"));
|
||||
assert!(!MNOTE_CSS.contains("[data-state=\"open\"] { display: block"));
|
||||
assert!(MNOTE_CSS.contains(".wolai-page-ai-drawer[data-state=\"closed\"]"));
|
||||
assert!(MNOTE_CSS.contains(".wolai-page-settings-popover[data-state=\"closed\"]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sidebar_filetree_icons_use_css_masks_instead_of_text_glyphs() {
|
||||
assert!(MNOTE_CSS.contains("--mnote-filetree-icon-file"));
|
||||
|
||||
@@ -819,11 +819,6 @@ html[data-mnote-sidebar-resizing="true"] .mnote-sidebar-resizer::before {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ===== data-state 通用规则 ===== */
|
||||
/* popover / dropdown / dialog 打开/关闭状态 */
|
||||
[data-state="open"] { display: block; }
|
||||
[data-state="closed"] { display: none !important; }
|
||||
|
||||
/* dialog/modal 状态 */
|
||||
.mnote-profile-dialog[data-state="open"],
|
||||
.ui-debug-dialog-overlay[data-state="open"],
|
||||
|
||||
@@ -2214,3 +2214,298 @@ button.wolai-page-ai-history-main span {
|
||||
}
|
||||
}
|
||||
|
||||
.wolai-page-ai-drawer[data-page-ai-opencode-host="true"] .wolai-page-ai-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-header {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-chrome {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px 12px 10px;
|
||||
border-bottom: 1px solid rgba(27, 28, 28, 0.08);
|
||||
background: rgba(247, 247, 245, 0.92);
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-row {
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr);
|
||||
gap: 6px;
|
||||
align-items: baseline;
|
||||
font-size: 12px;
|
||||
color: rgba(27, 28, 28, 0.58);
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-row strong,
|
||||
.wolai-page-ai-opencode-row code {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: rgba(27, 28, 28, 0.86);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-badges,
|
||||
.wolai-page-ai-opencode-files {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-badges span,
|
||||
.wolai-page-ai-opencode-empty,
|
||||
.wolai-page-ai-opencode-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
min-height: 24px;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.08);
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: rgba(27, 28, 28, 0.68);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-chip {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-chip:hover {
|
||||
border-color: rgba(35, 131, 226, 0.32);
|
||||
color: var(--wolai-accent, #2383e2);
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-chip span {
|
||||
margin-left: 6px;
|
||||
color: rgba(27, 28, 28, 0.45);
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-frame-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-iframe {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-iframe[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-iframe-fallback {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
color: rgba(27, 28, 28, 0.62);
|
||||
text-align: center;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-iframe-fallback[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-chat {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-messages {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-message {
|
||||
margin: 0 0 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.08);
|
||||
border-radius: 12px;
|
||||
background: rgba(247, 247, 245, 0.9);
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-message[data-role="assistant"] {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-message-role {
|
||||
margin-bottom: 4px;
|
||||
color: rgba(27, 28, 28, 0.52);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-message-body {
|
||||
color: rgba(27, 28, 28, 0.88);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-composer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 12px 12px;
|
||||
border-top: 1px solid rgba(27, 28, 28, 0.08);
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-composer textarea {
|
||||
flex: 1 1 auto;
|
||||
min-height: 42px;
|
||||
resize: vertical;
|
||||
border: 1px solid rgba(27, 28, 28, 0.14);
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-send,
|
||||
.wolai-page-ai-opencode-permission button {
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
padding: 0 12px;
|
||||
background: #1f6feb;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-permissions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-permission {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(227, 115, 14, 0.24);
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 247, 237, 0.92);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-permission span {
|
||||
overflow: hidden;
|
||||
color: rgba(27, 28, 28, 0.62);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-sessions-wrap {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-sessions {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-session-row {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.08);
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-session-row[data-active="true"] {
|
||||
border-color: rgba(31, 111, 235, 0.38);
|
||||
background: rgba(31, 111, 235, 0.08);
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-session-row span,
|
||||
.wolai-page-ai-opencode-message-role span {
|
||||
overflow: hidden;
|
||||
color: rgba(27, 28, 28, 0.52);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-message-role {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-part {
|
||||
margin-top: 8px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.08);
|
||||
border-radius: 10px;
|
||||
padding: 8px;
|
||||
background: rgba(250, 250, 249, 0.88);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-part summary {
|
||||
cursor: pointer;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-part pre,
|
||||
.wolai-page-ai-opencode-error {
|
||||
margin: 8px 0 0;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-tool summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-patch {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-patch button,
|
||||
.wolai-page-ai-opencode-part button {
|
||||
border: 1px solid rgba(27, 28, 28, 0.12);
|
||||
border-radius: 999px;
|
||||
padding: 3px 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.wolai-page-ai-opencode-todo {
|
||||
margin: 0;
|
||||
padding: 0 12px;
|
||||
list-style-position: inside;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
.mnote-toast-region {
|
||||
position: fixed;
|
||||
top: var(--wolai-spacing-lg);
|
||||
right: var(--wolai-spacing-lg);
|
||||
z-index: var(--wolai-z-toast, 1300);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--wolai-spacing-sm);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mnote-toast {
|
||||
min-width: 220px;
|
||||
max-width: min(360px, calc(100vw - 32px));
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--wolai-radius-md, 6px);
|
||||
box-shadow: var(--wolai-shadow-overlay, var(--wolai-shadow-lg));
|
||||
background: var(--wolai-bg);
|
||||
border: 1px solid var(--wolai-border);
|
||||
color: var(--wolai-text-primary);
|
||||
font: var(--mnote-font-weight-medium) var(--mnote-font-size-md) / var(--mnote-line-height-normal) var(--wolai-font-sans);
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
transition: opacity 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.mnote-toast--visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.mnote-toast--success {
|
||||
border-color: rgba(34, 165, 89, 0.28);
|
||||
background: #ecfdf3;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.mnote-toast--warning {
|
||||
border-color: rgba(217, 119, 6, 0.26);
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.mnote-toast--error {
|
||||
border-color: rgba(220, 38, 38, 0.24);
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.mnote-toast-region {
|
||||
left: var(--wolai-spacing-md);
|
||||
right: var(--wolai-spacing-md);
|
||||
}
|
||||
|
||||
.mnote-toast {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,10 @@
|
||||
--wolai-accent-hover: #1d4ed8;
|
||||
--wolai-font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", "PingFang SC", Helvetica, Arial, sans-serif;
|
||||
--wolai-radius: 4px;
|
||||
--wolai-radius-sm: 4px;
|
||||
--wolai-radius-md: 6px;
|
||||
--wolai-radius-lg: 8px;
|
||||
--wolai-radius-xl: 12px;
|
||||
|
||||
/* font-size scale */
|
||||
--mnote-font-size-2xs: 10px;
|
||||
@@ -54,7 +58,11 @@
|
||||
--wolai-z-popover: 90;
|
||||
--wolai-z-dropdown: 120;
|
||||
--wolai-z-context-menu: 1000;
|
||||
--wolai-z-dialog-backdrop: 1100;
|
||||
--wolai-z-modal: 1200;
|
||||
--wolai-z-dialog: 1200;
|
||||
--wolai-z-toast: 1300;
|
||||
--wolai-z-tooltip: 1400;
|
||||
|
||||
/* spacing tokens */
|
||||
--wolai-spacing-xs: 4px;
|
||||
@@ -70,6 +78,7 @@
|
||||
--wolai-shadow-lg: 0 18px 48px rgba(15, 23, 42, 0.12);
|
||||
--wolai-shadow-popover: 0 4px 16px rgba(15, 23, 42, 0.10);
|
||||
--wolai-shadow-modal: 0 18px 54px rgba(15, 23, 42, 0.18);
|
||||
--wolai-shadow-overlay: 0 18px 48px rgba(15, 23, 42, 0.22), 0 2px 8px rgba(15, 23, 42, 0.08);
|
||||
|
||||
/* state tokens */
|
||||
--wolai-state-hover-bg: rgba(55, 53, 47, 0.06);
|
||||
|
||||
Reference in New Issue
Block a user