feat: Page AI Reasonix desktop session alignment, settings IA cleanup, knowledge RAG hardening
- ACP client/session manager: Reasonix desktop live session context - Hermes tools: knowledge_rag tool manifest and skill updates - Browser runtime: sidebar page AI permission/profile/render/session/tree modules - Routes: hermes_client, hermes_tools, knowledge_rag, web_shell - Scripts: reasonix ACP wrapper, LightRAG MCP, smoke tasks 159/558/559/561/562 - Skills: mnote-knowledge-rag and mnote-lightrag-bridge SKILL.md updates
This commit is contained in:
@@ -219,7 +219,7 @@ impl AcpClient {
|
||||
///
|
||||
/// Returns `Result<R>` where `R` is the deserialized `result` field.
|
||||
/// On JSON-RPC error, returns [`AcpError::JsonRpc`].
|
||||
/// Default timeout: 300 seconds.
|
||||
/// Default timeout: 300 seconds. Override with `MNOTE_ACP_REQUEST_TIMEOUT_SECS`.
|
||||
///
|
||||
/// Reference: `acpClient.ts` L95-110 (`call()` method)
|
||||
pub async fn request<P: Serialize, R: DeserializeOwned>(
|
||||
@@ -227,7 +227,12 @@ impl AcpClient {
|
||||
method: &str,
|
||||
params: P,
|
||||
) -> Result<R, AcpError> {
|
||||
self.request_with_timeout(method, params, Duration::from_secs(300))
|
||||
let timeout_secs = std::env::var("MNOTE_ACP_REQUEST_TIMEOUT_SECS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.filter(|value| *value >= 30)
|
||||
.unwrap_or(300);
|
||||
self.request_with_timeout(method, params, Duration::from_secs(timeout_secs))
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -252,10 +257,12 @@ impl AcpClient {
|
||||
let line = serde_json::to_string(&req)?;
|
||||
debug!("ACP --> {} #{} ({} bytes)", method, id, line.len());
|
||||
|
||||
let mut writer = self.writer.lock().await;
|
||||
writer.write_all(line.as_bytes()).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
writer.flush().await?;
|
||||
{
|
||||
let mut writer = self.writer.lock().await;
|
||||
writer.write_all(line.as_bytes()).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
writer.flush().await?;
|
||||
}
|
||||
|
||||
match timeout(dur, rx).await {
|
||||
Ok(Ok(Ok(value))) => {
|
||||
@@ -280,10 +287,12 @@ impl AcpClient {
|
||||
let line = serde_json::to_string(&msg)?;
|
||||
debug!("ACP ~~> {} ({} bytes)", method, line.len());
|
||||
|
||||
let mut writer = self.writer.lock().await;
|
||||
writer.write_all(line.as_bytes()).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
writer.flush().await?;
|
||||
{
|
||||
let mut writer = self.writer.lock().await;
|
||||
writer.write_all(line.as_bytes()).await?;
|
||||
writer.write_all(b"\n").await?;
|
||||
writer.flush().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -187,6 +187,33 @@ impl AcpSessionManager {
|
||||
.unwrap_or("session/request_permission")
|
||||
.to_string();
|
||||
|
||||
if acp_permission_tool_auto_allowed(&tool_name)
|
||||
|| acp_permission_params_contain_auto_allowed_tool(¶ms)
|
||||
{
|
||||
let response = permission_response_for_decision(¶ms, "allow");
|
||||
if let Some(Ok(result)) = response {
|
||||
let handler = event_handler_for_incoming.lock().unwrap();
|
||||
if let Some(ref h) = *handler {
|
||||
h(AcpSessionEvent::PermissionRequest {
|
||||
permission_id: permission_id.clone(),
|
||||
tool_name: tool_name.clone(),
|
||||
params: params.clone(),
|
||||
decision: "allowed".into(),
|
||||
});
|
||||
}
|
||||
drop(handler);
|
||||
|
||||
let client_for_allow = client_for_incoming.clone();
|
||||
let allow_jsonrpc_id = id.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = client_for_allow
|
||||
.respond_to_incoming(allow_jsonrpc_id, result)
|
||||
.await;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 记录到 pending map,等待前端 allow / deny。
|
||||
{
|
||||
let mut pending = pending_for_incoming.lock().unwrap();
|
||||
@@ -336,7 +363,8 @@ impl AcpSessionManager {
|
||||
pub async fn resolve_permission(
|
||||
&self,
|
||||
permission_id: &str,
|
||||
decision: &str,
|
||||
decision: Option<&str>,
|
||||
option_id: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let pending = {
|
||||
let mut map = self.pending_permissions.lock().unwrap();
|
||||
@@ -367,20 +395,46 @@ impl AcpSessionManager {
|
||||
.unwrap_or("session/request_permission")
|
||||
.to_string();
|
||||
|
||||
let normalized_decision = match decision {
|
||||
"allow" | "allowed" => "allow",
|
||||
"deny" | "denied" => "deny",
|
||||
other => return Err(format!("unknown decision: {other} (expected allow/deny)")),
|
||||
let selected_option_id = option_id
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
let normalized_decision = if let Some(option_id) = selected_option_id.as_deref() {
|
||||
if permission_option_is_reject_like(&pending.params, option_id) {
|
||||
"deny"
|
||||
} else {
|
||||
"allow"
|
||||
}
|
||||
} else {
|
||||
match decision.unwrap_or("").trim() {
|
||||
"allow" | "allowed" => "allow",
|
||||
"deny" | "denied" => "deny",
|
||||
other => {
|
||||
return Err(format!(
|
||||
"unknown decision: {other} (expected allow/deny or optionId)"
|
||||
))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let response = permission_response_for_decision(&pending.params, normalized_decision)
|
||||
.unwrap_or_else(|| {
|
||||
if normalized_decision == "allow" {
|
||||
Err((-32000, "permission allow option not available".into()))
|
||||
} else {
|
||||
Err((-32000, "permission denied by user".into()))
|
||||
}
|
||||
});
|
||||
let response = if let Some(option_id) = selected_option_id.as_deref() {
|
||||
permission_response_for_option_id(&pending.params, option_id).unwrap_or_else(|| {
|
||||
Err((
|
||||
-32000,
|
||||
format!("permission option not available: {option_id}"),
|
||||
))
|
||||
})
|
||||
} else {
|
||||
permission_response_for_decision(&pending.params, normalized_decision).unwrap_or_else(
|
||||
|| {
|
||||
if normalized_decision == "allow" {
|
||||
Err((-32000, "permission allow option not available".into()))
|
||||
} else {
|
||||
Err((-32000, "permission denied by user".into()))
|
||||
}
|
||||
},
|
||||
)
|
||||
};
|
||||
|
||||
match response {
|
||||
Ok(result) => {
|
||||
@@ -867,10 +921,69 @@ fn permission_response_for_decision(
|
||||
}
|
||||
}
|
||||
|
||||
fn permission_response_for_option_id(
|
||||
params: &Value,
|
||||
option_id: &str,
|
||||
) -> Option<Result<Value, (i64, String)>> {
|
||||
let options = params.get("options").and_then(Value::as_array)?;
|
||||
let selected = option_id.trim();
|
||||
if selected.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let exists = options
|
||||
.iter()
|
||||
.filter_map(permission_option_id)
|
||||
.any(|id| id == selected);
|
||||
if !exists {
|
||||
return None;
|
||||
}
|
||||
Some(Ok(json!({
|
||||
"outcome": {
|
||||
"outcome": "selected",
|
||||
"optionId": selected
|
||||
}
|
||||
})))
|
||||
}
|
||||
|
||||
fn acp_permission_tool_auto_allowed(tool_name: &str) -> bool {
|
||||
ACP_AUTO_ALLOWED_PERMISSION_TOOLS.contains(&tool_name)
|
||||
}
|
||||
|
||||
const ACP_AUTO_ALLOWED_PERMISSION_TOOLS: &[&str] = &[
|
||||
"mcp__mnote_lightrag_bridge__mnote_knowledge_rag_status",
|
||||
"mcp__mnote_lightrag_bridge__mnote_knowledge_rag_query",
|
||||
"mcp__mnote_lightrag_bridge__mnote_knowledge_rag_section_context",
|
||||
"mcp__mnote_lightrag_bridge__mnote_knowledge_rag_open_reference",
|
||||
"mcp__mnote_lightrag_bridge__query_knowledge_graph",
|
||||
"mcp__mnote_lightrag_bridge__open_mnote_reference",
|
||||
];
|
||||
|
||||
fn acp_permission_params_contain_auto_allowed_tool(value: &Value) -> bool {
|
||||
match value {
|
||||
Value::String(text) => ACP_AUTO_ALLOWED_PERMISSION_TOOLS
|
||||
.iter()
|
||||
.any(|tool_name| text.contains(tool_name)),
|
||||
Value::Array(items) => items
|
||||
.iter()
|
||||
.any(acp_permission_params_contain_auto_allowed_tool),
|
||||
Value::Object(map) => map
|
||||
.values()
|
||||
.any(acp_permission_params_contain_auto_allowed_tool),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn permission_option_id_by_decision(params: &Value, decision: &str) -> Option<String> {
|
||||
let options = params.get("options").and_then(Value::as_array)?;
|
||||
let preferred: &[&str] = if decision == "allow" {
|
||||
&["allow_once", "allow", "approve", "yes"]
|
||||
&[
|
||||
"allow_once",
|
||||
"allow",
|
||||
"approve",
|
||||
"yes",
|
||||
"allow_always",
|
||||
"allow_persistent",
|
||||
]
|
||||
} else {
|
||||
&["deny_once", "reject_once", "deny", "reject", "no"]
|
||||
};
|
||||
@@ -922,6 +1035,36 @@ fn permission_option_id(option: &Value) -> Option<String> {
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn permission_option_is_reject_like(params: &Value, option_id: &str) -> bool {
|
||||
let selected = option_id.trim();
|
||||
if selected.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let Some(options) = params.get("options").and_then(Value::as_array) else {
|
||||
return false;
|
||||
};
|
||||
options.iter().any(|option| {
|
||||
if permission_option_id(option).as_deref() != Some(selected) {
|
||||
return false;
|
||||
}
|
||||
let haystack = [
|
||||
option
|
||||
.get("optionId")
|
||||
.or_else(|| option.get("option_id"))
|
||||
.or_else(|| option.get("id"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(""),
|
||||
option.get("kind").and_then(Value::as_str).unwrap_or(""),
|
||||
option.get("name").and_then(Value::as_str).unwrap_or(""),
|
||||
]
|
||||
.join(" ")
|
||||
.to_ascii_lowercase();
|
||||
["reject", "deny", "cancel", "stop", "no"]
|
||||
.iter()
|
||||
.any(|needle| haystack.contains(needle))
|
||||
})
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -979,6 +1122,55 @@ rl.on('line', (line) => {
|
||||
Arc::new(client)
|
||||
}
|
||||
|
||||
/// Creates a mock ACP that asks for permission during session/prompt and
|
||||
/// only completes after MNote responds to that incoming request.
|
||||
async fn spawn_mock_acp_with_permission_gate() -> Arc<AcpClient> {
|
||||
let script = r#"
|
||||
import * as readline from 'node:readline';
|
||||
import { stdin as input, stdout as output } from 'node:process';
|
||||
const rl = readline.createInterface({ input, output, terminal: false });
|
||||
let promptId = null;
|
||||
let sessionId = 'test_session_1';
|
||||
rl.on('line', (line) => {
|
||||
const msg = JSON.parse(line);
|
||||
if (msg.method === 'initialize') {
|
||||
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { protocolVersion: 1, agentCapabilities: {}, agentInfo: { name: 'mock', version: '1.0' }, authMethods: [] } }) + '\n');
|
||||
} else if (msg.method === 'session/new') {
|
||||
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { sessionId } }) + '\n');
|
||||
} else if (msg.method === 'session/prompt') {
|
||||
promptId = msg.id;
|
||||
process.stdout.write(JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 7001,
|
||||
method: 'session/request_permission',
|
||||
params: {
|
||||
permissionId: 'perm_prompt_gate_1',
|
||||
toolCall: { toolCallId: 'perm_prompt_gate_1', title: 'bash', kind: 'execute' },
|
||||
options: [
|
||||
{ optionId: 'allow_once', name: 'Allow', kind: 'allow_once' },
|
||||
{ optionId: 'reject_once', name: 'Reject', kind: 'reject_once' }
|
||||
]
|
||||
}
|
||||
}) + '\n');
|
||||
} else if (msg.id === 7001 && msg.result) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
method: 'session/update',
|
||||
params: { sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'PERMISSION_GATE_CONTINUED' } } }
|
||||
}) + '\n');
|
||||
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: promptId, result: { stopReason: 'end_turn' } }) + '\n');
|
||||
}
|
||||
});
|
||||
"#;
|
||||
let dir = std::env::temp_dir();
|
||||
let script_path = dir.join("acp_session_permission_gate_mock.mjs");
|
||||
std::fs::write(&script_path, script).expect("write permission mock");
|
||||
let client = AcpClient::spawn("node", &[script_path.to_str().unwrap()])
|
||||
.await
|
||||
.expect("spawn permission mock");
|
||||
Arc::new(client)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_session() {
|
||||
let client = spawn_mock_acp().await;
|
||||
@@ -1048,6 +1240,58 @@ rl.on('line', (line) => {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn permission_resolution_unblocks_prompt_request() {
|
||||
let client = spawn_mock_acp_with_permission_gate().await;
|
||||
let mgr = Arc::new(AcpSessionManager::new(client));
|
||||
let permission_id = Arc::new(std::sync::Mutex::new(None::<String>));
|
||||
let permission_id_for_event = permission_id.clone();
|
||||
mgr.on_event(move |event| {
|
||||
if let AcpSessionEvent::PermissionRequest {
|
||||
permission_id,
|
||||
decision,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
if decision == "requested" {
|
||||
*permission_id_for_event.lock().unwrap() = Some(permission_id);
|
||||
}
|
||||
}
|
||||
});
|
||||
mgr.create_session(Some("/test"), None)
|
||||
.await
|
||||
.expect("create_session");
|
||||
|
||||
let mgr_for_prompt = mgr.clone();
|
||||
let prompt_task = tokio::spawn(async move {
|
||||
mgr_for_prompt
|
||||
.run_prompt(vec![ContentBlock::Text {
|
||||
text: "needs permission".into(),
|
||||
}])
|
||||
.await
|
||||
});
|
||||
|
||||
let mut observed_permission = None;
|
||||
for _ in 0..50 {
|
||||
observed_permission = permission_id.lock().unwrap().clone();
|
||||
if observed_permission.is_some() {
|
||||
break;
|
||||
}
|
||||
sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
let observed_permission = observed_permission.expect("permission requested");
|
||||
mgr.resolve_permission(&observed_permission, Some("allow"), Some("allow_once"))
|
||||
.await
|
||||
.expect("resolve permission");
|
||||
|
||||
let result = tokio::time::timeout(Duration::from_secs(2), prompt_task)
|
||||
.await
|
||||
.expect("prompt should not remain blocked")
|
||||
.expect("prompt task join")
|
||||
.expect("prompt result");
|
||||
assert_eq!(format!("{:?}", result.stop_reason), "EndTurn".to_string());
|
||||
}
|
||||
|
||||
/// Creates a mock ACP that supports session/load (success case).
|
||||
async fn spawn_mock_acp_with_load() -> Arc<AcpClient> {
|
||||
let script = r#"
|
||||
@@ -1488,7 +1732,10 @@ rl.on('line', (line) => {
|
||||
.await
|
||||
.expect("prompt after load");
|
||||
let output = chunks.lock().unwrap().join("\n");
|
||||
assert!(output.contains("CURRENT_PROMPT_AFTER_LOAD"), "prompt output missing: {output}");
|
||||
assert!(
|
||||
output.contains("CURRENT_PROMPT_AFTER_LOAD"),
|
||||
"prompt output missing: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1563,4 +1810,49 @@ rl.on('line', (line) => {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permission_response_can_select_exact_plan_option() {
|
||||
let params = json!({
|
||||
"toolCall": {
|
||||
"toolCallId": "gate-plan_1",
|
||||
"title": "Approve plan",
|
||||
"kind": "other"
|
||||
},
|
||||
"options": [
|
||||
{"optionId": "allow_once", "name": "Approve plan", "kind": "allow_once"},
|
||||
{"optionId": "refine", "name": "Refine", "kind": "allow_once"},
|
||||
{"optionId": "cancel", "name": "Cancel", "kind": "reject_once"}
|
||||
]
|
||||
});
|
||||
|
||||
let refine = permission_response_for_option_id(¶ms, "refine")
|
||||
.expect("exact option response")
|
||||
.expect("refine should select exact option");
|
||||
assert_eq!(refine["outcome"]["optionId"], "refine");
|
||||
assert!(!permission_option_is_reject_like(¶ms, "refine"));
|
||||
assert!(permission_option_is_reject_like(¶ms, "cancel"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mnote_lightrag_bridge_read_tools_are_auto_allowed() {
|
||||
assert!(acp_permission_tool_auto_allowed(
|
||||
"mcp__mnote_lightrag_bridge__mnote_knowledge_rag_query"
|
||||
));
|
||||
assert!(acp_permission_tool_auto_allowed(
|
||||
"mcp__mnote_lightrag_bridge__mnote_knowledge_rag_section_context"
|
||||
));
|
||||
assert!(acp_permission_params_contain_auto_allowed_tool(&json!({
|
||||
"toolCall": {
|
||||
"title": "调用工具",
|
||||
"rawInput": {
|
||||
"name": "mcp__mnote_lightrag_bridge__mnote_knowledge_rag_section_context"
|
||||
}
|
||||
}
|
||||
})));
|
||||
assert!(acp_permission_tool_auto_allowed(
|
||||
"mcp__mnote_lightrag_bridge__open_mnote_reference"
|
||||
));
|
||||
assert!(!acp_permission_tool_auto_allowed("bash"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::ToolCallInput;
|
||||
use crate::routes::knowledge_rag::{
|
||||
KnowledgeRagOpenReferenceRequest, KnowledgeRagQueryRequest, KnowledgeRagStatusQuery,
|
||||
KnowledgeRagOpenReferenceRequest, KnowledgeRagQueryRequest, KnowledgeRagSectionContextRequest,
|
||||
KnowledgeRagStatusQuery,
|
||||
};
|
||||
use axum::extract::{Extension, Json, Query, State};
|
||||
use serde_json::{json, Value};
|
||||
@@ -100,6 +101,39 @@ pub async fn open_reference(
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
pub async fn section_context(
|
||||
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);
|
||||
}
|
||||
}
|
||||
let body =
|
||||
serde_json::from_value::<KnowledgeRagSectionContextRequest>(args).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_knowledge_rag_section_context_payload_invalid",
|
||||
format!("资料库章节上下文参数无效: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
let Json(payload) = crate::routes::knowledge_rag::section_context(
|
||||
State(state.clone()),
|
||||
Extension(context.clone()),
|
||||
Json(body),
|
||||
)
|
||||
.await?;
|
||||
Ok(compact_section_context_for_agent(payload))
|
||||
}
|
||||
|
||||
fn compact_query_result_for_agent(payload: Value) -> Value {
|
||||
let references = payload
|
||||
.get("references")
|
||||
@@ -139,16 +173,25 @@ fn compact_query_result_for_agent(payload: Value) -> Value {
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let ui_citations = citations.clone();
|
||||
let document_structure_index = payload
|
||||
.get("documentStructureIndex")
|
||||
.cloned()
|
||||
.filter(|value| !value.is_null())
|
||||
.unwrap_or(Value::Null);
|
||||
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")),
|
||||
"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. 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.",
|
||||
"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,
|
||||
"citationMarkdowns": citation_markdowns,
|
||||
"uiCitations": ui_citations,
|
||||
"documentStructureIndex": compact_document_structure_index_for_agent(&document_structure_index),
|
||||
"citationRendering": "MNote UI appends uiCitations/citations after the final answer; agent must not hand-write citation links.",
|
||||
"requestedRetrievalMode": payload.get("requestedRetrievalMode").cloned().unwrap_or(Value::Null),
|
||||
"effectiveRetrievalMode": payload.get("effectiveRetrievalMode").cloned().or_else(|| payload.get("retrievalMode").cloned()).unwrap_or(Value::Null),
|
||||
"retrievalModeReason": payload.get("retrievalModeReason").cloned().unwrap_or(Value::Null),
|
||||
"sourceScope": payload.get("sourceScope").cloned().unwrap_or(Value::Array(Vec::new())),
|
||||
"sourceScopeMode": payload.get("sourceScopeMode").cloned().unwrap_or_else(|| json!("post_filter_mapped_references")),
|
||||
"rawScopeFiltered": payload.get("rawScopeFiltered").cloned().unwrap_or(Value::Bool(false)),
|
||||
@@ -159,6 +202,168 @@ fn compact_query_result_for_agent(payload: Value) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn compact_document_structure_index_for_agent(value: &Value) -> Value {
|
||||
if value.is_null() {
|
||||
return Value::Null;
|
||||
}
|
||||
let documents = value
|
||||
.get("documents")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.take(4)
|
||||
.map(|doc| {
|
||||
let mut sections = doc
|
||||
.get("sections")
|
||||
.and_then(Value::as_array)
|
||||
.map(|sections| {
|
||||
sections
|
||||
.iter()
|
||||
.filter(|section| {
|
||||
section
|
||||
.get("queryMatchCount")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0)
|
||||
> 0
|
||||
|| section
|
||||
.get("matchedReferenceCount")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0)
|
||||
> 0
|
||||
})
|
||||
.take(12)
|
||||
.map(compact_structure_section_for_agent)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if sections.is_empty() {
|
||||
sections = doc
|
||||
.get("sections")
|
||||
.and_then(Value::as_array)
|
||||
.map(|sections| {
|
||||
sections
|
||||
.iter()
|
||||
.take(12)
|
||||
.map(compact_structure_section_for_agent)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
}
|
||||
json!({
|
||||
"sourceRootRelativePath": doc.get("sourceRootRelativePath").cloned().unwrap_or(Value::Null),
|
||||
"sourceId": doc.get("sourceId").cloned().unwrap_or(Value::Null),
|
||||
"lightRagDocId": doc.get("lightRagDocId").cloned().unwrap_or(Value::Null),
|
||||
"sectionCount": doc.get("sectionCount").cloned().unwrap_or(Value::Null),
|
||||
"queryMatchedSections": doc.get("queryMatchedSections").cloned().unwrap_or(Value::Null),
|
||||
"referenceMatchedSections": doc.get("referenceMatchedSections").cloned().unwrap_or(Value::Null),
|
||||
"sections": sections,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
json!({
|
||||
"schema": value.get("schema").cloned().unwrap_or_else(|| json!("mnote.knowledge_rag.document_structure_index.v1")),
|
||||
"mode": value.get("mode").cloned().unwrap_or(Value::Null),
|
||||
"referenceCount": value.get("referenceCount").cloned().unwrap_or(Value::Null),
|
||||
"documents": documents,
|
||||
})
|
||||
}
|
||||
|
||||
fn compact_structure_section_for_agent(section: &Value) -> Value {
|
||||
json!({
|
||||
"sectionId": section.get("sectionId").cloned().unwrap_or(Value::Null),
|
||||
"title": section.get("title").cloned().unwrap_or(Value::Null),
|
||||
"level": section.get("level").cloned().unwrap_or(Value::Null),
|
||||
"headingPath": section.get("headingPath").cloned().unwrap_or_else(|| json!([])),
|
||||
"startBlockOrdinal": section.get("startBlockOrdinal").cloned().unwrap_or(Value::Null),
|
||||
"endBlockOrdinal": section.get("endBlockOrdinal").cloned().unwrap_or(Value::Null),
|
||||
"startParagraphOrdinal": section.get("startParagraphOrdinal").cloned().unwrap_or(Value::Null),
|
||||
"endParagraphOrdinal": section.get("endParagraphOrdinal").cloned().unwrap_or(Value::Null),
|
||||
"blockCount": section.get("blockCount").cloned().unwrap_or(Value::Null),
|
||||
"queryMatchCount": section.get("queryMatchCount").cloned().unwrap_or(Value::Null),
|
||||
"matchedReferenceCount": section.get("matchedReferenceCount").cloned().unwrap_or(Value::Null),
|
||||
"sample": section
|
||||
.get("sample")
|
||||
.and_then(Value::as_str)
|
||||
.map(|value| value.chars().take(220).collect::<String>())
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
fn compact_section_context_for_agent(payload: Value) -> Value {
|
||||
let blocks = payload
|
||||
.get("blocks")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.take(40)
|
||||
.map(|block| {
|
||||
let text = block
|
||||
.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.map(|value| value.chars().take(900).collect::<String>())
|
||||
.unwrap_or_default();
|
||||
json!({
|
||||
"blockOrdinal": block.get("blockOrdinal").cloned().unwrap_or(Value::Null),
|
||||
"blockId": block.get("blockId").cloned().unwrap_or(Value::Null),
|
||||
"paragraphOrdinal": block.get("paragraphOrdinal").cloned().unwrap_or(Value::Null),
|
||||
"headingPath": block.get("headingPath").cloned().unwrap_or_else(|| json!([])),
|
||||
"text": text,
|
||||
"textTruncated": block.get("textTruncated").cloned().unwrap_or(Value::Bool(false)),
|
||||
"locator": block.get("locator").cloned().unwrap_or(Value::Null),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let text = payload
|
||||
.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.map(|value| value.chars().take(12_000).collect::<String>())
|
||||
.unwrap_or_default();
|
||||
let chunks = payload
|
||||
.get("chunks")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.take(20)
|
||||
.map(|chunk| {
|
||||
let text = chunk
|
||||
.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.map(|value| value.chars().take(2_400).collect::<String>())
|
||||
.unwrap_or_default();
|
||||
json!({
|
||||
"chunkOrdinal": chunk.get("chunkOrdinal").cloned().unwrap_or(Value::Null),
|
||||
"startBlockOrdinal": chunk.get("startBlockOrdinal").cloned().unwrap_or(Value::Null),
|
||||
"endBlockOrdinal": chunk.get("endBlockOrdinal").cloned().unwrap_or(Value::Null),
|
||||
"blockIds": chunk.get("blockIds").cloned().unwrap_or_else(|| json!([])),
|
||||
"text": text,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
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")),
|
||||
"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),
|
||||
"section": payload.get("section").cloned().unwrap_or(Value::Null),
|
||||
"limits": payload.get("limits").cloned().unwrap_or(Value::Null),
|
||||
"blocks": blocks,
|
||||
"chunks": chunks,
|
||||
"text": text,
|
||||
"usageGuidance": "This is bounded sidecar context for interpreting a documentStructureIndex section. Use it as supporting reading context, but cite final answers with references/citations returned by mnote.knowledge_rag.query when possible.",
|
||||
})
|
||||
}
|
||||
|
||||
fn compact_reference_for_agent(reference: &Value) -> Value {
|
||||
let quote = reference
|
||||
.get("displayQuote")
|
||||
|
||||
@@ -14,6 +14,7 @@ pub fn manifest() -> Value {
|
||||
doc_find_tool(),
|
||||
knowledge_rag_status_tool(),
|
||||
knowledge_rag_query_tool(),
|
||||
knowledge_rag_section_context_tool(),
|
||||
knowledge_rag_open_reference_tool(),
|
||||
block_fetch_tool(),
|
||||
doc_plan_update_tool(),
|
||||
@@ -328,6 +329,14 @@ fn knowledge_rag_query_tool() -> Value {
|
||||
"includeChunkContent".into(),
|
||||
json!({ "type": "boolean", "default": true }),
|
||||
);
|
||||
map.insert(
|
||||
"includeDocumentStructureIndex".into(),
|
||||
json!({
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "返回由 LightRAG sidecar headings 派生的 document_structure_index;适合大书/长文档问题做章节导航和上下文扩展,不替代 references 引用证据。"
|
||||
}),
|
||||
);
|
||||
map.insert(
|
||||
"sourcePaths".into(),
|
||||
json!({
|
||||
@@ -376,6 +385,51 @@ fn knowledge_rag_open_reference_tool() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn knowledge_rag_section_context_tool() -> Value {
|
||||
let mut properties = base_identity_properties();
|
||||
if let Value::Object(map) = &mut properties {
|
||||
map.insert("rootUri".into(), json!({ "type": "string" }));
|
||||
map.insert("sourcePath".into(), json!({ "type": "string" }));
|
||||
map.insert("sourceId".into(), json!({ "type": "string" }));
|
||||
map.insert("lightRagDocId".into(), json!({ "type": "string" }));
|
||||
map.insert("filePath".into(), json!({ "type": "string" }));
|
||||
map.insert("sectionId".into(), json!({ "type": "string" }));
|
||||
map.insert("startBlockOrdinal".into(), json!({ "type": "integer" }));
|
||||
map.insert("endBlockOrdinal".into(), json!({ "type": "integer" }));
|
||||
map.insert("startParagraphOrdinal".into(), json!({ "type": "integer" }));
|
||||
map.insert("endParagraphOrdinal".into(), json!({ "type": "integer" }));
|
||||
map.insert(
|
||||
"contextBefore".into(),
|
||||
json!({ "type": "integer", "default": 1 }),
|
||||
);
|
||||
map.insert(
|
||||
"contextAfter".into(),
|
||||
json!({ "type": "integer", "default": 1 }),
|
||||
);
|
||||
map.insert(
|
||||
"maxBlocks".into(),
|
||||
json!({ "type": "integer", "default": 24 }),
|
||||
);
|
||||
map.insert(
|
||||
"maxChars".into(),
|
||||
json!({ "type": "integer", "default": 12000 }),
|
||||
);
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.knowledge_rag.section_context",
|
||||
"description": "按 documentStructureIndex section 的 block/paragraph range,从 LightRAG native sidecar 拉取有限正文 blocks/chunks,供大书/长文档二次解读;不做检索、重排或 fallback。",
|
||||
"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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 旧 local index agent 工具仅保留为历史对照;当前 manifest() 不注册这些工具。
|
||||
#[allow(dead_code)]
|
||||
fn index_status_tool() -> Value {
|
||||
|
||||
@@ -49,6 +49,7 @@ const CAPABILITY_PACKS: &[MnoteCapabilityPack] = &[
|
||||
"mnote.context.resolve_target",
|
||||
"mnote.knowledge_rag.status",
|
||||
"mnote.knowledge_rag.query",
|
||||
"mnote.knowledge_rag.section_context",
|
||||
"mnote.knowledge_rag.open_reference",
|
||||
],
|
||||
content: include_str!("../../../../../skills/mnote-knowledge-rag/SKILL.md"),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -374,6 +374,9 @@ pub(crate) async fn execute_mnote_tool_call(
|
||||
}
|
||||
"mnote.knowledge_rag.status" => knowledge_rag::status(&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
|
||||
}
|
||||
"mnote.knowledge_rag.open_reference" => {
|
||||
knowledge_rag::open_reference(&state, &context, &input).await
|
||||
}
|
||||
@@ -726,6 +729,7 @@ fn is_read_tool(tool_name: &str) -> bool {
|
||||
| "mnote.evidence.open"
|
||||
| "mnote.knowledge_rag.status"
|
||||
| "mnote.knowledge_rag.query"
|
||||
| "mnote.knowledge_rag.section_context"
|
||||
| "mnote.knowledge_rag.open_reference"
|
||||
| "mnote.index.status"
|
||||
| "mnote.index.refresh"
|
||||
@@ -758,6 +762,7 @@ fn is_evidence_receipt_tool(tool_name: &str) -> bool {
|
||||
| "mnote.evidence.open"
|
||||
| "mnote.knowledge_rag.status"
|
||||
| "mnote.knowledge_rag.query"
|
||||
| "mnote.knowledge_rag.section_context"
|
||||
| "mnote.knowledge_rag.open_reference"
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -91,6 +91,10 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.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))
|
||||
.route(
|
||||
"/api/knowledge-rag/section-context",
|
||||
post(knowledge_rag::section_context),
|
||||
)
|
||||
.route(
|
||||
"/api/knowledge-rag/open-reference",
|
||||
post(knowledge_rag::open_reference),
|
||||
@@ -517,6 +521,46 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/api/page-ai/sessions/{session_id}/active-run",
|
||||
get(hermes_client::get_page_ai_session_active_run),
|
||||
)
|
||||
.route(
|
||||
"/api/page-ai/runtime/status",
|
||||
get(hermes_client::get_page_ai_runtime_status),
|
||||
)
|
||||
.route(
|
||||
"/api/page-ai/runtime/reset",
|
||||
post(hermes_client::reset_page_ai_runtime),
|
||||
)
|
||||
.route(
|
||||
"/api/page-ai/sessions",
|
||||
get(hermes_client::list_sessions).post(hermes_client::create_session),
|
||||
)
|
||||
.route(
|
||||
"/api/page-ai/sessions/search",
|
||||
get(hermes_client::search_sessions),
|
||||
)
|
||||
.route(
|
||||
"/api/page-ai/sessions/{session_id}",
|
||||
get(hermes_client::get_session).delete(hermes_client::delete_session),
|
||||
)
|
||||
.route(
|
||||
"/api/page-ai/sessions/{session_id}/resume",
|
||||
post(hermes_client::resume_session),
|
||||
)
|
||||
.route(
|
||||
"/api/page-ai/sessions/{session_id}/rename",
|
||||
post(hermes_client::rename_session),
|
||||
)
|
||||
.route(
|
||||
"/api/page-ai/sessions/{session_id}/export",
|
||||
get(hermes_client::export_session),
|
||||
)
|
||||
.route(
|
||||
"/api/page-ai/sessions/{session_id}/auto-title",
|
||||
post(hermes_client::auto_title_session),
|
||||
)
|
||||
.route(
|
||||
"/api/page-ai/sessions/{session_id}/queue/{queue_id}",
|
||||
delete(hermes_client::cancel_queued_run),
|
||||
)
|
||||
.route(
|
||||
"/api/onlyoffice/bridge/commands",
|
||||
post(onlyoffice_bridge::enqueue_command),
|
||||
@@ -936,6 +980,8 @@ mod tests {
|
||||
assert!(!html.contains(">下载<"));
|
||||
assert!(html.contains(r#"data-page-width-content-type="word""#));
|
||||
assert!(html.contains("applyPreviewWidthPreference"));
|
||||
assert!(html.contains("data-mnote-office-preview-footnotes-disabled"));
|
||||
assert!(html.contains("renderFootnotes: false"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1930,13 +1930,27 @@ pub async fn office_preview_page(Query(query): Query<OfficePreviewQuery>) -> Res
|
||||
}}
|
||||
const buffer = await fetchArrayBuffer();
|
||||
viewer.replaceChildren();
|
||||
await window.docx.renderAsync(buffer, viewer, null, {{
|
||||
const options = {{
|
||||
className: 'mnote-docx',
|
||||
inWrapper: true,
|
||||
breakPages: true,
|
||||
renderHeaders: true,
|
||||
renderFooters: true
|
||||
}});
|
||||
}};
|
||||
try {{
|
||||
await window.docx.renderAsync(buffer, viewer, null, options);
|
||||
}} catch (error) {{
|
||||
if (!String(error && error.message || '').includes("reading 'type'")) {{
|
||||
throw error;
|
||||
}}
|
||||
viewer.replaceChildren();
|
||||
document.documentElement.setAttribute('data-mnote-office-preview-footnotes-disabled', 'true');
|
||||
await window.docx.renderAsync(buffer.slice(0), viewer, null, {{
|
||||
...options,
|
||||
renderFootnotes: false,
|
||||
renderEndnotes: false
|
||||
}});
|
||||
}}
|
||||
}}
|
||||
|
||||
async function renderWorkbook() {{
|
||||
|
||||
@@ -2626,7 +2626,7 @@ body {
|
||||
}
|
||||
|
||||
.mnote-main-tab {
|
||||
height: 34px;
|
||||
height: 36px;
|
||||
max-width: 240px;
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
@@ -2639,7 +2639,7 @@ body {
|
||||
padding: 0 9px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.mnote-main-tab:hover {
|
||||
@@ -2725,6 +2725,7 @@ body {
|
||||
|
||||
.mnote-main-tab-title {
|
||||
min-width: 0;
|
||||
line-height: 20px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -4731,6 +4732,39 @@ html[data-mnote-page-ai-resizing="true"] {
|
||||
content: "·";
|
||||
}
|
||||
|
||||
.wolai-page-ai-runtime-strip {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 28px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 14px 8px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #6B6762;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.wolai-page-ai-runtime-strip:hover span {
|
||||
border-color: rgba(27, 28, 28, 0.16);
|
||||
background: #F1F1EF;
|
||||
}
|
||||
|
||||
.wolai-page-ai-runtime-strip span {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.08);
|
||||
border-radius: 6px;
|
||||
background: #F8F8F7;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wolai-page-ai-statusvalue {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
@@ -5024,6 +5058,11 @@ html[data-mnote-page-ai-resizing="true"] {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.wolai-page-ai-settings-stack {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-memory-card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -5396,6 +5435,228 @@ html[data-mnote-page-ai-resizing="true"] {
|
||||
background: rgba(27, 28, 28, 0.12);
|
||||
}
|
||||
|
||||
.wolai-page-ai-plan-card {
|
||||
gap: 10px;
|
||||
border: 1px solid rgba(14, 116, 144, 0.22);
|
||||
background: #F0F9FF;
|
||||
color: #0F172A;
|
||||
}
|
||||
|
||||
.wolai-page-ai-plan-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-plan-card-title-wrap {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-plan-card-kicker {
|
||||
color: #0369A1;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wolai-page-ai-plan-card-title {
|
||||
color: #0C4A6E;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-plan-card-badge {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
background: #BAE6FD;
|
||||
color: #075985;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wolai-page-ai-plan-card-markdown {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: #1E293B;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-plan-card-markdown > * {
|
||||
margin-top: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.wolai-page-ai-plan-card-markdown .wolai-page-ai-markdown-table-wrap {
|
||||
border: 1px solid rgba(14, 116, 144, 0.16);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.wolai-page-ai-plan-step-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.wolai-page-ai-plan-step-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.78);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-plan-step-item[data-status="completed"] {
|
||||
border-color: rgba(16, 185, 129, 0.28);
|
||||
background: rgba(236, 253, 245, 0.88);
|
||||
}
|
||||
|
||||
.wolai-page-ai-plan-step-item[data-status="inProgress"] {
|
||||
border-color: rgba(245, 158, 11, 0.28);
|
||||
background: rgba(255, 251, 235, 0.9);
|
||||
}
|
||||
|
||||
.wolai-page-ai-plan-step-status {
|
||||
display: inline-flex;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 18px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
background: #E2E8F0;
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.wolai-page-ai-plan-step-status[data-status="completed"] {
|
||||
background: #BBF7D0;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.wolai-page-ai-plan-step-status[data-status="inProgress"] {
|
||||
background: #FDE68A;
|
||||
color: #92400E;
|
||||
}
|
||||
|
||||
.wolai-page-ai-plan-step-text {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.wolai-page-ai-plan-card-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-plan-card-button {
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid rgba(14, 116, 144, 0.14);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
color: #0F172A;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wolai-page-ai-plan-card-button--primary {
|
||||
border-color: #0284C7;
|
||||
background: #0284C7;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.wolai-page-ai-plan-card-button--danger {
|
||||
color: #B33A3A;
|
||||
}
|
||||
|
||||
.wolai-page-ai-permission-card {
|
||||
gap: 10px;
|
||||
border: 1px solid rgba(180, 83, 9, 0.18);
|
||||
background: #FFF7ED;
|
||||
}
|
||||
|
||||
.wolai-page-ai-permission-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-permission-badge {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
background: #FED7AA;
|
||||
color: #9A3412;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wolai-page-ai-permission-summary {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-permission-row {
|
||||
display: grid;
|
||||
grid-template-columns: 54px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
padding: 7px 8px;
|
||||
border: 1px solid rgba(180, 83, 9, 0.12);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.74);
|
||||
}
|
||||
|
||||
.wolai-page-ai-permission-row span {
|
||||
color: #9A3412;
|
||||
font-size: 11px;
|
||||
line-height: 17px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wolai-page-ai-permission-row strong {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: #1B1C1C;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wolai-page-ai-permission-card .wolai-page-ai-message-actions,
|
||||
.wolai-page-ai-permission-dialog .wolai-page-ai-message-actions {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-markdown-table-wrap {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
@@ -5802,6 +6063,37 @@ button.wolai-page-ai-history-main span {
|
||||
border-color: rgba(27, 28, 28, 0.32);
|
||||
}
|
||||
|
||||
.wolai-page-ai-reasonix-controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
padding: 0 8px 6px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-reasonix-controls[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.wolai-page-ai-reasonix-control {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
color: #8B8782;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-reasonix-control select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.1);
|
||||
border-radius: 7px;
|
||||
background: #FFF;
|
||||
color: #1B1C1C;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wolai-page-ai-composer-bar {
|
||||
display: flex;
|
||||
min-height: 36px;
|
||||
@@ -6037,6 +6329,10 @@ mod tests {
|
||||
assert!(MNOTE_CSS.contains("[data-mnote-tab-badge-kind=\"code\"]"));
|
||||
assert!(MNOTE_CSS.contains("[data-mnote-tab-badge-kind=\"pdf\"]"));
|
||||
assert!(MNOTE_CSS.contains("[data-mnote-tab-badge-kind=\"file\"]"));
|
||||
assert!(MNOTE_CSS.contains(".mnote-main-tab {\n height: 36px;"));
|
||||
assert!(
|
||||
MNOTE_CSS.contains(".mnote-main-tab-title {\n min-width: 0;\n line-height: 20px;")
|
||||
);
|
||||
assert!(MNOTE_CSS.contains("background: #e74c3c"));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user