feat: keep AI conversations routed and streaming
This commit is contained in:
parent
94b97c7b88
commit
d084bad542
@ -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.
|
||||
@ -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("进入第五域 · 内置唤醒技能"))));
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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": [
|
||||
{
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user