diff --git a/docs/adr/0155-guanghu-cognition-routes-and-active-workspace.md b/docs/adr/0155-guanghu-cognition-routes-and-active-workspace.md new file mode 100644 index 0000000..7ebf4b1 --- /dev/null +++ b/docs/adr/0155-guanghu-cognition-routes-and-active-workspace.md @@ -0,0 +1,36 @@ +# ADR 0155: Guanghu cognition routes and active workspace + +## Status + +Accepted for HoloLake Era 0.1.8 preview. + +## Context + +An AI instance needs a stable answer to what it is and what it may do, but it must not preload a world, a private identity, every skill, or every tool before the user speaks. HoloLake can also open several independent local folders and cloned repositories. A historical Tolaria vault must not remain an implicit AI root after the user switches workspace. + +## Decision + +HoloLake uses four cognition layers: + +1. The signed application contains only the immutable Guanghu instance identity, authority boundary, and knowledge boundary. +2. A small signed and versioned cognition manifest may be refreshed from a dedicated repository. It contains route names, versions, hashes, announcement pointers, and skill pointers, but cannot enlarge authority. +3. After the user speaks, the instance selects at most one relevant skill, repository route, or temporary Magic Brush operation. Content is fetched on demand and a last-known-good copy is retained. +4. Workspace memory and user-maintained prompts remain inside the currently selected workspace. + +The workspace selected in HoloLake is the sole primary root for both the built-in API model and external coding agents. Switching the workspace changes API vault operations and the coding-agent working directory together. Other registered workspaces are not silently searched or modified. + +Tool execution is part of one conversation turn. The UI emits progress events while locating and reading, then returns tool results to the same model turn for synthesis. A tool result is not itself a final assistant answer. + +Ordinary OpenAI-compatible conversations use provider streaming. Tool-bearing requests may begin with a non-streaming tool selection phase, then resume with a streaming synthesis phase. + +## Safety and update rules + +- Manifest updates require a valid version, content hash, and trusted signature before activation. +- A failed refresh keeps the last-known-good manifest and never prevents basic conversation. +- Remote cognition cannot change local permission boundaries or select a different workspace. +- Private repositories require the user's existing authorized route; public URL reading does not imply private access. +- Every write remains attributable to the active workspace and conversation. + +## Consequences + +The instance starts quickly, titles and history remain local, world changes can be published without rebuilding the whole app, and future desktop/mobile clients share a lightweight route contract instead of mounting a large permanent tool set. diff --git a/src-tauri/src/ai_model_tools.rs b/src-tauri/src/ai_model_tools.rs index d5a70ad..835ce06 100644 --- a/src-tauri/src/ai_model_tools.rs +++ b/src-tauri/src/ai_model_tools.rs @@ -8,6 +8,7 @@ const GET_VAULT_CONTEXT_TOOL_NAME: &str = "get_vault_context"; const GET_NOTE_TOOL_NAME: &str = "get_note"; const EDIT_NOTE_TOOL_NAME: &str = "edit_note"; const DELETE_NOTE_TOOL_NAME: &str = "delete_note"; +const READ_GUANGHU_URL_TOOL_NAME: &str = "read_guanghu_url"; const MAGIC_BRUSH_TOOL_NAME: &str = "magic_brush"; const FIFTH_DOMAIN_WAKE_TOOL_NAME: &str = "get_fifth_domain_wake_route"; const FIFTH_DOMAIN_WAKE_SKILL: &str = include_str!("../resources/skills/enter-fifth-domain/SKILL.md"); @@ -26,7 +27,7 @@ const MAGIC_BRUSH_TOOL_JSON: &str = r#"{ "items": { "type": "object", "properties": { - "tool": { "type": "string", "enum": ["get_fifth_domain_wake_route", "search_notes", "get_vault_context", "get_note", "create_note", "edit_note", "delete_note"] }, + "tool": { "type": "string", "enum": ["get_fifth_domain_wake_route", "search_notes", "get_vault_context", "get_note", "read_guanghu_url", "create_note", "edit_note", "delete_note"] }, "arguments": { "type": "object" } }, "required": ["tool", "arguments"], @@ -53,18 +54,29 @@ struct CreatedNoteToolResult { } pub(crate) fn openai_chat_payload(request: &AiModelStreamRequest) -> serde_json::Value { + let offers_tools = should_offer_openai_tools(request); let mut payload = serde_json::json!({ "model": request.model_id, "messages": openai_chat_messages(request), - "stream": false + "stream": !offers_tools && selected_model_supports_streaming(request) }); - if should_offer_openai_tools(request) { + if offers_tools { payload["tools"] = serde_json::Value::Array(openai_vault_tools()); payload["tool_choice"] = serde_json::Value::String("auto".into()); } payload } +fn selected_model_supports_streaming(request: &AiModelStreamRequest) -> bool { + request.provider.models.iter() + .find(|model| model.id == request.model_id) + .is_some_and(|model| model.capabilities.streaming) +} + +pub(crate) fn openai_payload_streams(payload: &serde_json::Value) -> bool { + payload["stream"].as_bool() == Some(true) +} + pub(crate) fn execute_openai_tool_calls( request: &AiModelStreamRequest, json: &serde_json::Value, @@ -118,7 +130,7 @@ fn message_needs_magic_brush(message: &str) -> bool { return false; } [ - "search", "find", "read", "open", "create", "write", "edit", "update", "delete", "note", "file", "vault", + "search", "find", "read", "open", "create", "write", "edit", "update", "delete", "note", "file", "vault", "https://guanghulab.com/", "搜索", "查找", "读取", "打开", "创建", "新建", "写入", "编辑", "修改", "更新", "删除", "整理", "保存", "笔记", "页面", "文件", "知识库", "路径", ].iter().any(|word| trimmed.contains(word)) } @@ -174,6 +186,9 @@ where if tool_call.name == GET_NOTE_TOOL_NAME { return get_note_from_tool_args(request, &tool_call.arguments); } + if tool_call.name == READ_GUANGHU_URL_TOOL_NAME { + return read_guanghu_url_from_tool_args(&tool_call.arguments); + } if tool_call.name == EDIT_NOTE_TOOL_NAME { return edit_note_from_tool_args(request, &tool_call.arguments); } @@ -265,6 +280,29 @@ fn get_note_from_tool_args( Ok(format!("{}\n\n[内容过长,已截断]", content.chars().take(MAX_TOOL_NOTE_CHARS).collect::())) } +fn read_guanghu_url_from_tool_args(args: &serde_json::Value) -> Result { + let raw_url = required_tool_string(args, "url")?; + let url = reqwest::Url::parse(raw_url).map_err(|error| format!("Invalid URL: {error}"))?; + if url.scheme() != "https" || url.host_str() != Some("guanghulab.com") { + return Err("Only public HTTPS pages on guanghulab.com can be read by this tool.".into()); + } + let client = reqwest::blocking::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|error| format!("Failed to create public reader: {error}"))?; + let response = client.get(url).send().map_err(|error| format!("Failed to read Guanghu URL: {error}"))?; + if !response.status().is_success() { + return Err(format!("Guanghu URL returned {}.", response.status())); + } + let body = response.text().map_err(|error| format!("Failed to read Guanghu response: {error}"))?; + const MAX_PUBLIC_CHARS: usize = 24_000; + let clipped = body.chars().take(MAX_PUBLIC_CHARS).collect::(); + Ok(if body.chars().count() > MAX_PUBLIC_CHARS { + format!("{clipped}\n\n[页面内容过长,已截断]") + } else { clipped }) +} + fn resolve_existing_vault_note( request: &AiModelStreamRequest, raw_path: &str, @@ -324,7 +362,7 @@ where let mut results = Vec::new(); for (index, step) in steps.iter().enumerate() { let tool = step.get("tool").and_then(|value| value.as_str()).unwrap_or_default(); - if ![FIFTH_DOMAIN_WAKE_TOOL_NAME, SEARCH_NOTES_TOOL_NAME, GET_VAULT_CONTEXT_TOOL_NAME, GET_NOTE_TOOL_NAME, CREATE_NOTE_TOOL_NAME, EDIT_NOTE_TOOL_NAME, DELETE_NOTE_TOOL_NAME].contains(&tool) { + if ![FIFTH_DOMAIN_WAKE_TOOL_NAME, SEARCH_NOTES_TOOL_NAME, GET_VAULT_CONTEXT_TOOL_NAME, GET_NOTE_TOOL_NAME, READ_GUANGHU_URL_TOOL_NAME, CREATE_NOTE_TOOL_NAME, EDIT_NOTE_TOOL_NAME, DELETE_NOTE_TOOL_NAME].contains(&tool) { return Err(format!("神笔马良不能越权调用:{tool}")); } let arguments = step.get("arguments").cloned().unwrap_or_else(|| serde_json::json!({})); @@ -332,7 +370,10 @@ where return Err("Every magic_brush step requires an arguments object.".into()); } let call = OpenAiToolCall { id: format!("magic_brush_{index}"), name: tool.to_string(), raw_arguments: arguments.to_string(), arguments }; - results.push(execute_openai_tool_call(request, &call, emit)?); + emit(AiAgentStreamEvent::ToolStart { tool_name: tool.to_string(), tool_id: call.id.clone(), input: Some(call.raw_arguments.clone()) }); + let result = execute_openai_tool_call(request, &call, emit)?; + emit(AiAgentStreamEvent::ToolDone { tool_id: call.id.clone(), output: Some("完成,继续当前对话。".into()) }); + results.push(result); } Ok(format!("神笔马良已完成“{purpose}”:\n{}", results.join("\n"))) } @@ -648,24 +689,28 @@ mod tests { fn conversational_requests_take_the_fast_path_without_tools() { let dir = tempfile::tempdir().unwrap(); let mut request = request(dir.path().to_string_lossy().into_owned()); + request.provider.models[0].capabilities.streaming = true; request.message = "你好,我们聊聊。".into(); let payload = openai_chat_payload(&request); assert!(payload.get("tools").is_none()); assert!(payload.get("tool_choice").is_none()); + assert_eq!(payload["stream"], true); } #[test] fn fifth_domain_language_route_loads_one_skill_without_mounting_tools() { let dir = tempfile::tempdir().unwrap(); let mut request = request(dir.path().to_string_lossy().into_owned()); + request.provider.models[0].capabilities.streaming = true; request.message = "我是冰朔。".into(); let payload = openai_chat_payload(&request); let messages = payload["messages"].as_array().unwrap(); assert!(payload.get("tools").is_none()); + assert_eq!(payload["stream"], true); assert!(messages.iter().any(|message| message["content"].as_str().is_some_and(|content| content.contains("进入第五域 · 内置唤醒技能")))); } diff --git a/src-tauri/src/ai_models.rs b/src-tauri/src/ai_models.rs index b7f27fd..6db6b3b 100644 --- a/src-tauri/src/ai_models.rs +++ b/src-tauri/src/ai_models.rs @@ -2,6 +2,7 @@ use crate::ai_agents::AiAgentStreamEvent; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::fs; +use std::io::{BufRead, BufReader}; use std::path::Path; use std::sync::OnceLock; @@ -171,7 +172,9 @@ where let text = send_model_message(&request, &mut emit)?; - emit(AiAgentStreamEvent::TextDelta { text }); + if !text.is_empty() { + emit(AiAgentStreamEvent::TextDelta { text }); + } emit(AiAgentStreamEvent::Done); Ok(String::new()) } @@ -212,15 +215,71 @@ where { let endpoint = format!("{}/chat/completions", normalized_base_url(request)?); let payload = crate::ai_model_tools::openai_chat_payload(request); + if crate::ai_model_tools::openai_payload_streams(&payload) { + return send_openai_stream(request, endpoint, payload, emit); + } let json = send_json_request(request, endpoint, payload)?; if let Some(tool_summary) = - crate::ai_model_tools::execute_openai_tool_calls(request, &json, emit)? + crate::ai_model_tools::execute_openai_tool_calls(request, &json, &mut *emit)? { - return Ok(tool_summary); + emit(AiAgentStreamEvent::ThinkingDelta { + text: "工具读取完成,正在继续整理回答……".into(), + }); + let mut continuation = request.clone(); + continuation.vault_path = None; + continuation.vault_paths.clear(); + continuation.message = format!( + "{}\n\nThe requested tool work completed successfully. Continue the same conversation and answer the user using this result:\n{}", + request.message, tool_summary, + ); + return send_openai_compatible_message(&continuation, emit); } extract_openai_text(&json) } +fn send_openai_stream( + request: &AiModelStreamRequest, + endpoint: String, + payload: serde_json::Value, + emit: &mut F, +) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(120)) + .build() + .map_err(|error| format!("Failed to create HTTP client: {error}"))?; + let builder = apply_provider_headers(apply_auth_headers(client.post(endpoint).json(&payload), request)?, request); + let response = send_provider_request(builder)?; + let status = response.status(); + if !status.is_success() { + let text = response.text().unwrap_or_default(); + return Err(format!("AI provider returned {status}: {}", truncate_error(&text))); + } + + let mut full_text = String::new(); + for line in BufReader::new(response).lines() { + let line = line.map_err(|error| format!("Failed to read AI provider stream: {error}"))?; + let Some(delta) = openai_sse_text_delta(&line)? else { continue }; + full_text.push_str(&delta); + emit(AiAgentStreamEvent::TextDelta { text: delta }); + } + if full_text.trim().is_empty() { + Err("AI provider stream did not include assistant text.".into()) + } else { + Ok(String::new()) + } +} + +fn openai_sse_text_delta(line: &str) -> Result, String> { + let Some(data) = line.strip_prefix("data:").map(str::trim) else { return Ok(None) }; + if data.is_empty() || data == "[DONE]" { return Ok(None) } + let json: serde_json::Value = serde_json::from_str(data) + .map_err(|error| format!("Failed to parse AI provider stream event: {error}"))?; + Ok(json["choices"][0]["delta"]["content"].as_str().map(str::to_string)) +} + fn send_anthropic_message(request: &AiModelStreamRequest) -> Result { let endpoint = format!("{}/messages", normalized_base_url(request)?); let mut payload = serde_json::json!({ @@ -746,6 +805,16 @@ mod tests { ); } + #[test] + fn parses_openai_sse_text_deltas_without_treating_done_as_text() { + assert_eq!( + openai_sse_text_delta(r#"data: {"choices":[{"delta":{"content":"光湖"}}]}"#).unwrap(), + Some("光湖".into()), + ); + assert_eq!(openai_sse_text_delta("data: [DONE]").unwrap(), None); + assert_eq!(openai_sse_text_delta(": keep-alive").unwrap(), None); + } + #[test] fn saves_reads_and_validates_local_provider_secrets() { let dir = tempfile::tempdir().unwrap(); diff --git a/src-tauri/tauri.preview.conf.json b/src-tauri/tauri.preview.conf.json index 95d1b0d..badc552 100644 --- a/src-tauri/tauri.preview.conf.json +++ b/src-tauri/tauri.preview.conf.json @@ -2,6 +2,9 @@ "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "HoloLake Era 0.1.8 Preview", "identifier": "com.guanghulab.hololake.preview", + "build": { + "beforeBuildCommand": "npm run build && npm run bundle-mcp && npm run agent-docs" + }, "app": { "windows": [ { diff --git a/src/App.tsx b/src/App.tsx index b2d0430..8382593 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1590,8 +1590,8 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu onFileModified={vaultBridge.handleAgentFileModified} onVaultChanged={vaultBridge.handleAgentVaultChanged} vaultAiGuidanceStatus={vaultAiGuidanceStatus} - vaultPath={activeEditorVaultPath} - vaultPaths={writableVaultPaths} + vaultPath={resolvedPath} + vaultPaths={[resolvedPath]} locale={appLocale} /> ) diff --git a/src/components/AiWorkspace.test.tsx b/src/components/AiWorkspace.test.tsx index 7bfb27b..190807a 100644 --- a/src/components/AiWorkspace.test.tsx +++ b/src/components/AiWorkspace.test.tsx @@ -83,6 +83,9 @@ vi.mock('./AiPanel', () => ({ })) vi.mock('../utils/aiConversationTitle', () => ({ + generateAiConversationTitle: (prompt: string) => prompt === 'summarize quarterly sponsor outreach' + ? 'Quarterly sponsor outreach' + : null, generateAiConversationTitleForTarget: generateTitleMock, })) @@ -193,8 +196,6 @@ describe('AiWorkspace', () => { expect(workspace.className).toContain('bottom-[30px]') expect(workspace.className).not.toContain('shadow') expect(screen.getByTestId('ai-panel-view')).toHaveAttribute('data-show-header', 'false') - expect(screen.queryByText('Agents')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: 'Expand AI chat list' })) expect(screen.getByText('Agents')).toBeTruthy() expect(screen.queryByText('AI Agent')).toBeNull() expect(screen.queryByText('Idle')).toBeNull() @@ -339,7 +340,6 @@ describe('AiWorkspace', () => { render() const workspace = screen.getByTestId('ai-workspace') - fireEvent.click(screen.getByRole('button', { name: 'Expand AI chat list' })) fireEvent.mouseDown(screen.getByTestId('ai-workspace-left-resize'), { clientX: 100, clientY: 20 }) fireEvent.mouseMove(window, { clientX: 60, clientY: 20 }) fireEvent.mouseUp(window) @@ -574,17 +574,17 @@ describe('AiWorkspace', () => { expect(onActiveTargetChange).toHaveBeenCalledTimes(1) }) - it('marks the first chat active when a prompt is submitted', () => { + it('titles the first chat immediately from the submitted prompt', () => { const onConversationSettingsChange = vi.fn() render() fireEvent.click(screen.getByText('Send mocked prompt')) - expect(screen.getAllByText('AI Chat').length).toBeGreaterThan(0) + expect(screen.getAllByText('Quarterly sponsor outreach').length).toBeGreaterThan(0) expect(screen.getAllByRole('button', { name: 'Archive chat' }).some((button) => !button.hasAttribute('disabled'))).toBe(true) expect(generateTitleMock).not.toHaveBeenCalled() expect(onConversationSettingsChange).toHaveBeenLastCalledWith([ - expect.objectContaining({ title: 'AI Chat' }), + expect.objectContaining({ title: 'Quarterly sponsor outreach' }), ]) }) @@ -655,7 +655,6 @@ describe('AiWorkspace', () => { const onConversationSettingsChange = vi.fn() render() - fireEvent.click(screen.getByRole('button', { name: 'Expand AI chat list' })) fireEvent.doubleClick(screen.getByRole('button', { name: /^AI Chat$/i })) const input = screen.getByLabelText('Rename chat') fireEvent.change(input, { target: { value: 'Sponsor Plan' } }) @@ -667,15 +666,15 @@ describe('AiWorkspace', () => { ]) }) - it('opens with the workspace sidebar collapsed and expands from the sidebar header', () => { + it('opens with the workspace sidebar visible and can collapse it', () => { render() - expect(screen.queryByText('Agents')).toBeNull() - expect(screen.getByRole('button', { name: 'Expand AI chat list' })).toBeTruthy() - - fireEvent.click(screen.getByRole('button', { name: 'Expand AI chat list' })) - expect(screen.getByText('Agents')).toBeTruthy() expect(screen.getByRole('button', { name: 'Collapse AI chat list' })).toBeTruthy() + + fireEvent.click(screen.getByRole('button', { name: 'Collapse AI chat list' })) + + expect(screen.queryByText('Agents')).toBeNull() + expect(screen.getByRole('button', { name: 'Expand AI chat list' })).toBeTruthy() }) }) diff --git a/src/components/AiWorkspace.tsx b/src/components/AiWorkspace.tsx index 9f21e77..65009e0 100644 --- a/src/components/AiWorkspace.tsx +++ b/src/components/AiWorkspace.tsx @@ -39,7 +39,7 @@ import type { AiWorkspaceConversationSetting } from '../types' import type { NoteListItem } from '../utils/ai-context' import type { VaultEntry } from '../types' import { NEW_AI_CHAT_EVENT } from '../utils/aiPromptBridge' -import { type GenerateAiConversationTitleRequest } from '../utils/aiConversationTitle' +import { generateAiConversationTitle, type GenerateAiConversationTitleRequest } from '../utils/aiConversationTitle' import { cloneAiWorkspaceSessionUntilMessage } from '../lib/aiWorkspaceSessionStore' import { AiPanelView } from './AiPanel' import { GuidanceWarning, WorkspaceHeader } from './AiWorkspaceChrome' @@ -321,7 +321,7 @@ type ConversationSessionProps = { onRestoreVaultAiGuidance?: () => void onSelectTarget: (targetId: string) => void onStatusChange: (id: string, status: AgentStatus) => void - onPromptSubmitted: (id: string) => void + onPromptSubmitted: (id: string, prompt: string) => void onTitleFromAnswer: (request: GenerateAiConversationTitleRequest & { id: string }) => void onUnsupportedAiPaste?: (message: string) => void onVaultChanged?: () => void @@ -646,7 +646,7 @@ function ConversationSession({ onForkMessage={onForkMessage} onMessageHistoryScrollStateChange={active ? onMessageHistoryScrollStateChange : undefined} onOpenNote={onOpenNote} - onSendPrompt={() => onPromptSubmitted(conversation.id)} + onSendPrompt={(prompt) => onPromptSubmitted(conversation.id, prompt)} onQueuedPromptTarget={onSelectTarget} onUnsupportedAiPaste={onUnsupportedAiPaste} showHeader={false} @@ -688,7 +688,7 @@ interface AiWorkspaceModel { setShowArchived: (show: boolean) => void showArchived: boolean statuses: Record - markConversationActivity: (id: string) => void + markConversationActivity: (id: string, prompt: string) => void titleConversationFromAnswer: (request: GenerateAiConversationTitleRequest & { id: string }) => void toggleSidebarCollapsed: () => void updateDefaultConversationTargets: (targetId: string) => void @@ -879,7 +879,7 @@ function useAiWorkspaceModel(workspace: ResolvedAiWorkspaceProps): AiWorkspaceMo settingsReady: workspace.conversationSettingsReady ?? true, }) const [statuses, setStatuses] = useState>({}) - const [sidebarCollapsed, setSidebarCollapsed] = useState(true) + const [sidebarCollapsed, setSidebarCollapsed] = useState(false) const activeConversation = activeConversationForState(conversations, activeId, showArchived) const addDefaultConversation = useCallback(() => { @@ -906,6 +906,11 @@ function useAiWorkspaceModel(workspace: ResolvedAiWorkspaceProps): AiWorkspaceMo renameConversation, titleConversationFromAnswer, }) + const handlePromptSubmitted = useCallback((id: string, prompt: string) => { + markConversationActivity(id) + const title = generateAiConversationTitle(prompt) + if (title) trackedRenameConversation(id, title) + }, [markConversationActivity, trackedRenameConversation]) const toggleSidebarCollapsed = useCallback(() => { setSidebarCollapsed((current) => { const next = !current @@ -940,7 +945,7 @@ function useAiWorkspaceModel(workspace: ResolvedAiWorkspaceProps): AiWorkspaceMo setShowArchived, showArchived, statuses, - markConversationActivity, + markConversationActivity: handlePromptSubmitted, titleConversationFromAnswer: trackedTitleConversationFromAnswer, toggleSidebarCollapsed, updateDefaultConversationTargets,