Implement persistent conversational AI chat

This commit is contained in:
2026-07-19 16:18:01 +02:00
parent fb5cae2131
commit ac611e3f7f
31 changed files with 4035 additions and 43 deletions

18
Cargo.lock generated
View File

@@ -817,7 +817,7 @@ dependencies = [
"liquid-core", "liquid-core",
"mlua", "mlua",
"pagefind", "pagefind",
"pulldown-cmark", "pulldown-cmark 0.13.3",
"quick-xml 0.41.0", "quick-xml 0.41.0",
"rayon", "rayon",
"regex", "regex",
@@ -872,6 +872,7 @@ dependencies = [
"objc2-app-kit 0.3.2", "objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2", "objc2-foundation 0.3.2",
"open", "open",
"regex",
"rfd", "rfd",
"serde_json", "serde_json",
"syn 2.0.117", "syn 2.0.117",
@@ -3443,9 +3444,11 @@ dependencies = [
"iced_runtime", "iced_runtime",
"num-traits", "num-traits",
"once_cell", "once_cell",
"pulldown-cmark 0.11.3",
"rustc-hash 2.1.2", "rustc-hash 2.1.2",
"thiserror 1.0.69", "thiserror 1.0.69",
"unicode-segmentation", "unicode-segmentation",
"url",
] ]
[[package]] [[package]]
@@ -5870,6 +5873,19 @@ dependencies = [
"syn 2.0.117", "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]] [[package]]
name = "pulldown-cmark" name = "pulldown-cmark"
version = "0.13.3" version = "0.13.3"

View File

@@ -54,7 +54,7 @@ mlua = { version = "0.12", features = ["lua54", "vendored", "serialize"] }
url = "2.5" url = "2.5"
# UI framework # 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 # Editor widget
cosmic-text = "0.12" cosmic-text = "0.12"

View File

@@ -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. - 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. - 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. - 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. - 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. - 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. - Site, media, and translation validation plus `ruds://new-post` Blogmark capture and Lua transforms; bDS2 keeps its separate `bds2://` bookmarklet protocol.

View File

@@ -94,7 +94,7 @@ Open:
- Keep closing concrete output differences found against bDS2; approved normalization differences belong in this document when discovered. - 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: Available:
@@ -103,13 +103,7 @@ Available:
- Model catalog discovery and model selection. - Model catalog discovery and model selection.
- Post translation, media translation, image alt text, post analysis, taxonomy analysis, WordPress-import taxonomy mapping, and language detection. - 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. - Explicit offline gating and user-visible errors.
- SQLite fields for input, output, cache-read, and cache-write token usage. - 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.
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.
Interactive chat, tools, agents, and MCP belong to the extension plan. Interactive chat, tools, agents, and MCP belong to the extension plan.

View File

@@ -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. - 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. - 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. - Persistent conversation/message repositories with rename, reopen, deletion, model and provider-session selection, and four-way token accounting.
- Streaming OpenAI-compatible responses and tool-call parsing. - 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.
- Tools over posts, media, templates, search, and other core engines. - Project-aware tools over statistics, FTS search, posts, media, templates, scripts, tags/categories, metadata mutation through shared engines, and allowlisted workspace navigation.
- Agent integrations such as Claude Code and Copilot where required by the specs. - 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.
- Replace the current Chat placeholders with the working feature. - 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 ### Embeddings, semantic search, and duplicates — Open

View File

@@ -199,9 +199,7 @@ CREATE TABLE IF NOT EXISTS chat_messages (
content TEXT, content TEXT,
tool_call_id TEXT, tool_call_id TEXT,
tool_calls TEXT, tool_calls TEXT,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL
cache_read_tokens INTEGER,
cache_write_tokens INTEGER
); );
CREATE TABLE IF NOT EXISTS ai_providers ( CREATE TABLE IF NOT EXISTS ai_providers (

View File

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

View File

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

View File

@@ -36,7 +36,7 @@ mod tests {
let applied = db let applied = db
.conn() .conn()
.with_migrations(|conn| conn.applied_migrations().unwrap().len()); .with_migrations(|conn| conn.applied_migrations().unwrap().len());
assert_eq!(applied, 4); assert_eq!(applied, 5);
} }
#[test] #[test]
@@ -156,4 +156,52 @@ mod tests {
assert_eq!(model_ref.as_deref(), Some("model-package")); assert_eq!(model_ref.as_deref(), Some("model-package"));
assert_eq!(usage_tokens, (Some(56), Some(78), Some(12), Some(34))); 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<i32>, Option<i32>, Option<i32>, Option<i32>)>(conn)
})
.unwrap();
assert_eq!(usage, (Some(8), Some(5), None, None));
}
} }

View File

