feat: keep AI conversations routed and streaming

This commit is contained in:
冰朔 2026-07-21 17:55:25 +08:00
parent 94b97c7b88
commit d084bad542
7 changed files with 187 additions and 30 deletions

View File

@ -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.

View File

@ -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<F>(
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::<String>()))
}
fn read_guanghu_url_from_tool_args(args: &serde_json::Value) -> Result<String, String> {
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::<String>();
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("进入第五域 · 内置唤醒技能"))));
}

View File

@ -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<F>(
request: &AiModelStreamRequest,
endpoint: String,
payload: serde_json::Value,
emit: &mut F,
) -> Result<String, String>
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<Option<String>, 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<String, String> {
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();

View File

@ -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": [
{

View File

@ -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}
/>
)

View File

@ -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(<AiWorkspace open mode="docked" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} />)
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(<AiWorkspace open mode="docked" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} onConversationSettingsChange={onConversationSettingsChange} />)
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(<AiWorkspace open mode="docked" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} onConversationSettingsChange={onConversationSettingsChange} />)
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(<AiWorkspace open mode="docked" aiAgentsStatus={installedStatuses()} aiModelProviders={providers} vaultPath="/tmp/vault" onClose={vi.fn()} />)
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()
})
})

View File

@ -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<string, AgentStatus>
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<Record<string, AgentStatus>>({})
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,