From ac611e3f7f51aedcef3c0250a2dde6caf97515d1 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sun, 19 Jul 2026 16:18:01 +0200 Subject: [PATCH] Implement persistent conversational AI chat --- Cargo.lock | 18 +- Cargo.toml | 2 +- README.md | 1 + RUST_PLAN_CORE.md | 10 +- RUST_PLAN_EXTENSION.md | 16 +- .../20260718000000_initial_schema/up.sql | 4 +- .../down.sql | 3 + .../up.sql | 3 + crates/bds-core/src/db/migrations.rs | 50 +- crates/bds-core/src/db/queries/chat.rs | 134 +++ crates/bds-core/src/db/queries/mod.rs | 1 + crates/bds-core/src/db/types.rs | 5 +- crates/bds-core/src/engine/chat.rs | 977 ++++++++++++++++++ crates/bds-core/src/engine/chat_tools.rs | 861 +++++++++++++++ crates/bds-core/src/engine/git.rs | 4 +- crates/bds-core/src/engine/mod.rs | 2 + crates/bds-core/src/model/chat.rs | 109 ++ crates/bds-core/src/model/mod.rs | 2 + crates/bds-core/tests/chat.rs | 570 ++++++++++ crates/bds-ui/Cargo.toml | 1 + crates/bds-ui/src/app.rs | 560 +++++++++- crates/bds-ui/src/views/chat_view.rs | 429 ++++++++ crates/bds-ui/src/views/mod.rs | 1 + crates/bds-ui/src/views/sidebar.rs | 59 +- crates/bds-ui/src/views/status_bar.rs | 24 + crates/bds-ui/src/views/workspace.rs | 47 +- locales/ui/de.ftl | 37 +- locales/ui/en.ftl | 37 +- locales/ui/es.ftl | 37 +- locales/ui/fr.ftl | 37 +- locales/ui/it.ftl | 37 +- 31 files changed, 4035 insertions(+), 43 deletions(-) create mode 100644 crates/bds-core/migrations/20260719141036_add_chat_cache_token_usage/down.sql create mode 100644 crates/bds-core/migrations/20260719141036_add_chat_cache_token_usage/up.sql create mode 100644 crates/bds-core/src/db/queries/chat.rs create mode 100644 crates/bds-core/src/engine/chat.rs create mode 100644 crates/bds-core/src/engine/chat_tools.rs create mode 100644 crates/bds-core/src/model/chat.rs create mode 100644 crates/bds-core/tests/chat.rs create mode 100644 crates/bds-ui/src/views/chat_view.rs diff --git a/Cargo.lock b/Cargo.lock index 897fc73..e77ff85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -817,7 +817,7 @@ dependencies = [ "liquid-core", "mlua", "pagefind", - "pulldown-cmark", + "pulldown-cmark 0.13.3", "quick-xml 0.41.0", "rayon", "regex", @@ -872,6 +872,7 @@ dependencies = [ "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "open", + "regex", "rfd", "serde_json", "syn 2.0.117", @@ -3443,9 +3444,11 @@ dependencies = [ "iced_runtime", "num-traits", "once_cell", + "pulldown-cmark 0.11.3", "rustc-hash 2.1.2", "thiserror 1.0.69", "unicode-segmentation", + "url", ] [[package]] @@ -5870,6 +5873,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "pulldown-cmark" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "679341d22c78c6c649893cbd6c3278dcbe9fc4faa62fea3a9296ae2b50c14625" +dependencies = [ + "bitflags 2.11.0", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + [[package]] name = "pulldown-cmark" version = "0.13.3" diff --git a/Cargo.toml b/Cargo.toml index 286ab14..74fb059 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,7 +54,7 @@ mlua = { version = "0.12", features = ["lua54", "vendored", "serialize"] } url = "2.5" # UI framework -iced = { version = "0.13", features = ["wgpu", "advanced", "image", "svg", "tokio"] } +iced = { version = "0.13", features = ["wgpu", "advanced", "image", "svg", "tokio", "markdown"] } # Editor widget cosmic-text = "0.12" diff --git a/README.md b/README.md index 62946ad..7d1ef32 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ The project is under active development. Core blogging workflows are broadly ava - Markdown/Liquid rendering with native macros, multilingual routes, feeds, sitemap, Pagefind, and incremental site generation through cancellable section task groups. - Local preview in the app or system browser. - Optional one-shot AI translation, description, analysis, taxonomy, and language-detection operations using online or local OpenAI-compatible endpoints with airplane-mode gating. +- Persistent conversational AI with safe Markdown, streamed and cancellable responses, model/session/token tracking, bounded project-aware blog tools, and localized conversation management in the Chat workspace. - SSH-agent-based SCP or rsync publishing. - Integrated Git workflow with repository initialization, Git LFS image tracking, status and diffs, branch/file history, commits, remotes, cancellable fetch/pull/push, and post-pull filesystem reconciliation; network actions respect airplane mode. - Site, media, and translation validation plus `ruds://new-post` Blogmark capture and Lua transforms; bDS2 keeps its separate `bds2://` bookmarklet protocol. diff --git a/RUST_PLAN_CORE.md b/RUST_PLAN_CORE.md index 0d44787..3a11253 100644 --- a/RUST_PLAN_CORE.md +++ b/RUST_PLAN_CORE.md @@ -94,7 +94,7 @@ Open: - Keep closing concrete output differences found against bDS2; approved normalization differences belong in this document when discovered. -### One-shot AI — Mostly done +### One-shot AI — Done Available: @@ -103,13 +103,7 @@ Available: - Model catalog discovery and model selection. - Post translation, media translation, image alt text, post analysis, taxonomy analysis, WordPress-import taxonomy mapping, and language detection. - Explicit offline gating and user-visible errors. -- SQLite fields for input, output, cache-read, and cache-write token usage. - -Open: - -- Parse actual input, output, cache-read, and cache-write token usage from AI - responses and return it with each one-shot result. Chat persistence of those - counters belongs to the extension chat workflow. +- Parsed input, output, cache-read, and cache-write token usage returned from every one-shot operation; persistent chat accounting is tracked in the extension plan. Interactive chat, tools, agents, and MCP belong to the extension plan. diff --git a/RUST_PLAN_EXTENSION.md b/RUST_PLAN_EXTENSION.md index b450b84..b067a7b 100644 --- a/RUST_PLAN_EXTENSION.md +++ b/RUST_PLAN_EXTENSION.md @@ -31,17 +31,17 @@ Done: - Localized native import sidebar/editor with WXR and uploads pickers, cached analysis reopening, conflict resolution, manual and airplane-gated AI taxonomy mapping, item review, live progress/ETA, and execution results. - Taxonomy/posts/media/pages execution through core persistence engines in recoverable 500-item batches, including filesystem rollback, source metadata/status/timestamps, unique-slug import, overwrite/ignore behavior, and media-parent links. -### Conversational AI and agent tools — Open +### Conversational AI and agent tools — Complete -Open: +Done: -- Conversation persistence and chat UI. -- Streaming OpenAI-compatible responses and tool-call parsing. -- Tools over posts, media, templates, search, and other core engines. -- Agent integrations such as Claude Code and Copilot where required by the specs. -- Replace the current Chat placeholders with the working feature. +- Persistent conversation/message repositories with rename, reopen, deletion, model and provider-session selection, and four-way token accounting. +- OpenAI-compatible SSE streaming with split-frame content/tool assembly, provider-error handling, independent cancellation, bounded tool rounds, and context truncation that preserves system messages and tool pairs. +- Project-aware tools over statistics, FTS search, posts, media, templates, scripts, tags/categories, metadata mutation through shared engines, and allowlisted workspace navigation. +- Localized Chat sidebar/editor with conversation and model controls, safe GFM text rendering with blocked external images, streaming/tool state, multiline send/stop controls, and status-bar token totals. +- Online/airplane endpoint routing uses the shared secure endpoint and model infrastructure; unavailable modes direct the user to the existing localized AI settings. -Core endpoint settings, offline gating, key storage, model discovery, and seven one-shot operations are already implemented. +Persistent A2UI surfaces remain separately tracked below. ### Embeddings, semantic search, and duplicates — Open diff --git a/crates/bds-core/migrations/20260718000000_initial_schema/up.sql b/crates/bds-core/migrations/20260718000000_initial_schema/up.sql index f6075be..47f5fb0 100644 --- a/crates/bds-core/migrations/20260718000000_initial_schema/up.sql +++ b/crates/bds-core/migrations/20260718000000_initial_schema/up.sql @@ -199,9 +199,7 @@ CREATE TABLE IF NOT EXISTS chat_messages ( content TEXT, tool_call_id TEXT, tool_calls TEXT, - created_at INTEGER NOT NULL, - cache_read_tokens INTEGER, - cache_write_tokens INTEGER + created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS ai_providers ( diff --git a/crates/bds-core/migrations/20260719141036_add_chat_cache_token_usage/down.sql b/crates/bds-core/migrations/20260719141036_add_chat_cache_token_usage/down.sql new file mode 100644 index 0000000..e3e12d2 --- /dev/null +++ b/crates/bds-core/migrations/20260719141036_add_chat_cache_token_usage/down.sql @@ -0,0 +1,3 @@ +-- This file should undo anything in `up.sql` +ALTER TABLE chat_messages DROP COLUMN cache_write_tokens; +ALTER TABLE chat_messages DROP COLUMN cache_read_tokens; diff --git a/crates/bds-core/migrations/20260719141036_add_chat_cache_token_usage/up.sql b/crates/bds-core/migrations/20260719141036_add_chat_cache_token_usage/up.sql new file mode 100644 index 0000000..0070c64 --- /dev/null +++ b/crates/bds-core/migrations/20260719141036_add_chat_cache_token_usage/up.sql @@ -0,0 +1,3 @@ +-- Your SQL goes here +ALTER TABLE chat_messages ADD COLUMN cache_read_tokens INTEGER; +ALTER TABLE chat_messages ADD COLUMN cache_write_tokens INTEGER; diff --git a/crates/bds-core/src/db/migrations.rs b/crates/bds-core/src/db/migrations.rs index 3f0864f..343bfde 100644 --- a/crates/bds-core/src/db/migrations.rs +++ b/crates/bds-core/src/db/migrations.rs @@ -36,7 +36,7 @@ mod tests { let applied = db .conn() .with_migrations(|conn| conn.applied_migrations().unwrap().len()); - assert_eq!(applied, 4); + assert_eq!(applied, 5); } #[test] @@ -156,4 +156,52 @@ mod tests { assert_eq!(model_ref.as_deref(), Some("model-package")); assert_eq!(usage_tokens, (Some(56), Some(78), Some(12), Some(34))); } + + #[test] + fn existing_chat_schema_is_upgraded_with_cache_token_columns() { + let db = Database::open_in_memory().unwrap(); + db.conn().with_migrations(|conn| { + for _ in 0..4 { + conn.run_next_migration(MIGRATIONS).unwrap(); + } + }); + db.conn() + .with(|conn| { + diesel::insert_into(chat_conversations::table) + .values(( + chat_conversations::id.eq("existing"), + chat_conversations::title.eq("Existing chat"), + chat_conversations::created_at.eq(1_i64), + chat_conversations::updated_at.eq(1_i64), + )) + .execute(conn)?; + diesel::insert_into(chat_messages::table) + .values(( + chat_messages::conversation_id.eq("existing"), + chat_messages::role.eq("assistant"), + chat_messages::created_at.eq(1_i64), + chat_messages::token_usage_input.eq(Some(8)), + chat_messages::token_usage_output.eq(Some(5)), + )) + .execute(conn) + }) + .unwrap(); + + run_migrations(db.conn()).unwrap(); + + let usage = db + .conn() + .with(|conn| { + chat_messages::table + .select(( + chat_messages::token_usage_input, + chat_messages::token_usage_output, + chat_messages::cache_read_tokens, + chat_messages::cache_write_tokens, + )) + .first::<(Option, Option, Option, Option)>(conn) + }) + .unwrap(); + assert_eq!(usage, (Some(8), Some(5), None, None)); + } } diff --git a/crates/bds-core/src/db/queries/chat.rs b/crates/bds-core/src/db/queries/chat.rs new file mode 100644 index 0000000..d09cc6d --- /dev/null +++ b/crates/bds-core/src/db/queries/chat.rs @@ -0,0 +1,134 @@ +use diesel::prelude::*; + +use crate::db::DbConnection; +use crate::db::schema::{chat_conversations, chat_messages}; +use crate::model::{ChatConversation, ChatMessage, NewChatConversation, NewChatMessage}; + +pub fn insert_conversation( + conn: &DbConnection, + conversation: &NewChatConversation<'_>, +) -> QueryResult { + conn.with(|connection| { + diesel::insert_into(chat_conversations::table) + .values(conversation) + .execute(connection)?; + chat_conversations::table + .find(conversation.id) + .select(ChatConversation::as_select()) + .first(connection) + }) +} + +pub fn get_conversation(conn: &DbConnection, id: &str) -> QueryResult { + conn.with(|connection| { + chat_conversations::table + .find(id) + .select(ChatConversation::as_select()) + .first(connection) + }) +} + +pub fn list_conversations(conn: &DbConnection) -> QueryResult> { + conn.with(|connection| { + chat_conversations::table + .order(( + chat_conversations::updated_at.desc(), + chat_conversations::id.desc(), + )) + .select(ChatConversation::as_select()) + .load(connection) + }) +} + +pub fn rename_conversation( + conn: &DbConnection, + id: &str, + title: &str, + updated_at: i64, +) -> QueryResult { + conn.with(|connection| { + diesel::update(chat_conversations::table.find(id)) + .set(( + chat_conversations::title.eq(title), + chat_conversations::updated_at.eq(updated_at), + )) + .execute(connection)?; + chat_conversations::table + .find(id) + .select(ChatConversation::as_select()) + .first(connection) + }) +} + +pub fn set_conversation_model( + conn: &DbConnection, + id: &str, + model: &str, + updated_at: i64, +) -> QueryResult { + conn.with(|connection| { + diesel::update(chat_conversations::table.find(id)) + .set(( + chat_conversations::model.eq(model), + chat_conversations::updated_at.eq(updated_at), + )) + .execute(connection) + }) +} + +pub fn set_session_id( + conn: &DbConnection, + id: &str, + session_id: Option<&str>, + updated_at: i64, +) -> QueryResult { + conn.with(|connection| { + diesel::update(chat_conversations::table.find(id)) + .set(( + chat_conversations::copilot_session_id.eq(session_id), + chat_conversations::updated_at.eq(updated_at), + )) + .execute(connection) + }) +} + +pub fn delete_conversation(conn: &DbConnection, id: &str) -> QueryResult { + conn.with(|connection| { + connection.transaction(|connection| { + diesel::delete(chat_messages::table.filter(chat_messages::conversation_id.eq(id))) + .execute(connection)?; + diesel::delete(chat_conversations::table.find(id)).execute(connection) + }) + }) +} + +pub fn insert_message( + conn: &DbConnection, + message: &NewChatMessage<'_>, + updated_at: i64, +) -> QueryResult { + conn.with(|connection| { + connection.transaction(|connection| { + diesel::insert_into(chat_messages::table) + .values(message) + .execute(connection)?; + diesel::update(chat_conversations::table.find(message.conversation_id)) + .set(chat_conversations::updated_at.eq(updated_at)) + .execute(connection)?; + chat_messages::table + .order(chat_messages::id.desc()) + .select(ChatMessage::as_select()) + .first(connection) + }) + }) +} + +pub fn list_messages(conn: &DbConnection, conversation_id: &str) -> QueryResult> { + conn.with(|connection| { + chat_messages::table + .filter(chat_messages::conversation_id.eq(conversation_id)) + .order((chat_messages::created_at.asc(), chat_messages::id.asc())) + .select(ChatMessage::as_select()) + .load(connection) + }) +} diff --git a/crates/bds-core/src/db/queries/mod.rs b/crates/bds-core/src/db/queries/mod.rs index 65e0625..e2b85b6 100644 --- a/crates/bds-core/src/db/queries/mod.rs +++ b/crates/bds-core/src/db/queries/mod.rs @@ -1,3 +1,4 @@ +pub mod chat; pub mod db_notification; pub mod generated_file_hash; pub mod import_definition; diff --git a/crates/bds-core/src/db/types.rs b/crates/bds-core/src/db/types.rs index 69da482..b2edb1c 100644 --- a/crates/bds-core/src/db/types.rs +++ b/crates/bds-core/src/db/types.rs @@ -5,8 +5,8 @@ use diesel::sql_types::{Integer, Text}; use diesel::sqlite::{Sqlite, SqliteValue}; use crate::model::{ - NotificationAction, NotificationEntity, PostStatus, ProposalKind, ProposalStatus, ScriptKind, - ScriptStatus, TemplateKind, TemplateStatus, + ChatRole, NotificationAction, NotificationEntity, PostStatus, ProposalKind, ProposalStatus, + ScriptKind, ScriptStatus, TemplateKind, TemplateStatus, }; #[derive(Debug, AsExpression, FromSqlRow)] @@ -95,3 +95,4 @@ text_enum_sql!(NotificationEntity); text_enum_sql!(NotificationAction); text_enum_sql!(ProposalKind); text_enum_sql!(ProposalStatus); +text_enum_sql!(ChatRole); diff --git a/crates/bds-core/src/engine/chat.rs b/crates/bds-core/src/engine/chat.rs new file mode 100644 index 0000000..d278b0a --- /dev/null +++ b/crates/bds-core/src/engine/chat.rs @@ -0,0 +1,977 @@ +use std::collections::{BTreeMap, HashMap}; +use std::io::Read; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, mpsc}; +use std::time::Duration; + +use diesel::prelude::*; +use reqwest::blocking::Client; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use uuid::Uuid; + +use crate::db::DbConnection as Connection; +use crate::db::queries::chat as queries; +use crate::db::schema::ai_models; +use crate::engine::ai::{self, AiEndpointConfig, TokenUsage}; +use crate::engine::{EngineError, EngineResult, chat_tools}; +use crate::model::{ChatConversation, ChatMessage, ChatRole, NewChatConversation, NewChatMessage}; +use crate::util::now_unix_ms; + +const DEFAULT_CONTEXT_TOKENS: usize = 32_768; +const DEFAULT_OUTPUT_TOKENS: u64 = 16_384; +const MAX_TOOL_ROUNDS: usize = 10; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ChatToolCall { + pub id: String, + pub name: String, + pub arguments: Value, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChatModelInfo { + pub provider: String, + pub id: String, + pub name: String, + pub family: Option, + pub context_window: u64, + pub max_output_tokens: u64, + pub supports_tools: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ChatEvent { + Started { + conversation_id: String, + }, + Content { + conversation_id: String, + content: String, + }, + ToolStarted { + conversation_id: String, + name: String, + }, + ToolFinished { + conversation_id: String, + name: String, + }, + Finished { + conversation_id: String, + usage: TokenUsage, + }, + Failed { + conversation_id: String, + message: String, + }, + Cancelled { + conversation_id: String, + }, + Navigate { + destination: String, + entity_id: Option, + }, +} + +#[derive(Clone)] +pub struct ChatSendOptions { + pub endpoint: Option, + pub model: Option, + pub context_tokens: Option, + pub max_output_tokens: u64, + pub max_tool_rounds: usize, + pub enable_tools: bool, + pub event_handler: Option>, +} + +impl Default for ChatSendOptions { + fn default() -> Self { + Self { + endpoint: None, + model: None, + context_tokens: None, + max_output_tokens: DEFAULT_OUTPUT_TOKENS, + max_tool_rounds: MAX_TOOL_ROUNDS, + enable_tools: true, + event_handler: None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ChatTurnResult { + pub content: String, + pub usage: TokenUsage, + pub cancelled: bool, +} + +#[derive(Debug, Clone, PartialEq, Default)] +pub struct AssembledResponse { + pub content: String, + pub tool_calls: Vec, + pub usage: TokenUsage, + pub session_id: Option, +} + +#[derive(Default)] +struct PartialToolCall { + id: String, + name: String, + arguments: String, +} + +#[derive(Default)] +pub struct SseAssembler { + buffer: Vec, + content: String, + tools: BTreeMap, + usage: TokenUsage, + session_id: Option, + done: bool, +} + +impl SseAssembler { + pub fn feed(&mut self, bytes: &[u8]) -> EngineResult<()> { + self.buffer.extend_from_slice(bytes); + while let Some((end, separator_len)) = event_boundary(&self.buffer) { + let event = self.buffer.drain(..end).collect::>(); + self.buffer.drain(..separator_len); + self.process_event(&event)?; + } + Ok(()) + } + + pub fn snapshot(&self) -> &str { + &self.content + } + + pub fn finish(mut self) -> EngineResult { + if !self.buffer.is_empty() { + let trailing = std::mem::take(&mut self.buffer); + self.process_event(&trailing)?; + } + let tool_calls = self + .tools + .into_values() + .map(|tool| { + let arguments = if tool.arguments.trim().is_empty() { + json!({}) + } else { + serde_json::from_str(&tool.arguments).map_err(|error| { + EngineError::Parse(format!( + "invalid arguments for tool {}: {error}", + tool.name + )) + })? + }; + Ok(ChatToolCall { + id: tool.id, + name: tool.name, + arguments, + }) + }) + .collect::>>()?; + Ok(AssembledResponse { + content: self.content, + tool_calls, + usage: self.usage, + session_id: self.session_id, + }) + } + + fn process_event(&mut self, event: &[u8]) -> EngineResult<()> { + let text = std::str::from_utf8(event) + .map_err(|error| EngineError::Parse(format!("invalid SSE encoding: {error}")))?; + let data = text + .lines() + .filter_map(|line| line.strip_prefix("data:")) + .map(str::trim_start) + .collect::>() + .join("\n"); + if data.is_empty() || data == "[DONE]" { + self.done |= data == "[DONE]"; + return Ok(()); + } + let body: Value = serde_json::from_str(&data) + .map_err(|error| EngineError::Parse(format!("malformed SSE event: {error}")))?; + self.apply_json(&body) + } + + fn apply_json(&mut self, body: &Value) -> EngineResult<()> { + if let Some(error) = body.get("error") { + return Err(EngineError::Parse(format!("provider error: {error}"))); + } + if let Some(session_id) = body + .get("session_id") + .or_else(|| body.get("sessionId")) + .and_then(Value::as_str) + { + self.session_id = Some(session_id.to_string()); + } + merge_usage(&mut self.usage, body); + for choice in body + .get("choices") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let delta = choice + .get("delta") + .or_else(|| choice.get("message")) + .unwrap_or(&Value::Null); + if let Some(content) = delta.get("content").and_then(Value::as_str) { + self.content.push_str(content); + } + for tool in delta + .get("tool_calls") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let index = tool.get("index").and_then(Value::as_u64).unwrap_or(0); + let partial = self.tools.entry(index).or_default(); + if let Some(id) = tool.get("id").and_then(Value::as_str) { + partial.id.push_str(id); + } + let function = tool.get("function").unwrap_or(&Value::Null); + if let Some(name) = function.get("name").and_then(Value::as_str) { + partial.name.push_str(name); + } + if let Some(arguments) = function.get("arguments").and_then(Value::as_str) { + partial.arguments.push_str(arguments); + } + } + } + Ok(()) + } +} + +pub fn create_conversation( + conn: &Connection, + model: Option<&str>, +) -> EngineResult { + let model = model.filter(|value| !value.trim().is_empty()); + let title = model + .map(|model| format!("Chat with {model}")) + .unwrap_or_else(|| "New Chat".to_string()); + create_conversation_titled(conn, model, &title) +} + +pub fn create_conversation_titled( + conn: &Connection, + model: Option<&str>, + title: &str, +) -> EngineResult { + let model = model.filter(|value| !value.trim().is_empty()); + let title = title.trim(); + if title.is_empty() { + return Err(EngineError::Validation( + "conversation title is required".to_string(), + )); + } + let id = Uuid::new_v4().to_string(); + let now = now_unix_ms(); + Ok(queries::insert_conversation( + conn, + &NewChatConversation { + id: &id, + title, + model, + copilot_session_id: None, + created_at: now, + updated_at: now, + }, + )?) +} + +pub fn rename_conversation( + conn: &Connection, + id: &str, + title: &str, +) -> EngineResult { + let title = title.trim(); + if title.is_empty() { + return Err(EngineError::Validation( + "conversation title is required".to_string(), + )); + } + Ok(queries::rename_conversation( + conn, + id, + title, + now_unix_ms(), + )?) +} + +pub fn set_conversation_model(conn: &Connection, id: &str, model: &str) -> EngineResult<()> { + let model = model.trim(); + if model.is_empty() { + return Err(EngineError::Validation( + "chat model is required".to_string(), + )); + } + if queries::set_conversation_model(conn, id, model, now_unix_ms())? == 0 { + return Err(EngineError::NotFound(format!("conversation {id}"))); + } + Ok(()) +} + +pub fn list_conversations(conn: &Connection) -> EngineResult> { + Ok(queries::list_conversations(conn)?) +} + +pub fn list_models(conn: &Connection) -> EngineResult> { + let rows = conn.with(|connection| { + ai_models::table + .select(( + ai_models::provider, + ai_models::model_id, + ai_models::name, + ai_models::family, + ai_models::context_window, + ai_models::max_output_tokens, + ai_models::tool_call, + )) + .order((ai_models::provider.asc(), ai_models::name.asc())) + .load::<(String, String, String, Option, i32, i32, i32)>(connection) + })?; + Ok(rows + .into_iter() + .map( + |(provider, id, name, family, context_window, max_output_tokens, tool_call)| { + ChatModelInfo { + provider, + id, + name, + family, + context_window: context_window.max(0) as u64, + max_output_tokens: max_output_tokens.max(0) as u64, + supports_tools: tool_call != 0, + } + }, + ) + .collect()) +} + +pub fn get_conversation(conn: &Connection, id: &str) -> EngineResult { + queries::get_conversation(conn, id).map_err(|error| match error { + diesel::result::Error::NotFound => EngineError::NotFound(format!("conversation {id}")), + error => error.into(), + }) +} + +pub fn delete_conversation(conn: &Connection, id: &str) -> EngineResult<()> { + cancel_chat(id); + if queries::delete_conversation(conn, id)? == 0 { + return Err(EngineError::NotFound(format!("conversation {id}"))); + } + Ok(()) +} + +pub fn list_messages(conn: &Connection, id: &str) -> EngineResult> { + Ok(queries::list_messages(conn, id)?) +} + +pub fn insert_message( + conn: &Connection, + conversation_id: &str, + role: ChatRole, + content: Option<&str>, + tool_call_id: Option<&str>, + tool_calls: Option<&str>, + usage: TokenUsage, +) -> EngineResult { + let now = now_unix_ms(); + Ok(queries::insert_message( + conn, + &NewChatMessage { + conversation_id, + role, + content, + tool_call_id, + tool_calls, + created_at: now, + cache_read_tokens: token_i32(usage.cache_read_tokens), + cache_write_tokens: token_i32(usage.cache_write_tokens), + token_usage_input: token_i32(usage.input_tokens), + token_usage_output: token_i32(usage.output_tokens), + }, + now, + )?) +} + +pub fn subscribe_events() -> mpsc::Receiver { + let (sender, receiver) = mpsc::channel(); + listeners() + .lock() + .expect("chat listeners lock") + .push(sender); + receiver +} + +pub fn cancel_chat(conversation_id: &str) -> bool { + let state = in_flight() + .lock() + .expect("chat cancellation lock") + .get(conversation_id) + .cloned(); + if let Some(state) = state { + state.store(true, Ordering::SeqCst); + true + } else { + false + } +} + +#[allow(clippy::too_many_arguments)] +pub fn send_chat_message( + conn: &Connection, + data_dir: &std::path::Path, + project_id: &str, + offline_mode: bool, + conversation_id: &str, + content: &str, + options: ChatSendOptions, +) -> EngineResult { + let content = content.trim(); + if content.is_empty() { + return Err(EngineError::Validation( + "chat message is required".to_string(), + )); + } + let conversation = get_conversation(conn, conversation_id)?; + let endpoint = match options.endpoint.clone() { + Some(endpoint) => validate_runtime_endpoint(endpoint)?, + None => ai::active_endpoint(conn, offline_mode)?, + }; + let model = options + .model + .clone() + .or(conversation.model.clone()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| endpoint.model.clone()); + if model.trim().is_empty() { + return Err(EngineError::Validation( + "AI unavailable - configure a chat model in Settings".to_string(), + )); + } + + let cancelled = Arc::new(AtomicBool::new(false)); + { + let mut active = in_flight().lock().expect("chat cancellation lock"); + if active.contains_key(conversation_id) { + return Err(EngineError::Conflict( + "a response is already streaming for this conversation".to_string(), + )); + } + active.insert(conversation_id.to_string(), Arc::clone(&cancelled)); + } + let _guard = InFlightGuard(conversation_id.to_string()); + emit( + &options, + ChatEvent::Started { + conversation_id: conversation_id.to_string(), + }, + ); + set_conversation_model(conn, conversation_id, &model)?; + insert_message( + conn, + conversation_id, + ChatRole::User, + Some(content), + None, + None, + TokenUsage::default(), + )?; + + let result = run_turns( + conn, + data_dir, + project_id, + conversation_id, + &endpoint, + &model, + &options, + &cancelled, + ); + match &result { + Ok(turn) if turn.cancelled => emit( + &options, + ChatEvent::Cancelled { + conversation_id: conversation_id.to_string(), + }, + ), + Ok(turn) => emit( + &options, + ChatEvent::Finished { + conversation_id: conversation_id.to_string(), + usage: turn.usage, + }, + ), + Err(error) => emit( + &options, + ChatEvent::Failed { + conversation_id: conversation_id.to_string(), + message: error.to_string(), + }, + ), + } + result +} + +#[allow(clippy::too_many_arguments)] +fn run_turns( + conn: &Connection, + data_dir: &std::path::Path, + project_id: &str, + conversation_id: &str, + endpoint: &AiEndpointConfig, + model: &str, + options: &ChatSendOptions, + cancelled: &AtomicBool, +) -> EngineResult { + let mut total_usage = TokenUsage::default(); + let max_rounds = options.max_tool_rounds.min(MAX_TOOL_ROUNDS); + let supports_tools = options.enable_tools && chat_tools::model_supports_tools(conn, model)?; + let catalog_model = list_models(conn)? + .into_iter() + .find(|candidate| candidate.id == model); + let context_window = options.context_tokens.unwrap_or_else(|| { + catalog_model + .as_ref() + .map(|model| model.context_window as usize) + .filter(|window| *window > 0) + .unwrap_or(DEFAULT_CONTEXT_TOKENS) + }); + let max_output_tokens = catalog_model + .as_ref() + .map(|model| model.max_output_tokens) + .filter(|limit| *limit > 0) + .map_or(options.max_output_tokens, |limit| { + limit.min(options.max_output_tokens) + }); + let tool_specs = supports_tools.then(chat_tools::tool_specs); + let tool_budget = tool_specs + .as_ref() + .map(|tools| approximate_tokens(&Value::Array(tools.clone()))) + .unwrap_or(0); + let context_budget = context_window + .saturating_sub(max_output_tokens as usize) + .saturating_sub(tool_budget) + .max(1_024.min(context_window)); + + for round in 0..=max_rounds { + if cancelled.load(Ordering::SeqCst) { + return Ok(ChatTurnResult { + usage: total_usage, + cancelled: true, + ..Default::default() + }); + } + let messages = build_context(conn, project_id, conversation_id, context_budget)?; + let mut payload = json!({ + "model": model, + "messages": messages, + "stream": true, + "stream_options": {"include_usage": true}, + "max_tokens": max_output_tokens, + }); + if let Some(tools) = tool_specs.as_ref() { + payload["tools"] = Value::Array(tools.clone()); + payload["tool_choice"] = json!("auto"); + } + let response = request_completion(endpoint, &payload, cancelled, |content| { + emit( + options, + ChatEvent::Content { + conversation_id: conversation_id.to_string(), + content: content.to_string(), + }, + ); + })?; + add_usage(&mut total_usage, response.usage); + if let Some(session_id) = response.session_id.as_deref() { + queries::set_session_id(conn, conversation_id, Some(session_id), now_unix_ms())?; + } + + if cancelled.load(Ordering::SeqCst) { + if !response.content.is_empty() { + insert_message( + conn, + conversation_id, + ChatRole::Assistant, + Some(&response.content), + None, + None, + response.usage, + )?; + } + return Ok(ChatTurnResult { + content: response.content, + usage: total_usage, + cancelled: true, + }); + } + + if !response.tool_calls.is_empty() && round == max_rounds { + if !response.content.is_empty() { + insert_message( + conn, + conversation_id, + ChatRole::Assistant, + Some(&response.content), + None, + None, + response.usage, + )?; + } + return Err(EngineError::Validation(format!( + "chat exceeded the {max_rounds}-round tool limit" + ))); + } + let serialized_calls = (!response.tool_calls.is_empty()) + .then(|| serialize_tool_calls(&response.tool_calls)) + .transpose()?; + insert_message( + conn, + conversation_id, + ChatRole::Assistant, + (!response.content.is_empty()).then_some(response.content.as_str()), + None, + serialized_calls.as_deref(), + response.usage, + )?; + if response.tool_calls.is_empty() { + return Ok(ChatTurnResult { + content: response.content, + usage: total_usage, + cancelled: false, + }); + } + for (index, call) in response.tool_calls.iter().enumerate() { + if cancelled.load(Ordering::SeqCst) { + persist_cancelled_tool_results( + conn, + conversation_id, + &response.tool_calls[index..], + )?; + return Ok(ChatTurnResult { + usage: total_usage, + cancelled: true, + ..Default::default() + }); + } + emit( + options, + ChatEvent::ToolStarted { + conversation_id: conversation_id.to_string(), + name: call.name.clone(), + }, + ); + if cancelled.load(Ordering::SeqCst) { + persist_cancelled_tool_results( + conn, + conversation_id, + &response.tool_calls[index..], + )?; + return Ok(ChatTurnResult { + usage: total_usage, + cancelled: true, + ..Default::default() + }); + } + let result = + chat_tools::execute(conn, data_dir, project_id, &call.name, &call.arguments); + let result = match result { + Ok(result) => result, + Err(error) => json!({"success": false, "error": error.to_string()}), + }; + if call.name == "navigate" + && let Some(navigation) = result.get("navigation") + && let Some(destination) = navigation.get("destination").and_then(Value::as_str) + { + emit( + options, + ChatEvent::Navigate { + destination: destination.to_string(), + entity_id: navigation + .get("entity_id") + .and_then(Value::as_str) + .map(str::to_string), + }, + ); + } + let result_text = serde_json::to_string(&result)?; + insert_message( + conn, + conversation_id, + ChatRole::Tool, + Some(&result_text), + Some(&call.id), + None, + TokenUsage::default(), + )?; + emit( + options, + ChatEvent::ToolFinished { + conversation_id: conversation_id.to_string(), + name: call.name.clone(), + }, + ); + } + } + unreachable!("bounded loop returns on its final iteration") +} + +fn persist_cancelled_tool_results( + conn: &Connection, + conversation_id: &str, + calls: &[ChatToolCall], +) -> EngineResult<()> { + let result = json!({"success": false, "cancelled": true}).to_string(); + for call in calls { + insert_message( + conn, + conversation_id, + ChatRole::Tool, + Some(&result), + Some(&call.id), + None, + TokenUsage::default(), + )?; + } + Ok(()) +} + +pub fn build_context( + conn: &Connection, + project_id: &str, + conversation_id: &str, + token_budget: usize, +) -> EngineResult> { + let system = chat_tools::system_prompt(conn, project_id)?; + let messages = list_messages(conn, conversation_id)?; + let mut groups: Vec> = Vec::new(); + let mut current = Vec::new(); + for message in messages { + let value = message_json(&message)?; + if message.role == ChatRole::System { + continue; + } + if message.role == ChatRole::User && !current.is_empty() { + groups.push(std::mem::take(&mut current)); + } + current.push(value); + } + if !current.is_empty() { + groups.push(current); + } + + let system_value = json!({"role": "system", "content": system}); + let mut used = approximate_tokens(&system_value); + let mut selected = Vec::new(); + for group in groups.into_iter().rev() { + let cost = group.iter().map(approximate_tokens).sum::(); + if used + cost > token_budget && !selected.is_empty() { + continue; + } + used += cost; + selected.push(group); + if used >= token_budget { + break; + } + } + selected.reverse(); + let mut result = vec![system_value]; + result.extend(selected.into_iter().flatten()); + Ok(result) +} + +fn request_completion( + endpoint: &AiEndpointConfig, + payload: &Value, + cancelled: &AtomicBool, + mut on_content: impl FnMut(&str), +) -> EngineResult { + let client = Client::builder() + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(300)) + .build()?; + let mut request = client + .post(chat_completions_url(&endpoint.url)) + .json(payload); + if let Some(api_key) = endpoint.api_key.as_deref() { + request = request.bearer_auth(api_key); + } + let mut response = request.send()?.error_for_status()?; + let is_stream = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.contains("text/event-stream")); + if !is_stream { + let body: Value = response.json()?; + let mut assembler = SseAssembler::default(); + assembler.apply_json(&body)?; + return assembler.finish(); + } + let mut assembler = SseAssembler::default(); + let mut chunk = [0_u8; 4096]; + loop { + if cancelled.load(Ordering::SeqCst) { + break; + } + let count = response.read(&mut chunk)?; + if count == 0 { + break; + } + let old_len = assembler.snapshot().len(); + assembler.feed(&chunk[..count])?; + if assembler.snapshot().len() != old_len { + on_content(assembler.snapshot()); + } + } + assembler.finish() +} + +fn message_json(message: &ChatMessage) -> EngineResult { + let mut value = json!({"role": message.role.as_str()}); + if let Some(content) = message.content.as_deref() { + value["content"] = json!(content); + } + if let Some(tool_call_id) = message.tool_call_id.as_deref() { + value["tool_call_id"] = json!(tool_call_id); + } + if let Some(tool_calls) = message.tool_calls.as_deref() { + value["tool_calls"] = serde_json::from_str(tool_calls)?; + } + Ok(value) +} + +fn serialize_tool_calls(calls: &[ChatToolCall]) -> EngineResult { + let calls = calls + .iter() + .map(|call| { + json!({ + "id": call.id, + "type": "function", + "function": { + "name": call.name, + "arguments": call.arguments.to_string(), + } + }) + }) + .collect::>(); + Ok(serde_json::to_string(&calls)?) +} + +fn approximate_tokens(value: &Value) -> usize { + value.to_string().chars().count().div_ceil(4).max(1) +} + +fn merge_usage(usage: &mut TokenUsage, body: &Value) { + let source = body.get("usage").unwrap_or(&Value::Null); + usage.input_tokens = source + .get("prompt_tokens") + .and_then(Value::as_u64) + .or(usage.input_tokens); + usage.output_tokens = source + .get("completion_tokens") + .and_then(Value::as_u64) + .or(usage.output_tokens); + usage.cache_read_tokens = source + .get("prompt_tokens_details") + .and_then(|value| value.get("cached_tokens")) + .and_then(Value::as_u64) + .or_else(|| source.get("cache_read_tokens").and_then(Value::as_u64)) + .or(usage.cache_read_tokens); + usage.cache_write_tokens = source + .get("completion_tokens_details") + .and_then(|value| value.get("cached_tokens")) + .and_then(Value::as_u64) + .or_else(|| source.get("cache_write_tokens").and_then(Value::as_u64)) + .or(usage.cache_write_tokens); +} + +fn add_usage(total: &mut TokenUsage, current: TokenUsage) { + total.input_tokens = add_optional(total.input_tokens, current.input_tokens); + total.output_tokens = add_optional(total.output_tokens, current.output_tokens); + total.cache_read_tokens = add_optional(total.cache_read_tokens, current.cache_read_tokens); + total.cache_write_tokens = add_optional(total.cache_write_tokens, current.cache_write_tokens); +} + +fn add_optional(left: Option, right: Option) -> Option { + match (left, right) { + (None, None) => None, + (left, right) => Some(left.unwrap_or(0).saturating_add(right.unwrap_or(0))), + } +} + +fn token_i32(value: Option) -> Option { + value.map(|value| i32::try_from(value).unwrap_or(i32::MAX)) +} + +fn validate_runtime_endpoint(endpoint: AiEndpointConfig) -> EngineResult { + if endpoint.url.trim().is_empty() || endpoint.model.trim().is_empty() { + return Err(EngineError::Validation( + "AI unavailable - configure endpoint and model in Settings".to_string(), + )); + } + Ok(endpoint) +} + +fn chat_completions_url(base_url: &str) -> String { + let base = base_url.trim_end_matches('/'); + if base.ends_with("/chat/completions") { + base.to_string() + } else if base.ends_with("/v1") { + format!("{base}/chat/completions") + } else { + format!("{base}/v1/chat/completions") + } +} + +fn event_boundary(buffer: &[u8]) -> Option<(usize, usize)> { + buffer + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|position| (position, 4)) + .or_else(|| { + buffer + .windows(2) + .position(|window| window == b"\n\n") + .map(|position| (position, 2)) + }) +} + +fn in_flight() -> &'static Mutex>> { + static IN_FLIGHT: OnceLock>>> = OnceLock::new(); + IN_FLIGHT.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn listeners() -> &'static Mutex>> { + static LISTENERS: OnceLock>>> = OnceLock::new(); + LISTENERS.get_or_init(|| Mutex::new(Vec::new())) +} + +fn emit(options: &ChatSendOptions, event: ChatEvent) { + if let Some(handler) = options.event_handler.as_deref() { + handler(event.clone()); + } + listeners() + .lock() + .expect("chat listeners lock") + .retain(|sender| sender.send(event.clone()).is_ok()); +} + +struct InFlightGuard(String); + +impl Drop for InFlightGuard { + fn drop(&mut self) { + in_flight() + .lock() + .expect("chat cancellation lock") + .remove(&self.0); + } +} diff --git a/crates/bds-core/src/engine/chat_tools.rs b/crates/bds-core/src/engine/chat_tools.rs new file mode 100644 index 0000000..c5695b2 --- /dev/null +++ b/crates/bds-core/src/engine/chat_tools.rs @@ -0,0 +1,861 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::Path; + +use base64::Engine as _; +use diesel::prelude::*; +use serde_json::{Value, json}; + +use crate::db::DbConnection as Connection; +use crate::db::queries::{media, post, post_link, post_media, script, template}; +use crate::db::schema::ai_models; +use crate::engine::{EngineError, EngineResult}; +use crate::util::frontmatter::read_post_file; + +pub fn model_supports_tools(conn: &Connection, model: &str) -> EngineResult { + let catalog_value = conn.with(|connection| { + ai_models::table + .filter(ai_models::model_id.eq(model)) + .select(ai_models::tool_call) + .first::(connection) + .optional() + })?; + Ok(catalog_value.map_or_else( + || { + let model = model.to_ascii_lowercase(); + model.contains("gpt") + || model.contains("claude") + || model.contains("tool") + || model.contains("qwen") + || model.contains("mistral") + }, + |value| value != 0, + )) +} + +pub fn system_prompt(conn: &Connection, project_id: &str) -> EngineResult { + let posts = post::list_posts_by_project(conn, project_id)?; + let media_count = media::count_media_by_project(conn, project_id)?; + let tags = posts + .iter() + .flat_map(|post| post.tags.iter()) + .collect::>() + .len(); + let categories = posts + .iter() + .flat_map(|post| post.categories.iter()) + .collect::>() + .len(); + let configured = crate::engine::settings::get(conn, "ai.system_prompt")?.unwrap_or_default(); + let contract = format!( + "You are the conversational assistant for this blog project. Use tools when facts from the project are needed. Never invent project content or identifiers. There are {} posts, {media_count} media items, {tags} tags, and {categories} categories. Keep answers concise and use GitHub-flavored Markdown when useful.", + posts.len() + ); + Ok(if configured.trim().is_empty() { + contract + } else { + format!("{}\n\n{contract}", configured.trim()) + }) +} + +pub fn tool_specs() -> Vec { + vec![ + spec( + "get_blog_stats", + "Return aggregate project statistics.", + json!({}), + ), + spec( + "check_term", + "Check whether a term is used as a tag or category.", + json!({"term": {"type": "string"}}), + ), + spec( + "search_posts", + "Search post titles, slugs, excerpts, bodies, tags, and categories.", + json!({"query": {"type": "string"}, "language": {"type": "string"}, "limit": {"type": "integer"}}), + ), + spec( + "read_post", + "Read one post by id.", + json!({"post_id": {"type": "string"}}), + ), + spec( + "read_post_by_slug", + "Read one post by slug.", + json!({"slug": {"type": "string"}}), + ), + spec( + "list_posts", + "List posts, optionally filtering by status, tag, or category.", + json!({"status": {"type": "string"}, "tag": {"type": "string"}, "category": {"type": "string"}, "limit": {"type": "integer"}}), + ), + spec( + "count_posts", + "Count posts and optionally group by status, tag, or category.", + json!({"group_by": {"type": "string", "enum": ["status", "tag", "category"]}}), + ), + spec( + "update_post_metadata", + "Update title, excerpt, tags, or categories on a post.", + json!({"post_id": {"type": "string"}, "title": {"type": "string"}, "excerpt": {"type": ["string", "null"]}, "tags": {"type": "array", "items": {"type": "string"}}, "categories": {"type": "array", "items": {"type": "string"}}}), + ), + spec( + "list_media", + "List project media.", + json!({"limit": {"type": "integer"}}), + ), + spec( + "get_media", + "Get one media item.", + json!({"media_id": {"type": "string"}}), + ), + spec( + "view_image", + "Return a local image thumbnail as a data URL for visual inspection.", + json!({"media_id": {"type": "string"}, "size": {"type": "string", "enum": ["small", "medium", "large"]}}), + ), + spec( + "update_media_metadata", + "Update title, alt text, caption, or tags on media.", + json!({"media_id": {"type": "string"}, "title": {"type": ["string", "null"]}, "alt": {"type": ["string", "null"]}, "caption": {"type": ["string", "null"]}, "tags": {"type": "array", "items": {"type": "string"}}}), + ), + spec("list_tags", "List tags and usage counts.", json!({})), + spec( + "list_categories", + "List categories and usage counts.", + json!({}), + ), + spec( + "get_post_backlinks", + "Get posts that link to a post.", + json!({"post_id": {"type": "string"}}), + ), + spec( + "get_post_outlinks", + "Get posts linked from a post.", + json!({"post_id": {"type": "string"}}), + ), + spec( + "get_post_media", + "Get media linked to a post.", + json!({"post_id": {"type": "string"}}), + ), + spec( + "get_media_posts", + "Get posts that use a media item.", + json!({"media_id": {"type": "string"}}), + ), + spec("list_templates", "List templates.", json!({})), + spec( + "read_template", + "Read a template by id.", + json!({"template_id": {"type": "string"}}), + ), + spec("list_scripts", "List scripts.", json!({})), + spec( + "read_script", + "Read a script by id.", + json!({"script_id": {"type": "string"}}), + ), + spec( + "navigate", + "Open a project area or entity in the application.", + json!({ + "action": {"type": "string", "enum": ["open_post", "open_media", "open_settings", "open_chat", "switch_view", "toggle_sidebar", "toggle_panel", "toggle_assistant_sidebar"]}, + "destination": {"type": "string", "enum": ["posts", "pages", "media", "templates", "scripts", "tags", "chat", "import", "git", "settings"]}, + "entity_id": {"type": "string"}, + "value": {"type": "string"} + }), + ), + ] +} + +pub fn execute( + conn: &Connection, + data_dir: &Path, + project_id: &str, + name: &str, + arguments: &Value, +) -> EngineResult { + match name { + "blog_stats" | "get_blog_stats" => blog_stats(conn, project_id), + "check_term" => check_term(conn, project_id, required_str(arguments, "term")?), + "search_posts" => search_posts(conn, project_id, arguments), + "read_post" => { + let item = post::get_post_by_id(conn, required_id(arguments, "post_id", "postId")?)?; + ensure_project(&item.project_id, project_id)?; + post_detail(data_dir, item) + } + "read_post_by_slug" => { + let item = post::get_post_by_project_and_slug( + conn, + project_id, + required_str(arguments, "slug")?, + )?; + post_detail(data_dir, item) + } + "list_posts" => list_posts(conn, project_id, arguments), + "count_posts" => count_posts(conn, project_id, arguments), + "update_post_metadata" => update_post_metadata(conn, data_dir, project_id, arguments), + "list_media" => list_media(conn, project_id, arguments), + "get_media" => { + let item = + media::get_media_by_id(conn, required_id(arguments, "media_id", "mediaId")?)?; + ensure_project(&item.project_id, project_id)?; + Ok(json!({"success": true, "media": item})) + } + "view_image" => view_image(conn, data_dir, project_id, arguments), + "update_media_metadata" => update_media_metadata(conn, data_dir, project_id, arguments), + "list_tags" => counted_terms(conn, project_id, true), + "list_categories" => counted_terms(conn, project_id, false), + "get_post_backlinks" => post_links(conn, project_id, arguments, true), + "get_post_outlinks" => post_links(conn, project_id, arguments, false), + "get_post_media" => linked_media(conn, project_id, arguments), + "get_media_posts" => linked_posts(conn, project_id, arguments), + "list_templates" => Ok(json!({ + "templates": template::list_templates_by_project(conn, project_id)?, + })), + "read_template" => { + let item = template::get_template_by_id( + conn, + required_id(arguments, "template_id", "templateId")?, + )?; + ensure_project(&item.project_id, project_id)?; + Ok(json!({"success": true, "template": item})) + } + "list_scripts" => Ok(json!({ + "scripts": script::list_scripts_by_project(conn, project_id)?, + })), + "read_script" => { + let item = + script::get_script_by_id(conn, required_id(arguments, "script_id", "scriptId")?)?; + ensure_project(&item.project_id, project_id)?; + Ok(json!({"success": true, "script": item})) + } + "navigate" => navigate(arguments), + _ => Ok(json!({"success": false, "error": "unknown_tool", "name": name})), + } +} + +fn blog_stats(conn: &Connection, project_id: &str) -> EngineResult { + let posts = post::list_posts_by_project(conn, project_id)?; + let media_count = media::count_media_by_project(conn, project_id)?; + let templates = template::list_templates_by_project(conn, project_id)?.len(); + let scripts = script::list_scripts_by_project(conn, project_id)?.len(); + let tags = posts + .iter() + .flat_map(|item| &item.tags) + .collect::>() + .len(); + let categories = posts + .iter() + .flat_map(|item| &item.categories) + .collect::>() + .len(); + Ok(json!({ + "posts": posts.len(), "media": media_count, "templates": templates, + "scripts": scripts, "tags": tags, "categories": categories, + })) +} + +fn check_term(conn: &Connection, project_id: &str, term: &str) -> EngineResult { + let term = term.to_lowercase(); + let posts = post::list_posts_by_project(conn, project_id)?; + let tag_count = posts + .iter() + .filter(|item| item.tags.iter().any(|value| value.to_lowercase() == term)) + .count(); + let category_count = posts + .iter() + .filter(|item| { + item.categories + .iter() + .any(|value| value.to_lowercase() == term) + }) + .count(); + Ok(json!({"term": term, "tag_posts": tag_count, "category_posts": category_count})) +} + +fn search_posts(conn: &Connection, project_id: &str, arguments: &Value) -> EngineResult { + let query = required_str(arguments, "query")?; + let language = arguments + .get("language") + .and_then(Value::as_str) + .unwrap_or("en"); + let limit = limit(arguments); + let mut matches = Vec::new(); + for id in crate::db::fts::search_posts(conn, query, language)? { + if let Ok(item) = post::get_post_by_id(conn, &id) + && item.project_id == project_id + { + matches.push(post_summary(&item)); + if matches.len() == limit { + break; + } + } + } + Ok(json!({"posts": matches, "count": matches.len()})) +} + +fn list_posts(conn: &Connection, project_id: &str, arguments: &Value) -> EngineResult { + let status = arguments.get("status").and_then(Value::as_str); + let tag = arguments.get("tag").and_then(Value::as_str); + let category = arguments.get("category").and_then(Value::as_str); + let items = post::list_posts_by_project(conn, project_id)? + .into_iter() + .filter(|item| status.is_none_or(|value| item.status.as_str() == value)) + .filter(|item| tag.is_none_or(|value| contains_case_insensitive(&item.tags, value))) + .filter(|item| { + category.is_none_or(|value| contains_case_insensitive(&item.categories, value)) + }) + .take(limit(arguments)) + .map(|item| post_summary(&item)) + .collect::>(); + Ok(json!({"posts": items, "count": items.len()})) +} + +fn count_posts(conn: &Connection, project_id: &str, arguments: &Value) -> EngineResult { + let items = post::list_posts_by_project(conn, project_id)?; + let Some(group_by) = arguments + .get("group_by") + .or_else(|| arguments.get("groupBy")) + .and_then(Value::as_str) + else { + return Ok(json!({"total_posts": items.len()})); + }; + let mut groups = BTreeMap::::new(); + for item in &items { + let values: Vec = match group_by { + "status" => vec![item.status.as_str().to_string()], + "tag" => item.tags.clone(), + "category" => item.categories.clone(), + _ => { + return Err(EngineError::Validation(format!( + "unsupported post grouping: {group_by}" + ))); + } + }; + for value in values { + *groups.entry(value).or_default() += 1; + } + } + Ok(json!({"total_posts": items.len(), "group_by": group_by, "groups": groups})) +} + +fn update_post_metadata( + conn: &Connection, + data_dir: &Path, + project_id: &str, + arguments: &Value, +) -> EngineResult { + let id = required_id(arguments, "post_id", "postId")?; + let existing = post::get_post_by_id(conn, id)?; + ensure_project(&existing.project_id, project_id)?; + if !["title", "excerpt", "tags", "categories"] + .iter() + .any(|key| arguments.get(key).is_some()) + { + return Err(EngineError::Validation( + "no post metadata updates provided".to_string(), + )); + } + let excerpt = optional_nullable_str(arguments, "excerpt")?; + let item = crate::engine::post::update_post( + conn, + data_dir, + id, + arguments.get("title").and_then(Value::as_str), + None, + excerpt, + None, + optional_string_array(arguments, "tags")?, + optional_string_array(arguments, "categories")?, + None, + None, + None, + None, + )?; + Ok(json!({"success": true, "post": post_summary(&item)})) +} + +fn list_media(conn: &Connection, project_id: &str, arguments: &Value) -> EngineResult { + let items = media::list_media_by_project(conn, project_id)? + .into_iter() + .take(limit(arguments)) + .collect::>(); + Ok(json!({"media": items, "count": items.len()})) +} + +fn update_media_metadata( + conn: &Connection, + data_dir: &Path, + project_id: &str, + arguments: &Value, +) -> EngineResult { + let id = required_id(arguments, "media_id", "mediaId")?; + let existing = media::get_media_by_id(conn, id)?; + ensure_project(&existing.project_id, project_id)?; + if !["title", "alt", "caption", "tags"] + .iter() + .any(|key| arguments.get(key).is_some()) + { + return Err(EngineError::Validation( + "no media metadata updates provided".to_string(), + )); + } + let item = crate::engine::media::update_media( + conn, + data_dir, + id, + optional_nullable_str(arguments, "title")?, + optional_nullable_str(arguments, "alt")?, + optional_nullable_str(arguments, "caption")?, + None, + None, + optional_string_array(arguments, "tags")?, + )?; + Ok(json!({"success": true, "media": item})) +} + +fn view_image( + conn: &Connection, + data_dir: &Path, + project_id: &str, + arguments: &Value, +) -> EngineResult { + let item = media::get_media_by_id(conn, required_id(arguments, "media_id", "mediaId")?)?; + ensure_project(&item.project_id, project_id)?; + if !item.mime_type.starts_with("image/") { + return Ok(json!({"success": false, "error": "not_image", "mime_type": item.mime_type})); + } + let size = arguments + .get("size") + .and_then(Value::as_str) + .unwrap_or("medium"); + if !["small", "medium", "large"].contains(&size) { + return Err(EngineError::Validation(format!( + "unsupported thumbnail size: {size}" + ))); + } + let path = data_dir.join(crate::util::thumbnail_path(&item.id, size, "webp")); + if !path.is_file() { + return Ok(json!({"success": false, "error": "thumbnail_not_available"})); + } + let encoded = base64::engine::general_purpose::STANDARD.encode(fs::read(path)?); + Ok(json!({ + "success": true, + "media": item, + "data_url": format!("data:image/webp;base64,{encoded}"), + })) +} + +fn post_links( + conn: &Connection, + project_id: &str, + arguments: &Value, + incoming: bool, +) -> EngineResult { + let id = required_id(arguments, "post_id", "postId")?; + let source = post::get_post_by_id(conn, id)?; + ensure_project(&source.project_id, project_id)?; + let links = if incoming { + post_link::list_links_by_target(conn, id)? + } else { + post_link::list_links_by_source(conn, id)? + }; + let mut items = Vec::with_capacity(links.len()); + for link in links { + let linked_id = if incoming { + &link.source_post_id + } else { + &link.target_post_id + }; + let linked = post::get_post_by_id(conn, linked_id)?; + ensure_project(&linked.project_id, project_id)?; + items.push(json!({ + "post": post_summary(&linked), + "link_text": link.link_text, + })); + } + if incoming { + Ok(json!({"success": true, "post_id": id, "linked_by": items})) + } else { + Ok(json!({"success": true, "post_id": id, "links_to": items})) + } +} + +fn linked_media(conn: &Connection, project_id: &str, arguments: &Value) -> EngineResult { + let id = required_id(arguments, "post_id", "postId")?; + let item = post::get_post_by_id(conn, id)?; + ensure_project(&item.project_id, project_id)?; + let mut items = Vec::new(); + for link in post_media::list_post_media_by_post(conn, id)? { + ensure_project(&link.project_id, project_id)?; + let item = media::get_media_by_id(conn, &link.media_id)?; + ensure_project(&item.project_id, project_id)?; + items.push(json!({"media": item, "sort_order": link.sort_order})); + } + Ok(json!({"success": true, "post_id": id, "media": items})) +} + +fn linked_posts(conn: &Connection, project_id: &str, arguments: &Value) -> EngineResult { + let id = required_id(arguments, "media_id", "mediaId")?; + let item = media::get_media_by_id(conn, id)?; + ensure_project(&item.project_id, project_id)?; + let mut items = Vec::new(); + for link in post_media::list_post_media_by_media(conn, id)? { + ensure_project(&link.project_id, project_id)?; + let item = post::get_post_by_id(conn, &link.post_id)?; + ensure_project(&item.project_id, project_id)?; + items.push(json!({"post": post_summary(&item), "sort_order": link.sort_order})); + } + Ok(json!({"success": true, "media_id": id, "posts": items})) +} + +fn counted_terms(conn: &Connection, project_id: &str, tags: bool) -> EngineResult { + let mut counts = BTreeMap::::new(); + for item in post::list_posts_by_project(conn, project_id)? { + for term in if tags { &item.tags } else { &item.categories } { + *counts.entry(term.clone()).or_default() += 1; + } + } + let values = counts + .into_iter() + .map(|(name, count)| json!({"name": name, "count": count})) + .collect::>(); + Ok(if tags { + json!({"tags": values, "count": values.len()}) + } else { + json!({"categories": values, "count": values.len()}) + }) +} + +fn navigate(arguments: &Value) -> EngineResult { + let action = arguments.get("action").and_then(Value::as_str); + let value = arguments + .get("value") + .or_else(|| arguments.get("entity_id")) + .or_else(|| arguments.get("entityId")) + .and_then(Value::as_str); + let (destination, entity_id) = match action { + Some("open_post" | "openPost") => ("posts", required_navigation_value(value, "post")?), + Some("open_media" | "openMedia") => ("media", required_navigation_value(value, "media")?), + Some("open_chat" | "openChat") => ("chat", required_navigation_value(value, "chat")?), + Some("open_settings" | "openSettings") => ("settings", None), + Some("switch_view" | "switchView") => { + (required_navigation_value(value, "view")?.unwrap(), None) + } + Some("toggle_sidebar" | "toggleSidebar") => ("toggle_sidebar", None), + Some("toggle_panel" | "togglePanel") => ("toggle_panel", None), + Some("toggle_assistant_sidebar" | "toggleAssistantSidebar") => { + ("toggle_assistant_sidebar", None) + } + Some(action) => { + return Err(EngineError::Validation(format!( + "unsupported navigation action: {action}" + ))); + } + None => (required_str(arguments, "destination")?, value), + }; + if ![ + "posts", + "pages", + "media", + "templates", + "scripts", + "tags", + "chat", + "import", + "git", + "settings", + "toggle_sidebar", + "toggle_panel", + "toggle_assistant_sidebar", + ] + .contains(&destination) + { + return Err(EngineError::Validation(format!( + "unsupported navigation destination: {destination}" + ))); + } + Ok(json!({ + "success": true, + "navigation": { + "destination": destination, + "entity_id": entity_id, + } + })) +} + +fn post_detail(data_dir: &Path, item: crate::model::Post) -> EngineResult { + let body = post_body(data_dir, &item)?; + Ok(json!({"success": true, "post": item, "body": body})) +} + +fn post_body(data_dir: &Path, item: &crate::model::Post) -> EngineResult { + if let Some(content) = item.content.as_deref() { + return Ok(content.to_string()); + } + if item.file_path.is_empty() { + return Ok(String::new()); + } + let raw = fs::read_to_string(data_dir.join(&item.file_path))?; + let (_, body) = read_post_file(&raw) + .map_err(|error| EngineError::Parse(format!("invalid post file: {error}")))?; + Ok(body) +} + +fn post_summary(item: &crate::model::Post) -> Value { + json!({ + "id": item.id, "title": item.title, "slug": item.slug, + "excerpt": item.excerpt, "status": item.status, "tags": item.tags, + "categories": item.categories, "created_at": item.created_at, + "updated_at": item.updated_at, + }) +} + +fn spec(name: &str, description: &str, properties: Value) -> Value { + let required = properties + .as_object() + .into_iter() + .flat_map(|values| values.iter()) + .filter(|(_, schema)| !schema.get("type").is_some_and(Value::is_array)) + .filter(|(name, _)| { + matches!( + name.as_str(), + "term" + | "query" + | "post_id" + | "slug" + | "media_id" + | "template_id" + | "script_id" + | "destination" + ) + }) + .map(|(name, _)| Value::String(name.clone())) + .collect::>(); + let required = if name == "navigate" { + Vec::new() + } else { + required + }; + json!({ + "type": "function", + "function": { + "name": name, + "description": description, + "parameters": { + "type": "object", + "properties": properties, + "required": required, + "additionalProperties": false, + } + } + }) +} + +fn required_navigation_value<'a>( + value: Option<&'a str>, + target: &str, +) -> EngineResult> { + value + .filter(|value| !value.trim().is_empty()) + .map(Some) + .ok_or_else(|| { + EngineError::Validation(format!("navigation {target} identifier is required")) + }) +} + +fn required_str<'a>(arguments: &'a Value, key: &str) -> EngineResult<&'a str> { + arguments + .get(key) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| EngineError::Validation(format!("tool argument {key} is required"))) +} + +fn required_id<'a>(arguments: &'a Value, snake: &str, camel: &str) -> EngineResult<&'a str> { + arguments + .get(snake) + .or_else(|| arguments.get(camel)) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| EngineError::Validation(format!("tool argument {snake} is required"))) +} + +fn ensure_project(actual: &str, expected: &str) -> EngineResult<()> { + if actual == expected { + Ok(()) + } else { + Err(EngineError::NotFound("project entity".to_string())) + } +} + +fn limit(arguments: &Value) -> usize { + arguments + .get("limit") + .and_then(Value::as_u64) + .unwrap_or(25) + .clamp(1, 100) as usize +} + +fn contains_case_insensitive(values: &[String], needle: &str) -> bool { + values + .iter() + .any(|value| value.eq_ignore_ascii_case(needle)) +} + +fn optional_string_array(arguments: &Value, key: &str) -> EngineResult>> { + let Some(value) = arguments.get(key) else { + return Ok(None); + }; + let values = value + .as_array() + .ok_or_else(|| EngineError::Validation(format!("tool argument {key} must be an array")))? + .iter() + .map(|value| { + value.as_str().map(str::to_string).ok_or_else(|| { + EngineError::Validation(format!("tool argument {key} must contain strings")) + }) + }) + .collect::>>()?; + Ok(Some(values)) +} + +fn optional_nullable_str<'a>( + arguments: &'a Value, + key: &str, +) -> EngineResult>> { + match arguments.get(key) { + None => Ok(None), + Some(Value::Null) => Ok(Some(None)), + Some(Value::String(value)) => Ok(Some(Some(value))), + Some(_) => Err(EngineError::Validation(format!( + "tool argument {key} must be a string or null" + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::Database; + use crate::db::queries::media::{insert_media, make_test_media}; + use crate::db::queries::post::{insert_post, make_test_post}; + use crate::db::queries::post_link::insert_post_link; + use crate::db::queries::post_media::link_media; + use crate::db::queries::project::{insert_project, make_test_project}; + use crate::model::{PostLink, PostMedia}; + + fn setup() -> Database { + let db = Database::open_in_memory().unwrap(); + db.migrate().unwrap(); + insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap(); + insert_post(db.conn(), &make_test_post("source", "p1", "source")).unwrap(); + insert_post(db.conn(), &make_test_post("target", "p1", "target")).unwrap(); + insert_media(db.conn(), &make_test_media("media1", "p1")).unwrap(); + insert_post_link( + db.conn(), + &PostLink { + id: "link1".into(), + source_post_id: "source".into(), + target_post_id: "target".into(), + link_text: Some("read next".into()), + created_at: 1, + }, + ) + .unwrap(); + link_media( + db.conn(), + &PostMedia { + id: "post-media1".into(), + project_id: "p1".into(), + post_id: "source".into(), + media_id: "media1".into(), + sort_order: 3, + created_at: 1, + }, + ) + .unwrap(); + db + } + + #[test] + fn relationship_tools_return_project_entities() { + let db = setup(); + let dir = tempfile::tempdir().unwrap(); + let outlinks = execute( + db.conn(), + dir.path(), + "p1", + "get_post_outlinks", + &json!({"post_id": "source"}), + ) + .unwrap(); + assert_eq!(outlinks["links_to"][0]["post"]["id"], "target"); + assert_eq!(outlinks["links_to"][0]["link_text"], "read next"); + + let backlinks = execute( + db.conn(), + dir.path(), + "p1", + "get_post_backlinks", + &json!({"post_id": "target"}), + ) + .unwrap(); + assert_eq!(backlinks["linked_by"][0]["post"]["id"], "source"); + + let post_media = execute( + db.conn(), + dir.path(), + "p1", + "get_post_media", + &json!({"post_id": "source"}), + ) + .unwrap(); + assert_eq!(post_media["media"][0]["media"]["id"], "media1"); + assert_eq!(post_media["media"][0]["sort_order"], 3); + + let media_posts = execute( + db.conn(), + dir.path(), + "p1", + "get_media_posts", + &json!({"media_id": "media1"}), + ) + .unwrap(); + assert_eq!(media_posts["posts"][0]["post"]["id"], "source"); + } + + #[test] + fn view_image_is_bounded_to_generated_image_thumbnails() { + let db = setup(); + let dir = tempfile::tempdir().unwrap(); + let relative = crate::util::thumbnail_path("media1", "medium", "webp"); + let path = dir.path().join(relative); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, b"thumbnail").unwrap(); + + let result = execute( + db.conn(), + dir.path(), + "p1", + "view_image", + &json!({"media_id": "media1", "size": "medium"}), + ) + .unwrap(); + assert_eq!(result["success"], true); + assert_eq!(result["data_url"], "data:image/webp;base64,dGh1bWJuYWls"); + + let invalid = execute( + db.conn(), + dir.path(), + "p1", + "view_image", + &json!({"media_id": "media1", "size": "original"}), + ); + assert!(matches!(invalid, Err(EngineError::Validation(_)))); + } +} diff --git a/crates/bds-core/src/engine/git.rs b/crates/bds-core/src/engine/git.rs index ae734ab..50d2b72 100644 --- a/crates/bds-core/src/engine/git.rs +++ b/crates/bds-core/src/engine/git.rs @@ -1635,7 +1635,7 @@ mod tests { dir.path(), &executable, Duration::from_secs(1), - Duration::from_secs(1), + Duration::from_secs(3), ); let error = engine.fetch(|| false, |_| {}).unwrap_err(); assert!( @@ -1658,7 +1658,7 @@ mod tests { dir.path(), executable, Duration::from_secs(1), - Duration::from_secs(1), + Duration::from_secs(3), ); let error = engine .push( diff --git a/crates/bds-core/src/engine/mod.rs b/crates/bds-core/src/engine/mod.rs index 48b9831..19e3f07 100644 --- a/crates/bds-core/src/engine/mod.rs +++ b/crates/bds-core/src/engine/mod.rs @@ -2,6 +2,8 @@ pub mod ai; pub mod auto_translation; pub mod blogmark; pub mod calendar; +pub mod chat; +mod chat_tools; pub mod cli_launcher; pub mod cli_sync; pub mod domain_events; diff --git a/crates/bds-core/src/model/chat.rs b/crates/bds-core/src/model/chat.rs new file mode 100644 index 0000000..1f04724 --- /dev/null +++ b/crates/bds-core/src/model/chat.rs @@ -0,0 +1,109 @@ +use serde::{Deserialize, Serialize}; + +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Serialize, + Deserialize, + diesel::AsExpression, + diesel::FromSqlRow, +)] +#[diesel(sql_type = diesel::sql_types::Text)] +#[serde(rename_all = "lowercase")] +pub enum ChatRole { + System, + User, + Assistant, + Tool, +} + +impl ChatRole { + pub const fn as_str(self) -> &'static str { + match self { + Self::System => "system", + Self::User => "user", + Self::Assistant => "assistant", + Self::Tool => "tool", + } + } +} + +impl std::str::FromStr for ChatRole { + type Err = String; + + fn from_str(value: &str) -> Result { + match value { + "system" => Ok(Self::System), + "user" => Ok(Self::User), + "assistant" => Ok(Self::Assistant), + "tool" => Ok(Self::Tool), + _ => Err(format!("invalid chat role: {value}")), + } + } +} + +#[derive( + Debug, Clone, PartialEq, Eq, Serialize, Deserialize, diesel::Queryable, diesel::Selectable, +)] +#[diesel( + table_name = crate::db::schema::chat_conversations, + check_for_backend(diesel::sqlite::Sqlite) +)] +pub struct ChatConversation { + pub id: String, + pub title: String, + pub model: Option, + pub copilot_session_id: Option, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Debug, diesel::Insertable)] +#[diesel(table_name = crate::db::schema::chat_conversations)] +pub struct NewChatConversation<'a> { + pub id: &'a str, + pub title: &'a str, + pub model: Option<&'a str>, + pub copilot_session_id: Option<&'a str>, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive( + Debug, Clone, PartialEq, Eq, Serialize, Deserialize, diesel::Queryable, diesel::Selectable, +)] +#[diesel( + table_name = crate::db::schema::chat_messages, + check_for_backend(diesel::sqlite::Sqlite) +)] +pub struct ChatMessage { + pub id: i32, + pub conversation_id: String, + pub role: ChatRole, + pub content: Option, + pub tool_call_id: Option, + pub tool_calls: Option, + pub created_at: i64, + pub cache_read_tokens: Option, + pub cache_write_tokens: Option, + pub token_usage_input: Option, + pub token_usage_output: Option, +} + +#[derive(Debug, diesel::Insertable)] +#[diesel(table_name = crate::db::schema::chat_messages)] +pub struct NewChatMessage<'a> { + pub conversation_id: &'a str, + pub role: ChatRole, + pub content: Option<&'a str>, + pub tool_call_id: Option<&'a str>, + pub tool_calls: Option<&'a str>, + pub created_at: i64, + pub cache_read_tokens: Option, + pub cache_write_tokens: Option, + pub token_usage_input: Option, + pub token_usage_output: Option, +} diff --git a/crates/bds-core/src/model/mod.rs b/crates/bds-core/src/model/mod.rs index 6f5e6b7..d8bb512 100644 --- a/crates/bds-core/src/model/mod.rs +++ b/crates/bds-core/src/model/mod.rs @@ -1,3 +1,4 @@ +mod chat; mod event; mod generation; mod import; @@ -10,6 +11,7 @@ mod script; mod tag; mod template; +pub use chat::{ChatConversation, ChatMessage, ChatRole, NewChatConversation, NewChatMessage}; pub use event::DomainEvent; pub use generation::{ DbNotification, DomainEntity, GeneratedFileHash, NotificationAction, NotificationEntity, diff --git a/crates/bds-core/tests/chat.rs b/crates/bds-core/tests/chat.rs new file mode 100644 index 0000000..2e09210 --- /dev/null +++ b/crates/bds-core/tests/chat.rs @@ -0,0 +1,570 @@ +use bds_core::db::Database; +use bds_core::engine::ai::{AiEndpointConfig, AiEndpointKind}; +use bds_core::engine::{chat, project}; +use bds_core::model::ChatRole; +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +fn setup() -> (tempfile::TempDir, Database, String, std::path::PathBuf) { + let root = tempfile::tempdir().unwrap(); + let database_path = root.path().join("bds.db"); + let data_dir = root.path().join("project"); + std::fs::create_dir_all(&data_dir).unwrap(); + let db = Database::open(&database_path).unwrap(); + db.migrate().unwrap(); + let project = project::create_project(db.conn(), "Chat Test", data_dir.to_str()).unwrap(); + project::set_active_project(db.conn(), &project.id).unwrap(); + (root, db, project.id, data_dir) +} + +#[test] +fn conversation_repository_round_trips_rename_model_messages_and_delete() { + let (_root, db, _project_id, _data_dir) = setup(); + let conversation = chat::create_conversation(db.conn(), Some("tool-model")).unwrap(); + assert_eq!(conversation.title, "Chat with tool-model"); + assert_eq!(conversation.model.as_deref(), Some("tool-model")); + + let renamed = chat::rename_conversation(db.conn(), &conversation.id, "Rust notes").unwrap(); + assert_eq!(renamed.title, "Rust notes"); + chat::set_conversation_model(db.conn(), &conversation.id, "other-model").unwrap(); + chat::insert_message( + db.conn(), + &conversation.id, + ChatRole::User, + Some("Hello"), + None, + None, + Default::default(), + ) + .unwrap(); + + assert_eq!(chat::list_conversations(db.conn()).unwrap().len(), 1); + assert_eq!( + chat::list_messages(db.conn(), &conversation.id) + .unwrap() + .len(), + 1 + ); + assert_eq!( + chat::list_messages(db.conn(), &conversation.id).unwrap()[0].role, + ChatRole::User + ); + chat::delete_conversation(db.conn(), &conversation.id).unwrap(); + assert!(chat::list_conversations(db.conn()).unwrap().is_empty()); + assert!( + chat::list_messages(db.conn(), &conversation.id) + .unwrap() + .is_empty() + ); +} + +#[test] +fn sse_assembler_handles_split_frames_multiple_tools_and_usage() { + let mut assembler = chat::SseAssembler::default(); + assembler + .feed(b"data: {\"choices\":[{\"delta\":{\"content\":\"Hel") + .unwrap(); + assembler + .feed(b"lo\"}}]}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call-1\",\"function\":{\"name\":\"search_posts\",\"arguments\":\"{\\\"query\\\":\"}},{\"index\":1,\"id\":\"call-2\",\"function\":{\"name\":\"get_blog_stats\",\"arguments\":\"{}\"}}]}}]}\n\n") + .unwrap(); + assembler + .feed(b"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"rust\\\"}\"}}]}}],\"usage\":{\"prompt_tokens\":11,\"completion_tokens\":4,\"prompt_tokens_details\":{\"cached_tokens\":7},\"completion_tokens_details\":{\"cached_tokens\":2}}}\n\ndata: [DONE]\n\n") + .unwrap(); + let assembled = assembler.finish().unwrap(); + assert_eq!(assembled.content, "Hello"); + assert_eq!(assembled.tool_calls.len(), 2); + assert_eq!(assembled.tool_calls[0].name, "search_posts"); + assert_eq!(assembled.tool_calls[0].arguments["query"], "rust"); + assert_eq!(assembled.usage.input_tokens, Some(11)); + assert_eq!(assembled.usage.output_tokens, Some(4)); + assert_eq!(assembled.usage.cache_read_tokens, Some(7)); + assert_eq!(assembled.usage.cache_write_tokens, Some(2)); +} + +#[test] +fn unavailable_endpoint_refuses_chat_without_mutating_transcript() { + let (_root, db, project_id, data_dir) = setup(); + let conversation = chat::create_conversation(db.conn(), None).unwrap(); + let result = chat::send_chat_message( + db.conn(), + &data_dir, + &project_id, + false, + &conversation.id, + "Hello", + chat::ChatSendOptions::default(), + ); + assert!(result.is_err()); + assert!( + chat::list_messages(db.conn(), &conversation.id) + .unwrap() + .is_empty() + ); +} + +#[test] +fn streamed_turn_persists_content_session_and_all_token_fields() { + let (_root, db, project_id, data_dir) = setup(); + let conversation = chat::create_conversation(db.conn(), Some("plain-model")).unwrap(); + let (url, server) = serve(vec![MockResponse::delayed_sse(vec![ + "data: {\"session_id\":\"session-7\",\"choices\":[{\"delta\":{\"content\":\"Hi \"}}]}\n\n", + "data: {\"choices\":[{\"delta\":{\"content\":\"there\"}}],\"usage\":{\"prompt_tokens\":12,\"completion_tokens\":3,\"cache_read_tokens\":4,\"cache_write_tokens\":2}}\n\n", + "data: [DONE]\n\n", + ])]); + let snapshots = Arc::new(std::sync::Mutex::new(Vec::new())); + let captured = Arc::clone(&snapshots); + let result = chat::send_chat_message( + db.conn(), + &data_dir, + &project_id, + false, + &conversation.id, + "Hello", + options(url, move |event| { + if let chat::ChatEvent::Content { content, .. } = event { + captured.lock().unwrap().push(content); + } + }), + ) + .unwrap(); + server.join().unwrap(); + + assert_eq!(result.content, "Hi there"); + assert_eq!(&*snapshots.lock().unwrap(), &["Hi ", "Hi there"]); + let messages = chat::list_messages(db.conn(), &conversation.id).unwrap(); + assert_eq!(messages.len(), 2); + assert_eq!(messages[1].content.as_deref(), Some("Hi there")); + assert_eq!(messages[1].token_usage_input, Some(12)); + assert_eq!(messages[1].token_usage_output, Some(3)); + assert_eq!(messages[1].cache_read_tokens, Some(4)); + assert_eq!(messages[1].cache_write_tokens, Some(2)); + assert_eq!( + chat::get_conversation(db.conn(), &conversation.id) + .unwrap() + .copilot_session_id + .as_deref(), + Some("session-7") + ); +} + +#[test] +fn tool_loop_persists_valid_assistant_tool_pair_before_final_answer() { + let (_root, db, project_id, data_dir) = setup(); + let conversation = chat::create_conversation(db.conn(), Some("tool-model")).unwrap(); + let (url, server) = serve(vec![ + MockResponse::sse(vec![ + "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"stats-1\",\"function\":{\"name\":\"get_blog_stats\",\"arguments\":\"{}\"}}]}}]}\n\n", + "data: [DONE]\n\n", + ]), + MockResponse::sse(vec![ + "data: {\"choices\":[{\"delta\":{\"content\":\"There are no posts.\"}}]}\n\n", + "data: [DONE]\n\n", + ]), + ]); + let result = chat::send_chat_message( + db.conn(), + &data_dir, + &project_id, + false, + &conversation.id, + "How many posts?", + options(url, |_| {}), + ) + .unwrap(); + server.join().unwrap(); + + assert_eq!(result.content, "There are no posts."); + let messages = chat::list_messages(db.conn(), &conversation.id).unwrap(); + assert_eq!( + messages.iter().map(|item| item.role).collect::>(), + vec![ + ChatRole::User, + ChatRole::Assistant, + ChatRole::Tool, + ChatRole::Assistant, + ] + ); + assert!( + messages[1] + .tool_calls + .as_deref() + .unwrap() + .contains("stats-1") + ); + assert_eq!(messages[2].tool_call_id.as_deref(), Some("stats-1")); + assert!( + messages[2] + .content + .as_deref() + .unwrap() + .contains("\"posts\":0") + ); +} + +#[test] +fn malformed_stream_and_provider_error_keep_reopenable_user_turn() { + for response in [ + MockResponse::sse(vec!["data: {not-json}\n\n"]), + MockResponse::status(500, "application/json", "{\"error\":\"down\"}"), + ] { + let (_root, db, project_id, data_dir) = setup(); + let conversation = chat::create_conversation(db.conn(), Some("plain-model")).unwrap(); + let (url, server) = serve(vec![response]); + assert!( + chat::send_chat_message( + db.conn(), + &data_dir, + &project_id, + false, + &conversation.id, + "Remember this", + options(url, |_| {}), + ) + .is_err() + ); + server.join().unwrap(); + let messages = chat::list_messages(db.conn(), &conversation.id).unwrap(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].role, ChatRole::User); + assert_eq!(messages[0].content.as_deref(), Some("Remember this")); + } +} + +#[test] +fn cancellation_persists_only_received_content_and_never_runs_later_work() { + let (_root, db, project_id, data_dir) = setup(); + let conversation = chat::create_conversation(db.conn(), Some("plain-model")).unwrap(); + let conversation_id = conversation.id.clone(); + let (url, server) = serve(vec![MockResponse::delayed_sse(vec![ + "data: {\"choices\":[{\"delta\":{\"content\":\"Partial\"}}]}\n\n", + "data: {\"choices\":[{\"delta\":{\"content\":\" ignored\"}}]}\n\n", + "data: [DONE]\n\n", + ])]); + let cancel_id = conversation_id.clone(); + let result = chat::send_chat_message( + db.conn(), + &data_dir, + &project_id, + false, + &conversation_id, + "Start", + options(url, move |event| { + if matches!(event, chat::ChatEvent::Content { .. }) { + chat::cancel_chat(&cancel_id); + } + }), + ) + .unwrap(); + server.join().unwrap(); + assert!(result.cancelled); + assert_eq!(result.content, "Partial"); + let messages = chat::list_messages(db.conn(), &conversation_id).unwrap(); + assert_eq!(messages.len(), 2); + assert_eq!(messages[1].content.as_deref(), Some("Partial")); +} + +#[test] +fn context_truncation_keeps_system_and_complete_tool_pairs() { + let (_root, db, _project_id, _data_dir) = setup(); + let conversation = chat::create_conversation(db.conn(), None).unwrap(); + chat::insert_message( + db.conn(), + &conversation.id, + ChatRole::User, + Some(&"old ".repeat(100)), + None, + None, + Default::default(), + ) + .unwrap(); + chat::insert_message( + db.conn(), + &conversation.id, + ChatRole::Assistant, + Some("old answer"), + None, + None, + Default::default(), + ) + .unwrap(); + chat::insert_message( + db.conn(), + &conversation.id, + ChatRole::User, + Some("new question"), + None, + None, + Default::default(), + ) + .unwrap(); + chat::insert_message(db.conn(), &conversation.id, ChatRole::Assistant, None, None, Some("[{\"id\":\"c1\",\"type\":\"function\",\"function\":{\"name\":\"get_blog_stats\",\"arguments\":\"{}\"}}]"), Default::default()).unwrap(); + chat::insert_message( + db.conn(), + &conversation.id, + ChatRole::Tool, + Some("{\"posts\":0}"), + Some("c1"), + None, + Default::default(), + ) + .unwrap(); + let context = chat::build_context(db.conn(), &_project_id, &conversation.id, 100).unwrap(); + assert_eq!(context[0]["role"], "system"); + assert!(context.iter().any(|item| item["content"] == "new question")); + let assistant_index = context + .iter() + .position(|item| item.get("tool_calls").is_some()) + .unwrap(); + assert_eq!(context[assistant_index + 1]["tool_call_id"], "c1"); + assert!(!context.iter().any(|item| { + item["content"] + .as_str() + .is_some_and(|value| value.starts_with("old ")) + })); +} + +#[test] +fn airplane_mode_selects_the_local_endpoint_and_persists_its_model() { + let (_root, db, project_id, data_dir) = setup(); + let conversation = chat::create_conversation(db.conn(), None).unwrap(); + let (url, server) = serve(vec![MockResponse::sse(vec![ + "data: {\"choices\":[{\"delta\":{\"content\":\"Local answer\"}}]}\n\n", + "data: [DONE]\n\n", + ])]); + bds_core::engine::ai::save_endpoint( + db.conn(), + &AiEndpointConfig { + kind: AiEndpointKind::Airplane, + url, + model: "local-airplane-model".to_string(), + api_key: None, + }, + ) + .unwrap(); + + let result = chat::send_chat_message( + db.conn(), + &data_dir, + &project_id, + true, + &conversation.id, + "Stay offline", + Default::default(), + ) + .unwrap(); + server.join().unwrap(); + assert_eq!(result.content, "Local answer"); + assert_eq!( + chat::get_conversation(db.conn(), &conversation.id) + .unwrap() + .model + .as_deref(), + Some("local-airplane-model") + ); +} + +#[test] +fn tool_round_limit_stops_without_leaving_an_unpaired_tool_call() { + let (_root, db, project_id, data_dir) = setup(); + let conversation = chat::create_conversation(db.conn(), Some("tool-model")).unwrap(); + let tool_frame = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"stats\",\"function\":{\"name\":\"get_blog_stats\",\"arguments\":\"{}\"}}]}}]}\n\ndata: [DONE]\n\n"; + let (url, server) = serve(vec![ + MockResponse::sse(vec![tool_frame]), + MockResponse::sse(vec![tool_frame]), + ]); + let mut send_options = options(url, |_| {}); + send_options.max_tool_rounds = 1; + let result = chat::send_chat_message( + db.conn(), + &data_dir, + &project_id, + false, + &conversation.id, + "Loop forever", + send_options, + ); + assert!(result.is_err()); + server.join().unwrap(); + + let messages = chat::list_messages(db.conn(), &conversation.id).unwrap(); + assert_eq!(messages.len(), 3); + assert_eq!(messages[1].role, ChatRole::Assistant); + assert_eq!(messages[2].role, ChatRole::Tool); + assert_eq!(messages[2].tool_call_id.as_deref(), Some("stats")); + assert!(messages[1].tool_calls.as_deref().unwrap().contains("stats")); +} + +#[test] +fn cancellation_before_mutating_tool_execution_records_pairs_without_mutation() { + let (_root, db, project_id, data_dir) = setup(); + bds_core::db::fts::ensure_fts_tables(db.conn()).unwrap(); + let post = bds_core::engine::post::create_post( + db.conn(), + &data_dir, + &project_id, + "Original", + Some("Body"), + Vec::new(), + Vec::new(), + None, + Some("en"), + None, + ) + .unwrap(); + let conversation = chat::create_conversation(db.conn(), Some("tool-model")).unwrap(); + let tool_frame = format!( + "data: {{\"choices\":[{{\"delta\":{{\"tool_calls\":[{{\"index\":0,\"id\":\"update-1\",\"function\":{{\"name\":\"update_post_metadata\",\"arguments\":\"{{\\\"post_id\\\":\\\"{}\\\",\\\"title\\\":\\\"Changed\\\"}}\"}}}},{{\"index\":1,\"id\":\"stats-2\",\"function\":{{\"name\":\"get_blog_stats\",\"arguments\":\"{{}}\"}}}}]}}}}]}}\n\ndata: [DONE]\n\n", + post.id + ); + let (url, server) = serve(vec![MockResponse::sse(vec![tool_frame.as_str()])]); + let cancel_id = conversation.id.clone(); + let result = chat::send_chat_message( + db.conn(), + &data_dir, + &project_id, + false, + &conversation.id, + "Change it", + options(url, move |event| { + if matches!(event, chat::ChatEvent::ToolStarted { .. }) { + chat::cancel_chat(&cancel_id); + } + }), + ) + .unwrap(); + server.join().unwrap(); + + assert!(result.cancelled); + assert_eq!( + bds_core::db::queries::post::get_post_by_id(db.conn(), &post.id) + .unwrap() + .title, + "Original" + ); + let messages = chat::list_messages(db.conn(), &conversation.id).unwrap(); + assert_eq!(messages.len(), 4); + assert_eq!(messages[2].tool_call_id.as_deref(), Some("update-1")); + assert_eq!(messages[3].tool_call_id.as_deref(), Some("stats-2")); + assert!( + messages[2] + .content + .as_deref() + .unwrap() + .contains("cancelled") + ); +} + +fn options( + url: String, + handler: impl Fn(chat::ChatEvent) + Send + Sync + 'static, +) -> chat::ChatSendOptions { + chat::ChatSendOptions { + endpoint: Some(AiEndpointConfig { + kind: AiEndpointKind::Online, + url, + model: "plain-model".to_string(), + api_key: Some("test-key".to_string()), + }), + event_handler: Some(Arc::new(handler)), + ..Default::default() + } +} + +struct MockResponse { + status: u16, + content_type: &'static str, + chunks: Vec, + delay: bool, +} + +impl MockResponse { + fn sse(chunks: Vec<&str>) -> Self { + Self { + status: 200, + content_type: "text/event-stream", + chunks: chunks.into_iter().map(str::to_string).collect(), + delay: false, + } + } + + fn delayed_sse(chunks: Vec<&str>) -> Self { + Self { + status: 200, + content_type: "text/event-stream", + chunks: chunks.into_iter().map(str::to_string).collect(), + delay: true, + } + } + + fn status(status: u16, content_type: &'static str, body: &str) -> Self { + Self { + status, + content_type, + chunks: vec![body.to_string()], + delay: false, + } + } +} + +fn serve(responses: Vec) -> (String, thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + for response in responses { + let (mut socket, _) = listener.accept().unwrap(); + read_request(&mut socket); + let reason = if response.status == 200 { + "OK" + } else { + "Error" + }; + write!( + socket, + "HTTP/1.1 {} {}\r\nContent-Type: {}\r\nConnection: close\r\n\r\n", + response.status, reason, response.content_type + ) + .unwrap(); + for chunk in response.chunks { + if socket.write_all(chunk.as_bytes()).is_err() || socket.flush().is_err() { + break; + } + if response.delay { + thread::sleep(Duration::from_millis(60)); + } + } + } + }); + (format!("http://{address}"), server) +} + +fn read_request(socket: &mut std::net::TcpStream) { + socket + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut received = Vec::new(); + let mut chunk = [0_u8; 2048]; + loop { + let count = socket.read(&mut chunk).unwrap(); + received.extend_from_slice(&chunk[..count]); + let Some(headers_end) = received.windows(4).position(|value| value == b"\r\n\r\n") else { + continue; + }; + let headers = String::from_utf8_lossy(&received[..headers_end]); + let content_length = headers + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length:") + .map(str::trim) + .map(str::to_string) + }) + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + if received.len() >= headers_end + 4 + content_length { + break; + } + } +} diff --git a/crates/bds-ui/Cargo.toml b/crates/bds-ui/Cargo.toml index bb3d9b4..d273df2 100644 --- a/crates/bds-ui/Cargo.toml +++ b/crates/bds-ui/Cargo.toml @@ -11,6 +11,7 @@ iced = { workspace = true } muda = { workspace = true } rfd = { workspace = true } serde_json = { workspace = true } +regex = { workspace = true } dirs = { workspace = true } chrono = { workspace = true } open = { workspace = true } diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index b0ba842..6110ce5 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -13,8 +13,9 @@ use bds_core::engine::ai::{self, AiEndpointConfig, AiEndpointKind}; use bds_core::engine::task::{TaskId, TaskManager, TaskStatus}; use bds_core::i18n::{UiLocale, detect_os_locale, normalize_language}; use bds_core::model::{ - DomainEntity, DomainEvent, ImportDefinition, ImportReport, Media, NotificationAction, Post, - PostStatus, PostTranslation, Project, PublishingPreferences, Script, SshMode, Template, + ChatConversation, DomainEntity, DomainEvent, ImportDefinition, ImportReport, Media, + NotificationAction, Post, PostStatus, PostTranslation, Project, PublishingPreferences, Script, + SshMode, Template, }; use crate::components::webview::{self, WebViewConfig, WebViewController}; @@ -27,6 +28,7 @@ use crate::state::sidebar_filter::{CalendarMonth, CalendarYear, MediaFilter, Pos use crate::state::tabs::{self, Tab, TabType}; use crate::state::toast::{Toast, ToastLevel}; use crate::views::{ + chat_view::{ChatEditorState, ChatModelChoice}, dashboard::{ DashboardCategory, DashboardRecentPost, DashboardState, DashboardStats, DashboardTag, DashboardTimelineMonth, @@ -322,6 +324,21 @@ pub enum Message { CreateTemplate, CreateImport, + // Conversational AI + ChatCreate, + ChatRenameInputChanged(String), + ChatRename, + ChatDelete(String), + ChatModelChanged(String), + ChatInputAction(iced::widget::text_editor::Action), + ChatSend, + ChatCancel, + ChatLinkClicked(String), + ChatFinished { + conversation_id: String, + result: Result, + }, + Noop, InitMenuBar, } @@ -812,6 +829,7 @@ pub struct BdsApp { sidebar_scripts: Vec", + ); + let debug = format!("{rendered:?}"); + assert!(debug.contains("Hello")); + assert!(debug.contains("secret")); + assert!(!debug.contains("script")); + assert!(debug.contains("unsafe")); + } + + #[test] + fn external_images_become_safe_links_before_gfm_rendering() { + let safe = external_images_as_links("![diagram](https://example.com/a.png)"); + assert_eq!(safe, "[🖼 diagram](https://example.com/a.png)"); + assert_eq!(parse_safe_markdown(&safe).len(), 1); + } +} diff --git a/crates/bds-ui/src/views/mod.rs b/crates/bds-ui/src/views/mod.rs index c6727e2..efd40d3 100644 --- a/crates/bds-ui/src/views/mod.rs +++ b/crates/bds-ui/src/views/mod.rs @@ -1,4 +1,5 @@ pub mod activity_bar; +pub mod chat_view; pub mod dashboard; pub mod git; pub mod import_editor; diff --git a/crates/bds-ui/src/views/sidebar.rs b/crates/bds-ui/src/views/sidebar.rs index 9013753..50fcf6f 100644 --- a/crates/bds-ui/src/views/sidebar.rs +++ b/crates/bds-ui/src/views/sidebar.rs @@ -5,7 +5,7 @@ use iced::widget::{Space, button, column, container, image, row, scrollable, tex use iced::{Background, Border, Color, Element, Length, Theme}; use bds_core::i18n::UiLocale; -use bds_core::model::{ImportDefinition, Media, Post, Script, Template}; +use bds_core::model::{ChatConversation, ImportDefinition, Media, Post, Script, Template}; use crate::app::Message; use crate::components::inputs; @@ -78,7 +78,7 @@ fn placeholder_key(view: SidebarView) -> &'static str { SidebarView::Scripts => "sidebar.noScriptsYet", SidebarView::Templates => "sidebar.noTemplatesYet", SidebarView::Tags => "sidebar.tagsHeader", - SidebarView::Chat => "sidebar.chatPlaceholder", + SidebarView::Chat => "chat.sidebar.empty", SidebarView::Import => "sidebar.importPlaceholder", SidebarView::Git => "git.noChanges", SidebarView::Settings => "sidebar.settingsHeader", @@ -628,6 +628,7 @@ pub fn view( scripts: &[Script], templates: &[Template], imports: &[ImportDefinition], + conversations: &[ChatConversation], post_filter: &PostFilter, media_filter: &MediaFilter, media_thumbs: &std::collections::HashMap>, @@ -655,6 +656,7 @@ pub fn view( SidebarView::Scripts => Some(Message::CreateScript), SidebarView::Templates => Some(Message::CreateTemplate), SidebarView::Import => Some(Message::CreateImport), + SidebarView::Chat => Some(Message::ChatCreate), _ => None, }; @@ -1132,6 +1134,54 @@ pub fn view( iced::widget::Column::with_children(items).spacing(1).into() } } + SidebarView::Chat => { + if conversations.is_empty() { + text(t(locale, "chat.sidebar.empty")) + .size(12) + .shaping(Shaping::Advanced) + .color(muted) + .into() + } else { + let items = conversations + .iter() + .map(|conversation| { + let style = if active_tab == Some(conversation.id.as_str()) { + item_active_style + } else { + item_style + }; + let model = conversation + .model + .clone() + .unwrap_or_else(|| t(locale, "chat.model.none")); + button( + container( + column![ + text(conversation.title.clone()) + .size(12) + .shaping(Shaping::Advanced), + text(model).size(10).shaping(Shaping::Advanced).color(muted), + ] + .spacing(1), + ) + .width(Length::Fill), + ) + .on_press(Message::OpenTab(Tab { + id: conversation.id.clone(), + tab_type: TabType::Chat, + title: conversation.title.clone(), + is_transient: false, + is_dirty: false, + })) + .padding([5, 8]) + .width(Length::Fill) + .style(style) + .into() + }) + .collect::>>(); + iced::widget::Column::with_children(items).spacing(1).into() + } + } SidebarView::Settings => { // Per sidebar_views.allium SettingsNav: 9 fixed-order sections use crate::views::settings_view::SettingsSection; @@ -1197,11 +1247,6 @@ pub fn view( iced::widget::Column::with_children(items).spacing(1).into() } SidebarView::Git => crate::views::git::sidebar_view(git_state, offline_mode, locale), - _ => text(t(locale, placeholder_key(sidebar_view))) - .size(12) - .shaping(Shaping::Advanced) - .color(muted) - .into(), }; let content = column![header_row, Space::with_height(8.0), body,] diff --git a/crates/bds-ui/src/views/status_bar.rs b/crates/bds-ui/src/views/status_bar.rs index ce6e0ac..7faef5e 100644 --- a/crates/bds-ui/src/views/status_bar.rs +++ b/crates/bds-ui/src/views/status_bar.rs @@ -123,6 +123,7 @@ pub fn view( task_snapshots: &[TaskSnapshot], theme_badge: &str, active_post_status: Option<&str>, + chat_tokens: Option<(u64, u64, u64, u64)>, ) -> Element<'static, Message> { let label_color = Color::from_rgb(0.60, 0.60, 0.65); @@ -194,6 +195,28 @@ pub fn view( "statusBar.media", &[("count", &media_count.to_string())], ); + let chat_token_label = chat_tokens.map(|(input, output, cache_read, cache_write)| { + tw( + locale, + "statusBar.chatTokens", + &[ + ("input", &input.to_string()), + ("output", &output.to_string()), + ("cacheRead", &cache_read.to_string()), + ("cacheWrite", &cache_write.to_string()), + ], + ) + }); + let chat_tokens_el: Element<'static, Message> = chat_token_label.map_or_else( + || Space::with_width(0).into(), + |label| { + text(label) + .size(11) + .shaping(Shaping::Advanced) + .color(label_color) + .into() + }, + ); // Airplane mode toggle — ✈ icon let airplane_btn = button(text("\u{2708}").size(13).shaping(Shaping::Advanced)) @@ -225,6 +248,7 @@ pub fn view( .size(11) .shaping(Shaping::Advanced) .color(label_color), + chat_tokens_el, text(theme_badge.to_string()) .size(11) .shaping(Shaping::Advanced) diff --git a/crates/bds-ui/src/views/workspace.rs b/crates/bds-ui/src/views/workspace.rs index 9b950eb..c813781 100644 --- a/crates/bds-ui/src/views/workspace.rs +++ b/crates/bds-ui/src/views/workspace.rs @@ -7,7 +7,7 @@ use iced::{Alignment, Background, Color, Element, Length, Padding, Theme}; use bds_core::engine::git::GitCommit; use bds_core::i18n::UiLocale; -use bds_core::model::{ImportDefinition, Media, Post, Project, Script, Template}; +use bds_core::model::{ChatConversation, ImportDefinition, Media, Post, Project, Script, Template}; use crate::app::Message; use crate::state::navigation::{OutputEntry, PanelTab, SidebarView, TaskSnapshot}; @@ -16,6 +16,7 @@ use crate::state::tabs::{Tab, TabType}; use crate::state::toast::Toast; use crate::views::{ activity_bar, + chat_view::{self, ChatEditorState}, dashboard::DashboardState, git::{self, GitDiffState, GitUiState}, media_editor::{self, MediaEditorState}, @@ -88,6 +89,7 @@ pub fn view<'a>( sidebar_scripts: &'a [Script], sidebar_templates: &'a [Template], sidebar_imports: &'a [ImportDefinition], + chat_conversations: &'a [ChatConversation], // Sidebar filters post_filter: &'a PostFilter, media_filter: &'a MediaFilter, @@ -121,6 +123,7 @@ pub fn view<'a>( template_editors: &'a HashMap, script_editors: &'a HashMap, import_editors: &'a HashMap, + chat_editors: &'a HashMap, tags_view_state: Option<&'a TagsViewState>, settings_state: Option<&'a SettingsViewState>, dashboard_state: Option<&'a DashboardState>, @@ -150,6 +153,7 @@ pub fn view<'a>( template_editors, script_editors, import_editors, + chat_editors, tags_view_state, settings_state, dashboard_state, @@ -204,6 +208,7 @@ pub fn view<'a>( sidebar_scripts, sidebar_templates, sidebar_imports, + chat_conversations, post_filter, media_filter, sidebar_media_thumbs, @@ -262,6 +267,9 @@ pub fn view<'a>( task_snapshots, theme_badge, active_post_status.as_deref(), + active_tab + .and_then(|id| chat_editors.get(id)) + .map(ChatEditorState::token_totals), ); let base_layout: Element<'a, Message> = column![main_row, separator_h(), status] @@ -387,6 +395,7 @@ fn route_content_area<'a>( template_editors: &'a HashMap, script_editors: &'a HashMap, import_editors: &'a HashMap, + chat_editors: &'a HashMap, tags_view_state: Option<&'a TagsViewState>, settings_state: Option<&'a SettingsViewState>, dashboard_state: Option<&'a DashboardState>, @@ -458,6 +467,13 @@ fn route_content_area<'a>( loading_view(locale) } } + ContentRoute::Chat(tab_id) => { + if let Some(state) = chat_editors.get(tab_id) { + chat_view::view(state, locale, is_ai_enabled(settings_state, offline_mode)) + } else { + loading_view(locale) + } + } ContentRoute::Tags => { if let Some(state) = tags_view_state { tags_view::view(state, locale) @@ -504,6 +520,7 @@ enum ContentRoute<'a> { Templates(&'a str), Scripts(&'a str), Import(&'a str), + Chat(&'a str), Tags, Settings, SiteValidation, @@ -577,6 +594,7 @@ fn route_kind<'a>( ContentRoute::Loading } } + TabType::Chat => ContentRoute::Chat(tab_id), TabType::Tags => { if tags_view_state.is_some() { ContentRoute::Tags @@ -595,7 +613,6 @@ fn route_kind<'a>( TabType::MetadataDiff => ContentRoute::MetadataDiff, TabType::GitDiff => ContentRoute::GitDiff(tab_id), TabType::Style - | TabType::Chat | TabType::MenuEditor | TabType::Documentation | TabType::ApiDocumentation @@ -658,7 +675,6 @@ mod tests { let site_validation_state = SiteValidationState::default(); let unsupported = [ TabType::Style, - TabType::Chat, TabType::MenuEditor, TabType::Documentation, TabType::ApiDocumentation, @@ -712,6 +728,31 @@ mod tests { assert!(matches!(route, ContentRoute::GitDiff("git-diff:file.txt"))); } + #[test] + fn chat_tab_routes_to_real_chat_view() { + let empty_posts = HashMap::new(); + let empty_media = HashMap::new(); + let empty_templates = HashMap::new(); + let empty_scripts = HashMap::new(); + let empty_imports = HashMap::new(); + let site_validation_state = SiteValidationState::default(); + let tabs = vec![tab("conversation", TabType::Chat, "Chat")]; + let route = route_kind( + &tabs, + Some("conversation"), + &empty_posts, + &empty_media, + &empty_templates, + &empty_scripts, + &empty_imports, + None, + None, + None, + &site_validation_state, + ); + assert!(matches!(route, ContentRoute::Chat("conversation"))); + } + #[test] fn validation_tabs_route_to_real_views() { let empty_posts = HashMap::new(); diff --git a/locales/ui/de.ftl b/locales/ui/de.ftl index 79a694a..e080ca7 100644 --- a/locales/ui/de.ftl +++ b/locales/ui/de.ftl @@ -43,6 +43,42 @@ activity-sourceControl = Versionskontrolle common-save = Speichern common-open = Öffnen common-cancel = Abbrechen +common-delete = Löschen +chat-sidebar-empty = Noch keine Unterhaltungen +chat-new = Neue Unterhaltung +chat-newWithModel = Unterhaltung mit { $model } +chat-unavailable-title = Die dialogbasierte KI ist nicht verfügbar +chat-unavailable-guidance = Konfiguriere den aktiven KI-Endpunkt und das Modell in den Einstellungen. Im Flugmodus wird nur der lokale Endpunkt verwendet. +chat-unavailable-openSettings = KI-Einstellungen öffnen +chat-model-none = Kein Modell ausgewählt +chat-model-label = Modell +chat-model-option = { $provider } — { $name } · { $context }k Kontext · { $output }k Ausgabe · { $capability } +chat-model-tools = Werkzeuge +chat-model-textOnly = nur Text +chat-rename-placeholder = Titel der Unterhaltung +chat-rename-action = Umbenennen +chat-streaming = Assistent · antwortet… +chat-tool-running = { $name } wird ausgeführt… +chat-tool-used = { $name } verwendet +chat-stop = Stoppen +chat-send = Senden +chat-input-placeholder = Frage etwas über deinen Blog… +chat-input-hint = Eingabetaste sendet · Umschalt+Eingabe fügt eine Zeile ein +chat-welcome-title = Wie kann ich bei deinem Blog helfen? +chat-welcome-subtitle = Ich kann Projektinhalte prüfen und Blog-Werkzeuge nutzen, wenn das gewählte Modell sie unterstützt. +chat-welcome-tip1 = Den aktuellen Blog zusammenfassen +chat-welcome-tip2 = Beiträge zu einem Thema finden +chat-welcome-tip3 = Tags und Kategorien prüfen +chat-welcome-tip4 = Medien, Vorlagen oder Skripte untersuchen +chat-welcome-tip5 = Metadaten von Beiträgen oder Medien aktualisieren +chat-role-user = Du +chat-role-assistant = Assistent +chat-role-system = System +chat-role-tool = Werkzeug +chat-deleted = Unterhaltung gelöscht +chat-link-refused = Dieser Linktyp ist nicht erlaubt +chat-navigation-invalid = Das angeforderte Blog-Element konnte nicht geöffnet werden +statusBar-chatTokens = KI ↑{ $input } ↓{ $output } Cache { $cacheRead }/{ $cacheWrite } common-settings = Einstellungen common-tasks = Aufgaben common-running = laufend @@ -120,7 +156,6 @@ sidebar-noPagesYet = Noch keine Seiten sidebar-noMediaYet = Noch keine Medien sidebar-noScriptsYet = Noch keine Skripte sidebar-noTemplatesYet = Noch keine Vorlagen -sidebar-chatPlaceholder = KI-Assistent erscheint hier sidebar-importPlaceholder = Inhalte aus externen Quellen importieren sidebar-loading = Lädt... sidebar-settingsHeader = Einstellungen diff --git a/locales/ui/en.ftl b/locales/ui/en.ftl index 987a000..0c992ca 100644 --- a/locales/ui/en.ftl +++ b/locales/ui/en.ftl @@ -43,6 +43,42 @@ activity-sourceControl = Source Control common-save = Save common-open = Open common-cancel = Cancel +common-delete = Delete +chat-sidebar-empty = No conversations yet +chat-new = New Chat +chat-newWithModel = Chat with { $model } +chat-unavailable-title = Conversational AI is unavailable +chat-unavailable-guidance = Configure the active AI endpoint and model in Settings. Airplane mode uses only the local endpoint. +chat-unavailable-openSettings = Open AI Settings +chat-model-none = No model selected +chat-model-label = Model +chat-model-option = { $provider } — { $name } · { $context }k context · { $output }k output · { $capability } +chat-model-tools = tools +chat-model-textOnly = text only +chat-rename-placeholder = Conversation title +chat-rename-action = Rename +chat-streaming = Assistant · responding… +chat-tool-running = Running { $name }… +chat-tool-used = Used { $name } +chat-stop = Stop +chat-send = Send +chat-input-placeholder = Ask about your blog… +chat-input-hint = Enter sends · Shift+Enter adds a line +chat-welcome-title = How can I help with your blog? +chat-welcome-subtitle = I can inspect project content and use blog tools when the selected model supports them. +chat-welcome-tip1 = Summarize the current blog +chat-welcome-tip2 = Find posts about a topic +chat-welcome-tip3 = Review tags and categories +chat-welcome-tip4 = Inspect media, templates, or scripts +chat-welcome-tip5 = Update post or media metadata +chat-role-user = You +chat-role-assistant = Assistant +chat-role-system = System +chat-role-tool = Tool +chat-deleted = Conversation deleted +chat-link-refused = This link type is not allowed +chat-navigation-invalid = The requested blog item could not be opened +statusBar-chatTokens = AI ↑{ $input } ↓{ $output } cache { $cacheRead }/{ $cacheWrite } common-settings = Settings common-tasks = Tasks common-running = running @@ -120,7 +156,6 @@ sidebar-noPagesYet = No pages yet sidebar-noMediaYet = No media yet sidebar-noScriptsYet = No scripts yet sidebar-noTemplatesYet = No templates yet -sidebar-chatPlaceholder = AI assistant will appear here sidebar-importPlaceholder = Import content from external sources sidebar-loading = Loading... sidebar-settingsHeader = Settings diff --git a/locales/ui/es.ftl b/locales/ui/es.ftl index 476a7ac..0907750 100644 --- a/locales/ui/es.ftl +++ b/locales/ui/es.ftl @@ -43,6 +43,42 @@ activity-sourceControl = Control de código fuente common-save = Guardar common-open = Abrir common-cancel = Cancelar +common-delete = Eliminar +chat-sidebar-empty = No hay conversaciones +chat-new = Nueva conversación +chat-newWithModel = Conversación con { $model } +chat-unavailable-title = La IA conversacional no está disponible +chat-unavailable-guidance = Configura el punto de acceso de IA activo y el modelo en Ajustes. El modo avión usa únicamente el punto de acceso local. +chat-unavailable-openSettings = Abrir ajustes de IA +chat-model-none = Ningún modelo seleccionado +chat-model-label = Modelo +chat-model-option = { $provider } — { $name } · contexto { $context }k · salida { $output }k · { $capability } +chat-model-tools = herramientas +chat-model-textOnly = solo texto +chat-rename-placeholder = Título de la conversación +chat-rename-action = Cambiar nombre +chat-streaming = Asistente · respondiendo… +chat-tool-running = Ejecutando { $name }… +chat-tool-used = Se usó { $name } +chat-stop = Detener +chat-send = Enviar +chat-input-placeholder = Pregunta sobre tu blog… +chat-input-hint = Intro envía · Mayús+Intro añade una línea +chat-welcome-title = ¿Cómo puedo ayudarte con tu blog? +chat-welcome-subtitle = Puedo examinar el proyecto y usar las herramientas del blog si el modelo las admite. +chat-welcome-tip1 = Resume el blog actual +chat-welcome-tip2 = Busca entradas sobre un tema +chat-welcome-tip3 = Revisa etiquetas y categorías +chat-welcome-tip4 = Examina medios, plantillas o scripts +chat-welcome-tip5 = Actualiza metadatos de entradas o medios +chat-role-user = Tú +chat-role-assistant = Asistente +chat-role-system = Sistema +chat-role-tool = Herramienta +chat-deleted = Conversación eliminada +chat-link-refused = Este tipo de enlace no está permitido +chat-navigation-invalid = No se pudo abrir el elemento del blog solicitado +statusBar-chatTokens = IA ↑{ $input } ↓{ $output } caché { $cacheRead }/{ $cacheWrite } common-settings = Configuración common-tasks = Tareas common-running = en ejecución @@ -120,7 +156,6 @@ sidebar-noPagesYet = Aún no hay páginas sidebar-noMediaYet = Aún no hay medios sidebar-noScriptsYet = Aún no hay scripts sidebar-noTemplatesYet = Aún no hay plantillas -sidebar-chatPlaceholder = El asistente IA aparecerá aquí sidebar-importPlaceholder = Importar contenido desde fuentes externas sidebar-loading = Cargando... sidebar-settingsHeader = Configuración diff --git a/locales/ui/fr.ftl b/locales/ui/fr.ftl index 1de2d96..8354559 100644 --- a/locales/ui/fr.ftl +++ b/locales/ui/fr.ftl @@ -43,6 +43,42 @@ activity-sourceControl = Contrôle de source common-save = Enregistrer common-open = Ouvrir common-cancel = Annuler +common-delete = Supprimer +chat-sidebar-empty = Aucune conversation +chat-new = Nouvelle conversation +chat-newWithModel = Conversation avec { $model } +chat-unavailable-title = L’IA conversationnelle est indisponible +chat-unavailable-guidance = Configurez le point d’accès IA actif et le modèle dans les réglages. Le mode avion utilise uniquement le point d’accès local. +chat-unavailable-openSettings = Ouvrir les réglages IA +chat-model-none = Aucun modèle sélectionné +chat-model-label = Modèle +chat-model-option = { $provider } — { $name } · contexte { $context }k · sortie { $output }k · { $capability } +chat-model-tools = outils +chat-model-textOnly = texte uniquement +chat-rename-placeholder = Titre de la conversation +chat-rename-action = Renommer +chat-streaming = Assistant · réponse en cours… +chat-tool-running = Exécution de { $name }… +chat-tool-used = { $name } utilisé +chat-stop = Arrêter +chat-send = Envoyer +chat-input-placeholder = Posez une question sur votre blog… +chat-input-hint = Entrée envoie · Maj+Entrée ajoute une ligne +chat-welcome-title = Comment puis-je aider votre blog ? +chat-welcome-subtitle = Je peux examiner le contenu du projet et utiliser les outils du blog si le modèle les prend en charge. +chat-welcome-tip1 = Résumer le blog actuel +chat-welcome-tip2 = Rechercher des articles sur un sujet +chat-welcome-tip3 = Examiner les étiquettes et catégories +chat-welcome-tip4 = Examiner médias, modèles ou scripts +chat-welcome-tip5 = Modifier les métadonnées d’un article ou média +chat-role-user = Vous +chat-role-assistant = Assistant +chat-role-system = Système +chat-role-tool = Outil +chat-deleted = Conversation supprimée +chat-link-refused = Ce type de lien n’est pas autorisé +chat-navigation-invalid = L’élément du blog demandé n’a pas pu être ouvert +statusBar-chatTokens = IA ↑{ $input } ↓{ $output } cache { $cacheRead }/{ $cacheWrite } common-settings = Paramètres common-tasks = Tâches common-running = en cours @@ -120,7 +156,6 @@ sidebar-noPagesYet = Aucune page pour le moment sidebar-noMediaYet = Aucun média pour le moment sidebar-noScriptsYet = Aucun script sidebar-noTemplatesYet = Aucun modèle -sidebar-chatPlaceholder = L'assistant IA apparaîtra ici sidebar-importPlaceholder = Importer du contenu depuis des sources externes sidebar-loading = Chargement... sidebar-settingsHeader = Paramètres diff --git a/locales/ui/it.ftl b/locales/ui/it.ftl index dbd7c10..ae3e8e4 100644 --- a/locales/ui/it.ftl +++ b/locales/ui/it.ftl @@ -43,6 +43,42 @@ activity-sourceControl = Controllo sorgente common-save = Salva common-open = Apri common-cancel = Annulla +common-delete = Elimina +chat-sidebar-empty = Nessuna conversazione +chat-new = Nuova conversazione +chat-newWithModel = Conversazione con { $model } +chat-unavailable-title = L’IA conversazionale non è disponibile +chat-unavailable-guidance = Configura l’endpoint IA attivo e il modello nelle impostazioni. La modalità aereo usa solo l’endpoint locale. +chat-unavailable-openSettings = Apri impostazioni IA +chat-model-none = Nessun modello selezionato +chat-model-label = Modello +chat-model-option = { $provider } — { $name } · contesto { $context }k · uscita { $output }k · { $capability } +chat-model-tools = strumenti +chat-model-textOnly = solo testo +chat-rename-placeholder = Titolo della conversazione +chat-rename-action = Rinomina +chat-streaming = Assistente · risposta in corso… +chat-tool-running = Esecuzione di { $name }… +chat-tool-used = Usato { $name } +chat-stop = Interrompi +chat-send = Invia +chat-input-placeholder = Chiedi qualcosa sul tuo blog… +chat-input-hint = Invio spedisce · Maiusc+Invio aggiunge una riga +chat-welcome-title = Come posso aiutarti con il blog? +chat-welcome-subtitle = Posso esaminare il progetto e usare gli strumenti del blog se il modello li supporta. +chat-welcome-tip1 = Riassumi il blog attuale +chat-welcome-tip2 = Trova articoli su un argomento +chat-welcome-tip3 = Controlla tag e categorie +chat-welcome-tip4 = Esamina media, modelli o script +chat-welcome-tip5 = Aggiorna i metadati di articoli o media +chat-role-user = Tu +chat-role-assistant = Assistente +chat-role-system = Sistema +chat-role-tool = Strumento +chat-deleted = Conversazione eliminata +chat-link-refused = Questo tipo di collegamento non è consentito +chat-navigation-invalid = Impossibile aprire l’elemento del blog richiesto +statusBar-chatTokens = IA ↑{ $input } ↓{ $output } cache { $cacheRead }/{ $cacheWrite } common-settings = Impostazioni common-tasks = Attività common-running = in esecuzione @@ -120,7 +156,6 @@ sidebar-noPagesYet = Nessuna pagina sidebar-noMediaYet = Nessun media sidebar-noScriptsYet = Nessuno script sidebar-noTemplatesYet = Nessun modello -sidebar-chatPlaceholder = L'assistente IA apparirà qui sidebar-importPlaceholder = Importa contenuti da fonti esterne sidebar-loading = Caricamento... sidebar-settingsHeader = Impostazioni