chore: harden lightrag knowledge rag runtime

This commit is contained in:
lix-2026
2026-06-09 22:12:09 +08:00
parent 922965d30f
commit f698aad964
22 changed files with 899 additions and 101 deletions
@@ -158,6 +158,14 @@ impl AcpSessionManager {
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned)
.or_else(|| {
params
.get("toolCall")
.and_then(|tool_call| tool_call.get("toolCallId"))
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned)
})
.unwrap_or_else(|| format!("acp_perm_{}", id));
let tool_name = params
@@ -165,6 +173,16 @@ impl AcpSessionManager {
.or_else(|| params.get("tool"))
.or_else(|| params.get("name"))
.or_else(|| params.get("method"))
.or_else(|| {
params
.get("toolCall")
.and_then(|tool_call| tool_call.get("title"))
})
.or_else(|| {
params
.get("toolCall")
.and_then(|tool_call| tool_call.get("kind"))
})
.and_then(Value::as_str)
.unwrap_or("session/request_permission")
.to_string();
@@ -333,6 +351,18 @@ impl AcpSessionManager {
.get("toolName")
.or_else(|| pending.params.get("tool"))
.or_else(|| pending.params.get("name"))
.or_else(|| {
pending
.params
.get("toolCall")
.and_then(|tool_call| tool_call.get("title"))
})
.or_else(|| {
pending
.params
.get("toolCall")
.and_then(|tool_call| tool_call.get("kind"))
})
.and_then(Value::as_str)
.unwrap_or("session/request_permission")
.to_string();
@@ -827,8 +857,10 @@ fn permission_response_for_decision(
let option_id = permission_option_id_by_decision(params, decision);
match (decision, option_id) {
("allow", Some(option_id)) | ("deny", Some(option_id)) => Some(Ok(json!({
"outcome": "selected",
"optionId": option_id
"outcome": {
"outcome": "selected",
"optionId": option_id
}
}))),
("deny", None) => Some(Err((-32000, "permission denied by user".into()))),
_ => None,
@@ -1271,13 +1303,44 @@ rl.on('line', (line) => {
let allow = permission_response_for_decision(&params, "allow")
.expect("allow response")
.expect("allow should select an option");
assert_eq!(allow["outcome"], "selected");
assert_eq!(allow["optionId"], "allow_once");
assert_eq!(allow["outcome"]["outcome"], "selected");
assert_eq!(allow["outcome"]["optionId"], "allow_once");
let deny = permission_response_for_decision(&params, "deny")
.expect("deny response")
.expect("deny should select an option");
assert_eq!(deny["outcome"], "selected");
assert_eq!(deny["optionId"], "reject_once");
assert_eq!(deny["outcome"]["outcome"], "selected");
assert_eq!(deny["outcome"]["optionId"], "reject_once");
}
#[test]
fn permission_response_matches_reasonix_v2_acp_result_shape() {
let params = json!({
"sessionId": "sess_1",
"toolCall": {
"toolCallId": "gate-call_1",
"title": "bash",
"kind": "execute",
"rawInput": {"command": "reasonix --version"}
},
"options": [
{"optionId": "allow_once", "name": "Allow", "kind": "allow_once"},
{"optionId": "allow_always", "name": "Allow Bash(reasonix --version)", "kind": "allow_always"},
{"optionId": "reject_once", "name": "Reject", "kind": "reject_once"}
]
});
let allow = permission_response_for_decision(&params, "allow")
.expect("allow response")
.expect("allow should select an option");
assert_eq!(
allow,
json!({
"outcome": {
"outcome": "selected",
"optionId": "allow_once"
}
})
);
}
}
+1 -1
View File
@@ -221,7 +221,7 @@ async fn log_failed_response(request: Request, next: Next) -> Response {
uri = %uri,
status = %status,
error_code = %error_code,
"退役 Convex 兼容路径被请求"
"退役 legacy cloud/Convex 兼容路径被请求"
);
} else {
error!(
@@ -1,7 +1,7 @@
use crate::app::{AppConfig, AppState};
use crate::context::RequestContext;
use crate::error::WebError;
use crate::transport::convex::{
use crate::transport::legacy_cloud_guard::{
execute_retired_command_plan, execute_retired_command_plan_with_artifacts,
RetiredCloudCommandExecution,
};
@@ -208,7 +208,7 @@ fn ok_response(context: &RequestContext, result: Value) -> (StatusCode, HeaderMa
}
fn execution_artifacts_json(
execution: &crate::transport::convex::RetiredCloudCommandExecution,
execution: &crate::transport::legacy_cloud_guard::RetiredCloudCommandExecution,
) -> Value {
execution
.artifacts
+1 -1
View File
@@ -18,7 +18,7 @@ use crate::routes::web_shell::{
render_local_file_tree_html_scoped, render_local_sidebar_tree_html,
render_local_sidebar_tree_html_from_snapshot,
};
use crate::transport::convex::execute_retired_mutation_by_name;
use crate::transport::legacy_cloud_guard::execute_retired_mutation_by_name;
use crate::workspace_shell::{
apply_active_page, build_workspace_shell_projection, render_workspace_shell_sidebar_html,
};
@@ -3,7 +3,9 @@ use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::hermes_tools::manifest;
use crate::transport::convex::{execute_retired_mutation_by_name, execute_retired_query_by_name};
use crate::transport::legacy_cloud_guard::{
execute_retired_mutation_by_name, execute_retired_query_by_name,
};
use axum::body::Body;
use axum::extract::{Extension, Path, Query, State};
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode};
@@ -3,11 +3,14 @@ use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::evidence::{citation_markdown_for_locator, citation_url_for_locator};
use crate::routes::local_folder_source;
use axum::body::Body;
use axum::extract::{Extension, Json, Query, State};
use axum::http::StatusCode;
use axum::http::{header, StatusCode};
use axum::response::Response;
use core_protocol::evidence::{
EvidenceBBox, EvidenceLocator, EvidenceOpenAction, EvidenceResourceKind,
};
use futures_util::TryStreamExt;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::hash_map::DefaultHasher;
@@ -327,6 +330,59 @@ pub async fn status(
})))
}
pub async fn pipeline_events(
Extension(context): Extension<RequestContext>,
) -> Result<Response, WebError> {
let endpoint = lightrag_endpoint();
let url = format!(
"{}/documents/pipeline_status/events",
endpoint.trim_end_matches('/')
);
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
.build()
.map_err(|error| {
WebError::internal(format!("LightRAG SSE client 初始化失败: {error}"))
.with_context(&context)
})?;
let mut request = client.get(&url).header("accept", "text/event-stream");
if let Some(api_key) = lightrag_api_key() {
request = request.header("X-API-Key", api_key);
}
let upstream = request.send().await.map_err(|error| {
WebError::bad_gateway_code(
"knowledge_rag_lightrag_events_unreachable",
format!("无法连接 LightRAG pipeline 事件流: {error}"),
)
.with_context(&context)
})?;
let upstream_status = upstream.status();
if !upstream_status.is_success() {
let text = upstream.text().await.unwrap_or_default();
return Err(WebError::bad_gateway_code(
"knowledge_rag_lightrag_events_error",
format!("LightRAG pipeline 事件流返回 HTTP {upstream_status}: {text}"),
)
.with_context(&context));
}
let stream = upstream.bytes_stream().map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::Other,
format!("LightRAG pipeline 事件流读取失败: {error}"),
)
});
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8")
.header(header::CACHE_CONTROL, "no-cache, no-transform")
.header("x-accel-buffering", "no")
.body(Body::from_stream(stream))
.map_err(|error| {
WebError::internal(format!("LightRAG pipeline 事件响应构造失败: {error}"))
.with_context(&context)
})
}
pub(crate) async fn sync_registry_for_root(
state: &AppState,
context: &RequestContext,
@@ -1447,13 +1503,29 @@ fn lightrag_pipeline_status_summary(value: &Value) -> Value {
.get("latest_message")
.and_then(Value::as_str)
.unwrap_or_default();
let progress = parse_lightrag_chunk_progress(latest_message);
let history_messages = lightrag_pipeline_history_messages(value);
let progress = parse_lightrag_chunk_progress(latest_message).or_else(|| {
history_messages
.iter()
.rev()
.find_map(|message| parse_lightrag_chunk_progress(message))
});
json!({
"ok": true,
"busy": value.get("busy").and_then(Value::as_bool).unwrap_or(false),
"destructiveBusy": value.get("destructive_busy").and_then(Value::as_bool).unwrap_or(false),
"scanning": value.get("scanning").and_then(Value::as_bool).unwrap_or(false),
"scanningExclusive": value.get("scanning_exclusive").and_then(Value::as_bool).unwrap_or(false),
"requestPending": value.get("request_pending").and_then(Value::as_bool).unwrap_or(false),
"pendingEnqueues": value.get("pending_enqueues").and_then(Value::as_u64).unwrap_or(0),
"pendingRequests": value.get("pending_requests").and_then(Value::as_bool).unwrap_or(false),
"docs": value.get("docs").and_then(Value::as_u64).unwrap_or(0),
"batches": value.get("batchs").and_then(Value::as_u64).unwrap_or(0),
"currentBatch": value.get("cur_batch").and_then(Value::as_u64).unwrap_or(0),
"jobName": value.get("job_name").and_then(Value::as_str).unwrap_or_default(),
"jobStart": value.get("job_start").cloned().unwrap_or(Value::Null),
"latestMessage": latest_message,
"historyMessages": history_messages,
"cancellationRequested": value.get("cancellation_requested").and_then(Value::as_bool).unwrap_or(false),
"cancellationReason": value.get("cancellation_reason").cloned().unwrap_or(Value::Null),
"progress": progress.map(|progress| json!({
@@ -1464,6 +1536,25 @@ fn lightrag_pipeline_status_summary(value: &Value) -> Value {
})
}
fn lightrag_pipeline_history_messages(value: &Value) -> Vec<String> {
value
.get("history_messages")
.and_then(Value::as_array)
.map(|messages| {
messages
.iter()
.filter_map(Value::as_str)
.rev()
.take(80)
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect()
})
.unwrap_or_default()
}
fn lightrag_rerank_status_summary(value: &Value) -> Value {
let configuration = value.get("configuration").unwrap_or(&Value::Null);
let queue = value.get("rerank_queue_status").unwrap_or(&Value::Null);
@@ -1510,13 +1601,15 @@ fn parse_lightrag_chunk_progress(message: &str) -> Option<LightRagChunkProgress>
let current = current.trim().parse::<u64>().ok()?;
let (total, _) = rest.split_once(' ')?;
let total = total.trim().parse::<u64>().ok()?;
let doc_token = message
.split_whitespace()
.find(|token| token.starts_with("doc-") && token.contains("-chunk-"))?;
let doc_token = message.split_whitespace().find(|token| {
token.starts_with("doc-") && (token.contains("-chunk-") || token.contains("-mm-"))
})?;
let doc_id = doc_token
.rsplit_once("-chunk-")
.or_else(|| doc_token.rsplit_once("-mm-"))
.map(|(doc_id, _)| doc_id)
.unwrap_or(doc_token)
.trim_end_matches(|ch: char| !ch.is_ascii_alphanumeric())
.to_string();
if total == 0 || current == 0 || doc_id.is_empty() {
return None;
@@ -3630,6 +3723,11 @@ mod tests {
let status = json!({
"busy": true,
"scanning": true,
"destructive_busy": false,
"pending_enqueues": 1,
"docs": 1,
"batchs": 2,
"cur_batch": 1,
"job_name": "book.docx",
"latest_message": "Chunk 212 of 333 extracted 4 Ent + 0 Rel doc-ff0b60997a285a85e5704a114d7b3ffa-chunk-212"
});
@@ -3638,6 +3736,10 @@ mod tests {
assert_eq!(summary["busy"], true);
assert_eq!(summary["scanning"], true);
assert_eq!(summary["pendingEnqueues"], 1);
assert_eq!(summary["docs"], 1);
assert_eq!(summary["batches"], 2);
assert_eq!(summary["currentBatch"], 1);
assert_eq!(summary["progress"]["current"], 212);
assert_eq!(summary["progress"]["total"], 333);
assert_eq!(
@@ -3646,6 +3748,31 @@ mod tests {
);
}
#[test]
fn lightrag_pipeline_summary_extracts_chunk_progress_from_history() {
let status = json!({
"busy": true,
"scanning": false,
"job_name": "book.pdf",
"latest_message": "Merging stage 1/1: book.pdf",
"history_messages": [
"Analyzing multimodal: doc-e51400269a2c41a2fcb22f1071193fad",
"Chunk 5 of 21 extracted 2 Ent + 1 Rel doc-e51400269a2c41a2fcb22f1071193fad-mm-drawing-001",
"Merging stage 1/1: book.pdf"
]
});
let summary = lightrag_pipeline_status_summary(&status);
assert_eq!(summary["progress"]["current"], 5);
assert_eq!(summary["progress"]["total"], 21);
assert_eq!(
summary["progress"]["docId"],
"doc-e51400269a2c41a2fcb22f1071193fad"
);
assert_eq!(summary["historyMessages"].as_array().unwrap().len(), 3);
}
#[test]
fn old_registry_json_defaults_indexed_roots() {
let registry: KnowledgeRagSourceRegistry = serde_json::from_value(json!({
@@ -129,7 +129,7 @@ async fn resolve_mindmap_workspace_id(
}
fn execution_artifacts_json(
execution: &crate::transport::convex::RetiredCloudCommandExecution,
execution: &crate::transport::legacy_cloud_guard::RetiredCloudCommandExecution,
) -> Value {
execution
.artifacts
+4
View File
@@ -84,6 +84,10 @@ pub fn build_router(state: AppState) -> Router {
.route("/api/evidence/read", post(evidence::read))
.route("/api/evidence/open", post(evidence::open))
.route("/api/knowledge-rag/status", get(knowledge_rag::status))
.route(
"/api/knowledge-rag/pipeline-events",
get(knowledge_rag::pipeline_events),
)
.route("/api/knowledge-rag/ingest", post(knowledge_rag::ingest))
.route("/api/knowledge-rag/search", post(knowledge_rag::search))
.route("/api/knowledge-rag/query", post(knowledge_rag::query_rag))
@@ -1,7 +1,7 @@
use crate::app::AppConfig;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::transport::convex::execute_retired_query_plan;
use crate::transport::legacy_cloud_guard::execute_retired_query_plan;
use bridge_runtime::{
build_query_request, execute_runtime_input, execute_runtime_query, BridgeContext,
RuntimeActorWire, RuntimeBridgeContextWire, RuntimeExecutionPlan, RuntimeInput,
@@ -7,7 +7,7 @@ use crate::routes::command_support::{
use crate::routes::local_folder_source::{
ensure_local_workspace_access, execute_local_tree_command,
};
use crate::transport::convex::{
use crate::transport::legacy_cloud_guard::{
execute_retired_mutation_by_name, execute_retired_query_by_name,
persist_runtime_command_artifacts,
};
+1 -1
View File
@@ -15,7 +15,7 @@ use crate::routes::query_support::{
fetch_documents_meta_via_legacy_cloud, resolve_effective_workspace_id,
};
use crate::routes::snapshot_support::{load_projection_snapshot, ProjectionSnapshotSpec};
use crate::transport::convex::execute_retired_mutation_by_name;
use crate::transport::legacy_cloud_guard::execute_retired_mutation_by_name;
use crate::tree_shell::filetree_renderer::{
render_initial_filetree_html, FileTreeInitialRenderInput, FileTreeRenderRow,
};
+75 -8
View File
@@ -79,8 +79,21 @@ pub async fn document_page_shell(
)
));
}
let primary_source_kind = normalize_source_kind(query.source_kind.as_deref());
let mut primary_source_kind = normalize_source_kind(query.source_kind.as_deref());
let primary_root_uri = normalize_optional_query_value(query.root_uri.as_deref());
if primary_source_kind.is_none()
&& document_id.trim().starts_with("local-md:")
&& primary_root_uri.is_some()
{
primary_source_kind = Some("local_folder");
}
if primary_source_kind.is_none() && document_id.trim().starts_with("local-md:") {
return redirect_response(&format!(
"/?routeGuard={}&missingPage={}",
query_escape("local_folder_source_required"),
query_escape(&document_id),
));
}
let aggregate = match build_page_aggregate_snapshot(
&state,
&context,
@@ -2249,12 +2262,25 @@ pub async fn pdf_preview_page(Query(query): Query<PdfPreviewQuery>) -> Response
if (viewer) viewer.append(canvas);
if (evidencePage === pageNumber) {{
canvas.setAttribute('data-mnote-evidence-page', 'true');
let evidenceTargetY = null;
if (evidenceBBox) {{
const rect = viewport.convertToViewportRectangle([evidenceBBox.x0, evidenceBBox.y0, evidenceBBox.x1, evidenceBBox.y1]);
const x = Math.min(rect[0], rect[2]);
const y = Math.min(rect[1], rect[3]);
const width = Math.max(1, Math.abs(rect[2] - rect[0]));
const height = Math.max(1, Math.abs(rect[3] - rect[1]));
const normalizedMineruBox = evidenceBBox.x0 >= 0 && evidenceBBox.y0 >= 0 && evidenceBBox.x1 <= 1000 && evidenceBBox.y1 <= 1000;
let x;
let y;
let width;
let height;
if (normalizedMineruBox) {{
x = Math.min(evidenceBBox.x0, evidenceBBox.x1) / 1000 * viewport.width;
y = Math.min(evidenceBBox.y0, evidenceBBox.y1) / 1000 * viewport.height;
width = Math.max(1, Math.abs(evidenceBBox.x1 - evidenceBBox.x0) / 1000 * viewport.width);
height = Math.max(1, Math.abs(evidenceBBox.y1 - evidenceBBox.y0) / 1000 * viewport.height);
}} else {{
const rect = viewport.convertToViewportRectangle([evidenceBBox.x0, evidenceBBox.y0, evidenceBBox.x1, evidenceBBox.y1]);
x = Math.min(rect[0], rect[2]);
y = Math.min(rect[1], rect[3]);
width = Math.max(1, Math.abs(rect[2] - rect[0]));
height = Math.max(1, Math.abs(rect[3] - rect[1]));
}}
context.save();
context.scale(outputScale, outputScale);
context.fillStyle = 'rgba(37, 99, 235, 0.18)';
@@ -2263,8 +2289,17 @@ pub async fn pdf_preview_page(Query(query): Query<PdfPreviewQuery>) -> Response
context.fillRect(x, y, width, height);
context.strokeRect(x, y, width, height);
context.restore();
evidenceTargetY = y + height / 2;
}}
window.setTimeout(() => canvas.scrollIntoView({{ block: 'center', inline: 'nearest' }}), 0);
window.setTimeout(() => {{
if (Number.isFinite(evidenceTargetY)) {{
const rect = canvas.getBoundingClientRect();
const absoluteTargetTop = rect.top + window.scrollY + evidenceTargetY;
window.scrollTo({{ top: Math.max(0, absoluteTargetTop - window.innerHeight / 2), behavior: 'auto' }});
return;
}}
canvas.scrollIntoView({{ block: 'center', inline: 'nearest' }});
}}, 0);
}}
}}
@@ -3869,6 +3904,38 @@ mod tests {
assert!(location.contains("missingPage=local-md%3Adocs%7E2FPlan.md"));
}
#[tokio::test]
async fn document_shell_local_markdown_without_source_kind_does_not_fall_back_to_convex() {
let response = app_with_unreachable_convex_without_fixture()
.oneshot(
Request::builder()
.uri("/documents/local-md:docs~2FPlan.md?resourceTab=primary%3A%3Aresource%3Afile%3Afile%3A%2F%2F%2Ftmp%3Adocs%2FPlan.pdf")
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_ne!(
response
.headers()
.get("x-error-code")
.and_then(|value| value.to_str().ok()),
Some("convex_retired")
);
let location = response
.headers()
.get(header::LOCATION)
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
assert!(location.starts_with("/?"));
assert!(location.contains("routeGuard=local_folder_source_required"));
assert!(location.contains("missingPage=local-md%3Adocs%7E2FPlan.md"));
}
#[tokio::test]
async fn document_shell_records_local_markdown_page_recent() {
let root = temp_root("mnote-document-shell-record-page-recent");
@@ -4490,7 +4557,7 @@ mod tests {
.headers()
.get("x-upstream-service")
.and_then(|value| value.to_str().ok()),
Some("convex-retired")
Some("legacy-cloud-retired")
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
@@ -543,6 +543,7 @@ mod tests {
fn sidebar_settings_runtime_routes_index_and_ocr_to_lightrag_settings() {
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("mnote-knowledge-rag-settings-popover"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("/api/knowledge-rag/status?"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("/api/knowledge-rag/pipeline-events"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("/api/knowledge-rag/ingest"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("/api/knowledge-rag/prune-registry"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("use-filetree-selection"));
@@ -553,6 +554,22 @@ mod tests {
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("Rerank"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("scheduleKnowledgeRagStatusBridge"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("knowledgeRagStatusHasInFlight"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("new EventSource(url.toString())"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("pipeline_status"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("pipeline.pendingEnqueues"));
assert!(
SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-mnote-knowledge-rag-watcher-required")
);
assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("runKnowledgeRagStatusBridge"));
assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("knowledgeRagBridgeDelays"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("knowledgeRagRegistryStatusDigest"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("registry-status-changed"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("knowledgeRagIndeterminateProgress"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-progress-mode"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("indeterminate"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("analyzing"));
assert!(crate::ssr::styles::MNOTE_CSS
.contains("mnote-knowledge-rag-source-progress[data-progress-mode=\"indeterminate\"]"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("delete_submitted"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("delete_completed"));
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("publicKnowledgeRagDashboardUrl"));
@@ -695,11 +712,15 @@ mod tests {
SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("function pageAiLoadBackendSessionDetail")
);
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("function pageAiSearchBackendSessions"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS
.contains("function pageAiDeleteSelectedBackendSessions"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("function pageAiCheckActiveRun"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("/api/page-ai/sessions/"));
assert!(SIDEBAR_PAGE_AI_SESSION_RUNTIME_JS.contains("/active-run?"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-rename"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-delete"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-select"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-delete-selected"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-resume"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("data-page-ai-session-search"));
assert!(SIDEBAR_PAGE_AI_RENDER_RUNTIME_JS.contains("function pageAiRenderThoughtGroup"));
+104
View File
@@ -4038,9 +4038,17 @@ body {
background: #2F7D4A;
}
.mnote-knowledge-rag-source-progress[data-progress-mode="indeterminate"] i {
width: 38%;
animation: mnote-task-progress-slide 1.15s ease-in-out infinite;
}
.mnote-knowledge-rag-source-progress span {
max-width: 72px;
overflow: hidden;
color: #5F5A54;
font: 10px/14px "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -4537,6 +4545,10 @@ body {
cursor: pointer;
}
.wolai-page-ai-ghost--danger {
color: #B33A3A;
}
.wolai-page-share-dialog {
position: fixed;
inset: 0;
@@ -5234,8 +5246,16 @@ html[data-mnote-page-ai-resizing="true"] {
}
.wolai-page-ai-message--history {
position: relative;
display: grid;
grid-template-columns: 24px minmax(0, 1fr);
width: 100%;
min-height: 58px;
align-items: flex-start;
gap: 6px;
padding: 8px;
border: 1px solid rgba(27, 28, 28, 0.08);
border-radius: 8px;
text-align: left;
}
@@ -5244,6 +5264,59 @@ html[data-mnote-page-ai-resizing="true"] {
background: #F4F8FF;
}
.wolai-page-ai-message--history.is-selected {
border-color: rgba(37, 99, 235, 0.36);
background: #F5F8FF;
}
.wolai-page-ai-history-selection {
display: flex;
min-width: 0;
align-items: center;
justify-content: flex-end;
gap: 6px;
color: #5A5A5A;
font-size: 12px;
line-height: 18px;
}
.wolai-page-ai-history-selection[hidden] {
display: none !important;
}
.wolai-page-ai-session-check {
width: 20px;
height: 20px;
margin-top: 1px;
padding: 0;
border: 1px solid rgba(27, 28, 28, 0.18);
border-radius: 6px;
background: #FFF;
color: #2563EB;
cursor: pointer;
opacity: 0;
}
.wolai-page-ai-session-check span {
display: grid;
width: 100%;
height: 100%;
place-items: center;
font-size: 12px;
line-height: 1;
}
.wolai-page-ai-message--history:hover .wolai-page-ai-session-check,
.wolai-page-ai-message--history:focus-within .wolai-page-ai-session-check,
.wolai-page-ai-message--history.is-selected .wolai-page-ai-session-check {
opacity: 1;
}
.wolai-page-ai-session-check[aria-checked="true"] {
border-color: rgba(37, 99, 235, 0.5);
background: #EAF1FF;
}
.wolai-page-ai-message--user {
background: #F4F8FF;
}
@@ -5358,12 +5431,43 @@ button.wolai-page-ai-message-text {
cursor: pointer;
}
button.wolai-page-ai-history-main {
display: grid;
gap: 1px;
min-width: 0;
}
button.wolai-page-ai-history-main strong,
button.wolai-page-ai-history-main span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wolai-page-ai-message-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.wolai-page-ai-history-actions {
position: absolute;
top: 6px;
right: 6px;
flex-wrap: nowrap;
justify-content: flex-end;
opacity: 0;
pointer-events: none;
background: inherit;
}
.wolai-page-ai-message--history:hover .wolai-page-ai-history-actions,
.wolai-page-ai-message--history:focus-within .wolai-page-ai-history-actions {
opacity: 1;
pointer-events: auto;
}
.wolai-page-ai-permission-dialog {
position: fixed;
right: 28px;
+1 -1
View File
@@ -1 +1 @@
pub mod convex;
pub mod legacy_cloud_guard;
@@ -19,11 +19,11 @@ pub struct RetiredCloudCommandExecution {
fn retired_error(context: &RequestContext, phase: &'static str) -> WebError {
WebError::service_unavailable_code(
"convex_retired",
"Convex 运行时已退役;请使用 local-first Rust/SQLite control-plane 路径",
"旧 cloud/Convex 兼容运行时已退役;请使用 local-first Rust/SQLite control-plane 路径",
)
.with_context(context)
.with_header("x-error-phase", phase)
.with_header("x-upstream-service", "convex-retired")
.with_header("x-upstream-service", "legacy-cloud-retired")
}
fn load_query_fixture(