集成 OpenHub 与 WeKnora Page AI
This commit is contained in:
@@ -3,8 +3,8 @@ use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::ToolCallInput;
|
||||
use crate::routes::knowledge_rag::{
|
||||
KnowledgeRagOpenReferenceRequest, KnowledgeRagQueryRequest, KnowledgeRagSectionContextRequest,
|
||||
KnowledgeRagStatusQuery,
|
||||
KnowledgeRagOpenReferenceRequest, KnowledgeRagQueryRequest, KnowledgeRagSearchRequest,
|
||||
KnowledgeRagSectionContextRequest, KnowledgeRagStatusQuery,
|
||||
};
|
||||
use axum::extract::{Extension, Json, Query, State};
|
||||
use serde_json::{json, Value};
|
||||
@@ -36,22 +36,37 @@ pub async fn status(
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
pub async fn search(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
let mut args = input.args.clone().unwrap_or_else(|| json!({}));
|
||||
ensure_weknora_scope(&args, input, context)?;
|
||||
inject_identity_args(&mut args, input);
|
||||
let body = serde_json::from_value::<KnowledgeRagSearchRequest>(args).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_weknora_search_payload_invalid",
|
||||
format!("WeKnora 检索参数无效: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
let Json(payload) = crate::routes::knowledge_rag::search(
|
||||
State(state.clone()),
|
||||
Extension(context.clone()),
|
||||
Json(body),
|
||||
)
|
||||
.await?;
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
pub async fn query(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
let mut args = input.args.clone().unwrap_or_else(|| json!({}));
|
||||
if args.get("workspaceId").is_none() {
|
||||
if let Some(workspace_id) = input.effective_workspace_id() {
|
||||
args["workspaceId"] = json!(workspace_id);
|
||||
}
|
||||
}
|
||||
if args.get("rootUri").is_none() {
|
||||
if let Some(root_uri) = input.effective_root_uri() {
|
||||
args["rootUri"] = json!(root_uri);
|
||||
}
|
||||
}
|
||||
inject_identity_args(&mut args, input);
|
||||
let body = serde_json::from_value::<KnowledgeRagQueryRequest>(args).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_knowledge_rag_query_payload_invalid",
|
||||
@@ -74,16 +89,8 @@ pub async fn open_reference(
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
let mut args = input.args.clone().unwrap_or_else(|| json!({}));
|
||||
if args.get("workspaceId").is_none() {
|
||||
if let Some(workspace_id) = input.effective_workspace_id() {
|
||||
args["workspaceId"] = json!(workspace_id);
|
||||
}
|
||||
}
|
||||
if args.get("rootUri").is_none() {
|
||||
if let Some(root_uri) = input.effective_root_uri() {
|
||||
args["rootUri"] = json!(root_uri);
|
||||
}
|
||||
}
|
||||
ensure_weknora_scope(&args, input, context)?;
|
||||
inject_identity_args(&mut args, input);
|
||||
let body =
|
||||
serde_json::from_value::<KnowledgeRagOpenReferenceRequest>(args).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
@@ -107,16 +114,7 @@ pub async fn section_context(
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
let mut args = input.args.clone().unwrap_or_else(|| json!({}));
|
||||
if args.get("workspaceId").is_none() {
|
||||
if let Some(workspace_id) = input.effective_workspace_id() {
|
||||
args["workspaceId"] = json!(workspace_id);
|
||||
}
|
||||
}
|
||||
if args.get("rootUri").is_none() {
|
||||
if let Some(root_uri) = input.effective_root_uri() {
|
||||
args["rootUri"] = json!(root_uri);
|
||||
}
|
||||
}
|
||||
inject_identity_args(&mut args, input);
|
||||
let body =
|
||||
serde_json::from_value::<KnowledgeRagSectionContextRequest>(args).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
@@ -134,6 +132,109 @@ pub async fn section_context(
|
||||
Ok(compact_section_context_for_agent(payload))
|
||||
}
|
||||
|
||||
pub async fn list_sources(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
let args = input.args.clone().unwrap_or_else(|| json!({}));
|
||||
ensure_weknora_scope(&args, input, context)?;
|
||||
let payload = status(state, context, input).await?;
|
||||
Ok(json!({
|
||||
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
|
||||
"schema": "mnote.weknora.sources_result.v1",
|
||||
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("weknora")),
|
||||
"providerConfig": payload.get("providerConfig").cloned().unwrap_or(Value::Null),
|
||||
"registry": payload.get("registry").cloned().unwrap_or(Value::Null),
|
||||
"documents": payload.get("documents").cloned().unwrap_or(Value::Null),
|
||||
"locatorPolicy": "WeKnora provider ids are returned separately; filenames and chunk ids are not local paths.",
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_source_status(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
let args = input.args.clone().unwrap_or_else(|| json!({}));
|
||||
ensure_weknora_scope(&args, input, context)?;
|
||||
let payload = list_sources(state, context, input).await?;
|
||||
let requested_source = args
|
||||
.get("sourcePath")
|
||||
.or_else(|| args.get("providerKnowledgeId"))
|
||||
.or_else(|| args.get("providerSourceId"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let entries = payload
|
||||
.pointer("/registry/entries")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
let matched = entries
|
||||
.into_iter()
|
||||
.filter(|entry| {
|
||||
requested_source.is_empty()
|
||||
|| entry
|
||||
.get("sourceRootRelativePath")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value == requested_source)
|
||||
|| entry
|
||||
.get("sourceId")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value == requested_source)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"schema": "mnote.weknora.source_status_result.v1",
|
||||
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("weknora")),
|
||||
"source": if matched.len() == 1 { matched[0].clone() } else { Value::Null },
|
||||
"sources": matched,
|
||||
"requestedSource": requested_source,
|
||||
}))
|
||||
}
|
||||
|
||||
fn inject_identity_args(args: &mut Value, input: &ToolCallInput) {
|
||||
if args.get("workspaceId").is_none() {
|
||||
if let Some(workspace_id) = input.effective_workspace_id() {
|
||||
args["workspaceId"] = json!(workspace_id);
|
||||
}
|
||||
}
|
||||
if args.get("rootUri").is_none() {
|
||||
if let Some(root_uri) = input.effective_root_uri() {
|
||||
args["rootUri"] = json!(root_uri);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_weknora_scope(
|
||||
args: &Value,
|
||||
input: &ToolCallInput,
|
||||
context: &RequestContext,
|
||||
) -> Result<(), WebError> {
|
||||
let has_root_uri = args
|
||||
.get("rootUri")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
|| input.effective_root_uri().is_some();
|
||||
let has_scope = args.get("scope").is_some()
|
||||
|| args.get("allowlist").is_some()
|
||||
|| args.get("allowedRoots").is_some()
|
||||
|| args.get("aiAccessScope").is_some()
|
||||
|| args
|
||||
.get("sourcePaths")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|items| !items.is_empty());
|
||||
if has_root_uri && has_scope {
|
||||
return Ok(());
|
||||
}
|
||||
Err(WebError::bad_request_code(
|
||||
"mnote_weknora_scope_required",
|
||||
"WeKnora tool 调用必须包含 rootUri 以及 scope/allowlist/allowedRoots/aiAccessScope/sourcePaths 之一",
|
||||
)
|
||||
.with_context(context))
|
||||
}
|
||||
|
||||
fn compact_query_result_for_agent(payload: Value) -> Value {
|
||||
let references = payload
|
||||
.get("references")
|
||||
@@ -181,7 +282,7 @@ fn compact_query_result_for_agent(payload: Value) -> Value {
|
||||
json!({
|
||||
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
|
||||
"schema": "mnote.knowledge_rag.agent_query_result.v1",
|
||||
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("lightrag")),
|
||||
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("weknora")),
|
||||
"answerGuidance": "Final answers should answer the user in plain text. Do not copy citationMarkdown, citationUrl, /documents, mnote://, or search-engine wrapped local links into the answer. MNote UI will render citations[] / references[].citationMarkdown after the answer as clickable source locators. Treat rawMetadata.keywords and entity/relation counts only as query analysis, not proof that a source contains those words. Use references[].quote/contentDiagnostics as evidence. For book-like or skip-KG documents, call the tool with sourcePaths and includeDocumentStructureIndex=true; MNote fixes their effective retrieval mode to naive because they intentionally do not build KG. If documentStructureIndex is present, use it as a section map and call mnote.knowledge_rag.section_context with the section range when you need bounded chapter text for second-pass reading; do not cite the map itself unless the same claim appears in references[].quote or section_context text. If locatorDegraded is true, say the source location is degraded instead of inventing page or bbox. If contentDiagnostics.ocrTextExposed is false, say OCR text was not exposed in the returned reference instead of claiming OCR succeeded.",
|
||||
"references": references,
|
||||
"citations": citations,
|
||||
@@ -351,7 +452,7 @@ fn compact_section_context_for_agent(payload: Value) -> Value {
|
||||
json!({
|
||||
"ok": payload.get("ok").cloned().unwrap_or_else(|| json!(true)),
|
||||
"schema": payload.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.section_context.v1")),
|
||||
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("lightrag")),
|
||||
"provider": payload.get("provider").cloned().unwrap_or_else(|| json!("weknora")),
|
||||
"sourceId": payload.get("sourceId").cloned().unwrap_or(Value::Null),
|
||||
"sourceRootRelativePath": payload.get("sourceRootRelativePath").cloned().unwrap_or(Value::Null),
|
||||
"lightRagDocId": payload.get("lightRagDocId").cloned().unwrap_or(Value::Null),
|
||||
@@ -382,7 +483,7 @@ fn compact_reference_for_agent(reference: &Value) -> Value {
|
||||
.unwrap_or_else(|| quote_diagnostics("e));
|
||||
json!({
|
||||
"schema": reference.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.reference.v1")),
|
||||
"provider": reference.get("provider").cloned().unwrap_or_else(|| json!("lightrag")),
|
||||
"provider": reference.get("provider").cloned().unwrap_or_else(|| json!("weknora")),
|
||||
"citationId": reference.get("citationId").cloned().unwrap_or(Value::Null),
|
||||
"citationLabel": reference.get("citationLabel").cloned().unwrap_or(Value::Null),
|
||||
"filePath": reference.get("filePath").cloned().unwrap_or(Value::Null),
|
||||
@@ -416,7 +517,7 @@ fn compact_citation_for_agent(citation: &Value) -> Value {
|
||||
.unwrap_or_default();
|
||||
json!({
|
||||
"schema": citation.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.citation.v1")),
|
||||
"provider": citation.get("provider").cloned().unwrap_or_else(|| json!("lightrag")),
|
||||
"provider": citation.get("provider").cloned().unwrap_or_else(|| json!("weknora")),
|
||||
"citationId": citation.get("citationId").cloned().unwrap_or(Value::Null),
|
||||
"citationLabel": citation.get("citationLabel").cloned().unwrap_or(Value::Null),
|
||||
"sourceId": citation.get("sourceId").cloned().unwrap_or(Value::Null),
|
||||
@@ -490,7 +591,7 @@ mod tests {
|
||||
fn compact_query_result_marks_post_filter_scope_without_raw_chunks() {
|
||||
let payload = json!({
|
||||
"ok": true,
|
||||
"provider": "lightrag",
|
||||
"provider": "lightrag_legacy",
|
||||
"sourceScope": ["docs/a.md"],
|
||||
"sourceScopeMode": "post_filter_mapped_references",
|
||||
"rawScopeFiltered": false,
|
||||
@@ -522,7 +623,9 @@ mod tests {
|
||||
assert!(compact.get("chunks").is_none());
|
||||
assert_eq!(
|
||||
compact["rawMetadataMeaning"].as_str(),
|
||||
Some("provider metadata describes query processing/retrieval bookkeeping; it is not source text evidence unless the same text appears in references[].quote")
|
||||
Some(
|
||||
"provider metadata describes query processing/retrieval bookkeeping; it is not source text evidence unless the same text appears in references[].quote"
|
||||
)
|
||||
);
|
||||
assert!(compact["answerGuidance"]
|
||||
.as_str()
|
||||
|
||||
@@ -57,6 +57,10 @@ fn doc_tools() -> Vec<Value> {
|
||||
fn knowledge_rag_tools() -> Vec<Value> {
|
||||
vec![
|
||||
knowledge_rag_status_tool(),
|
||||
weknora_search_tool(),
|
||||
weknora_list_sources_tool(),
|
||||
weknora_get_source_status_tool(),
|
||||
weknora_open_reference_tool(),
|
||||
knowledge_rag_query_tool(),
|
||||
knowledge_rag_section_context_tool(),
|
||||
knowledge_rag_open_reference_tool(),
|
||||
@@ -355,7 +359,7 @@ fn knowledge_rag_status_tool() -> Value {
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.knowledge_rag.status",
|
||||
"description": "查看 LightRAG 资料库 provider 状态、dashboard 地址、source registry 和同步状态。",
|
||||
"description": "查看当前资料库 provider 状态、dashboard 地址、source registry 和同步状态。默认 provider 是 WeKnora;LightRAG 仅作为 legacy fallback。",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
|
||||
"status": "available",
|
||||
@@ -367,6 +371,114 @@ fn knowledge_rag_status_tool() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn weknora_scope_properties() -> Value {
|
||||
let mut properties = base_identity_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
map.insert("rootUri".into(), json!({ "type": "string" }));
|
||||
map.insert("scope".into(), json!({ "type": "object" }));
|
||||
map.insert(
|
||||
"allowlist".into(),
|
||||
json!({ "type": "array", "items": { "type": "string" } }),
|
||||
);
|
||||
map.insert("allowedRoots".into(), json!({ "type": "array" }));
|
||||
map.insert("aiAccessScope".into(), json!({ "type": "object" }));
|
||||
map.insert(
|
||||
"sourcePaths".into(),
|
||||
json!({ "type": "array", "items": { "type": "string" } }),
|
||||
);
|
||||
}
|
||||
properties
|
||||
}
|
||||
|
||||
fn weknora_search_tool() -> Value {
|
||||
let mut properties = weknora_scope_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
map.insert("query".into(), json!({ "type": "string" }));
|
||||
map.insert("topK".into(), json!({ "type": "integer", "default": 10 }));
|
||||
map.insert(
|
||||
"includeChunkContent".into(),
|
||||
json!({ "type": "boolean", "default": true }),
|
||||
);
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.weknora.search",
|
||||
"description": "只读调用 WeKnora 检索。必须带 rootUri 和 scope/allowlist,返回 provider ids、MNote source registry 映射以及 locatorDegraded;不要把 WeKnora filename/chunk id 当成本地 path。",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
|
||||
"status": "available",
|
||||
"annotations": tool_annotations(true, false, true, false),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["workspaceId", "rootUri", "query"],
|
||||
"properties": properties
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn weknora_list_sources_tool() -> Value {
|
||||
json!({
|
||||
"name": "mnote.weknora.list_sources",
|
||||
"description": "只读列出 MNote source registry 与 WeKnora provider 状态。必须带 rootUri 和 scope/allowlist。",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
|
||||
"status": "available",
|
||||
"annotations": tool_annotations(true, false, true, false),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["workspaceId", "rootUri"],
|
||||
"properties": weknora_scope_properties()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn weknora_get_source_status_tool() -> Value {
|
||||
let mut properties = weknora_scope_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
map.insert("sourcePath".into(), json!({ "type": "string" }));
|
||||
map.insert("providerKnowledgeId".into(), json!({ "type": "string" }));
|
||||
map.insert("providerSourceId".into(), json!({ "type": "string" }));
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.weknora.get_source_status",
|
||||
"description": "只读查询某个 MNote source 或 WeKnora provider knowledge id 的映射状态。必须带 rootUri 和 scope/allowlist。",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
|
||||
"status": "available",
|
||||
"annotations": tool_annotations(true, false, true, false),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["workspaceId", "rootUri"],
|
||||
"properties": properties
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn weknora_open_reference_tool() -> Value {
|
||||
let mut properties = weknora_scope_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
map.insert("reference".into(), json!({ "type": "object" }));
|
||||
map.insert(
|
||||
"providerKnowledgeBaseId".into(),
|
||||
json!({ "type": "string" }),
|
||||
);
|
||||
map.insert("providerKnowledgeId".into(), json!({ "type": "string" }));
|
||||
map.insert("providerChunkId".into(), json!({ "type": "string" }));
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.weknora.open_reference",
|
||||
"description": "只读把 WeKnora reference 映射为 MNote open action。provider ids 独立返回;未命中 source registry 时必须视为 locatorDegraded。",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
|
||||
"status": "available",
|
||||
"annotations": tool_annotations(true, false, true, false),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["workspaceId", "rootUri", "reference"],
|
||||
"properties": properties
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn knowledge_rag_query_tool() -> Value {
|
||||
let mut properties = base_identity_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
@@ -391,7 +503,7 @@ fn knowledge_rag_query_tool() -> Value {
|
||||
json!({
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "返回由 LightRAG sidecar headings 派生的 document_structure_index;适合大书/长文档问题做章节导航和上下文扩展,不替代 references 引用证据。"
|
||||
"description": "返回由 provider sidecar/headings 派生的 document_structure_index;适合大书/长文档问题做章节导航和上下文扩展,不替代 references 引用证据。"
|
||||
}),
|
||||
);
|
||||
map.insert(
|
||||
@@ -399,13 +511,13 @@ fn knowledge_rag_query_tool() -> Value {
|
||||
json!({
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "可选 MNote workspace 相对路径范围;可传文件或目录。当前语义是 LightRAG provider 检索后,MNote 只过滤返回的 references;provider raw 仍可能是全局结果。"
|
||||
"description": "可选 MNote workspace 相对路径范围;可传文件或目录。当前语义是 provider 检索后,MNote 只过滤返回的 references;provider raw 仍可能是全局结果。"
|
||||
}),
|
||||
);
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.knowledge_rag.query",
|
||||
"description": "向 LightRAG 资料库提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的 references/citations。用户要求链接、来源、引用或证据时优先调用;返回的 citationMarkdown 由 MNote 前端自动追加成可点击来源定位,agent 不应在最终回答中手写 /documents、mnote:// 或搜索引擎包装链接。",
|
||||
"description": "向当前资料库 provider 提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的 references/citations。默认 provider 是 WeKnora,LightRAG 仅 legacy fallback。用户要求链接、来源、引用或证据时优先调用;返回的 citationMarkdown 由 MNote 前端自动追加成可点击来源定位,agent 不应在最终回答中手写 /documents、mnote:// 或搜索引擎包装链接。",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
|
||||
"status": "available",
|
||||
@@ -429,7 +541,7 @@ fn knowledge_rag_open_reference_tool() -> Value {
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.knowledge_rag.open_reference",
|
||||
"description": "把 LightRAG reference 映射为 MNote 可打开的本地 resource action;没有 registry 命中时明确返回定位降级。",
|
||||
"description": "把 provider reference 映射为 MNote 可打开的本地 resource action;没有 registry 命中时明确返回定位降级。",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
|
||||
"status": "available",
|
||||
@@ -474,7 +586,7 @@ fn knowledge_rag_section_context_tool() -> Value {
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.knowledge_rag.section_context",
|
||||
"description": "按 documentStructureIndex section 的 block/paragraph range,从 LightRAG native sidecar 拉取有限正文 blocks/chunks,供大书/长文档二次解读;不做检索、重排或 fallback。",
|
||||
"description": "按 documentStructureIndex section 的 block/paragraph range,从 provider sidecar 拉取有限正文 blocks/chunks,供大书/长文档二次解读;不做检索、重排或 fallback。",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
|
||||
"status": "available",
|
||||
|
||||
@@ -38,8 +38,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
|
||||
MnoteCapabilityPack {
|
||||
id: "mnote-knowledge-rag",
|
||||
title: "资料库问答",
|
||||
description:
|
||||
"通过 LightRAG provider 检索多本书、论文、PDF 和附件资料库,并返回可回跳来源。",
|
||||
description: "通过当前知识库 provider 检索多本书、论文、PDF 和附件资料库,并返回可回跳来源;默认 provider 是 WeKnora。",
|
||||
category: "knowledge",
|
||||
agent_ids: &["hermes", "reasonix"],
|
||||
read_only: true,
|
||||
@@ -47,6 +46,10 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
|
||||
tool_names: &[
|
||||
"mnote.context.snapshot",
|
||||
"mnote.context.resolve_target",
|
||||
"mnote.weknora.search",
|
||||
"mnote.weknora.list_sources",
|
||||
"mnote.weknora.get_source_status",
|
||||
"mnote.weknora.open_reference",
|
||||
"mnote.knowledge_rag.status",
|
||||
"mnote.knowledge_rag.query",
|
||||
"mnote.knowledge_rag.section_context",
|
||||
|
||||
@@ -16,6 +16,7 @@ pub mod hermes_tools;
|
||||
pub mod local_folder_watcher_registry;
|
||||
pub mod middleware;
|
||||
pub mod page_aggregate;
|
||||
pub mod provider_identity_sync;
|
||||
pub mod routes;
|
||||
pub mod ssr;
|
||||
pub mod transport;
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
use crate::error::WebError;
|
||||
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;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProviderIdentitySyncResult {
|
||||
pub provider: &'static str,
|
||||
pub ok: bool,
|
||||
pub message: String,
|
||||
pub provider_user_id: Option<String>,
|
||||
}
|
||||
|
||||
fn env_flag(key: &str, default: bool) -> bool {
|
||||
env::var(key)
|
||||
.ok()
|
||||
.map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES"))
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn clean_url(value: String) -> Option<String> {
|
||||
let trimmed = value.trim().trim_end_matches('/').to_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"))
|
||||
.ok()
|
||||
.and_then(clean_url)
|
||||
.unwrap_or_else(|| DEFAULT_WEKNORA_ENDPOINT.to_string())
|
||||
}
|
||||
|
||||
fn join_url(base_url: &str, path: &str) -> String {
|
||||
format!(
|
||||
"{}/{}",
|
||||
base_url.trim_end_matches('/'),
|
||||
path.trim_start_matches('/')
|
||||
)
|
||||
}
|
||||
|
||||
fn internal_headers() -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
if let Ok(secret) = env::var("MNOTE_PROVIDER_IDENTITY_SYNC_SECRET")
|
||||
.or_else(|_| env::var("MNOTE_INTERNAL_API_SECRET"))
|
||||
.or_else(|_| env::var("INTERNAL_API_SECRET"))
|
||||
{
|
||||
if let Ok(value) = HeaderValue::from_str(secret.trim()) {
|
||||
headers.insert("x-internal-token", value);
|
||||
}
|
||||
}
|
||||
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()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.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)
|
||||
}
|
||||
|
||||
pub async fn sync_provider_identities(
|
||||
user: &UserRecord,
|
||||
password: &str,
|
||||
) -> Vec<ProviderIdentitySyncResult> {
|
||||
if sync_disabled() {
|
||||
return vec![ProviderIdentitySyncResult {
|
||||
provider: "all",
|
||||
ok: true,
|
||||
message: "provider identity sync disabled".to_string(),
|
||||
provider_user_id: None,
|
||||
}];
|
||||
}
|
||||
|
||||
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),
|
||||
sync_weknora_identity(user, password)
|
||||
);
|
||||
vec![openhub, weknora]
|
||||
}
|
||||
(true, false) => vec![sync_openhub_identity(user, password).await],
|
||||
(false, true) => vec![sync_weknora_identity(user, password).await],
|
||||
(false, false) => vec![ProviderIdentitySyncResult {
|
||||
provider: "all",
|
||||
ok: true,
|
||||
message: "provider identity sync disabled".to_string(),
|
||||
provider_user_id: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
async fn post_json(url: String, body: Value) -> Result<Value, WebError> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(PROVIDER_IDENTITY_SYNC_TIMEOUT_MS))
|
||||
.build()
|
||||
.map_err(|error| WebError::internal(format!("provider identity sync client: {error}")))?;
|
||||
let response = client
|
||||
.post(url.clone())
|
||||
.headers(internal_headers())
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| {
|
||||
WebError::bad_gateway_code(
|
||||
"provider_identity_sync_unreachable",
|
||||
format!("{url}: {error}"),
|
||||
)
|
||||
})?;
|
||||
let status = response.status();
|
||||
let payload = response.json::<Value>().await.unwrap_or_else(|_| json!({}));
|
||||
if !status.is_success() {
|
||||
return Err(WebError::bad_gateway_code(
|
||||
"provider_identity_sync_failed",
|
||||
format!("{url} returned {status}: {payload}"),
|
||||
));
|
||||
}
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
async fn sync_openhub_identity(user: &UserRecord, password: &str) -> 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),
|
||||
"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()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn sync_weknora_identity(user: &UserRecord, password: &str) -> ProviderIdentitySyncResult {
|
||||
let payload = json!({
|
||||
"mnote_user_id": user.id,
|
||||
"username": user.username,
|
||||
"email": fallback_email(user),
|
||||
"password": password,
|
||||
"role": "contributor",
|
||||
"is_active": user.status == "active",
|
||||
});
|
||||
match post_json(
|
||||
join_url(&weknora_endpoint(), "/internal/mnote/users/provision"),
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(payload) => ProviderIdentitySyncResult {
|
||||
provider: "weknora",
|
||||
ok: true,
|
||||
message: "synced".to_string(),
|
||||
provider_user_id: payload
|
||||
.pointer("/user/id")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned),
|
||||
},
|
||||
Err(error) => ProviderIdentitySyncResult {
|
||||
provider: "weknora",
|
||||
ok: false,
|
||||
message: error.message().to_string(),
|
||||
provider_user_id: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::provider_identity_sync::sync_provider_identities;
|
||||
use crate::routes::local_folder_source::{
|
||||
control_plane_db_path_display, create_default_local_workspace_for_actor,
|
||||
ensure_local_workspace_read_access_with_state, is_local_access_policy_admin_context,
|
||||
@@ -136,7 +137,7 @@ pub async fn auth_api(
|
||||
return Ok(build_sqlite_sign_out_response(&state, &context));
|
||||
}
|
||||
|
||||
handle_sqlite_auth_action(&state, &context, &payload)
|
||||
handle_sqlite_auth_action(&state, &context, &payload).await
|
||||
}
|
||||
|
||||
pub async fn auth_entry(
|
||||
@@ -2069,7 +2070,7 @@ fn has_real_auth_context(state: &AppState, context: &RequestContext) -> bool {
|
||||
|| extract_cookie_value(context, COOKIE_MNOTE_WEB_CONVEX_TOKEN).is_some()
|
||||
}
|
||||
|
||||
fn handle_sqlite_auth_action(
|
||||
async fn handle_sqlite_auth_action(
|
||||
state: &AppState,
|
||||
context: &RequestContext,
|
||||
payload: &serde_json::Value,
|
||||
@@ -2098,6 +2099,7 @@ fn handle_sqlite_auth_action(
|
||||
})?;
|
||||
let session_token = new_session_token();
|
||||
let token_hash = session_token_hash(&session_token);
|
||||
let password_for_provider_sync = password.clone();
|
||||
let resolved = if flow == "signUp" {
|
||||
let email = params
|
||||
.get("email")
|
||||
@@ -2200,14 +2202,46 @@ fn handle_sqlite_auth_action(
|
||||
.unwrap_or_else(|_| "{}".to_string()),
|
||||
});
|
||||
|
||||
Ok(build_sqlite_auth_response(
|
||||
let provider_sync_results = if flow == "signUp" {
|
||||
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(),
|
||||
target_kind: "user".to_string(),
|
||||
target_id: Some(resolved.user.id.clone()),
|
||||
metadata_json: serde_json::to_string(&json!({
|
||||
"providers": results.iter().map(|item| json!({
|
||||
"provider": item.provider,
|
||||
"ok": item.ok,
|
||||
"message": item.message,
|
||||
"providerUserId": item.provider_user_id,
|
||||
})).collect::<Vec<_>>(),
|
||||
}))
|
||||
.unwrap_or_else(|_| "{}".to_string()),
|
||||
});
|
||||
Some(results)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut response = build_sqlite_auth_response(
|
||||
context,
|
||||
&session_token,
|
||||
&resolved.user.id,
|
||||
resolved.user.email.as_deref().unwrap_or_default(),
|
||||
&resolved.user.display_name,
|
||||
&effective_sqlite_auth_actor_type(&resolved.user.id, &resolved.user.role),
|
||||
))
|
||||
);
|
||||
if let Some(results) = provider_sync_results {
|
||||
let all_ok = results.iter().all(|item| item.ok);
|
||||
let header_value = if all_ok { "ok" } else { "partial" };
|
||||
if let Ok(value) = HeaderValue::from_str(header_value) {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("x-mnote-provider-identity-sync", value);
|
||||
}
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn sqlite_auth_error(
|
||||
|
||||
@@ -368,11 +368,21 @@ pub(crate) async fn execute_mnote_tool_call(
|
||||
Err(WebError::new(
|
||||
StatusCode::GONE,
|
||||
"mnote_evidence_tools_retired",
|
||||
"旧 docs/evidence/LiteParse tools 已退役;请使用 mnote.knowledge_rag.query/open_reference",
|
||||
"旧 docs/evidence/LiteParse tools 已退役;请使用 mnote.weknora.search/open_reference 或兼容 mnote.knowledge_rag.query/open_reference",
|
||||
)
|
||||
.with_context(&context))
|
||||
}
|
||||
"mnote.knowledge_rag.status" => knowledge_rag::status(&state, &context, &input).await,
|
||||
"mnote.weknora.search" => knowledge_rag::search(&state, &context, &input).await,
|
||||
"mnote.weknora.list_sources" => {
|
||||
knowledge_rag::list_sources(&state, &context, &input).await
|
||||
}
|
||||
"mnote.weknora.get_source_status" => {
|
||||
knowledge_rag::get_source_status(&state, &context, &input).await
|
||||
}
|
||||
"mnote.weknora.open_reference" => {
|
||||
knowledge_rag::open_reference(&state, &context, &input).await
|
||||
}
|
||||
"mnote.knowledge_rag.query" => knowledge_rag::query(&state, &context, &input).await,
|
||||
"mnote.knowledge_rag.section_context" => {
|
||||
knowledge_rag::section_context(&state, &context, &input).await
|
||||
@@ -384,7 +394,7 @@ pub(crate) async fn execute_mnote_tool_call(
|
||||
WebError::new(
|
||||
StatusCode::GONE,
|
||||
"mnote_index_tools_retired",
|
||||
"旧本地索引 tools 已退役;资料索引统一由 LightRAG provider 处理",
|
||||
"旧本地索引 tools 已退役;资料索引统一由当前知识库 provider 处理",
|
||||
)
|
||||
.with_context(&context),
|
||||
),
|
||||
@@ -728,6 +738,10 @@ fn is_read_tool(tool_name: &str) -> bool {
|
||||
| "mnote.evidence.read"
|
||||
| "mnote.evidence.open"
|
||||
| "mnote.knowledge_rag.status"
|
||||
| "mnote.weknora.search"
|
||||
| "mnote.weknora.list_sources"
|
||||
| "mnote.weknora.get_source_status"
|
||||
| "mnote.weknora.open_reference"
|
||||
| "mnote.knowledge_rag.query"
|
||||
| "mnote.knowledge_rag.section_context"
|
||||
| "mnote.knowledge_rag.open_reference"
|
||||
@@ -761,6 +775,10 @@ fn is_evidence_receipt_tool(tool_name: &str) -> bool {
|
||||
| "mnote.evidence.read"
|
||||
| "mnote.evidence.open"
|
||||
| "mnote.knowledge_rag.status"
|
||||
| "mnote.weknora.search"
|
||||
| "mnote.weknora.list_sources"
|
||||
| "mnote.weknora.get_source_status"
|
||||
| "mnote.weknora.open_reference"
|
||||
| "mnote.knowledge_rag.query"
|
||||
| "mnote.knowledge_rag.section_context"
|
||||
| "mnote.knowledge_rag.open_reference"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,7 @@ mod onlyoffice;
|
||||
pub(crate) mod onlyoffice_bridge;
|
||||
mod page_ai_board;
|
||||
mod page_ai_opencode;
|
||||
mod page_ai_openhub;
|
||||
mod page_ai_workflow;
|
||||
mod query_support;
|
||||
mod resource_trash;
|
||||
@@ -91,6 +92,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/knowledge-rag/pipeline-events",
|
||||
get(knowledge_rag::pipeline_events),
|
||||
)
|
||||
.route(
|
||||
"/api/knowledge-rag/knowledge-bases",
|
||||
post(knowledge_rag::create_knowledge_base),
|
||||
)
|
||||
.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))
|
||||
@@ -389,6 +394,68 @@ 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),
|
||||
@@ -452,16 +519,31 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.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-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(
|
||||
"/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(
|
||||
"/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))
|
||||
@@ -472,10 +554,19 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.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(
|
||||
"/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(
|
||||
"/{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(
|
||||
|
||||
@@ -142,7 +142,9 @@ fn file_uri_to_path(root_uri: &str) -> Option<String> {
|
||||
Some(percent_decode_path_lossy(&normalized))
|
||||
}
|
||||
|
||||
fn opencode_project_directory_for_request(request: Option<&OpencodeSessionRequest>) -> Option<String> {
|
||||
fn opencode_project_directory_for_request(
|
||||
request: Option<&OpencodeSessionRequest>,
|
||||
) -> Option<String> {
|
||||
if let Some(override_directory) = std::env::var("MNOTE_OPENCODE_PROJECT_DIR")
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
@@ -1025,6 +1027,23 @@ async fn proxy_to_opencode(
|
||||
body: Body,
|
||||
) -> Result<Response, WebError> {
|
||||
ensure_authenticated(&context)?;
|
||||
if std::env::var("MNOTE_PAGE_AI_OPENCODE_LEGACY_PROXY")
|
||||
.ok()
|
||||
.map(|value| value.trim() == "1")
|
||||
.unwrap_or(false)
|
||||
!= true
|
||||
{
|
||||
return Response::builder()
|
||||
.status(StatusCode::GONE)
|
||||
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
|
||||
.body(Body::from("page_ai_opencode_legacy_proxy_disabled"))
|
||||
.map_err(|error| {
|
||||
WebError::internal(format!(
|
||||
"opencode legacy proxy disabled response 构造失败: {error}"
|
||||
))
|
||||
.with_context(&context)
|
||||
});
|
||||
}
|
||||
let client =
|
||||
opencode_client(Duration::from_secs(120)).map_err(|error| error.with_context(&context))?;
|
||||
let url = upstream_url(&path, uri.query()).map_err(|error| error.with_context(&context))?;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2530,9 +2530,536 @@
|
||||
}
|
||||
|
||||
.mnote-knowledge-rag-settings-panel {
|
||||
width: min(440px, calc(100vw - 24px));
|
||||
width: min(1120px, calc(100vw - 24px));
|
||||
max-height: calc(100vh - 72px);
|
||||
overflow-y: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-page {
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
border-radius: 10px;
|
||||
background: #F6F7F9;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 18px;
|
||||
border-bottom: 1px solid #E5E7EB;
|
||||
background: #FFFFFF;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-page-title {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-page-eyebrow {
|
||||
color: #2F7D4A;
|
||||
font: 11px/16px "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-page-title strong {
|
||||
color: #111827;
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-page-title span:last-child {
|
||||
color: #6B7280;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-page-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-page-actions button {
|
||||
display: inline-flex;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 8px;
|
||||
background: #FFFFFF;
|
||||
color: #4B5563;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-page-actions button:hover:not(:disabled) {
|
||||
background: #F3F4F6;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-page-status {
|
||||
padding: 10px 18px;
|
||||
border-bottom: 1px solid #E5E7EB;
|
||||
background: #F9FAFB;
|
||||
color: #4B5563;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-page-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 320px) minmax(0, 1fr);
|
||||
min-height: 620px;
|
||||
max-height: calc(100vh - 150px);
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-list-pane,
|
||||
.mnote-weknora-kb-detail-pane {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-list-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-right: 1px solid #E5E7EB;
|
||||
background: #FFFFFF;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-list-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-list-head div {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-list-head strong {
|
||||
color: #111827;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-list-head span {
|
||||
min-width: 22px;
|
||||
border-radius: 999px;
|
||||
background: #EEF2FF;
|
||||
color: #3730A3;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-list-head button,
|
||||
.mnote-weknora-doc-toolbar button,
|
||||
.mnote-weknora-doc-actions button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: 30px;
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 8px;
|
||||
background: #FFFFFF;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-list-head button {
|
||||
width: 30px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-list-head button:hover,
|
||||
.mnote-weknora-doc-toolbar button:hover,
|
||||
.mnote-weknora-doc-actions button:hover {
|
||||
background: #F3F4F6;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-card-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-card {
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 8px;
|
||||
background: #FFFFFF;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-card.is-active {
|
||||
border-color: #2F7D4A;
|
||||
background: #F0F9F4;
|
||||
box-shadow: inset 3px 0 0 #2F7D4A;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-card-star {
|
||||
display: inline-flex;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: #EEF2FF;
|
||||
color: #3730A3;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-card-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-card-main strong,
|
||||
.mnote-weknora-kb-card-main em {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-card-main strong {
|
||||
color: #111827;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: 19px;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-card-main em {
|
||||
color: #6B7280;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-card-main span {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-card-main i {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
color: #6B7280;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-card-main i.is-processing {
|
||||
color: #B45309;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-card-main .material-symbols-outlined {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-card-status,
|
||||
.mnote-weknora-kb-type-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 999px;
|
||||
background: #ECFDF5;
|
||||
color: #047857;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
padding: 0 7px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-create-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 1px dashed #D1D5DB;
|
||||
border-radius: 8px;
|
||||
background: #F9FAFB;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-create-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #111827;
|
||||
font-size: 13px;
|
||||
line-height: 19px;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-detail-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-detail-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(210px, 280px);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
padding: 16px;
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 8px;
|
||||
background: #FFFFFF;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-detail-hero h2 {
|
||||
margin: 8px 0 4px;
|
||||
color: #111827;
|
||||
font-size: 22px;
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
color: #6B7280;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-breadcrumb strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: #111827;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mnote-weknora-kb-detail-hero p {
|
||||
margin: 0;
|
||||
color: #6B7280;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.mnote-weknora-doc-toolbar,
|
||||
.mnote-weknora-doc-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mnote-weknora-doc-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 184px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.mnote-weknora-doc-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 260px;
|
||||
padding: 12px;
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 8px;
|
||||
background: #FFFFFF;
|
||||
}
|
||||
|
||||
.mnote-weknora-doc-sidebar-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mnote-weknora-doc-sidebar-head strong {
|
||||
color: #111827;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.mnote-weknora-doc-sidebar-head span {
|
||||
color: #9CA3AF;
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
.mnote-weknora-doc-source-tags,
|
||||
.mnote-weknora-doc-tags {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mnote-weknora-doc-source-tags button,
|
||||
.mnote-weknora-doc-tags span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-height: 28px;
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 7px;
|
||||
background: #FFFFFF;
|
||||
color: #4B5563;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
padding: 0 9px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mnote-weknora-doc-source-tags button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mnote-weknora-doc-source-tags button[data-active="true"] {
|
||||
border-color: #A7D8B8;
|
||||
background: #F0F9F4;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.mnote-weknora-doc-source-tags .material-symbols-outlined {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.mnote-weknora-doc-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mnote-weknora-view-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 8px;
|
||||
background: #FFFFFF;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.mnote-weknora-view-switch button {
|
||||
width: 28px;
|
||||
min-height: 26px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #6B7280;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mnote-weknora-view-switch button.is-active {
|
||||
background: #EAF7EA;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.mnote-weknora-doc-search {
|
||||
display: flex;
|
||||
flex: 1 1 260px;
|
||||
min-width: 220px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 8px;
|
||||
background: #FFFFFF;
|
||||
}
|
||||
|
||||
.mnote-weknora-doc-search input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: #111827;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mnote-weknora-doc-table {
|
||||
overflow: hidden;
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 8px;
|
||||
background: #FFFFFF;
|
||||
}
|
||||
|
||||
.mnote-weknora-doc-table[data-weknora-document-view="grid"] {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.mnote-weknora-doc-table::before {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 104px 104px 124px;
|
||||
gap: 8px;
|
||||
padding: 9px 12px;
|
||||
border-bottom: 1px solid #E5E7EB;
|
||||
background: #F9FAFB;
|
||||
color: #6B7280;
|
||||
content: "文档 状态 Chunk 操作";
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.mnote-weknora-doc-table[data-weknora-document-view="grid"]::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mnote-weknora-placeholder-panel {
|
||||
display: flex;
|
||||
min-height: 220px;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 18px;
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 8px;
|
||||
background: #FFFFFF;
|
||||
color: #6B7280;
|
||||
}
|
||||
|
||||
.mnote-weknora-placeholder-panel strong {
|
||||
color: #111827;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.mnote-weknora-placeholder-panel span {
|
||||
max-width: 560px;
|
||||
font-size: 12px;
|
||||
line-height: 19px;
|
||||
}
|
||||
|
||||
.mnote-knowledge-rag-meta {
|
||||
@@ -2663,6 +3190,27 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mnote-knowledge-rag-source-main .mnote-weknora-source-aux {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.mnote-weknora-source-aux i {
|
||||
display: inline-flex;
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #F3F4F6;
|
||||
color: #4B5563;
|
||||
font-style: normal;
|
||||
line-height: 18px;
|
||||
padding: 0 7px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mnote-knowledge-rag-source-progress {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
@@ -2747,5 +3295,3 @@
|
||||
color: #1B1C1C;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -2221,10 +2221,71 @@ 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;
|
||||
|
||||
Reference in New Issue
Block a user