@@ -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<ChatConversation> {
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<ChatConversation> {
conn.with(|connection| {
chat_conversations::table
.find(id)
.select(ChatConversation::as_select())
.first(connection)
})
}
pub fn list_conversations(conn: &DbConnection) -> QueryResult<Vec<ChatConversation>> {
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<ChatConversation> {
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<usize> {
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<usize> {
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<usize> {
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<ChatMessage> {
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<Vec<ChatMessage>> {
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)
})
}

View File

@@ -1,3 +1,4 @@
pub mod chat;
pub mod db_notification; pub mod db_notification;
pub mod generated_file_hash; pub mod generated_file_hash;
pub mod import_definition; pub mod import_definition;

View File

@@ -5,8 +5,8 @@ use diesel::sql_types::{Integer, Text};
use diesel::sqlite::{Sqlite, SqliteValue}; use diesel::sqlite::{Sqlite, SqliteValue};
use crate::model::{ use crate::model::{
NotificationAction, NotificationEntity, PostStatus, ProposalKind, ProposalStatus, ScriptKind, ChatRole, NotificationAction, NotificationEntity, PostStatus, ProposalKind, ProposalStatus,
ScriptStatus, TemplateKind, TemplateStatus, ScriptKind, ScriptStatus, TemplateKind, TemplateStatus,
}; };
#[derive(Debug, AsExpression, FromSqlRow)] #[derive(Debug, AsExpression, FromSqlRow)]
@@ -95,3 +95,4 @@ text_enum_sql!(NotificationEntity);
text_enum_sql!(NotificationAction); text_enum_sql!(NotificationAction);
text_enum_sql!(ProposalKind); text_enum_sql!(ProposalKind);
text_enum_sql!(ProposalStatus); text_enum_sql!(ProposalStatus);
text_enum_sql!(ChatRole);

View File

@@ -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<String>,
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<String>,
},
}
#[derive(Clone)]
pub struct ChatSendOptions {
pub endpoint: Option<AiEndpointConfig>,
pub model: Option<String>,
pub context_tokens: Option<usize>,
pub max_output_tokens: u64,
pub max_tool_rounds: usize,
pub enable_tools: bool,
pub event_handler: Option<Arc<dyn Fn(ChatEvent) + Send + Sync>>,
}
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<ChatToolCall>,
pub usage: TokenUsage,
pub session_id: Option<String>,
}
#[derive(Default)]
struct PartialToolCall {
id: String,
name: String,
arguments: String,
}
#[derive(Default)]
pub struct SseAssembler {
buffer: Vec<u8>,
content: String,
tools: BTreeMap<u64, PartialToolCall>,
usage: TokenUsage,
session_id: Option<String>,
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::<Vec<_>>();
self.buffer.drain(..separator_len);
self.process_event(&event)?;
}
Ok(())
}
pub fn snapshot(&self) -> &str {
&self.content
}
pub fn finish(mut self) -> EngineResult<AssembledResponse> {
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::<EngineResult<Vec<_>>>()?;
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::<Vec<_>>()
.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<ChatConversation> {
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<ChatConversation> {
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<ChatConversation> {
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<Vec<ChatConversation>> {
Ok(queries::list_conversations(conn)?)
}
pub fn list_models(conn: &Connection) -> EngineResult<Vec<ChatModelInfo>> {
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<String>, 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<ChatConversation> {
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<Vec<ChatMessage>> {
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<ChatMessage> {
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<ChatEvent> {
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<ChatTurnResult> {
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<ChatTurnResult> {
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<Vec<Value>> {
let system = chat_tools::system_prompt(conn, project_id)?;
let messages = list_messages(conn, conversation_id)?;
let mut groups: Vec<Vec<Value>> = 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::<usize>();
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<AssembledResponse> {
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<Value> {
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<String> {
let calls = calls
.iter()
.map(|call| {
json!({
"id": call.id,
"type": "function",
"function": {
"name": call.name,
"arguments": call.arguments.to_string(),
}
})
})
.collect::<Vec<_>>();
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<u64>, right: Option<u64>) -> Option<u64> {
match (left, right) {
(None, None) => None,
(left, right) => Some(left.unwrap_or(0).saturating_add(right.unwrap_or(0))),
}
}
fn token_i32(value: Option<u64>) -> Option<i32> {
value.map(|value| i32::try_from(value).unwrap_or(i32::MAX))
}
fn validate_runtime_endpoint(endpoint: AiEndpointConfig) -> EngineResult<AiEndpointConfig> {
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<HashMap<String, Arc<AtomicBool>>> {
static IN_FLIGHT: OnceLock<Mutex<HashMap<String, Arc<AtomicBool>>>> = OnceLock::new();
IN_FLIGHT.get_or_init(|| Mutex::new(HashMap::new()))
}
fn listeners() -> &'static Mutex<Vec<mpsc::Sender<ChatEvent>>> {
static LISTENERS: OnceLock<Mutex<Vec<mpsc::Sender<ChatEvent>>>> = 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);
}
}

View File

@@ -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<bool> {
let catalog_value = conn.with(|connection| {
ai_models::table
.filter(ai_models::model_id.eq(model))
.select(ai_models::tool_call)
.first::<i32>(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<String> {
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::<BTreeSet<_>>()
.len();
let categories = posts
.iter()
.flat_map(|post| post.categories.iter())
.collect::<BTreeSet<_>>()
.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<Value> {
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<Value> {
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<Value> {
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::<BTreeSet<_>>()
.len();
let categories = posts
.iter()
.flat_map(|item| &item.categories)
.collect::<BTreeSet<_>>()
.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<Value> {
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<Value> {
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<Value> {
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::<Vec<_>>();
Ok(json!({"posts": items, "count": items.len()}))
}
fn count_posts(conn: &Connection, project_id: &str, arguments: &Value) -> EngineResult<Value> {
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::<String, usize>::new();
for item in &items {
let values: Vec<String> = 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<Value> {
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<Value> {
let items = media::list_media_by_project(conn, project_id)?
.into_iter()
.take(limit(arguments))
.collect::<Vec<_>>();
Ok(json!({"media": items, "count": items.len()}))
}
fn update_media_metadata(
conn: &Connection,
data_dir: &Path,
project_id: &str,
arguments: &Value,
) -> EngineResult<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
let mut counts = BTreeMap::<String, usize>::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::<Vec<_>>();
Ok(if tags {
json!({"tags": values, "count": values.len()})
} else {
json!({"categories": values, "count": values.len()})
})
}
fn navigate(arguments: &Value) -> EngineResult<Value> {
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<Value> {
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<String> {
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::<Vec<_>>();
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<Option<&'a str>> {
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<Option<Vec<String>>> {
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::<EngineResult<Vec<_>>>()?;
Ok(Some(values))
}
fn optional_nullable_str<'a>(
arguments: &'a Value,
key: &str,
) -> EngineResult<Option<Option<&'a str>>> {
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(_))));
}
}

View File

@@ -1635,7 +1635,7 @@ mod tests {
dir.path(), dir.path(),
&executable, &executable,
Duration::from_secs(1), Duration::from_secs(1),
Duration::from_secs(1), Duration::from_secs(3),
); );
let error = engine.fetch(|| false, |_| {}).unwrap_err(); let error = engine.fetch(|| false, |_| {}).unwrap_err();
assert!( assert!(
@@ -1658,7 +1658,7 @@ mod tests {
dir.path(), dir.path(),
executable, executable,
Duration::from_secs(1), Duration::from_secs(1),
Duration::from_secs(1), Duration::from_secs(3),
); );
let error = engine let error = engine
.push( .push(

View File

@@ -2,6 +2,8 @@ pub mod ai;
pub mod auto_translation; pub mod auto_translation;
pub mod blogmark; pub mod blogmark;
pub mod calendar; pub mod calendar;
pub mod chat;
mod chat_tools;
pub mod cli_launcher; pub mod cli_launcher;
pub mod cli_sync; pub mod cli_sync;
pub mod domain_events; pub mod domain_events;

View File

@@ -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<Self, Self::Err> {
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<String>,
pub copilot_session_id: Option<String>,
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<String>,
pub tool_call_id: Option<String>,
pub tool_calls: Option<String>,
pub created_at: i64,
pub cache_read_tokens: Option<i32>,
pub cache_write_tokens: Option<i32>,
pub token_usage_input: Option<i32>,
pub token_usage_output: Option<i32>,
}
#[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<i32>,
pub cache_write_tokens: Option<i32>,
pub token_usage_input: Option<i32>,
pub token_usage_output: Option<i32>,
}

View File

@@ -1,3 +1,4 @@
mod chat;
mod event; mod event;
mod generation; mod generation;
mod import; mod import;
@@ -10,6 +11,7 @@ mod script;
mod tag; mod tag;
mod template; mod template;
pub use chat::{ChatConversation, ChatMessage, ChatRole, NewChatConversation, NewChatMessage};
pub use event::DomainEvent; pub use event::DomainEvent;
pub use generation::{ pub use generation::{
DbNotification, DomainEntity, GeneratedFileHash, NotificationAction, NotificationEntity, DbNotification, DomainEntity, GeneratedFileHash, NotificationAction, NotificationEntity,

View File

@@ -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<_>>(),
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<String>,
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<MockResponse>) -> (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::<usize>().ok())
.unwrap_or(0);
if received.len() >= headers_end + 4 + content_length {
break;
}
}
}

View File

@@ -11,6 +11,7 @@ iced = { workspace = true }
muda = { workspace = true } muda = { workspace = true }
rfd = { workspace = true } rfd = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
regex = { workspace = true }
dirs = { workspace = true } dirs = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
open = { workspace = true } open = { workspace = true }

View File

@@ -13,8 +13,9 @@ use bds_core::engine::ai::{self, AiEndpointConfig, AiEndpointKind};
use bds_core::engine::task::{TaskId, TaskManager, TaskStatus}; use bds_core::engine::task::{TaskId, TaskManager, TaskStatus};
use bds_core::i18n::{UiLocale, detect_os_locale, normalize_language}; use bds_core::i18n::{UiLocale, detect_os_locale, normalize_language};
use bds_core::model::{ use bds_core::model::{
DomainEntity, DomainEvent, ImportDefinition, ImportReport, Media, NotificationAction, Post, ChatConversation, DomainEntity, DomainEvent, ImportDefinition, ImportReport, Media,
PostStatus, PostTranslation, Project, PublishingPreferences, Script, SshMode, Template, NotificationAction, Post, PostStatus, PostTranslation, Project, PublishingPreferences, Script,
SshMode, Template,
}; };
use crate::components::webview::{self, WebViewConfig, WebViewController}; 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::tabs::{self, Tab, TabType};
use crate::state::toast::{Toast, ToastLevel}; use crate::state::toast::{Toast, ToastLevel};
use crate::views::{ use crate::views::{
chat_view::{ChatEditorState, ChatModelChoice},
dashboard::{ dashboard::{
DashboardCategory, DashboardRecentPost, DashboardState, DashboardStats, DashboardTag, DashboardCategory, DashboardRecentPost, DashboardState, DashboardStats, DashboardTag,
DashboardTimelineMonth, DashboardTimelineMonth,
@@ -322,6 +324,21 @@ pub enum Message {
CreateTemplate, CreateTemplate,
CreateImport, 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<engine::chat::ChatTurnResult, String>,
},
Noop, Noop,
InitMenuBar, InitMenuBar,
} }
@@ -812,6 +829,7 @@ pub struct BdsApp {
sidebar_scripts: Vec<Script>, sidebar_scripts: Vec<Script>,
sidebar_templates: Vec<Template>, sidebar_templates: Vec<Template>,
sidebar_imports: Vec<ImportDefinition>, sidebar_imports: Vec<ImportDefinition>,
chat_conversations: Vec<ChatConversation>,
sidebar_media_thumbs: HashMap<String, Option<std::path::PathBuf>>, sidebar_media_thumbs: HashMap<String, Option<std::path::PathBuf>>,
sidebar_posts_has_more: bool, sidebar_posts_has_more: bool,
sidebar_media_has_more: bool, sidebar_media_has_more: bool,
@@ -838,6 +856,7 @@ pub struct BdsApp {
// Tasks // Tasks
task_manager: Arc<TaskManager>, task_manager: Arc<TaskManager>,
domain_events: engine::domain_events::EventSubscription, domain_events: engine::domain_events::EventSubscription,
chat_events: std::sync::mpsc::Receiver<engine::chat::ChatEvent>,
script_menu_actions: Arc<Mutex<Vec<MenuAction>>>, script_menu_actions: Arc<Mutex<Vec<MenuAction>>>,
task_snapshots: Vec<TaskSnapshot>, task_snapshots: Vec<TaskSnapshot>,
collapsed_task_groups: HashSet<String>, collapsed_task_groups: HashSet<String>,
@@ -883,6 +902,7 @@ pub struct BdsApp {
template_editors: HashMap<String, TemplateEditorState>, template_editors: HashMap<String, TemplateEditorState>,
script_editors: HashMap<String, ScriptEditorState>, script_editors: HashMap<String, ScriptEditorState>,
import_editors: HashMap<String, ImportEditorState>, import_editors: HashMap<String, ImportEditorState>,
chat_editors: HashMap<String, ChatEditorState>,
tags_view_state: Option<TagsViewState>, tags_view_state: Option<TagsViewState>,
settings_state: Option<SettingsViewState>, settings_state: Option<SettingsViewState>,
dashboard_state: Option<DashboardState>, dashboard_state: Option<DashboardState>,
@@ -980,6 +1000,10 @@ impl BdsApp {
registry.set_enabled(MenuAction::Find, false); registry.set_enabled(MenuAction::Find, false);
registry.set_enabled(MenuAction::Replace, false); registry.set_enabled(MenuAction::Replace, false);
registry.set_enabled(MenuAction::OpenInBrowser, false); registry.set_enabled(MenuAction::OpenInBrowser, false);
let chat_conversations = db
.as_ref()
.and_then(|db| engine::chat::list_conversations(db.conn()).ok())
.unwrap_or_default();
( (
Self { Self {
@@ -995,6 +1019,7 @@ impl BdsApp {
sidebar_scripts: Vec::new(), sidebar_scripts: Vec::new(),
sidebar_templates: Vec::new(), sidebar_templates: Vec::new(),
sidebar_imports: Vec::new(), sidebar_imports: Vec::new(),
chat_conversations,
sidebar_media_thumbs: HashMap::new(), sidebar_media_thumbs: HashMap::new(),
sidebar_posts_has_more: false, sidebar_posts_has_more: false,
sidebar_media_has_more: false, sidebar_media_has_more: false,
@@ -1011,6 +1036,7 @@ impl BdsApp {
panel_tab: PanelTab::Tasks, panel_tab: PanelTab::Tasks,
task_manager: Arc::new(TaskManager::default()), task_manager: Arc::new(TaskManager::default()),
domain_events: engine::domain_events::subscribe(), domain_events: engine::domain_events::subscribe(),
chat_events: engine::chat::subscribe_events(),
script_menu_actions: Arc::new(Mutex::new(Vec::new())), script_menu_actions: Arc::new(Mutex::new(Vec::new())),
task_snapshots: Vec::new(), task_snapshots: Vec::new(),
collapsed_task_groups: HashSet::new(), collapsed_task_groups: HashSet::new(),
@@ -1040,6 +1066,7 @@ impl BdsApp {
template_editors: HashMap::new(), template_editors: HashMap::new(),
script_editors: HashMap::new(), script_editors: HashMap::new(),
import_editors: HashMap::new(), import_editors: HashMap::new(),
chat_editors: HashMap::new(),
tags_view_state: None, tags_view_state: None,
settings_state: None, settings_state: None,
dashboard_state: None, dashboard_state: None,
@@ -1057,6 +1084,7 @@ impl BdsApp {
#[cfg(test)] #[cfg(test)]
fn new_for_tests(db: Database, project: Project, data_dir: PathBuf) -> Self { fn new_for_tests(db: Database, project: Project, data_dir: PathBuf) -> Self {
let chat_conversations = engine::chat::list_conversations(db.conn()).unwrap_or_default();
Self { Self {
db: Some(db), db: Some(db),
db_path: data_dir.join("bds.db"), db_path: data_dir.join("bds.db"),
@@ -1070,6 +1098,7 @@ impl BdsApp {
sidebar_scripts: Vec::new(), sidebar_scripts: Vec::new(),
sidebar_templates: Vec::new(), sidebar_templates: Vec::new(),
sidebar_imports: Vec::new(), sidebar_imports: Vec::new(),
chat_conversations,
sidebar_media_thumbs: HashMap::new(), sidebar_media_thumbs: HashMap::new(),
sidebar_posts_has_more: false, sidebar_posts_has_more: false,
sidebar_media_has_more: false, sidebar_media_has_more: false,
@@ -1086,6 +1115,7 @@ impl BdsApp {
panel_tab: PanelTab::Tasks, panel_tab: PanelTab::Tasks,
task_manager: Arc::new(TaskManager::default()), task_manager: Arc::new(TaskManager::default()),
domain_events: engine::domain_events::subscribe(), domain_events: engine::domain_events::subscribe(),
chat_events: engine::chat::subscribe_events(),
script_menu_actions: Arc::new(Mutex::new(Vec::new())), script_menu_actions: Arc::new(Mutex::new(Vec::new())),
task_snapshots: Vec::new(), task_snapshots: Vec::new(),
collapsed_task_groups: HashSet::new(), collapsed_task_groups: HashSet::new(),
@@ -1114,6 +1144,7 @@ impl BdsApp {
template_editors: HashMap::new(), template_editors: HashMap::new(),
script_editors: HashMap::new(), script_editors: HashMap::new(),
import_editors: HashMap::new(), import_editors: HashMap::new(),
chat_editors: HashMap::new(),
tags_view_state: None, tags_view_state: None,
settings_state: None, settings_state: None,
dashboard_state: None, dashboard_state: None,
@@ -1151,6 +1182,9 @@ impl BdsApp {
self.refresh_sidebar_posts() self.refresh_sidebar_posts()
} else if new_view == SidebarView::Git && new_visible { } else if new_view == SidebarView::Git && new_visible {
self.refresh_git() self.refresh_git()
} else if new_view == SidebarView::Chat && new_visible {
self.refresh_chat_conversations();
Task::none()
} else { } else {
Task::none() Task::none()
} }
@@ -1172,6 +1206,144 @@ impl BdsApp {
Message::CreateScript => self.create_sidebar_script(), Message::CreateScript => self.create_sidebar_script(),
Message::CreateTemplate => self.create_sidebar_template(), Message::CreateTemplate => self.create_sidebar_template(),
Message::CreateImport => self.create_sidebar_import(), Message::CreateImport => self.create_sidebar_import(),
Message::ChatCreate => self.create_chat_conversation(),
Message::ChatRenameInputChanged(value) => {
if let Some(state) = self.active_chat_state_mut() {
state.rename_input = value;
}
Task::none()
}
Message::ChatRename => {
let Some(id) = self.active_chat_id().map(str::to_string) else {
return Task::none();
};
let Some(title) = self
.chat_editors
.get(&id)
.map(|state| state.rename_input.clone())
else {
return Task::none();
};
let result = self
.db
.as_ref()
.ok_or_else(|| "database unavailable".to_string())
.and_then(|db| {
engine::chat::rename_conversation(db.conn(), &id, &title)
.map_err(|error| error.to_string())
});
match result {
Ok(conversation) => {
if let Some(state) = self.chat_editors.get_mut(&id) {
state.conversation = conversation.clone();
state.rename_input = conversation.title.clone();
}
if let Some(tab) = self.tabs.iter_mut().find(|tab| tab.id == id) {
tab.title = conversation.title.clone();
}
self.refresh_chat_conversations();
}
Err(error) => self.notify(ToastLevel::Error, &error),
}
Task::none()
}
Message::ChatDelete(id) => {
let result = self
.db
.as_ref()
.ok_or_else(|| "database unavailable".to_string())
.and_then(|db| {
engine::chat::delete_conversation(db.conn(), &id)
.map_err(|error| error.to_string())
});
match result {
Ok(()) => {
self.chat_editors.remove(&id);
if let Some(next) = tabs::close_tab(&mut self.tabs, &id) {
self.active_tab = self.tabs.get(next).map(|tab| tab.id.clone());
} else {
self.active_tab = None;
}
self.refresh_chat_conversations();
self.notify(ToastLevel::Success, &t(self.ui_locale, "chat.deleted"));
}
Err(error) => self.notify(ToastLevel::Error, &error),
}
Task::none()
}
Message::ChatModelChanged(model) => {
let Some(id) = self.active_chat_id().map(str::to_string) else {
return Task::none();
};
let result = self
.db
.as_ref()
.ok_or_else(|| "database unavailable".to_string())
.and_then(|db| {
engine::chat::set_conversation_model(db.conn(), &id, &model)
.map_err(|error| error.to_string())
});
match result {
Ok(()) => {
if let Some(state) = self.chat_editors.get_mut(&id) {
state.conversation.model = Some(model);
}
self.refresh_chat_conversations();
}
Err(error) => self.notify(ToastLevel::Error, &error),
}
Task::none()
}
Message::ChatInputAction(action) => {
if let Some(state) = self.active_chat_state_mut() {
state.input.perform(action);
}
Task::none()
}
Message::ChatSend => self.send_active_chat_message(),
Message::ChatCancel => {
if let Some(id) = self.active_chat_id() {
engine::chat::cancel_chat(id);
}
Task::none()
}
Message::ChatLinkClicked(url) => {
if url.starts_with("https://") || url.starts_with("http://") {
if let Err(error) = open::that_detached(&url) {
self.notify(ToastLevel::Error, &error.to_string());
}
} else {
self.notify(ToastLevel::Error, &t(self.ui_locale, "chat.link.refused"));
}
Task::none()
}
Message::ChatFinished {
conversation_id,
result,
} => {
if let Some(state) = self.chat_editors.get_mut(&conversation_id) {
state.streaming = false;
state.clear_streaming();
state.active_tool = None;
match result {
Ok(_) => state.error = None,
Err(error) => state.error = Some(error),
}
if let Some(db) = &self.db {
state.set_messages(
engine::chat::list_messages(db.conn(), &conversation_id)
.unwrap_or_default(),
);
if let Ok(conversation) =
engine::chat::get_conversation(db.conn(), &conversation_id)
{
state.conversation = conversation;
}
}
}
self.refresh_chat_conversations();
self.refresh_counts()
}
Message::OpenSettingsSection(section) => { Message::OpenSettingsSection(section) => {
self.sidebar_view = SidebarView::Settings; self.sidebar_view = SidebarView::Settings;
self.sidebar_visible = true; self.sidebar_visible = true;
@@ -1838,6 +2010,7 @@ impl BdsApp {
Message::TaskTick => { Message::TaskTick => {
self.task_manager.evict_expired(); self.task_manager.evict_expired();
self.refresh_task_snapshots(); self.refresh_task_snapshots();
self.process_chat_events();
if !self.search_index_rebuild_running { if !self.search_index_rebuild_running {
self.auto_save_due_post_editors(); self.auto_save_due_post_editors();
} }
@@ -2385,6 +2558,7 @@ impl BdsApp {
&self.sidebar_scripts, &self.sidebar_scripts,
&self.sidebar_templates, &self.sidebar_templates,
&self.sidebar_imports, &self.sidebar_imports,
&self.chat_conversations,
active_post_filter, active_post_filter,
&self.media_filter, &self.media_filter,
&self.sidebar_media_thumbs, &self.sidebar_media_thumbs,
@@ -2409,6 +2583,7 @@ impl BdsApp {
&self.template_editors, &self.template_editors,
&self.script_editors, &self.script_editors,
&self.import_editors, &self.import_editors,
&self.chat_editors,
self.tags_view_state.as_ref(), self.tags_view_state.as_ref(),
self.settings_state.as_ref(), self.settings_state.as_ref(),
self.dashboard_state.as_ref(), self.dashboard_state.as_ref(),
@@ -2458,6 +2633,324 @@ impl BdsApp {
// ── Private helpers ── // ── Private helpers ──
fn active_chat_id(&self) -> Option<&str> {
let id = self.active_tab.as_deref()?;
self.tabs
.iter()
.any(|tab| tab.id == id && tab.tab_type == TabType::Chat)
.then_some(id)
}
fn active_chat_state_mut(&mut self) -> Option<&mut ChatEditorState> {
let id = self.active_chat_id()?.to_string();
self.chat_editors.get_mut(&id)
}
fn refresh_chat_conversations(&mut self) {
self.chat_conversations = self
.db
.as_ref()
.and_then(|db| engine::chat::list_conversations(db.conn()).ok())
.unwrap_or_default();
}
fn chat_model_options(&self) -> Vec<ChatModelChoice> {
let mut models = self
.db
.as_ref()
.and_then(|db| engine::chat::list_models(db.conn()).ok())
.into_iter()
.flatten()
.map(|model| {
let context = model.context_window.div_ceil(1_000).to_string();
let output = model.max_output_tokens.div_ceil(1_000).to_string();
let capability = t(
self.ui_locale,
if model.supports_tools {
"chat.model.tools"
} else {
"chat.model.textOnly"
},
);
ChatModelChoice {
id: model.id,
label: tw(
self.ui_locale,
"chat.model.option",
&[
("provider", &model.provider),
("name", &model.name),
("context", &context),
("output", &output),
("capability", &capability),
],
),
}
})
.collect::<Vec<_>>();
if let Some(db) = &self.db
&& let Ok(settings) = ai::load_ai_settings(db.conn(), self.offline_mode)
{
if let Some(model) = settings.default_model
&& !models.iter().any(|choice| choice.id == model)
{
models.push(ChatModelChoice {
id: model.clone(),
label: model,
});
}
let endpoint_model = if self.offline_mode {
settings.airplane_endpoint.model
} else {
settings.online_endpoint.model
};
if !endpoint_model.trim().is_empty()
&& !models.iter().any(|choice| choice.id == endpoint_model)
{
models.push(ChatModelChoice {
id: endpoint_model.clone(),
label: endpoint_model,
});
}
}
models.sort_by(|left, right| left.label.cmp(&right.label));
models.dedup_by(|left, right| left.id == right.id);
models
}
fn create_chat_conversation(&mut self) -> Task<Message> {
let model = self
.chat_model_options()
.into_iter()
.next()
.map(|choice| choice.id);
let title = model.as_deref().map_or_else(
|| t(self.ui_locale, "chat.new"),
|model| tw(self.ui_locale, "chat.newWithModel", &[("model", model)]),
);
let result = self
.db
.as_ref()
.ok_or_else(|| "database unavailable".to_string())
.and_then(|db| {
engine::chat::create_conversation_titled(db.conn(), model.as_deref(), &title)
.map_err(|error| error.to_string())
});
match result {
Ok(conversation) => {
self.refresh_chat_conversations();
Task::done(Message::OpenTab(Tab {
id: conversation.id,
tab_type: TabType::Chat,
title: conversation.title,
is_transient: false,
is_dirty: false,
}))
}
Err(error) => {
self.notify(ToastLevel::Error, &error);
Task::none()
}
}
}
fn send_active_chat_message(&mut self) -> Task<Message> {
let Some(conversation_id) = self.active_chat_id().map(str::to_string) else {
return Task::none();
};
let Some(project_id) = self
.active_project
.as_ref()
.map(|project| project.id.clone())
else {
return Task::none();
};
let Some(data_dir) = self.data_dir.clone() else {
return Task::none();
};
let Some(state) = self.chat_editors.get_mut(&conversation_id) else {
return Task::none();
};
if state.streaming {
return Task::none();
}
let content = state.input.text().trim().to_string();
if content.is_empty() {
return Task::none();
}
state.input = iced::widget::text_editor::Content::new();
state.streaming = true;
state.clear_streaming();
state.active_tool = None;
state.error = None;
let db_path = self.db_path.clone();
let offline_mode = self.offline_mode;
let result_id = conversation_id.clone();
Task::perform(
async move {
tokio::task::spawn_blocking(move || {
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
engine::chat::send_chat_message(
db.conn(),
&data_dir,
&project_id,
offline_mode,
&conversation_id,
&content,
engine::chat::ChatSendOptions::default(),
)
.map_err(|error| error.to_string())
})
.await
.map_err(|error| error.to_string())?
},
move |result| Message::ChatFinished {
conversation_id: result_id.clone(),
result,
},
)
}
fn process_chat_events(&mut self) {
let events = self.chat_events.try_iter().collect::<Vec<_>>();
for event in events {
match event {
engine::chat::ChatEvent::Content {
conversation_id,
content,
} => {
if let Some(state) = self.chat_editors.get_mut(&conversation_id) {
state.set_streaming_content(content);
}
}
engine::chat::ChatEvent::ToolStarted {
conversation_id,
name,
} => {
if let Some(state) = self.chat_editors.get_mut(&conversation_id) {
state.active_tool = Some(name);
}
}
engine::chat::ChatEvent::ToolFinished {
conversation_id, ..
} => {
if let Some(state) = self.chat_editors.get_mut(&conversation_id) {
state.active_tool = None;
}
}
engine::chat::ChatEvent::Failed {
conversation_id,
message,
} => {
if let Some(state) = self.chat_editors.get_mut(&conversation_id) {
state.error = Some(message);
}
}
engine::chat::ChatEvent::Navigate {
destination,
entity_id,
} => self.dispatch_chat_navigation(&destination, entity_id.as_deref()),
engine::chat::ChatEvent::Started { .. }
| engine::chat::ChatEvent::Finished { .. }
| engine::chat::ChatEvent::Cancelled { .. } => {}
}
}
}
fn dispatch_chat_navigation(&mut self, destination: &str, entity_id: Option<&str>) {
match destination {
"toggle_sidebar" => {
self.sidebar_visible = !self.sidebar_visible;
return;
}
"toggle_panel" => {
self.panel_visible = !self.panel_visible;
return;
}
"toggle_assistant_sidebar" => {
if self.sidebar_view == SidebarView::Chat {
self.sidebar_visible = !self.sidebar_visible;
} else {
self.sidebar_view = SidebarView::Chat;
self.sidebar_visible = true;
}
return;
}
_ => {}
}
self.sidebar_view = match destination {
"posts" => SidebarView::Posts,
"pages" => SidebarView::Pages,
"media" => SidebarView::Media,
"templates" => SidebarView::Templates,
"scripts" => SidebarView::Scripts,
"tags" => SidebarView::Tags,
"chat" => SidebarView::Chat,
"import" => SidebarView::Import,
"git" => SidebarView::Git,
"settings" => SidebarView::Settings,
_ => return,
};
self.sidebar_visible = true;
if destination == "settings" && entity_id.is_none() {
self.open_singleton_tab(TabType::Settings, "common.settings");
if self.settings_state.is_none() {
self.settings_state = Some(self.hydrate_settings_state());
}
return;
}
if destination == "tags" && entity_id.is_none() {
self.open_singleton_tab(TabType::Tags, "tabBar.tags");
return;
}
let Some(id) = entity_id else { return };
let tab = self.db.as_ref().and_then(|db| match destination {
"posts" => bds_core::db::queries::post::get_post_by_id(db.conn(), id)
.ok()
.map(|item| (TabType::Post, item.title)),
"media" => bds_core::db::queries::media::get_media_by_id(db.conn(), id)
.ok()
.map(|item| {
(
TabType::Media,
item.title.unwrap_or_else(|| item.original_name.clone()),
)
}),
"templates" => bds_core::db::queries::template::get_template_by_id(db.conn(), id)
.ok()
.map(|item| (TabType::Templates, item.title)),
"scripts" => bds_core::db::queries::script::get_script_by_id(db.conn(), id)
.ok()
.map(|item| (TabType::Scripts, item.title)),
"chat" => engine::chat::get_conversation(db.conn(), id)
.ok()
.map(|item| (TabType::Chat, item.title)),
_ => None,
});
let Some((tab_type, title)) = tab else {
self.notify(
ToastLevel::Error,
&t(self.ui_locale, "chat.navigation.invalid"),
);
return;
};
let index = tabs::open_tab(
&mut self.tabs,
Tab {
id: id.to_string(),
tab_type,
title,
is_transient: false,
is_dirty: false,
},
);
if let Some(tab) = self.tabs.get(index).cloned() {
self.active_tab = Some(tab.id.clone());
self.load_editor_for_tab(&tab);
}
}
fn apply_ui_locale(&mut self, locale: UiLocale) { fn apply_ui_locale(&mut self, locale: UiLocale) {
self.ui_locale = locale; self.ui_locale = locale;
self.locale_dropdown_open = false; self.locale_dropdown_open = false;
@@ -6398,6 +6891,28 @@ impl BdsApp {
} }
} }
} }
TabType::Chat => {
if !self.chat_editors.contains_key(&tab.id) {
if self.settings_state.is_none() {
self.settings_state = Some(self.hydrate_settings_state());
}
match (
engine::chat::get_conversation(db.conn(), &tab.id),
engine::chat::list_messages(db.conn(), &tab.id),
) {
(Ok(conversation), Ok(messages)) => {
let models = self.chat_model_options();
self.chat_editors.insert(
tab.id.clone(),
ChatEditorState::new(conversation, messages, models),
);
}
(Err(error), _) | (_, Err(error)) => {
self.notify(ToastLevel::Error, &error.to_string());
}
}
}
}
TabType::Tags => { TabType::Tags => {
if self.tags_view_state.is_none() { if self.tags_view_state.is_none() {
let project_id = self let project_id = self
@@ -9169,4 +9684,45 @@ mod tests {
.is_empty() .is_empty()
); );
} }
#[test]
fn chat_ui_creates_reopens_renames_and_deletes_persistent_conversations() {
let (db, project, tmp) = setup();
let mut app = make_app(db, project, &tmp);
let _ = app.update(Message::ChatCreate);
assert_eq!(app.chat_conversations.len(), 1);
let conversation = app.chat_conversations[0].clone();
let tab = Tab {
id: conversation.id.clone(),
tab_type: TabType::Chat,
title: conversation.title,
is_transient: false,
is_dirty: false,
};
let _ = app.update(Message::OpenTab(tab));
assert!(app.chat_editors.contains_key(&conversation.id));
let _ = app.update(Message::ChatRenameInputChanged("Research".to_string()));
let _ = app.update(Message::ChatRename);
assert_eq!(app.chat_conversations[0].title, "Research");
assert_eq!(
bds_core::engine::chat::get_conversation(
app.db.as_ref().unwrap().conn(),
&conversation.id,
)
.unwrap()
.title,
"Research"
);
let _ = app.update(Message::ChatDelete(conversation.id.clone()));
assert!(app.chat_conversations.is_empty());
assert!(!app.chat_editors.contains_key(&conversation.id));
assert!(
bds_core::engine::chat::list_conversations(app.db.as_ref().unwrap().conn())
.unwrap()
.is_empty()
);
}
} }

View File

@@ -0,0 +1,429 @@
use bds_core::i18n::UiLocale;
use bds_core::model::{ChatConversation, ChatMessage, ChatRole};
use iced::widget::text::Shaping;
use iced::widget::{
Space, button, column, container, markdown, row, scrollable, text, text_editor, text_input,
};
use iced::{Alignment, Color, Element, Length};
use crate::app::Message;
use crate::components::inputs;
use crate::i18n::{t, tw};
pub struct ChatEditorState {
pub conversation: ChatConversation,
pub messages: Vec<ChatMessage>,
rendered_messages: std::collections::HashMap<i32, Vec<markdown::Item>>,
pub input: text_editor::Content,
pub rename_input: String,
pub model_options: Vec<ChatModelChoice>,
pub streaming: bool,
pub streaming_content: String,
streaming_markdown: Vec<markdown::Item>,
pub active_tool: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChatModelChoice {
pub id: String,
pub label: String,
}
impl std::fmt::Display for ChatModelChoice {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.label)
}
}
impl ChatEditorState {
pub fn new(
conversation: ChatConversation,
messages: Vec<ChatMessage>,
mut model_options: Vec<ChatModelChoice>,
) -> Self {
if let Some(model) = conversation.model.as_ref()
&& !model_options.iter().any(|choice| choice.id == *model)
{
model_options.push(ChatModelChoice {
id: model.clone(),
label: model.clone(),
});
}
model_options.sort_by(|left, right| left.label.cmp(&right.label));
model_options.dedup_by(|left, right| left.id == right.id);
let rendered_messages = messages
.iter()
.filter(|message| message.role == ChatRole::Assistant)
.map(|message| {
(
message.id,
parse_safe_markdown(message.content.as_deref().unwrap_or_default()),
)
})
.collect();
Self {
rename_input: conversation.title.clone(),
conversation,
messages,
rendered_messages,
input: text_editor::Content::new(),
model_options,
streaming: false,
streaming_content: String::new(),
streaming_markdown: Vec::new(),
active_tool: None,
error: None,
}
}
pub fn set_messages(&mut self, messages: Vec<ChatMessage>) {
self.rendered_messages = messages
.iter()
.filter(|message| message.role == ChatRole::Assistant)
.map(|message| {
(
message.id,
parse_safe_markdown(message.content.as_deref().unwrap_or_default()),
)
})
.collect();
self.messages = messages;
}
pub fn set_streaming_content(&mut self, content: String) {
self.streaming_markdown = parse_safe_markdown(&content);
self.streaming_content = content;
}
pub fn clear_streaming(&mut self) {
self.streaming_content.clear();
self.streaming_markdown.clear();
}
pub fn token_totals(&self) -> (u64, u64, u64, u64) {
self.messages
.iter()
.fold((0, 0, 0, 0), |mut total, message| {
total.0 += message.token_usage_input.unwrap_or(0).max(0) as u64;
total.1 += message.token_usage_output.unwrap_or(0).max(0) as u64;
total.2 += message.cache_read_tokens.unwrap_or(0).max(0) as u64;
total.3 += message.cache_write_tokens.unwrap_or(0).max(0) as u64;
total
})
}
}
pub fn view<'a>(
state: &'a ChatEditorState,
locale: UiLocale,
ai_available: bool,
) -> Element<'a, Message> {
if !ai_available {
return container(inputs::card(
column![
text(t(locale, "chat.unavailable.title")).size(20),
text(t(locale, "chat.unavailable.guidance"))
.size(13)
.color(inputs::SECTION_COLOR),
button(text(t(locale, "chat.unavailable.openSettings")))
.on_press(Message::OpenSettingsSection(
crate::views::settings_view::SettingsSection::AI,
))
.style(inputs::primary_button),
]
.spacing(12),
))
.padding(16)
.width(Length::Fill)
.height(Length::Fill)
.into();
}
let selected_model = state.conversation.model.as_ref().and_then(|selected| {
state
.model_options
.iter()
.find(|model| model.id == *selected)
});
let model_control: Element<'a, Message> = if state.model_options.is_empty() {
text(t(locale, "chat.model.none"))
.size(12)
.color(inputs::SECTION_COLOR)
.into()
} else {
inputs::labeled_select(
&t(locale, "chat.model.label"),
&state.model_options,
selected_model,
|choice| Message::ChatModelChanged(choice.id),
)
};
let header = inputs::card(
column![
row![
text_input(&t(locale, "chat.rename.placeholder"), &state.rename_input)
.on_input(Message::ChatRenameInputChanged)
.on_submit(Message::ChatRename)
.size(18)
.padding([7, 9])
.style(inputs::field_style),
button(text(t(locale, "chat.rename.action")))
.on_press(Message::ChatRename)
.padding([8, 12])
.style(inputs::secondary_button),
button(text(t(locale, "common.delete")))
.on_press(Message::ChatDelete(state.conversation.id.clone()))
.padding([8, 12])
.style(inputs::danger_button),
]
.spacing(8)
.align_y(Alignment::Center),
model_control,
]
.spacing(10),
);
let mut message_elements: Vec<Element<'a, Message>> = Vec::new();
if state.messages.is_empty() {
message_elements.push(welcome(locale));
} else {
for message in &state.messages {
if message.role == ChatRole::Tool {
continue;
}
message_elements.push(message_view(
message,
state.rendered_messages.get(&message.id),
locale,
));
}
}
if state.streaming && !state.streaming_content.is_empty() {
message_elements.push(assistant_card(
&state.streaming_markdown,
t(locale, "chat.streaming"),
));
}
if let Some(tool) = state.active_tool.as_deref() {
message_elements.push(
inputs::card(
row![
text("").size(13),
text(tw(locale, "chat.tool.running", &[("name", tool)]))
.size(12)
.color(inputs::SECTION_COLOR),
]
.spacing(8),
)
.into(),
);
}
if let Some(error) = state.error.as_deref() {
message_elements.push(
text(error.to_string())
.size(12)
.color(Color::from_rgb8(0xE0, 0x6C, 0x75))
.into(),
);
}
let transcript = scrollable(
iced::widget::Column::with_children(message_elements)
.spacing(10)
.width(Length::Fill),
)
.anchor_bottom()
.height(Length::Fill);
let send_label = if state.streaming {
t(locale, "chat.stop")
} else {
t(locale, "chat.send")
};
let mut send_button = button(text(send_label))
.padding([8, 16])
.style(if state.streaming {
inputs::danger_button
} else {
inputs::primary_button
});
if state.streaming {
send_button = send_button.on_press(Message::ChatCancel);
} else if !state.input.text().trim().is_empty() {
send_button = send_button.on_press(Message::ChatSend);
}
let input_height =
(state.input.text().lines().count().max(1) as f32 * 22.0 + 28.0).clamp(72.0, 200.0);
let input_placeholder = t(locale, "chat.input.placeholder");
let composer = inputs::card(
column![
text_editor(&state.input)
.placeholder(input_placeholder)
.on_action(Message::ChatInputAction)
.key_binding(|key_press| {
if matches!(
key_press.key,
iced::keyboard::Key::Named(iced::keyboard::key::Named::Enter)
) && !key_press.modifiers.shift()
{
Some(iced::widget::text_editor::Binding::Custom(
Message::ChatSend,
))
} else {
iced::widget::text_editor::Binding::from_key_press(key_press)
}
})
.height(Length::Fixed(input_height))
.style(inputs::text_editor_style),
row![
text(t(locale, "chat.input.hint"))
.size(11)
.color(inputs::SECTION_COLOR),
Space::with_width(Length::Fill),
send_button,
]
.align_y(Alignment::Center),
]
.spacing(8),
);
column![header, transcript, composer]
.spacing(12)
.padding(16)
.width(Length::Fill)
.height(Length::Fill)
.into()
}
fn welcome(locale: UiLocale) -> Element<'static, Message> {
let mut items = vec![
text(t(locale, "chat.welcome.title")).size(20).into(),
text(t(locale, "chat.welcome.subtitle"))
.size(13)
.color(inputs::SECTION_COLOR)
.into(),
];
for index in 1..=5 {
items.push(
text(format!(
"{}",
t(locale, &format!("chat.welcome.tip{index}"))
))
.size(13)
.into(),
);
}
inputs::card(iced::widget::Column::with_children(items).spacing(7)).into()
}
fn message_view<'a>(
message: &'a ChatMessage,
rendered_markdown: Option<&'a Vec<markdown::Item>>,
locale: UiLocale,
) -> Element<'a, Message> {
let label = match message.role {
ChatRole::User => t(locale, "chat.role.user"),
ChatRole::Assistant => t(locale, "chat.role.assistant"),
ChatRole::System => t(locale, "chat.role.system"),
ChatRole::Tool => t(locale, "chat.role.tool"),
};
let content = message.content.as_deref().unwrap_or_default();
let body: Element<'a, Message> = if let Some(items) = rendered_markdown {
markdown::view(
items,
markdown::Settings::default(),
markdown::Style::from_palette(crate::components::inputs::app_theme().palette()),
)
.map(|url| Message::ChatLinkClicked(url.to_string()))
} else {
text(content.to_string())
.size(14)
.shaping(Shaping::Advanced)
.into()
};
let mut children: Vec<Element<'a, Message>> = vec![
text(label).size(11).color(inputs::SECTION_COLOR).into(),
body,
];
if let Some(tool_calls) = message.tool_calls.as_deref()
&& let Ok(calls) = serde_json::from_str::<Vec<serde_json::Value>>(tool_calls)
{
for call in calls {
let raw_name = call
.get("function")
.and_then(|function| function.get("name"))
.and_then(serde_json::Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| t(locale, "chat.role.tool"));
let name = raw_name.chars().take(30).collect::<String>();
let arguments = call
.get("function")
.and_then(|function| function.get("arguments"))
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let args_preview = arguments.chars().take(30).collect::<String>();
let marker = if args_preview.is_empty() {
format!("{}", tw(locale, "chat.tool.used", &[("name", &name)]))
} else {
format!(
"{} · {}",
tw(locale, "chat.tool.used", &[("name", &name)]),
args_preview
)
};
children.push(text(marker).size(11).color(inputs::SECTION_COLOR).into());
}
}
inputs::card(iced::widget::Column::with_children(children).spacing(6)).into()
}
fn assistant_card<'a>(items: &'a [markdown::Item], label: String) -> Element<'a, Message> {
let body = markdown::view(
items,
markdown::Settings::default(),
markdown::Style::from_palette(crate::components::inputs::app_theme().palette()),
)
.map(|url| Message::ChatLinkClicked(url.to_string()));
inputs::card(column![text(label).size(11).color(inputs::SECTION_COLOR), body,].spacing(6))
.into()
}
fn parse_safe_markdown(markdown_source: &str) -> Vec<markdown::Item> {
let safe = external_images_as_links(markdown_source);
markdown::parse(&safe).collect()
}
fn external_images_as_links(markdown_source: &str) -> String {
static EXTERNAL_IMAGE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
let pattern = EXTERNAL_IMAGE.get_or_init(|| {
regex::Regex::new(r"!\[([^\]]*)\]\((https?://[^)\s]+)\)")
.expect("external image Markdown regex")
});
pattern
.replace_all(markdown_source, "[🖼 $1]($2)")
.into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn markdown_never_embeds_external_images_or_raw_html() {
let rendered = parse_safe_markdown(
"# Hello\n![secret](https://example.com/a.png)<script>unsafe</script>",
);
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);
}
}

View File

@@ -1,4 +1,5 @@
pub mod activity_bar; pub mod activity_bar;
pub mod chat_view;
pub mod dashboard; pub mod dashboard;
pub mod git; pub mod git;
pub mod import_editor; pub mod import_editor;

View File

@@ -5,7 +5,7 @@ use iced::widget::{Space, button, column, container, image, row, scrollable, tex
use iced::{Background, Border, Color, Element, Length, Theme}; use iced::{Background, Border, Color, Element, Length, Theme};
use bds_core::i18n::UiLocale; 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::app::Message;
use crate::components::inputs; use crate::components::inputs;
@@ -78,7 +78,7 @@ fn placeholder_key(view: SidebarView) -> &'static str {
SidebarView::Scripts => "sidebar.noScriptsYet", SidebarView::Scripts => "sidebar.noScriptsYet",
SidebarView::Templates => "sidebar.noTemplatesYet", SidebarView::Templates => "sidebar.noTemplatesYet",
SidebarView::Tags => "sidebar.tagsHeader", SidebarView::Tags => "sidebar.tagsHeader",
SidebarView::Chat => "sidebar.chatPlaceholder", SidebarView::Chat => "chat.sidebar.empty",
SidebarView::Import => "sidebar.importPlaceholder", SidebarView::Import => "sidebar.importPlaceholder",
SidebarView::Git => "git.noChanges", SidebarView::Git => "git.noChanges",
SidebarView::Settings => "sidebar.settingsHeader", SidebarView::Settings => "sidebar.settingsHeader",
@@ -628,6 +628,7 @@ pub fn view(
scripts: &[Script], scripts: &[Script],
templates: &[Template], templates: &[Template],
imports: &[ImportDefinition], imports: &[ImportDefinition],
conversations: &[ChatConversation],
post_filter: &PostFilter, post_filter: &PostFilter,
media_filter: &MediaFilter, media_filter: &MediaFilter,
media_thumbs: &std::collections::HashMap<String, Option<PathBuf>>, media_thumbs: &std::collections::HashMap<String, Option<PathBuf>>,
@@ -655,6 +656,7 @@ pub fn view(
SidebarView::Scripts => Some(Message::CreateScript), SidebarView::Scripts => Some(Message::CreateScript),
SidebarView::Templates => Some(Message::CreateTemplate), SidebarView::Templates => Some(Message::CreateTemplate),
SidebarView::Import => Some(Message::CreateImport), SidebarView::Import => Some(Message::CreateImport),
SidebarView::Chat => Some(Message::ChatCreate),
_ => None, _ => None,
}; };
@@ -1132,6 +1134,54 @@ pub fn view(
iced::widget::Column::with_children(items).spacing(1).into() 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::<Vec<Element<'static, Message>>>();
iced::widget::Column::with_children(items).spacing(1).into()
}
}
SidebarView::Settings => { SidebarView::Settings => {
// Per sidebar_views.allium SettingsNav: 9 fixed-order sections // Per sidebar_views.allium SettingsNav: 9 fixed-order sections
use crate::views::settings_view::SettingsSection; use crate::views::settings_view::SettingsSection;
@@ -1197,11 +1247,6 @@ pub fn view(
iced::widget::Column::with_children(items).spacing(1).into() iced::widget::Column::with_children(items).spacing(1).into()
} }
SidebarView::Git => crate::views::git::sidebar_view(git_state, offline_mode, locale), 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,] let content = column![header_row, Space::with_height(8.0), body,]

View File

@@ -123,6 +123,7 @@ pub fn view(
task_snapshots: &[TaskSnapshot], task_snapshots: &[TaskSnapshot],
theme_badge: &str, theme_badge: &str,
active_post_status: Option<&str>, active_post_status: Option<&str>,
chat_tokens: Option<(u64, u64, u64, u64)>,
) -> Element<'static, Message> { ) -> Element<'static, Message> {
let label_color = Color::from_rgb(0.60, 0.60, 0.65); let label_color = Color::from_rgb(0.60, 0.60, 0.65);
@@ -194,6 +195,28 @@ pub fn view(
"statusBar.media", "statusBar.media",
&[("count", &media_count.to_string())], &[("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 // Airplane mode toggle — ✈ icon
let airplane_btn = button(text("\u{2708}").size(13).shaping(Shaping::Advanced)) let airplane_btn = button(text("\u{2708}").size(13).shaping(Shaping::Advanced))
@@ -225,6 +248,7 @@ pub fn view(
.size(11) .size(11)
.shaping(Shaping::Advanced) .shaping(Shaping::Advanced)
.color(label_color), .color(label_color),
chat_tokens_el,
text(theme_badge.to_string()) text(theme_badge.to_string())
.size(11) .size(11)
.shaping(Shaping::Advanced) .shaping(Shaping::Advanced)

View File

@@ -7,7 +7,7 @@ use iced::{Alignment, Background, Color, Element, Length, Padding, Theme};
use bds_core::engine::git::GitCommit; use bds_core::engine::git::GitCommit;
use bds_core::i18n::UiLocale; 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::app::Message;
use crate::state::navigation::{OutputEntry, PanelTab, SidebarView, TaskSnapshot}; 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::state::toast::Toast;
use crate::views::{ use crate::views::{
activity_bar, activity_bar,
chat_view::{self, ChatEditorState},
dashboard::DashboardState, dashboard::DashboardState,
git::{self, GitDiffState, GitUiState}, git::{self, GitDiffState, GitUiState},
media_editor::{self, MediaEditorState}, media_editor::{self, MediaEditorState},
@@ -88,6 +89,7 @@ pub fn view<'a>(
sidebar_scripts: &'a [Script], sidebar_scripts: &'a [Script],
sidebar_templates: &'a [Template], sidebar_templates: &'a [Template],
sidebar_imports: &'a [ImportDefinition], sidebar_imports: &'a [ImportDefinition],
chat_conversations: &'a [ChatConversation],
// Sidebar filters // Sidebar filters
post_filter: &'a PostFilter, post_filter: &'a PostFilter,
media_filter: &'a MediaFilter, media_filter: &'a MediaFilter,
@@ -121,6 +123,7 @@ pub fn view<'a>(
template_editors: &'a HashMap<String, TemplateEditorState>, template_editors: &'a HashMap<String, TemplateEditorState>,
script_editors: &'a HashMap<String, ScriptEditorState>, script_editors: &'a HashMap<String, ScriptEditorState>,
import_editors: &'a HashMap<String, crate::views::import_editor::ImportEditorState>, import_editors: &'a HashMap<String, crate::views::import_editor::ImportEditorState>,
chat_editors: &'a HashMap<String, ChatEditorState>,
tags_view_state: Option<&'a TagsViewState>, tags_view_state: Option<&'a TagsViewState>,
settings_state: Option<&'a SettingsViewState>, settings_state: Option<&'a SettingsViewState>,
dashboard_state: Option<&'a DashboardState>, dashboard_state: Option<&'a DashboardState>,
@@ -150,6 +153,7 @@ pub fn view<'a>(
template_editors, template_editors,
script_editors, script_editors,
import_editors, import_editors,
chat_editors,
tags_view_state, tags_view_state,
settings_state, settings_state,
dashboard_state, dashboard_state,
@@ -204,6 +208,7 @@ pub fn view<'a>(
sidebar_scripts, sidebar_scripts,
sidebar_templates, sidebar_templates,
sidebar_imports, sidebar_imports,
chat_conversations,
post_filter, post_filter,
media_filter, media_filter,
sidebar_media_thumbs, sidebar_media_thumbs,
@@ -262,6 +267,9 @@ pub fn view<'a>(
task_snapshots, task_snapshots,
theme_badge, theme_badge,
active_post_status.as_deref(), 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] 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<String, TemplateEditorState>, template_editors: &'a HashMap<String, TemplateEditorState>,
script_editors: &'a HashMap<String, ScriptEditorState>, script_editors: &'a HashMap<String, ScriptEditorState>,
import_editors: &'a HashMap<String, crate::views::import_editor::ImportEditorState>, import_editors: &'a HashMap<String, crate::views::import_editor::ImportEditorState>,
chat_editors: &'a HashMap<String, ChatEditorState>,
tags_view_state: Option<&'a TagsViewState>, tags_view_state: Option<&'a TagsViewState>,
settings_state: Option<&'a SettingsViewState>, settings_state: Option<&'a SettingsViewState>,
dashboard_state: Option<&'a DashboardState>, dashboard_state: Option<&'a DashboardState>,
@@ -458,6 +467,13 @@ fn route_content_area<'a>(
loading_view(locale) 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 => { ContentRoute::Tags => {
if let Some(state) = tags_view_state { if let Some(state) = tags_view_state {
tags_view::view(state, locale) tags_view::view(state, locale)
@@ -504,6 +520,7 @@ enum ContentRoute<'a> {
Templates(&'a str), Templates(&'a str),
Scripts(&'a str), Scripts(&'a str),
Import(&'a str), Import(&'a str),
Chat(&'a str),
Tags, Tags,
Settings, Settings,
SiteValidation, SiteValidation,
@@ -577,6 +594,7 @@ fn route_kind<'a>(
ContentRoute::Loading ContentRoute::Loading
} }
} }
TabType::Chat => ContentRoute::Chat(tab_id),
TabType::Tags => { TabType::Tags => {
if tags_view_state.is_some() { if tags_view_state.is_some() {
ContentRoute::Tags ContentRoute::Tags
@@ -595,7 +613,6 @@ fn route_kind<'a>(
TabType::MetadataDiff => ContentRoute::MetadataDiff, TabType::MetadataDiff => ContentRoute::MetadataDiff,
TabType::GitDiff => ContentRoute::GitDiff(tab_id), TabType::GitDiff => ContentRoute::GitDiff(tab_id),
TabType::Style TabType::Style
| TabType::Chat
| TabType::MenuEditor | TabType::MenuEditor
| TabType::Documentation | TabType::Documentation
| TabType::ApiDocumentation | TabType::ApiDocumentation
@@ -658,7 +675,6 @@ mod tests {
let site_validation_state = SiteValidationState::default(); let site_validation_state = SiteValidationState::default();
let unsupported = [ let unsupported = [
TabType::Style, TabType::Style,
TabType::Chat,
TabType::MenuEditor, TabType::MenuEditor,
TabType::Documentation, TabType::Documentation,
TabType::ApiDocumentation, TabType::ApiDocumentation,
@@ -712,6 +728,31 @@ mod tests {
assert!(matches!(route, ContentRoute::GitDiff("git-diff:file.txt"))); 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] #[test]
fn validation_tabs_route_to_real_views() { fn validation_tabs_route_to_real_views() {
let empty_posts = HashMap::new(); let empty_posts = HashMap::new();

View File

@@ -43,6 +43,42 @@ activity-sourceControl = Versionskontrolle
common-save = Speichern common-save = Speichern
common-open = Öffnen common-open = Öffnen
common-cancel = Abbrechen 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-settings = Einstellungen
common-tasks = Aufgaben common-tasks = Aufgaben
common-running = laufend common-running = laufend
@@ -120,7 +156,6 @@ sidebar-noPagesYet = Noch keine Seiten
sidebar-noMediaYet = Noch keine Medien sidebar-noMediaYet = Noch keine Medien
sidebar-noScriptsYet = Noch keine Skripte sidebar-noScriptsYet = Noch keine Skripte
sidebar-noTemplatesYet = Noch keine Vorlagen sidebar-noTemplatesYet = Noch keine Vorlagen
sidebar-chatPlaceholder = KI-Assistent erscheint hier
sidebar-importPlaceholder = Inhalte aus externen Quellen importieren sidebar-importPlaceholder = Inhalte aus externen Quellen importieren
sidebar-loading = Lädt... sidebar-loading = Lädt...
sidebar-settingsHeader = Einstellungen sidebar-settingsHeader = Einstellungen

View File

@@ -43,6 +43,42 @@ activity-sourceControl = Source Control
common-save = Save common-save = Save
common-open = Open common-open = Open
common-cancel = Cancel 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-settings = Settings
common-tasks = Tasks common-tasks = Tasks
common-running = running common-running = running
@@ -120,7 +156,6 @@ sidebar-noPagesYet = No pages yet
sidebar-noMediaYet = No media yet sidebar-noMediaYet = No media yet
sidebar-noScriptsYet = No scripts yet sidebar-noScriptsYet = No scripts yet
sidebar-noTemplatesYet = No templates yet sidebar-noTemplatesYet = No templates yet
sidebar-chatPlaceholder = AI assistant will appear here
sidebar-importPlaceholder = Import content from external sources sidebar-importPlaceholder = Import content from external sources
sidebar-loading = Loading... sidebar-loading = Loading...
sidebar-settingsHeader = Settings sidebar-settingsHeader = Settings

View File

@@ -43,6 +43,42 @@ activity-sourceControl = Control de código fuente
common-save = Guardar common-save = Guardar
common-open = Abrir common-open = Abrir
common-cancel = Cancelar 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-settings = Configuración
common-tasks = Tareas common-tasks = Tareas
common-running = en ejecución 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-noMediaYet = Aún no hay medios
sidebar-noScriptsYet = Aún no hay scripts sidebar-noScriptsYet = Aún no hay scripts
sidebar-noTemplatesYet = Aún no hay plantillas sidebar-noTemplatesYet = Aún no hay plantillas
sidebar-chatPlaceholder = El asistente IA aparecerá aquí
sidebar-importPlaceholder = Importar contenido desde fuentes externas sidebar-importPlaceholder = Importar contenido desde fuentes externas
sidebar-loading = Cargando... sidebar-loading = Cargando...
sidebar-settingsHeader = Configuración sidebar-settingsHeader = Configuración

View File

@@ -43,6 +43,42 @@ activity-sourceControl = Contrôle de source
common-save = Enregistrer common-save = Enregistrer
common-open = Ouvrir common-open = Ouvrir
common-cancel = Annuler common-cancel = Annuler
common-delete = Supprimer
chat-sidebar-empty = Aucune conversation
chat-new = Nouvelle conversation
chat-newWithModel = Conversation avec { $model }
chat-unavailable-title = LIA conversationnelle est indisponible
chat-unavailable-guidance = Configurez le point daccès IA actif et le modèle dans les réglages. Le mode avion utilise uniquement le point daccè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 dun 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 nest pas autorisé
chat-navigation-invalid = Lélément du blog demandé na pas pu être ouvert
statusBar-chatTokens = IA ↑{ $input } ↓{ $output } cache { $cacheRead }/{ $cacheWrite }
common-settings = Paramètres common-settings = Paramètres
common-tasks = Tâches common-tasks = Tâches
common-running = en cours common-running = en cours
@@ -120,7 +156,6 @@ sidebar-noPagesYet = Aucune page pour le moment
sidebar-noMediaYet = Aucun média pour le moment sidebar-noMediaYet = Aucun média pour le moment
sidebar-noScriptsYet = Aucun script sidebar-noScriptsYet = Aucun script
sidebar-noTemplatesYet = Aucun modèle sidebar-noTemplatesYet = Aucun modèle
sidebar-chatPlaceholder = L'assistant IA apparaîtra ici
sidebar-importPlaceholder = Importer du contenu depuis des sources externes sidebar-importPlaceholder = Importer du contenu depuis des sources externes
sidebar-loading = Chargement... sidebar-loading = Chargement...
sidebar-settingsHeader = Paramètres sidebar-settingsHeader = Paramètres

View File

@@ -43,6 +43,42 @@ activity-sourceControl = Controllo sorgente
common-save = Salva common-save = Salva
common-open = Apri common-open = Apri
common-cancel = Annulla common-cancel = Annulla
common-delete = Elimina
chat-sidebar-empty = Nessuna conversazione
chat-new = Nuova conversazione
chat-newWithModel = Conversazione con { $model }
chat-unavailable-title = LIA conversazionale non è disponibile
chat-unavailable-guidance = Configura lendpoint IA attivo e il modello nelle impostazioni. La modalità aereo usa solo lendpoint 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 lelemento del blog richiesto
statusBar-chatTokens = IA ↑{ $input } ↓{ $output } cache { $cacheRead }/{ $cacheWrite }
common-settings = Impostazioni common-settings = Impostazioni
common-tasks = Attività common-tasks = Attività
common-running = in esecuzione common-running = in esecuzione
@@ -120,7 +156,6 @@ sidebar-noPagesYet = Nessuna pagina
sidebar-noMediaYet = Nessun media sidebar-noMediaYet = Nessun media
sidebar-noScriptsYet = Nessuno script sidebar-noScriptsYet = Nessuno script
sidebar-noTemplatesYet = Nessun modello sidebar-noTemplatesYet = Nessun modello
sidebar-chatPlaceholder = L'assistente IA apparirà qui
sidebar-importPlaceholder = Importa contenuti da fonti esterne sidebar-importPlaceholder = Importa contenuti da fonti esterne
sidebar-loading = Caricamento... sidebar-loading = Caricamento...
sidebar-settingsHeader = Impostazioni sidebar-settingsHeader = Impostazioni