Merge pull request 'Feature/open chat with persona' (#110) from feature/open-chat-with-persona into master
Reviewed-on: #110
This commit was merged in pull request #110.
This commit is contained in:
Generated
+1
-1
@@ -2051,7 +2051,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "image-api"
|
name = "image-api"
|
||||||
version = "1.4.0"
|
version = "1.5.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"actix",
|
"actix",
|
||||||
"actix-cors",
|
"actix-cors",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "image-api"
|
name = "image-api"
|
||||||
version = "1.4.0"
|
version = "1.5.0"
|
||||||
authors = ["Cameron Cordes <cameronc.dev@gmail.com>"]
|
authors = ["Cameron Cordes <cameronc.dev@gmail.com>"]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS persona_chat_conversations;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- Open chat with persona — file-anchored insight chat is keyed by
|
||||||
|
-- (library_id, file_path). The open chat is keyed by (user_id, persona_id)
|
||||||
|
-- with a single rolling transcript per pair. No tree branching in v1
|
||||||
|
-- (matches the locked-in "single rolling conversation per persona" scope);
|
||||||
|
-- a flat JSON blob is enough and avoids forcing a tree shape onto a
|
||||||
|
-- surface that's intentionally linear.
|
||||||
|
--
|
||||||
|
-- `messages_json` is the same `Vec<ChatMessage>` shape the file-anchored
|
||||||
|
-- chat persists, so the SSE replay / UI rendering on the mobile client
|
||||||
|
-- can reuse the same parser without a second schema.
|
||||||
|
|
||||||
|
CREATE TABLE persona_chat_conversations (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
persona_id TEXT NOT NULL,
|
||||||
|
messages_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
turn_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, persona_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_persona_chat_updated
|
||||||
|
ON persona_chat_conversations (user_id, updated_at DESC);
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- Collapse back to one conversation per (user, persona). Where a persona has
|
||||||
|
-- several, the most recently updated one wins and the rest are dropped —
|
||||||
|
-- the v1 schema has nowhere to put them.
|
||||||
|
|
||||||
|
CREATE TABLE persona_chat_conversations_old (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
persona_id TEXT NOT NULL,
|
||||||
|
messages_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
turn_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, persona_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO persona_chat_conversations_old (
|
||||||
|
user_id, persona_id, messages_json, turn_count, updated_at
|
||||||
|
)
|
||||||
|
SELECT user_id, persona_id, messages_json, turn_count, updated_at
|
||||||
|
FROM persona_chat_conversations c
|
||||||
|
WHERE c.updated_at = (
|
||||||
|
SELECT MAX(c2.updated_at)
|
||||||
|
FROM persona_chat_conversations c2
|
||||||
|
WHERE c2.user_id = c.user_id AND c2.persona_id = c.persona_id
|
||||||
|
)
|
||||||
|
GROUP BY user_id, persona_id;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_persona_chat_updated;
|
||||||
|
DROP INDEX IF EXISTS idx_persona_chat_persona;
|
||||||
|
DROP TABLE persona_chat_conversations;
|
||||||
|
ALTER TABLE persona_chat_conversations_old RENAME TO persona_chat_conversations;
|
||||||
|
|
||||||
|
CREATE INDEX idx_persona_chat_updated
|
||||||
|
ON persona_chat_conversations (user_id, updated_at DESC);
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
-- Multiple conversations per persona.
|
||||||
|
--
|
||||||
|
-- v1 keyed a transcript on (user_id, persona_id), so a persona had exactly
|
||||||
|
-- one rolling conversation and there was no way to start a fresh topic
|
||||||
|
-- without discarding the old one. The key is now an opaque `conversation_id`,
|
||||||
|
-- with (user_id, persona_id) demoted to an index.
|
||||||
|
--
|
||||||
|
-- `title` is a short generated summary of the opening exchange, used as the
|
||||||
|
-- conversation's name in the list. Empty until the first turn completes; the
|
||||||
|
-- client falls back to the persona name while it is blank.
|
||||||
|
--
|
||||||
|
-- SQLite cannot redefine a primary key in place, so this is the standard
|
||||||
|
-- create-copy-drop-rename dance. Existing transcripts carry over with a
|
||||||
|
-- generated id and an empty title.
|
||||||
|
|
||||||
|
CREATE TABLE persona_chat_conversations_new (
|
||||||
|
conversation_id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
persona_id TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
messages_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
turn_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at BIGINT NOT NULL,
|
||||||
|
updated_at BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO persona_chat_conversations_new (
|
||||||
|
conversation_id, user_id, persona_id, title,
|
||||||
|
messages_json, turn_count, created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
lower(hex(randomblob(16))),
|
||||||
|
user_id,
|
||||||
|
persona_id,
|
||||||
|
'',
|
||||||
|
messages_json,
|
||||||
|
turn_count,
|
||||||
|
updated_at,
|
||||||
|
updated_at
|
||||||
|
FROM persona_chat_conversations;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_persona_chat_updated;
|
||||||
|
DROP TABLE persona_chat_conversations;
|
||||||
|
ALTER TABLE persona_chat_conversations_new RENAME TO persona_chat_conversations;
|
||||||
|
|
||||||
|
CREATE INDEX idx_persona_chat_updated
|
||||||
|
ON persona_chat_conversations (user_id, updated_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX idx_persona_chat_persona
|
||||||
|
ON persona_chat_conversations (user_id, persona_id);
|
||||||
+55
-9
@@ -1819,6 +1819,19 @@ pub async fn turn_replay_handler(
|
|||||||
path: web::Path<String>,
|
path: web::Path<String>,
|
||||||
query: web::Query<ReplayQuery>,
|
query: web::Query<ReplayQuery>,
|
||||||
app_state: web::Data<AppState>,
|
app_state: web::Data<AppState>,
|
||||||
|
) -> HttpResponse {
|
||||||
|
turn_replay_impl(http_request, path, query, app_state).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Core of the SSE replay, kept attribute-free so the persona-chat routes
|
||||||
|
/// can reuse it under `/persona_chat/turn/{turn_id}`. The registry is keyed
|
||||||
|
/// on `turn_id` only, and `render_turn_info_frame` already scopes the
|
||||||
|
/// identity (persona turns carry `persona_id` and no `file_path`).
|
||||||
|
pub(crate) async fn turn_replay_impl(
|
||||||
|
http_request: HttpRequest,
|
||||||
|
path: web::Path<String>,
|
||||||
|
query: web::Query<ReplayQuery>,
|
||||||
|
app_state: web::Data<AppState>,
|
||||||
) -> HttpResponse {
|
) -> HttpResponse {
|
||||||
use crate::ai::turn_registry::ReplayOutcome;
|
use crate::ai::turn_registry::ReplayOutcome;
|
||||||
|
|
||||||
@@ -1949,15 +1962,31 @@ pub async fn turn_replay_handler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn render_turn_info_frame(info: &crate::ai::turn_registry::TurnInfo) -> String {
|
fn render_turn_info_frame(info: &crate::ai::turn_registry::TurnInfo) -> String {
|
||||||
let payload = serde_json::json!({
|
// Persona-scoped turns leak no `file_path` to the client — the open
|
||||||
"turn_id": info.turn_id,
|
// chat is keyed on `(persona_id)` only and a stray path echo from a
|
||||||
"file_path": info.file_path,
|
// quoted SMS would be a privacy regression. Insight-scoped turns
|
||||||
"library_id": info.library_id,
|
// include the path as before.
|
||||||
"status": info.status.as_str(),
|
let mut payload = serde_json::Map::new();
|
||||||
"total_events_pushed": info.total_events_pushed,
|
payload.insert("turn_id".into(), serde_json::json!(info.turn_id));
|
||||||
"buffered_count": info.buffered_count,
|
payload.insert("library_id".into(), serde_json::json!(info.library_id));
|
||||||
});
|
payload.insert("scope".into(), serde_json::json!(info.scope));
|
||||||
let data = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string());
|
payload.insert("status".into(), serde_json::json!(info.status.as_str()));
|
||||||
|
payload.insert(
|
||||||
|
"total_events_pushed".into(),
|
||||||
|
serde_json::json!(info.total_events_pushed),
|
||||||
|
);
|
||||||
|
payload.insert(
|
||||||
|
"buffered_count".into(),
|
||||||
|
serde_json::json!(info.buffered_count),
|
||||||
|
);
|
||||||
|
if info.scope == "insight" {
|
||||||
|
payload.insert("file_path".into(), serde_json::json!(info.file_path));
|
||||||
|
}
|
||||||
|
if let Some(ref pid) = info.persona_id {
|
||||||
|
payload.insert("persona_id".into(), serde_json::json!(pid));
|
||||||
|
}
|
||||||
|
let data = serde_json::to_string(&serde_json::Value::Object(payload))
|
||||||
|
.unwrap_or_else(|_| "{}".to_string());
|
||||||
format!("event: turn_info\ndata: {}\n\n", data)
|
format!("event: turn_info\ndata: {}\n\n", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1967,6 +1996,16 @@ pub async fn cancel_turn_handler(
|
|||||||
http_request: HttpRequest,
|
http_request: HttpRequest,
|
||||||
path: web::Path<String>,
|
path: web::Path<String>,
|
||||||
app_state: web::Data<AppState>,
|
app_state: web::Data<AppState>,
|
||||||
|
) -> impl Responder {
|
||||||
|
cancel_turn_impl(http_request, path, app_state).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Core of the turn-cancel, attribute-free so the persona-chat DELETE route
|
||||||
|
/// can reuse it under `/persona_chat/turn/{turn_id}`.
|
||||||
|
pub(crate) async fn cancel_turn_impl(
|
||||||
|
http_request: HttpRequest,
|
||||||
|
path: web::Path<String>,
|
||||||
|
app_state: web::Data<AppState>,
|
||||||
) -> impl Responder {
|
) -> impl Responder {
|
||||||
let turn_id = path.into_inner();
|
let turn_id = path.into_inner();
|
||||||
|
|
||||||
@@ -2017,6 +2056,13 @@ pub async fn cancel_turn_handler(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persona-chat handlers (`/persona_chat/*`) live in `ai::persona_chat` and
|
||||||
|
// are registered in main.rs. The persona SSE replay and cancel routes are
|
||||||
|
// thin wrappers over `turn_replay_handler` + `cancel_turn_handler`: both
|
||||||
|
// are keyed on `turn_id`, not file_path, and `TurnInfo::scope` ("persona")
|
||||||
|
// lets the shared `turn_info` frame carry the right identity (persona_id,
|
||||||
|
// no file_path).
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod turn_replay_tests {
|
mod turn_replay_tests {
|
||||||
use super::{cancel_turn_handler, render_indexed_frame, turn_replay_handler};
|
use super::{cancel_turn_handler, render_indexed_frame, turn_replay_handler};
|
||||||
|
|||||||
+219
-175
@@ -20,7 +20,7 @@ use crate::utils::{normalize_path, retry_with_backoff};
|
|||||||
use futures::stream::{BoxStream, StreamExt};
|
use futures::stream::{BoxStream, StreamExt};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
const DEFAULT_MAX_ITERATIONS: usize = 6;
|
pub const DEFAULT_MAX_ITERATIONS: usize = 6;
|
||||||
/// Assumed context window when the request doesn't specify `num_ctx`.
|
/// Assumed context window when the request doesn't specify `num_ctx`.
|
||||||
/// The llama-swap chat slots serve 20k-131k contexts and real conversations
|
/// The llama-swap chat slots serve 20k-131k contexts and real conversations
|
||||||
/// rarely pass ~16k tokens, so 32k keeps the truncation pass from gutting
|
/// rarely pass ~16k tokens, so 32k keeps the truncation pass from gutting
|
||||||
@@ -30,11 +30,11 @@ const DEFAULT_MAX_ITERATIONS: usize = 6;
|
|||||||
const DEFAULT_NUM_CTX: i32 = 32768;
|
const DEFAULT_NUM_CTX: i32 = 32768;
|
||||||
/// Headroom reserved for the model's response, deducted from the context
|
/// Headroom reserved for the model's response, deducted from the context
|
||||||
/// budget when deciding whether to truncate the replayed history.
|
/// budget when deciding whether to truncate the replayed history.
|
||||||
const RESPONSE_HEADROOM_TOKENS: usize = 2048;
|
pub(crate) const RESPONSE_HEADROOM_TOKENS: usize = 2048;
|
||||||
/// Cheap byte-to-token approximation used by the truncation pass. The real
|
/// Cheap byte-to-token approximation used by the truncation pass. The real
|
||||||
/// tokenization is model-specific; this avoids carrying tiktoken just for a
|
/// tokenization is model-specific; this avoids carrying tiktoken just for a
|
||||||
/// soft bound.
|
/// soft bound.
|
||||||
const BYTES_PER_TOKEN: usize = 4;
|
pub(crate) const BYTES_PER_TOKEN: usize = 4;
|
||||||
/// Flat token cost charged per inlined image in the truncation budget. A
|
/// Flat token cost charged per inlined image in the truncation budget. A
|
||||||
/// 1024px-longest-edge JPEG (see `load_image_as_base64`) costs vision models on
|
/// 1024px-longest-edge JPEG (see `load_image_as_base64`) costs vision models on
|
||||||
/// the order of ~1.3K tokens. Crucially, the raw base64 (hundreds of KB of
|
/// the order of ~1.3K tokens. Crucially, the raw base64 (hundreds of KB of
|
||||||
@@ -335,6 +335,13 @@ impl InsightChatService {
|
|||||||
if truncated {
|
if truncated {
|
||||||
span.set_attribute(KeyValue::new("history_truncated", true));
|
span.set_attribute(KeyValue::new("history_truncated", true));
|
||||||
}
|
}
|
||||||
|
// Everything in `messages` at this point is replayed history that is
|
||||||
|
// ALREADY in the tree; only what the turn appends past this mark
|
||||||
|
// becomes new nodes. Captured after the budget pass, not from
|
||||||
|
// `path.len()`: truncation drains from the middle, so `path.len()`
|
||||||
|
// over-counts and the slice below would either swallow the new user
|
||||||
|
// turn or panic on an out-of-range start index.
|
||||||
|
let history_len = messages.len();
|
||||||
|
|
||||||
// 7. Append the new user turn.
|
// 7. Append the new user turn.
|
||||||
messages.push(ChatMessage::user(req.user_message.clone()));
|
messages.push(ChatMessage::user(req.user_message.clone()));
|
||||||
@@ -461,8 +468,7 @@ impl InsightChatService {
|
|||||||
// relying on store_insight to flip prior rows' is_current=false.
|
// relying on store_insight to flip prior rows' is_current=false.
|
||||||
// Append new messages (this turn's user + assistant exchanges) as
|
// Append new messages (this turn's user + assistant exchanges) as
|
||||||
// tree nodes chained from the previous active_leaf_id.
|
// tree nodes chained from the previous active_leaf_id.
|
||||||
let path_len = path.len();
|
let new_messages = messages[history_len..].to_vec();
|
||||||
let new_messages = messages[path_len..].to_vec();
|
|
||||||
let mut parent_id = Some(store.active_leaf_id);
|
let mut parent_id = Some(store.active_leaf_id);
|
||||||
for msg in &new_messages {
|
for msg in &new_messages {
|
||||||
let new_id = store.append_node(parent_id, msg.clone());
|
let new_id = store.append_node(parent_id, msg.clone());
|
||||||
@@ -942,7 +948,6 @@ impl InsightChatService {
|
|||||||
let path = store
|
let path = store
|
||||||
.path_to_leaf(store.active_leaf_id)
|
.path_to_leaf(store.active_leaf_id)
|
||||||
.ok_or_else(|| anyhow!("active_leaf_id {} not found in tree", store.active_leaf_id))?;
|
.ok_or_else(|| anyhow!("active_leaf_id {} not found in tree", store.active_leaf_id))?;
|
||||||
let path_len = path.len();
|
|
||||||
let mut messages: Vec<ChatMessage> = path.iter().map(|n| n.message.clone()).collect();
|
let mut messages: Vec<ChatMessage> = path.iter().map(|n| n.message.clone()).collect();
|
||||||
|
|
||||||
let stored_backend = insight.backend.clone();
|
let stored_backend = insight.backend.clone();
|
||||||
@@ -1003,6 +1008,10 @@ impl InsightChatService {
|
|||||||
if truncated {
|
if truncated {
|
||||||
let _ = entry.push_event(ChatStreamEvent::Truncated).await;
|
let _ = entry.push_event(ChatStreamEvent::Truncated).await;
|
||||||
}
|
}
|
||||||
|
// See the note in `chat_turn`: the new-node boundary must be read
|
||||||
|
// after the budget pass, because truncation drains replayed history
|
||||||
|
// out of the middle of `messages`.
|
||||||
|
let history_len = messages.len();
|
||||||
|
|
||||||
messages.push(ChatMessage::user(req.user_message.clone()));
|
messages.push(ChatMessage::user(req.user_message.clone()));
|
||||||
|
|
||||||
@@ -1046,7 +1055,7 @@ impl InsightChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Append new messages as tree nodes.
|
// Append new messages as tree nodes.
|
||||||
let new_messages = messages[path_len..].to_vec();
|
let new_messages = messages[history_len..].to_vec();
|
||||||
let mut parent_id = Some(store.active_leaf_id);
|
let mut parent_id = Some(store.active_leaf_id);
|
||||||
for msg in &new_messages {
|
for msg in &new_messages {
|
||||||
let new_id = store.append_node(parent_id, msg.clone());
|
let new_id = store.append_node(parent_id, msg.clone());
|
||||||
@@ -1303,7 +1312,12 @@ impl InsightChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Agentic loop variant that pushes events to a `TurnEntry` buffer.
|
/// Agentic loop variant that pushes events to a `TurnEntry` buffer.
|
||||||
async fn run_streaming_agentic_loop_with_entry(
|
/// Same as `run_streaming_agentic_loop` but emits events to a
|
||||||
|
/// `TurnEntry` for SSE replay. Thin wrapper around the free function
|
||||||
|
/// `run_streaming_agentic_loop_with_entry` so the persona chat
|
||||||
|
/// (and any future chat-without-file surface) can reuse the loop
|
||||||
|
/// body without instantiating an `InsightChatService`.
|
||||||
|
pub async fn run_streaming_agentic_loop_with_entry(
|
||||||
&self,
|
&self,
|
||||||
backend: &ResolvedBackend,
|
backend: &ResolvedBackend,
|
||||||
messages: &mut Vec<ChatMessage>,
|
messages: &mut Vec<ChatMessage>,
|
||||||
@@ -1315,160 +1329,19 @@ impl InsightChatService {
|
|||||||
max_iterations: usize,
|
max_iterations: usize,
|
||||||
entry: &Arc<TurnEntry>,
|
entry: &Arc<TurnEntry>,
|
||||||
) -> Result<AgenticLoopOutcome> {
|
) -> Result<AgenticLoopOutcome> {
|
||||||
let mut tool_calls_made = 0usize;
|
crate::ai::insight_chat::run_streaming_agentic_loop_with_entry(
|
||||||
let mut iterations_used = 0usize;
|
&self.generator,
|
||||||
let mut last_prompt_eval_count: Option<i32> = None;
|
backend,
|
||||||
let mut last_eval_count: Option<i32> = None;
|
messages,
|
||||||
let mut final_content = String::new();
|
tools,
|
||||||
|
image_base64,
|
||||||
for iteration in 0..max_iterations {
|
normalized,
|
||||||
// Cooperative cancellation: a DELETE flips status out of Running
|
user_id,
|
||||||
// (and aborts this task). Check at the iteration boundary so an
|
active_persona,
|
||||||
// in-flight tool round finishes cleanly rather than mid-write.
|
max_iterations,
|
||||||
if !entry.is_running() {
|
entry,
|
||||||
return Ok(AgenticLoopOutcome {
|
)
|
||||||
tool_calls_made,
|
.await
|
||||||
iterations_used,
|
|
||||||
last_prompt_eval_count,
|
|
||||||
last_eval_count,
|
|
||||||
final_content,
|
|
||||||
cancelled: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
iterations_used = iteration + 1;
|
|
||||||
let _ = entry
|
|
||||||
.push_event(ChatStreamEvent::IterationStart {
|
|
||||||
n: iterations_used,
|
|
||||||
max: max_iterations,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let mut stream = backend
|
|
||||||
.chat()
|
|
||||||
.chat_with_tools_stream(messages.clone(), tools.clone())
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let mut final_message: Option<ChatMessage> = None;
|
|
||||||
while let Some(ev) = stream.next().await {
|
|
||||||
let ev = ev?;
|
|
||||||
match ev {
|
|
||||||
LlmStreamEvent::TextDelta(delta) => {
|
|
||||||
let _ = entry.push_event(ChatStreamEvent::TextDelta(delta)).await;
|
|
||||||
}
|
|
||||||
LlmStreamEvent::Done {
|
|
||||||
message,
|
|
||||||
prompt_eval_count,
|
|
||||||
eval_count,
|
|
||||||
} => {
|
|
||||||
last_prompt_eval_count = prompt_eval_count;
|
|
||||||
last_eval_count = eval_count;
|
|
||||||
final_message = Some(message);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut response =
|
|
||||||
final_message.ok_or_else(|| anyhow!("stream ended without a Done event"))?;
|
|
||||||
|
|
||||||
if let Some(ref mut tcs) = response.tool_calls {
|
|
||||||
for tc in tcs.iter_mut() {
|
|
||||||
if !tc.function.arguments.is_object() {
|
|
||||||
tc.function.arguments = serde_json::Value::Object(Default::default());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
messages.push(response.clone());
|
|
||||||
|
|
||||||
if let Some(ref tool_calls) = response.tool_calls
|
|
||||||
&& !tool_calls.is_empty()
|
|
||||||
{
|
|
||||||
for tool_call in tool_calls {
|
|
||||||
tool_calls_made += 1;
|
|
||||||
let call_index = tool_calls_made - 1;
|
|
||||||
let _ = entry
|
|
||||||
.push_event(ChatStreamEvent::ToolCall {
|
|
||||||
index: call_index,
|
|
||||||
name: tool_call.function.name.clone(),
|
|
||||||
arguments: tool_call.function.arguments.clone(),
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
let cx = opentelemetry::Context::new();
|
|
||||||
let result = self
|
|
||||||
.generator
|
|
||||||
.execute_tool(
|
|
||||||
&tool_call.function.name,
|
|
||||||
&tool_call.function.arguments,
|
|
||||||
backend,
|
|
||||||
image_base64,
|
|
||||||
normalized,
|
|
||||||
user_id,
|
|
||||||
active_persona,
|
|
||||||
&cx,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
let (result_preview, result_truncated) = truncate_tool_result(&result);
|
|
||||||
let _ = entry
|
|
||||||
.push_event(ChatStreamEvent::ToolResult {
|
|
||||||
index: call_index,
|
|
||||||
name: tool_call.function.name.clone(),
|
|
||||||
result: result_preview,
|
|
||||||
result_truncated,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
messages.push(ChatMessage::tool_result(result));
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
final_content = response.content;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No-tools fallback
|
|
||||||
if final_content.is_empty() {
|
|
||||||
let synthetic_idx = push_synthetic_final_prompt(messages);
|
|
||||||
let mut stream = backend
|
|
||||||
.chat()
|
|
||||||
.chat_with_tools_stream(messages.clone(), vec![])
|
|
||||||
.await?;
|
|
||||||
let mut final_message: Option<ChatMessage> = None;
|
|
||||||
while let Some(ev) = stream.next().await {
|
|
||||||
let ev = ev?;
|
|
||||||
match ev {
|
|
||||||
LlmStreamEvent::TextDelta(delta) => {
|
|
||||||
let _ = entry.push_event(ChatStreamEvent::TextDelta(delta)).await;
|
|
||||||
}
|
|
||||||
LlmStreamEvent::Done {
|
|
||||||
message,
|
|
||||||
prompt_eval_count,
|
|
||||||
eval_count,
|
|
||||||
} => {
|
|
||||||
last_prompt_eval_count = prompt_eval_count;
|
|
||||||
last_eval_count = eval_count;
|
|
||||||
final_message = Some(message);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let final_response =
|
|
||||||
final_message.ok_or_else(|| anyhow!("final stream ended without a Done event"))?;
|
|
||||||
final_content = final_response.content.clone();
|
|
||||||
messages.push(final_response);
|
|
||||||
remove_synthetic_final_prompt(messages, synthetic_idx);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(AgenticLoopOutcome {
|
|
||||||
tool_calls_made,
|
|
||||||
iterations_used,
|
|
||||||
last_prompt_eval_count,
|
|
||||||
last_eval_count,
|
|
||||||
// Strip any leaked <think> reasoning block from the content the
|
|
||||||
// caller persists as title/summary (the raw transcript keeps it).
|
|
||||||
final_content: crate::ai::llm_client::strip_think_blocks(&final_content),
|
|
||||||
cancelled: false,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_streaming_turn(
|
async fn run_streaming_turn(
|
||||||
@@ -2212,17 +2085,20 @@ fn resolve_bootstrap_backend(supplied: Option<&str>) -> Result<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Outcome of one streaming agentic loop pass. Shared between bootstrap
|
/// Outcome of one streaming agentic loop pass. Shared between bootstrap
|
||||||
/// and continuation.
|
/// and continuation. `pub` so the persona chat surface (and any future
|
||||||
struct AgenticLoopOutcome {
|
/// chat-without-file surface) can read the result of
|
||||||
tool_calls_made: usize,
|
/// `run_streaming_agentic_loop_with_entry` without reaching back into
|
||||||
iterations_used: usize,
|
/// private fields.
|
||||||
last_prompt_eval_count: Option<i32>,
|
pub struct AgenticLoopOutcome {
|
||||||
last_eval_count: Option<i32>,
|
pub tool_calls_made: usize,
|
||||||
final_content: String,
|
pub iterations_used: usize,
|
||||||
|
pub last_prompt_eval_count: Option<i32>,
|
||||||
|
pub last_eval_count: Option<i32>,
|
||||||
|
pub final_content: String,
|
||||||
/// True when the loop exited early because the turn was cancelled
|
/// True when the loop exited early because the turn was cancelled
|
||||||
/// (status flipped out of `Running`). Callers skip persistence and the
|
/// (status flipped out of `Running`). Callers skip persistence and the
|
||||||
/// terminal `Done` push — the cancel handler owns the terminal event.
|
/// terminal `Done` push — the cancel handler owns the terminal event.
|
||||||
cancelled: bool,
|
pub cancelled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Events emitted by `chat_turn_stream`. One stream per turn; ends after
|
/// Events emitted by `chat_turn_stream`. One stream per turn; ends after
|
||||||
@@ -2341,7 +2217,7 @@ pub(crate) fn find_raw_cut(
|
|||||||
/// Read AGENTIC_CHAT_MAX_ITERATIONS once per call. Cheap; keeps the code
|
/// Read AGENTIC_CHAT_MAX_ITERATIONS once per call. Cheap; keeps the code
|
||||||
/// free of static globals and lets the operator change the cap by env without
|
/// free of static globals and lets the operator change the cap by env without
|
||||||
/// a restart in test harnesses (the running server still caches via Default).
|
/// a restart in test harnesses (the running server still caches via Default).
|
||||||
fn env_max_iterations() -> usize {
|
pub fn env_max_iterations() -> usize {
|
||||||
std::env::var("AGENTIC_CHAT_MAX_ITERATIONS")
|
std::env::var("AGENTIC_CHAT_MAX_ITERATIONS")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|s| s.parse::<usize>().ok())
|
.and_then(|s| s.parse::<usize>().ok())
|
||||||
@@ -2352,7 +2228,7 @@ fn env_max_iterations() -> usize {
|
|||||||
/// Read AGENTIC_CHAT_DEFAULT_NUM_CTX once per call — the assumed context
|
/// Read AGENTIC_CHAT_DEFAULT_NUM_CTX once per call — the assumed context
|
||||||
/// window for the truncation budget when the request omits `num_ctx`. Same
|
/// window for the truncation budget when the request omits `num_ctx`. Same
|
||||||
/// no-static-global rationale as `env_max_iterations` above.
|
/// no-static-global rationale as `env_max_iterations` above.
|
||||||
fn env_default_num_ctx() -> i32 {
|
pub(crate) fn env_default_num_ctx() -> i32 {
|
||||||
std::env::var("AGENTIC_CHAT_DEFAULT_NUM_CTX")
|
std::env::var("AGENTIC_CHAT_DEFAULT_NUM_CTX")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|s| s.parse::<i32>().ok())
|
.and_then(|s| s.parse::<i32>().ok())
|
||||||
@@ -2395,6 +2271,174 @@ fn restore_system_content(messages: &mut [ChatMessage], original: Option<String>
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Append the synthetic "write your final answer" user prompt, returning the
|
/// Append the synthetic "write your final answer" user prompt, returning the
|
||||||
|
/// Free-function form of `InsightChatService::run_streaming_agentic_loop_with_entry`.
|
||||||
|
/// Persona chat (and any future chat-without-file surface) doesn't need an
|
||||||
|
/// `InsightChatService` — it just needs the agent loop. This is the same
|
||||||
|
/// body lifted out of the `InsightChatService` impl, taking
|
||||||
|
/// `&InsightGenerator` so any caller in the crate can drive it.
|
||||||
|
///
|
||||||
|
/// Public so the persona chat session can call into it directly.
|
||||||
|
pub async fn run_streaming_agentic_loop_with_entry(
|
||||||
|
generator: &crate::ai::insight_generator::InsightGenerator,
|
||||||
|
backend: &ResolvedBackend,
|
||||||
|
messages: &mut Vec<ChatMessage>,
|
||||||
|
tools: Vec<Tool>,
|
||||||
|
image_base64: &Option<String>,
|
||||||
|
normalized: &str,
|
||||||
|
user_id: i32,
|
||||||
|
active_persona: &str,
|
||||||
|
max_iterations: usize,
|
||||||
|
entry: &Arc<TurnEntry>,
|
||||||
|
) -> Result<AgenticLoopOutcome> {
|
||||||
|
let mut tool_calls_made = 0usize;
|
||||||
|
let mut iterations_used = 0usize;
|
||||||
|
let mut last_prompt_eval_count: Option<i32> = None;
|
||||||
|
let mut last_eval_count: Option<i32> = None;
|
||||||
|
let mut final_content = String::new();
|
||||||
|
|
||||||
|
for iteration in 0..max_iterations {
|
||||||
|
if !entry.is_running() {
|
||||||
|
return Ok(AgenticLoopOutcome {
|
||||||
|
tool_calls_made,
|
||||||
|
iterations_used,
|
||||||
|
last_prompt_eval_count,
|
||||||
|
last_eval_count,
|
||||||
|
final_content,
|
||||||
|
cancelled: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
iterations_used = iteration + 1;
|
||||||
|
let _ = entry
|
||||||
|
.push_event(ChatStreamEvent::IterationStart {
|
||||||
|
n: iterations_used,
|
||||||
|
max: max_iterations,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut stream = backend
|
||||||
|
.chat()
|
||||||
|
.chat_with_tools_stream(messages.clone(), tools.clone())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut final_message: Option<ChatMessage> = None;
|
||||||
|
while let Some(ev) = stream.next().await {
|
||||||
|
let ev = ev?;
|
||||||
|
match ev {
|
||||||
|
LlmStreamEvent::TextDelta(delta) => {
|
||||||
|
let _ = entry.push_event(ChatStreamEvent::TextDelta(delta)).await;
|
||||||
|
}
|
||||||
|
LlmStreamEvent::Done {
|
||||||
|
message,
|
||||||
|
prompt_eval_count,
|
||||||
|
eval_count,
|
||||||
|
} => {
|
||||||
|
last_prompt_eval_count = prompt_eval_count;
|
||||||
|
last_eval_count = eval_count;
|
||||||
|
final_message = Some(message);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut response =
|
||||||
|
final_message.ok_or_else(|| anyhow!("stream ended without a Done event"))?;
|
||||||
|
|
||||||
|
if let Some(ref mut tcs) = response.tool_calls {
|
||||||
|
for tc in tcs.iter_mut() {
|
||||||
|
if !tc.function.arguments.is_object() {
|
||||||
|
tc.function.arguments = serde_json::Value::Object(Default::default());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.push(response.clone());
|
||||||
|
|
||||||
|
if let Some(ref tool_calls) = response.tool_calls
|
||||||
|
&& !tool_calls.is_empty()
|
||||||
|
{
|
||||||
|
for tool_call in tool_calls {
|
||||||
|
tool_calls_made += 1;
|
||||||
|
let call_index = tool_calls_made - 1;
|
||||||
|
let _ = entry
|
||||||
|
.push_event(ChatStreamEvent::ToolCall {
|
||||||
|
index: call_index,
|
||||||
|
name: tool_call.function.name.clone(),
|
||||||
|
arguments: tool_call.function.arguments.clone(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let cx = opentelemetry::Context::new();
|
||||||
|
let result = generator
|
||||||
|
.execute_tool(
|
||||||
|
&tool_call.function.name,
|
||||||
|
&tool_call.function.arguments,
|
||||||
|
backend,
|
||||||
|
image_base64,
|
||||||
|
normalized,
|
||||||
|
user_id,
|
||||||
|
active_persona,
|
||||||
|
&cx,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let (result_preview, result_truncated) = truncate_tool_result(&result);
|
||||||
|
let _ = entry
|
||||||
|
.push_event(ChatStreamEvent::ToolResult {
|
||||||
|
index: call_index,
|
||||||
|
name: tool_call.function.name.clone(),
|
||||||
|
result: result_preview,
|
||||||
|
result_truncated,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
messages.push(ChatMessage::tool_result(result));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final_content = response.content;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if final_content.is_empty() {
|
||||||
|
let synthetic_idx = push_synthetic_final_prompt(messages);
|
||||||
|
let mut stream = backend
|
||||||
|
.chat()
|
||||||
|
.chat_with_tools_stream(messages.clone(), vec![])
|
||||||
|
.await?;
|
||||||
|
let mut final_message: Option<ChatMessage> = None;
|
||||||
|
while let Some(ev) = stream.next().await {
|
||||||
|
let ev = ev?;
|
||||||
|
match ev {
|
||||||
|
LlmStreamEvent::TextDelta(delta) => {
|
||||||
|
let _ = entry.push_event(ChatStreamEvent::TextDelta(delta)).await;
|
||||||
|
}
|
||||||
|
LlmStreamEvent::Done {
|
||||||
|
message,
|
||||||
|
prompt_eval_count,
|
||||||
|
eval_count,
|
||||||
|
} => {
|
||||||
|
last_prompt_eval_count = prompt_eval_count;
|
||||||
|
last_eval_count = eval_count;
|
||||||
|
final_message = Some(message);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let final_response =
|
||||||
|
final_message.ok_or_else(|| anyhow!("final stream ended without a Done event"))?;
|
||||||
|
final_content = final_response.content.clone();
|
||||||
|
messages.push(final_response);
|
||||||
|
remove_synthetic_final_prompt(messages, synthetic_idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(AgenticLoopOutcome {
|
||||||
|
tool_calls_made,
|
||||||
|
iterations_used,
|
||||||
|
last_prompt_eval_count,
|
||||||
|
last_eval_count,
|
||||||
|
final_content: crate::ai::llm_client::strip_think_blocks(&final_content),
|
||||||
|
cancelled: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// index the caller must later hand to [`remove_synthetic_final_prompt`].
|
/// index the caller must later hand to [`remove_synthetic_final_prompt`].
|
||||||
/// Used when the agentic loop exhausts its budget: the model gets one more
|
/// Used when the agentic loop exhausts its budget: the model gets one more
|
||||||
/// (tool-free) request, but the nudge itself must never persist — it would
|
/// (tool-free) request, but the nudge itself must never persist — it would
|
||||||
@@ -2499,7 +2543,7 @@ pub struct ForkInfo {
|
|||||||
/// tool-dispatch assistant (empty content + tool_calls) is still attributed to
|
/// tool-dispatch assistant (empty content + tool_calls) is still attributed to
|
||||||
/// the next rendered message. Detecting forks only on rendered nodes would miss
|
/// the next rendered message. Detecting forks only on rendered nodes would miss
|
||||||
/// regenerations where the model replied with a tool call.
|
/// regenerations where the model replied with a tool call.
|
||||||
fn render_tree_path(
|
pub(crate) fn render_tree_path(
|
||||||
store: &ChatHistoryStore,
|
store: &ChatHistoryStore,
|
||||||
path: &[&StoredChatNode],
|
path: &[&StoredChatNode],
|
||||||
) -> (Vec<RenderedMessage>, usize, Vec<u64>, Vec<Option<ForkInfo>>) {
|
) -> (Vec<RenderedMessage>, usize, Vec<u64>, Vec<Option<ForkInfo>>) {
|
||||||
@@ -2638,9 +2682,9 @@ pub struct ToolInvocation {
|
|||||||
/// Soft cap for tool-result bodies returned via the history API. Keeps
|
/// Soft cap for tool-result bodies returned via the history API. Keeps
|
||||||
/// payloads small for the mobile client — verbose SMS / geocoding responses
|
/// payloads small for the mobile client — verbose SMS / geocoding responses
|
||||||
/// don't need to ship in full for inspection.
|
/// don't need to ship in full for inspection.
|
||||||
const TOOL_RESULT_PREVIEW_MAX: usize = 2000;
|
pub(crate) const TOOL_RESULT_PREVIEW_MAX: usize = 2000;
|
||||||
|
|
||||||
fn truncate_tool_result(s: &str) -> (String, bool) {
|
pub(crate) fn truncate_tool_result(s: &str) -> (String, bool) {
|
||||||
if s.len() <= TOOL_RESULT_PREVIEW_MAX {
|
if s.len() <= TOOL_RESULT_PREVIEW_MAX {
|
||||||
(s.to_string(), false)
|
(s.to_string(), false)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -407,8 +407,8 @@ impl ChatHistoryStore {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The parent of the given node, if any. Test-only helper.
|
/// The parent of the given node, if any. Used to re-anchor a rewind
|
||||||
#[cfg(test)]
|
/// that discards every rendered message onto the seed node above them.
|
||||||
pub fn parent_of(&self, node_id: u64) -> Option<&StoredChatNode> {
|
pub fn parent_of(&self, node_id: u64) -> Option<&StoredChatNode> {
|
||||||
let node = self.nodes.iter().find(|n| n.id == node_id)?;
|
let node = self.nodes.iter().find(|n| n.id == node_id)?;
|
||||||
let parent_id = node.parent_id?;
|
let parent_id = node.parent_id?;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ pub mod local_llm;
|
|||||||
pub mod nl_query;
|
pub mod nl_query;
|
||||||
pub mod ollama;
|
pub mod ollama;
|
||||||
pub mod openrouter;
|
pub mod openrouter;
|
||||||
|
pub mod persona_chat;
|
||||||
pub mod pronunciation;
|
pub mod pronunciation;
|
||||||
pub mod sms_client;
|
pub mod sms_client;
|
||||||
pub mod tts;
|
pub mod tts;
|
||||||
@@ -38,6 +39,12 @@ pub use llamacpp::LlamaCppClient;
|
|||||||
pub use llm_client::{
|
pub use llm_client::{
|
||||||
ChatMessage, LlmClient, ModelCapabilities, Tool, ToolCall, ToolCallFunction, ToolFunction,
|
ChatMessage, LlmClient, ModelCapabilities, Tool, ToolCall, ToolCallFunction, ToolFunction,
|
||||||
};
|
};
|
||||||
|
pub use persona_chat::{
|
||||||
|
persona_chat_branches_handler, persona_chat_conversations_handler,
|
||||||
|
persona_chat_create_conversation_handler, persona_chat_delete_conversation_handler,
|
||||||
|
persona_chat_history_handler, persona_chat_rewind_handler, persona_chat_switch_branch_handler,
|
||||||
|
persona_chat_turn_handler, persona_turn_cancel_handler, persona_turn_replay_handler,
|
||||||
|
};
|
||||||
// LocalLlm is constructed by binaries (reembed_embeddings, importers), not the server
|
// LocalLlm is constructed by binaries (reembed_embeddings, importers), not the server
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use local_llm::LocalLlm;
|
pub use local_llm::LocalLlm;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -52,6 +52,10 @@ pub struct TurnInfo {
|
|||||||
pub turn_id: String,
|
pub turn_id: String,
|
||||||
pub file_path: String,
|
pub file_path: String,
|
||||||
pub library_id: i32,
|
pub library_id: i32,
|
||||||
|
/// Persona id for persona-scoped turns. None for insight-scoped turns.
|
||||||
|
pub persona_id: Option<String>,
|
||||||
|
/// `"insight"` (file-anchored chat) | `"persona"` (open chat).
|
||||||
|
pub scope: String,
|
||||||
pub status: TurnStatus,
|
pub status: TurnStatus,
|
||||||
pub total_events_pushed: u32,
|
pub total_events_pushed: u32,
|
||||||
pub buffered_count: u32,
|
pub buffered_count: u32,
|
||||||
@@ -78,8 +82,20 @@ pub enum ReplayOutcome {
|
|||||||
/// replay connections (readers).
|
/// replay connections (readers).
|
||||||
pub struct TurnEntry {
|
pub struct TurnEntry {
|
||||||
pub turn_id: String,
|
pub turn_id: String,
|
||||||
|
/// Stable identity used to scope the turn. For insight chat this is
|
||||||
|
/// `(file_path, library_id)`. For persona chat it is `(persona_id)`.
|
||||||
|
/// The generic fields stay populated regardless of scope so the SSE
|
||||||
|
/// `turn_info` frame can always surface a useful label — a future
|
||||||
|
/// "list active turns" debugging surface won't need to discriminate.
|
||||||
pub file_path: String,
|
pub file_path: String,
|
||||||
pub library_id: i32,
|
pub library_id: i32,
|
||||||
|
/// Persona id for persona-scoped turns (`scope == "persona"`). None for
|
||||||
|
/// the existing insight-scoped turns. The field is the cheapest way to
|
||||||
|
/// let the SSE `turn_info` payload round-trip without a parallel map.
|
||||||
|
pub persona_id: Option<String>,
|
||||||
|
/// `"insight"` (default — file-anchored chat) | `"persona"` (open chat).
|
||||||
|
/// Stable string so the SSE `turn_info` payload can carry it as-is.
|
||||||
|
pub scope: String,
|
||||||
/// Shared event buffer — multiple SSE connections can read independently.
|
/// Shared event buffer — multiple SSE connections can read independently.
|
||||||
/// Each connection tracks its own `skip_before` offset.
|
/// Each connection tracks its own `skip_before` offset.
|
||||||
events: Mutex<Vec<ChatStreamEvent>>,
|
events: Mutex<Vec<ChatStreamEvent>>,
|
||||||
@@ -100,10 +116,35 @@ pub struct TurnEntry {
|
|||||||
|
|
||||||
impl TurnEntry {
|
impl TurnEntry {
|
||||||
pub fn new(turn_id: String, file_path: String, library_id: i32) -> Self {
|
pub fn new(turn_id: String, file_path: String, library_id: i32) -> Self {
|
||||||
|
Self::with_scope(turn_id, file_path, library_id, None, "insight")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persona-scoped constructor. `file_path` is unused by the persona
|
||||||
|
/// chat loop but kept on the struct so the SSE `turn_info` frame has
|
||||||
|
/// the same shape across scopes.
|
||||||
|
pub fn new_persona(turn_id: String, persona_id: String, library_id: i32) -> Self {
|
||||||
|
Self::with_scope(
|
||||||
|
turn_id,
|
||||||
|
String::new(),
|
||||||
|
library_id,
|
||||||
|
Some(persona_id),
|
||||||
|
"persona",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_scope(
|
||||||
|
turn_id: String,
|
||||||
|
file_path: String,
|
||||||
|
library_id: i32,
|
||||||
|
persona_id: Option<String>,
|
||||||
|
scope: &str,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
turn_id,
|
turn_id,
|
||||||
file_path,
|
file_path,
|
||||||
library_id,
|
library_id,
|
||||||
|
persona_id,
|
||||||
|
scope: scope.to_string(),
|
||||||
events: Mutex::new(Vec::new()),
|
events: Mutex::new(Vec::new()),
|
||||||
total_events_pushed: AtomicU32::new(0),
|
total_events_pushed: AtomicU32::new(0),
|
||||||
base_index: AtomicU32::new(0),
|
base_index: AtomicU32::new(0),
|
||||||
@@ -170,6 +211,8 @@ impl TurnEntry {
|
|||||||
turn_id: self.turn_id.clone(),
|
turn_id: self.turn_id.clone(),
|
||||||
file_path: self.file_path.clone(),
|
file_path: self.file_path.clone(),
|
||||||
library_id: self.library_id,
|
library_id: self.library_id,
|
||||||
|
persona_id: self.persona_id.clone(),
|
||||||
|
scope: self.scope.clone(),
|
||||||
status: self.status.load(Ordering::Relaxed).into(),
|
status: self.status.load(Ordering::Relaxed).into(),
|
||||||
total_events_pushed: total,
|
total_events_pushed: total,
|
||||||
buffered_count: buffered,
|
buffered_count: buffered,
|
||||||
@@ -745,4 +788,67 @@ mod tests {
|
|||||||
let from_base = events_of(entry.replay_from(5).await);
|
let from_base = events_of(entry.replay_from(5).await);
|
||||||
assert_eq!(from_base.len(), MAX_BUFFERED_EVENTS);
|
assert_eq!(from_base.len(), MAX_BUFFERED_EVENTS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Persona-scope regression guards ───────────────────────────
|
||||||
|
//
|
||||||
|
// The open-chat surface relies on `scope == "persona"` and the
|
||||||
|
// optional `persona_id` surviving a round-trip through `info()`.
|
||||||
|
// A future refactor that hard-codes "insight" or drops the field
|
||||||
|
// would silently break the SSE `turn_info` payload — guard it.
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn turn_entry_supports_persona_scope_label() {
|
||||||
|
let entry = Arc::new(TurnEntry::new_persona(
|
||||||
|
"tp1".to_string(),
|
||||||
|
"journal".to_string(),
|
||||||
|
1,
|
||||||
|
));
|
||||||
|
assert_eq!(entry.scope, "persona");
|
||||||
|
assert_eq!(entry.persona_id.as_deref(), Some("journal"));
|
||||||
|
let info = entry.info().await;
|
||||||
|
assert_eq!(info.scope, "persona");
|
||||||
|
assert_eq!(info.persona_id.as_deref(), Some("journal"));
|
||||||
|
// File-anchored fields stay blank — the persona chat has no file.
|
||||||
|
assert!(info.file_path.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn turn_info_payload_for_persona_does_not_leak_file_path() {
|
||||||
|
// Defensive: even if some future code path accidentally populates
|
||||||
|
// file_path on a persona entry, the SSE `turn_info` payload
|
||||||
|
// should not surface it to clients. Today file_path is always
|
||||||
|
// empty for persona entries, so we assert that contract.
|
||||||
|
let entry = Arc::new(TurnEntry::new_persona(
|
||||||
|
"tp2".to_string(),
|
||||||
|
"default".to_string(),
|
||||||
|
1,
|
||||||
|
));
|
||||||
|
let info = entry.info().await;
|
||||||
|
let json = serde_json::to_string(&serde_json::json!({
|
||||||
|
"turn_id": info.turn_id,
|
||||||
|
"persona_id": info.persona_id,
|
||||||
|
"scope": info.scope,
|
||||||
|
"status": info.status.as_str(),
|
||||||
|
"total_events_pushed": info.total_events_pushed,
|
||||||
|
"buffered_count": info.buffered_count,
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
// Privacy: a persona chat reply that quotes an SMS must not echo
|
||||||
|
// the file path that message originally surfaced from.
|
||||||
|
assert!(
|
||||||
|
!json.contains("file_path"),
|
||||||
|
"persona turn_info payload should not leak file_path: {json}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn insight_scope_defaults_when_using_legacy_constructor() {
|
||||||
|
// The legacy `TurnEntry::new` constructor (used by the existing
|
||||||
|
// file-anchored insight chat) must still report scope="insight"
|
||||||
|
// so callers can discriminate without a parallel map.
|
||||||
|
let entry = Arc::new(TurnEntry::new("t-insight".into(), "/p.jpg".into(), 1));
|
||||||
|
let info = entry.info().await;
|
||||||
|
assert_eq!(info.scope, "insight");
|
||||||
|
assert!(info.persona_id.is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+494
-6
@@ -10,6 +10,51 @@ use crate::database::schema;
|
|||||||
use crate::database::{DbError, DbErrorKind, connect};
|
use crate::database::{DbError, DbErrorKind, connect};
|
||||||
use crate::otel::trace_db_call;
|
use crate::otel::trace_db_call;
|
||||||
|
|
||||||
|
/// One row of the persona-chat transcript. Lives in
|
||||||
|
/// `persona_chat_conversations` (one row per `(user_id, persona_id)`).
|
||||||
|
///
|
||||||
|
/// One persona conversation.
|
||||||
|
///
|
||||||
|
/// `turn_count` is the cumulative number of assistant turns on the active
|
||||||
|
/// branch — what the list screen shows as the length of a conversation. The
|
||||||
|
/// transcript itself is the `messages_json` blob, holding a serialized
|
||||||
|
/// `ChatHistoryStore` tree. `title` is a generated summary of the opening
|
||||||
|
/// exchange, empty until the first turn completes.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct PersonaChatRow {
|
||||||
|
pub conversation_id: String,
|
||||||
|
pub persona_id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub messages_json: String,
|
||||||
|
pub turn_count: i32,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub updated_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Column tuple → `PersonaChatRow`. Shared by the single-row and list
|
||||||
|
/// queries so their `select(...)` orders can never drift apart.
|
||||||
|
fn persona_chat_row(
|
||||||
|
(conversation_id, persona_id, title, messages_json, turn_count, created_at, updated_at): (
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
String,
|
||||||
|
i32,
|
||||||
|
i64,
|
||||||
|
i64,
|
||||||
|
),
|
||||||
|
) -> PersonaChatRow {
|
||||||
|
PersonaChatRow {
|
||||||
|
conversation_id,
|
||||||
|
persona_id,
|
||||||
|
title,
|
||||||
|
messages_json,
|
||||||
|
turn_count,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Patch shape for update_persona. None = leave field alone. Built-ins are
|
/// Patch shape for update_persona. None = leave field alone. Built-ins are
|
||||||
/// allowed to flip `include_all_memories` but should reject name/prompt
|
/// allowed to flip `include_all_memories` but should reject name/prompt
|
||||||
/// edits at the handler layer (built-in copy lives in the migration).
|
/// edits at the handler layer (built-in copy lives in the migration).
|
||||||
@@ -80,6 +125,76 @@ pub trait PersonaDao: Sync + Send {
|
|||||||
user_id: i32,
|
user_id: i32,
|
||||||
personas: &[ImportPersona],
|
personas: &[ImportPersona],
|
||||||
) -> Result<usize, DbError>;
|
) -> Result<usize, DbError>;
|
||||||
|
|
||||||
|
// ── Persona-chat (open chat with persona) persistence ───────────
|
||||||
|
//
|
||||||
|
// Keyed on an opaque `conversation_id` so one persona can hold several
|
||||||
|
// separate conversations. `user_id` is checked on every read and write:
|
||||||
|
// the id is a bearer token for a transcript, so ownership can never be
|
||||||
|
// assumed from the id alone.
|
||||||
|
|
||||||
|
/// Fetch one conversation. None when it doesn't exist or belongs to
|
||||||
|
/// another user — the two are deliberately indistinguishable to callers.
|
||||||
|
fn get_persona_chat(
|
||||||
|
&mut self,
|
||||||
|
cx: &opentelemetry::Context,
|
||||||
|
user_id: i32,
|
||||||
|
conversation_id: &str,
|
||||||
|
) -> Result<Option<PersonaChatRow>, DbError>;
|
||||||
|
|
||||||
|
/// Every conversation this user has going, newest first. Backs the chat
|
||||||
|
/// list screen, so it returns the transcript blob too — the snippet is
|
||||||
|
/// derived from the tree rather than denormalized into its own column.
|
||||||
|
fn list_persona_chats(
|
||||||
|
&mut self,
|
||||||
|
cx: &opentelemetry::Context,
|
||||||
|
user_id: i32,
|
||||||
|
) -> Result<Vec<PersonaChatRow>, DbError>;
|
||||||
|
|
||||||
|
/// Start a new conversation with a persona, returning its id. Several
|
||||||
|
/// conversations with the same persona are expected, so this never
|
||||||
|
/// reuses an existing row.
|
||||||
|
fn create_persona_chat(
|
||||||
|
&mut self,
|
||||||
|
cx: &opentelemetry::Context,
|
||||||
|
user_id: i32,
|
||||||
|
persona_id: &str,
|
||||||
|
created_at: i64,
|
||||||
|
) -> Result<String, DbError>;
|
||||||
|
|
||||||
|
/// Replace a conversation's transcript. Called once per completed turn
|
||||||
|
/// with the full new `messages_json`; `turn_count` is the cumulative
|
||||||
|
/// assistant-turn count on the resulting active branch. Returns the
|
||||||
|
/// number of rows written — 0 means the conversation is gone or is not
|
||||||
|
/// this user's.
|
||||||
|
fn update_persona_chat(
|
||||||
|
&mut self,
|
||||||
|
cx: &opentelemetry::Context,
|
||||||
|
user_id: i32,
|
||||||
|
conversation_id: &str,
|
||||||
|
messages_json: &str,
|
||||||
|
turn_count: i32,
|
||||||
|
updated_at: i64,
|
||||||
|
) -> Result<usize, DbError>;
|
||||||
|
|
||||||
|
/// Name a conversation. Written once, after the first turn produces
|
||||||
|
/// enough of an exchange to summarize.
|
||||||
|
fn set_persona_chat_title(
|
||||||
|
&mut self,
|
||||||
|
cx: &opentelemetry::Context,
|
||||||
|
user_id: i32,
|
||||||
|
conversation_id: &str,
|
||||||
|
title: &str,
|
||||||
|
) -> Result<(), DbError>;
|
||||||
|
|
||||||
|
/// Delete a conversation outright. Backs both the list screen's delete
|
||||||
|
/// affordance and the reset endpoint.
|
||||||
|
fn delete_persona_chat(
|
||||||
|
&mut self,
|
||||||
|
cx: &opentelemetry::Context,
|
||||||
|
user_id: i32,
|
||||||
|
conversation_id: &str,
|
||||||
|
) -> Result<(), DbError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct SqlitePersonaDao {
|
pub struct SqlitePersonaDao {
|
||||||
@@ -251,12 +366,28 @@ impl PersonaDao for SqlitePersonaDao {
|
|||||||
pid: &str,
|
pid: &str,
|
||||||
) -> Result<bool, DbError> {
|
) -> Result<bool, DbError> {
|
||||||
trace_db_call(cx, "delete", "delete_persona", |_span| {
|
trace_db_call(cx, "delete", "delete_persona", |_span| {
|
||||||
use schema::personas::dsl::*;
|
|
||||||
let mut conn = self.connection.lock().expect("PersonaDao lock");
|
let mut conn = self.connection.lock().expect("PersonaDao lock");
|
||||||
let n = diesel::delete(personas.filter(user_id.eq(uid)).filter(persona_id.eq(pid)))
|
// One transaction so a persona and its conversations can't get
|
||||||
.execute(conn.deref_mut())
|
// out of step. There is no FK between the two tables, and the
|
||||||
.map_err(|e| anyhow::anyhow!("Delete error: {}", e))?;
|
// chat list skips conversations whose persona is gone — so
|
||||||
Ok(n > 0)
|
// without this the transcripts would linger invisibly forever.
|
||||||
|
conn.deref_mut().transaction::<_, anyhow::Error, _>(|tx| {
|
||||||
|
{
|
||||||
|
use schema::persona_chat_conversations::dsl::*;
|
||||||
|
diesel::delete(
|
||||||
|
persona_chat_conversations
|
||||||
|
.filter(user_id.eq(uid))
|
||||||
|
.filter(persona_id.eq(pid)),
|
||||||
|
)
|
||||||
|
.execute(tx)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Delete error: {}", e))?;
|
||||||
|
}
|
||||||
|
use schema::personas::dsl::*;
|
||||||
|
let n = diesel::delete(personas.filter(user_id.eq(uid)).filter(persona_id.eq(pid)))
|
||||||
|
.execute(tx)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Delete error: {}", e))?;
|
||||||
|
Ok(n > 0)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||||
}
|
}
|
||||||
@@ -296,8 +427,168 @@ impl PersonaDao for SqlitePersonaDao {
|
|||||||
})
|
})
|
||||||
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
|
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
fn get_persona_chat(
|
||||||
|
&mut self,
|
||||||
|
cx: &opentelemetry::Context,
|
||||||
|
uid: i32,
|
||||||
|
cid: &str,
|
||||||
|
) -> Result<Option<PersonaChatRow>, DbError> {
|
||||||
|
trace_db_call(cx, "query", "get_persona_chat", |_span| {
|
||||||
|
use schema::persona_chat_conversations::dsl::*;
|
||||||
|
let mut conn = self.connection.lock().expect("PersonaDao lock");
|
||||||
|
persona_chat_conversations
|
||||||
|
.filter(conversation_id.eq(cid))
|
||||||
|
// Scoped by user as well as id: the id is a bearer token for
|
||||||
|
// someone's transcript, so it never grants access on its own.
|
||||||
|
.filter(user_id.eq(uid))
|
||||||
|
.select((
|
||||||
|
conversation_id,
|
||||||
|
persona_id,
|
||||||
|
title,
|
||||||
|
messages_json,
|
||||||
|
turn_count,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
))
|
||||||
|
.first::<(String, String, String, String, i32, i64, i64)>(conn.deref_mut())
|
||||||
|
.optional()
|
||||||
|
.map(|opt| opt.map(persona_chat_row))
|
||||||
|
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||||
|
})
|
||||||
|
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_persona_chats(
|
||||||
|
&mut self,
|
||||||
|
cx: &opentelemetry::Context,
|
||||||
|
uid: i32,
|
||||||
|
) -> Result<Vec<PersonaChatRow>, DbError> {
|
||||||
|
trace_db_call(cx, "query", "list_persona_chats", |_span| {
|
||||||
|
use schema::persona_chat_conversations::dsl::*;
|
||||||
|
let mut conn = self.connection.lock().expect("PersonaDao lock");
|
||||||
|
persona_chat_conversations
|
||||||
|
.filter(user_id.eq(uid))
|
||||||
|
.order(updated_at.desc())
|
||||||
|
.select((
|
||||||
|
conversation_id,
|
||||||
|
persona_id,
|
||||||
|
title,
|
||||||
|
messages_json,
|
||||||
|
turn_count,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
))
|
||||||
|
.load::<(String, String, String, String, i32, i64, i64)>(conn.deref_mut())
|
||||||
|
.map(|rows| rows.into_iter().map(persona_chat_row).collect())
|
||||||
|
.map_err(|e| anyhow::anyhow!("Query error: {}", e))
|
||||||
|
})
|
||||||
|
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_persona_chat(
|
||||||
|
&mut self,
|
||||||
|
cx: &opentelemetry::Context,
|
||||||
|
uid: i32,
|
||||||
|
pid: &str,
|
||||||
|
created: i64,
|
||||||
|
) -> Result<String, DbError> {
|
||||||
|
trace_db_call(cx, "insert", "create_persona_chat", |_span| {
|
||||||
|
use schema::persona_chat_conversations::dsl::*;
|
||||||
|
let mut conn = self.connection.lock().expect("PersonaDao lock");
|
||||||
|
let new_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
diesel::insert_into(persona_chat_conversations)
|
||||||
|
.values((
|
||||||
|
conversation_id.eq(&new_id),
|
||||||
|
user_id.eq(uid),
|
||||||
|
persona_id.eq(pid),
|
||||||
|
title.eq(""),
|
||||||
|
// The empty tree; the first turn seeds it with the
|
||||||
|
// persona's system prompt.
|
||||||
|
messages_json.eq("[]"),
|
||||||
|
turn_count.eq(0),
|
||||||
|
created_at.eq(created),
|
||||||
|
updated_at.eq(created),
|
||||||
|
))
|
||||||
|
.execute(conn.deref_mut())
|
||||||
|
.map_err(|e| anyhow::anyhow!("Insert error: {}", e))?;
|
||||||
|
Ok(new_id)
|
||||||
|
})
|
||||||
|
.map_err(|e| DbError::log(DbErrorKind::InsertError, e))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_persona_chat(
|
||||||
|
&mut self,
|
||||||
|
cx: &opentelemetry::Context,
|
||||||
|
uid: i32,
|
||||||
|
cid: &str,
|
||||||
|
json: &str,
|
||||||
|
count: i32,
|
||||||
|
updated: i64,
|
||||||
|
) -> Result<usize, DbError> {
|
||||||
|
trace_db_call(cx, "update", "update_persona_chat", |_span| {
|
||||||
|
use schema::persona_chat_conversations::dsl::*;
|
||||||
|
let mut conn = self.connection.lock().expect("PersonaDao lock");
|
||||||
|
diesel::update(
|
||||||
|
persona_chat_conversations
|
||||||
|
.filter(conversation_id.eq(cid))
|
||||||
|
.filter(user_id.eq(uid)),
|
||||||
|
)
|
||||||
|
.set((
|
||||||
|
messages_json.eq(json),
|
||||||
|
turn_count.eq(count),
|
||||||
|
updated_at.eq(updated),
|
||||||
|
))
|
||||||
|
.execute(conn.deref_mut())
|
||||||
|
.map_err(|e| anyhow::anyhow!("Update error: {}", e))
|
||||||
|
})
|
||||||
|
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_persona_chat_title(
|
||||||
|
&mut self,
|
||||||
|
cx: &opentelemetry::Context,
|
||||||
|
uid: i32,
|
||||||
|
cid: &str,
|
||||||
|
new_title: &str,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
trace_db_call(cx, "update", "set_persona_chat_title", |_span| {
|
||||||
|
use schema::persona_chat_conversations::dsl::*;
|
||||||
|
let mut conn = self.connection.lock().expect("PersonaDao lock");
|
||||||
|
diesel::update(
|
||||||
|
persona_chat_conversations
|
||||||
|
.filter(conversation_id.eq(cid))
|
||||||
|
.filter(user_id.eq(uid)),
|
||||||
|
)
|
||||||
|
.set(title.eq(new_title))
|
||||||
|
.execute(conn.deref_mut())
|
||||||
|
.map_err(|e| anyhow::anyhow!("Update error: {}", e))?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_persona_chat(
|
||||||
|
&mut self,
|
||||||
|
cx: &opentelemetry::Context,
|
||||||
|
uid: i32,
|
||||||
|
cid: &str,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
trace_db_call(cx, "delete", "delete_persona_chat", |_span| {
|
||||||
|
use schema::persona_chat_conversations::dsl::*;
|
||||||
|
let mut conn = self.connection.lock().expect("PersonaDao lock");
|
||||||
|
diesel::delete(
|
||||||
|
persona_chat_conversations
|
||||||
|
.filter(conversation_id.eq(cid))
|
||||||
|
.filter(user_id.eq(uid)),
|
||||||
|
)
|
||||||
|
.execute(conn.deref_mut())
|
||||||
|
.map_err(|e| anyhow::anyhow!("Delete error: {}", e))?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.map_err(|e| DbError::log(DbErrorKind::QueryError, e))
|
||||||
|
}
|
||||||
|
}
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -444,4 +735,201 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(updated.include_all_memories);
|
assert!(updated.include_all_memories);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Persona-chat DAO tests ─────────────────────────────────────
|
||||||
|
|
||||||
|
/// Second user, for the isolation tests.
|
||||||
|
fn second_user(dao: &SqlitePersonaDao, username: &str) -> i32 {
|
||||||
|
use crate::database::schema::users::dsl as u;
|
||||||
|
let conn = dao.connection.clone();
|
||||||
|
diesel::insert_into(u::users)
|
||||||
|
.values((u::username.eq(username), u::password.eq("x")))
|
||||||
|
.execute(conn.lock().unwrap().deref_mut())
|
||||||
|
.unwrap();
|
||||||
|
u::users
|
||||||
|
.filter(u::username.eq(username))
|
||||||
|
.select(u::id)
|
||||||
|
.first(conn.lock().unwrap().deref_mut())
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persona_chat_get_returns_none_for_an_unknown_conversation() {
|
||||||
|
let cx = opentelemetry::Context::new();
|
||||||
|
let (mut dao, uid) = dao_with_user("p1");
|
||||||
|
assert!(
|
||||||
|
dao.get_persona_chat(&cx, uid, "no-such-id")
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persona_chat_create_then_get_round_trip() {
|
||||||
|
let cx = opentelemetry::Context::new();
|
||||||
|
let (mut dao, uid) = dao_with_user("p2");
|
||||||
|
let cid = dao.create_persona_chat(&cx, uid, "journal", 100).unwrap();
|
||||||
|
|
||||||
|
let row = dao.get_persona_chat(&cx, uid, &cid).unwrap().unwrap();
|
||||||
|
assert_eq!(row.conversation_id, cid);
|
||||||
|
assert_eq!(row.persona_id, "journal");
|
||||||
|
assert_eq!(row.title, "", "unnamed until the first turn completes");
|
||||||
|
assert_eq!(row.turn_count, 0);
|
||||||
|
assert_eq!(row.created_at, 100);
|
||||||
|
assert_eq!(row.updated_at, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persona_chat_create_makes_a_separate_row_each_time() {
|
||||||
|
// The whole point of the conversation_id key: one persona, several
|
||||||
|
// independent threads.
|
||||||
|
let cx = opentelemetry::Context::new();
|
||||||
|
let (mut dao, uid) = dao_with_user("p3");
|
||||||
|
let first = dao.create_persona_chat(&cx, uid, "journal", 100).unwrap();
|
||||||
|
let second = dao.create_persona_chat(&cx, uid, "journal", 200).unwrap();
|
||||||
|
|
||||||
|
assert_ne!(first, second);
|
||||||
|
assert_eq!(dao.list_persona_chats(&cx, uid).unwrap().len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persona_chat_update_replaces_the_transcript() {
|
||||||
|
let cx = opentelemetry::Context::new();
|
||||||
|
let (mut dao, uid) = dao_with_user("p4");
|
||||||
|
let cid = dao.create_persona_chat(&cx, uid, "journal", 100).unwrap();
|
||||||
|
|
||||||
|
let rows = dao
|
||||||
|
.update_persona_chat(&cx, uid, &cid, "first", 1, 150)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(rows, 1);
|
||||||
|
let rows = dao
|
||||||
|
.update_persona_chat(&cx, uid, &cid, "second", 2, 200)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(rows, 1);
|
||||||
|
|
||||||
|
let row = dao.get_persona_chat(&cx, uid, &cid).unwrap().unwrap();
|
||||||
|
assert_eq!(row.messages_json, "second");
|
||||||
|
assert_eq!(row.turn_count, 2);
|
||||||
|
assert_eq!(row.updated_at, 200);
|
||||||
|
assert_eq!(row.created_at, 100, "creation time is not disturbed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persona_chat_update_reports_zero_rows_for_an_unknown_conversation() {
|
||||||
|
// The turn loop treats 0 as "the conversation went away mid-turn"
|
||||||
|
// rather than silently succeeding.
|
||||||
|
let cx = opentelemetry::Context::new();
|
||||||
|
let (mut dao, uid) = dao_with_user("p5");
|
||||||
|
let rows = dao
|
||||||
|
.update_persona_chat(&cx, uid, "no-such-id", "x", 1, 1)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(rows, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persona_chat_set_title_names_the_conversation() {
|
||||||
|
let cx = opentelemetry::Context::new();
|
||||||
|
let (mut dao, uid) = dao_with_user("p6");
|
||||||
|
let cid = dao.create_persona_chat(&cx, uid, "journal", 100).unwrap();
|
||||||
|
|
||||||
|
dao.set_persona_chat_title(&cx, uid, &cid, "June recap")
|
||||||
|
.unwrap();
|
||||||
|
let row = dao.get_persona_chat(&cx, uid, &cid).unwrap().unwrap();
|
||||||
|
assert_eq!(row.title, "June recap");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persona_chat_list_orders_newest_first() {
|
||||||
|
let cx = opentelemetry::Context::new();
|
||||||
|
let (mut dao, uid) = dao_with_user("p7");
|
||||||
|
let older = dao.create_persona_chat(&cx, uid, "journal", 100).unwrap();
|
||||||
|
let newer = dao.create_persona_chat(&cx, uid, "coach", 300).unwrap();
|
||||||
|
let middle = dao.create_persona_chat(&cx, uid, "default", 200).unwrap();
|
||||||
|
|
||||||
|
let ids: Vec<String> = dao
|
||||||
|
.list_persona_chats(&cx, uid)
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| r.conversation_id)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
ids,
|
||||||
|
vec![newer, middle, older],
|
||||||
|
"ordered by updated_at descending so the list screen needs no re-sort"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persona_chat_list_is_empty_for_a_user_who_has_never_chatted() {
|
||||||
|
let cx = opentelemetry::Context::new();
|
||||||
|
let (mut dao, uid) = dao_with_user("p8");
|
||||||
|
assert!(dao.list_persona_chats(&cx, uid).unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persona_chat_delete_removes_the_conversation() {
|
||||||
|
let cx = opentelemetry::Context::new();
|
||||||
|
let (mut dao, uid) = dao_with_user("p9");
|
||||||
|
let cid = dao.create_persona_chat(&cx, uid, "journal", 100).unwrap();
|
||||||
|
|
||||||
|
dao.delete_persona_chat(&cx, uid, &cid).unwrap();
|
||||||
|
assert!(dao.get_persona_chat(&cx, uid, &cid).unwrap().is_none());
|
||||||
|
assert!(dao.list_persona_chats(&cx, uid).unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleting_a_persona_takes_its_conversations_with_it() {
|
||||||
|
// No FK between the tables, and the chat list hides conversations
|
||||||
|
// whose persona is gone — so an orphan row would be an invisible,
|
||||||
|
// unreachable transcript sitting in the database forever.
|
||||||
|
let cx = opentelemetry::Context::new();
|
||||||
|
let (mut dao, uid) = dao_with_user("p-cascade");
|
||||||
|
dao.create_persona(&cx, uid, "custom-1", "Custom", "prompt", false, false)
|
||||||
|
.unwrap();
|
||||||
|
dao.create_persona_chat(&cx, uid, "custom-1", 100).unwrap();
|
||||||
|
dao.create_persona_chat(&cx, uid, "custom-1", 200).unwrap();
|
||||||
|
let survivor = dao.create_persona_chat(&cx, uid, "other", 300).unwrap();
|
||||||
|
|
||||||
|
assert!(dao.delete_persona(&cx, uid, "custom-1").unwrap());
|
||||||
|
|
||||||
|
let remaining: Vec<String> = dao
|
||||||
|
.list_persona_chats(&cx, uid)
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| r.conversation_id)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
remaining,
|
||||||
|
vec![survivor],
|
||||||
|
"only the untouched persona's conversation survives"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persona_chat_reads_and_writes_are_scoped_to_the_owner() {
|
||||||
|
// A conversation_id is a bearer token for someone's transcript, so
|
||||||
|
// holding one must not grant another user access to it.
|
||||||
|
let cx = opentelemetry::Context::new();
|
||||||
|
let (mut dao, uid1) = dao_with_user("owner");
|
||||||
|
let uid2 = second_user(&dao, "intruder");
|
||||||
|
let cid = dao.create_persona_chat(&cx, uid1, "journal", 100).unwrap();
|
||||||
|
dao.update_persona_chat(&cx, uid1, &cid, "private", 1, 100)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
dao.get_persona_chat(&cx, uid2, &cid).unwrap().is_none(),
|
||||||
|
"another user cannot read it"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
dao.update_persona_chat(&cx, uid2, &cid, "tampered", 9, 999)
|
||||||
|
.unwrap(),
|
||||||
|
0,
|
||||||
|
"another user cannot write it"
|
||||||
|
);
|
||||||
|
dao.delete_persona_chat(&cx, uid2, &cid).unwrap();
|
||||||
|
|
||||||
|
let row = dao.get_persona_chat(&cx, uid1, &cid).unwrap().unwrap();
|
||||||
|
assert_eq!(row.messages_json, "private", "and cannot delete it");
|
||||||
|
assert!(dao.list_persona_chats(&cx, uid2).unwrap().is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,6 +171,19 @@ diesel::table! {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
persona_chat_conversations (conversation_id) {
|
||||||
|
conversation_id -> Text,
|
||||||
|
user_id -> Integer,
|
||||||
|
persona_id -> Text,
|
||||||
|
title -> Text,
|
||||||
|
messages_json -> Text,
|
||||||
|
turn_count -> Integer,
|
||||||
|
created_at -> BigInt,
|
||||||
|
updated_at -> BigInt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
diesel::table! {
|
diesel::table! {
|
||||||
personas (id) {
|
personas (id) {
|
||||||
id -> Integer,
|
id -> Integer,
|
||||||
@@ -345,6 +358,7 @@ diesel::allow_tables_to_appear_in_same_query!(
|
|||||||
insight_generation_jobs,
|
insight_generation_jobs,
|
||||||
libraries,
|
libraries,
|
||||||
location_history,
|
location_history,
|
||||||
|
persona_chat_conversations,
|
||||||
personas,
|
personas,
|
||||||
persons,
|
persons,
|
||||||
photo_insights,
|
photo_insights,
|
||||||
|
|||||||
+10
@@ -382,6 +382,16 @@ fn main() -> std::io::Result<()> {
|
|||||||
.service(ai::turn_async_handler)
|
.service(ai::turn_async_handler)
|
||||||
.service(ai::turn_replay_handler)
|
.service(ai::turn_replay_handler)
|
||||||
.service(ai::cancel_turn_handler)
|
.service(ai::cancel_turn_handler)
|
||||||
|
.service(ai::persona_chat_history_handler)
|
||||||
|
.service(ai::persona_chat_turn_handler)
|
||||||
|
.service(ai::persona_chat_create_conversation_handler)
|
||||||
|
.service(ai::persona_chat_delete_conversation_handler)
|
||||||
|
.service(ai::persona_turn_replay_handler)
|
||||||
|
.service(ai::persona_turn_cancel_handler)
|
||||||
|
.service(ai::persona_chat_rewind_handler)
|
||||||
|
.service(ai::persona_chat_switch_branch_handler)
|
||||||
|
.service(ai::persona_chat_branches_handler)
|
||||||
|
.service(ai::persona_chat_conversations_handler)
|
||||||
.service(ai::rate_insight_handler)
|
.service(ai::rate_insight_handler)
|
||||||
.service(ai::export_training_data_handler)
|
.service(ai::export_training_data_handler)
|
||||||
.service(ai::tts_speech_handler)
|
.service(ai::tts_speech_handler)
|
||||||
|
|||||||
+24
-2
@@ -4,6 +4,7 @@ use crate::ai::face_client::FaceClient;
|
|||||||
use crate::ai::insight_chat::{ChatLockMap, InsightChatService};
|
use crate::ai::insight_chat::{ChatLockMap, InsightChatService};
|
||||||
use crate::ai::llamacpp::LlamaCppClient;
|
use crate::ai::llamacpp::LlamaCppClient;
|
||||||
use crate::ai::openrouter::OpenRouterClient;
|
use crate::ai::openrouter::OpenRouterClient;
|
||||||
|
use crate::ai::persona_chat::PersonaChatSession;
|
||||||
use crate::ai::turn_registry::TurnRegistry;
|
use crate::ai::turn_registry::TurnRegistry;
|
||||||
use crate::ai::{InsightGenerator, OllamaClient, SmsApiClient};
|
use crate::ai::{InsightGenerator, OllamaClient, SmsApiClient};
|
||||||
use crate::database::{
|
use crate::database::{
|
||||||
@@ -84,6 +85,9 @@ pub struct AppState {
|
|||||||
pub insight_generator: InsightGenerator,
|
pub insight_generator: InsightGenerator,
|
||||||
/// Chat continuation service. Hold an Arc so handlers can clone cheaply.
|
/// Chat continuation service. Hold an Arc so handlers can clone cheaply.
|
||||||
pub insight_chat: Arc<InsightChatService>,
|
pub insight_chat: Arc<InsightChatService>,
|
||||||
|
/// Open chat with persona service. Same shape as insight_chat but
|
||||||
|
/// anchored on (user_id, persona_id) instead of (library_id, file_path).
|
||||||
|
pub persona_chat_session: Arc<PersonaChatSession>,
|
||||||
pub turn_registry: Arc<TurnRegistry>,
|
pub turn_registry: Arc<TurnRegistry>,
|
||||||
pub face_client: FaceClient,
|
pub face_client: FaceClient,
|
||||||
pub clip_client: ClipClient,
|
pub clip_client: ClipClient,
|
||||||
@@ -133,6 +137,7 @@ impl AppState {
|
|||||||
sms_client: SmsApiClient,
|
sms_client: SmsApiClient,
|
||||||
insight_generator: InsightGenerator,
|
insight_generator: InsightGenerator,
|
||||||
insight_chat: Arc<InsightChatService>,
|
insight_chat: Arc<InsightChatService>,
|
||||||
|
persona_chat_session: Arc<PersonaChatSession>,
|
||||||
turn_registry: Arc<TurnRegistry>,
|
turn_registry: Arc<TurnRegistry>,
|
||||||
preview_dao: Arc<Mutex<Box<dyn PreviewDao>>>,
|
preview_dao: Arc<Mutex<Box<dyn PreviewDao>>>,
|
||||||
face_client: FaceClient,
|
face_client: FaceClient,
|
||||||
@@ -194,6 +199,7 @@ impl AppState {
|
|||||||
sms_client,
|
sms_client,
|
||||||
insight_generator,
|
insight_generator,
|
||||||
insight_chat,
|
insight_chat,
|
||||||
|
persona_chat_session,
|
||||||
turn_registry,
|
turn_registry,
|
||||||
face_client,
|
face_client,
|
||||||
clip_client,
|
clip_client,
|
||||||
@@ -316,7 +322,7 @@ impl Default for AppState {
|
|||||||
tag_dao.clone(),
|
tag_dao.clone(),
|
||||||
face_dao.clone(),
|
face_dao.clone(),
|
||||||
knowledge_dao,
|
knowledge_dao,
|
||||||
persona_dao,
|
persona_dao.clone(),
|
||||||
libraries_vec.clone(),
|
libraries_vec.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -330,6 +336,13 @@ impl Default for AppState {
|
|||||||
chat_locks,
|
chat_locks,
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// Open chat with persona: reuses the generator + persona DAO.
|
||||||
|
let persona_chat_session = Arc::new(PersonaChatSession::new(
|
||||||
|
Arc::new(insight_generator.clone()),
|
||||||
|
persona_dao.clone(),
|
||||||
|
Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
|
||||||
|
));
|
||||||
|
|
||||||
// Turn registry for reconnectable chat turns. 5-minute timeout for
|
// Turn registry for reconnectable chat turns. 5-minute timeout for
|
||||||
// stale turns (background cleaner drops entries older than this).
|
// stale turns (background cleaner drops entries older than this).
|
||||||
let timeout_secs: u64 = env::var("INSIGHT_CHAT_TURN_TIMEOUT_SECS")
|
let timeout_secs: u64 = env::var("INSIGHT_CHAT_TURN_TIMEOUT_SECS")
|
||||||
@@ -360,6 +373,7 @@ impl Default for AppState {
|
|||||||
sms_client,
|
sms_client,
|
||||||
insight_generator,
|
insight_generator,
|
||||||
insight_chat,
|
insight_chat,
|
||||||
|
persona_chat_session,
|
||||||
turn_registry,
|
turn_registry,
|
||||||
preview_dao,
|
preview_dao,
|
||||||
face_client,
|
face_client,
|
||||||
@@ -528,7 +542,7 @@ impl AppState {
|
|||||||
tag_dao.clone(),
|
tag_dao.clone(),
|
||||||
face_dao.clone(),
|
face_dao.clone(),
|
||||||
knowledge_dao,
|
knowledge_dao,
|
||||||
persona_dao,
|
persona_dao.clone(),
|
||||||
vec![test_lib],
|
vec![test_lib],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -540,6 +554,13 @@ impl AppState {
|
|||||||
chat_locks,
|
chat_locks,
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// Open chat with persona (test).
|
||||||
|
let persona_chat_session = Arc::new(PersonaChatSession::new(
|
||||||
|
Arc::new(insight_generator.clone()),
|
||||||
|
persona_dao.clone(),
|
||||||
|
Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
|
||||||
|
));
|
||||||
|
|
||||||
// Turn registry for test state.
|
// Turn registry for test state.
|
||||||
let turn_registry = Arc::new(TurnRegistry::new(300));
|
let turn_registry = Arc::new(TurnRegistry::new(300));
|
||||||
|
|
||||||
@@ -571,6 +592,7 @@ impl AppState {
|
|||||||
sms_client,
|
sms_client,
|
||||||
insight_generator,
|
insight_generator,
|
||||||
insight_chat,
|
insight_chat,
|
||||||
|
persona_chat_session,
|
||||||
turn_registry,
|
turn_registry,
|
||||||
preview_dao,
|
preview_dao,
|
||||||
FaceClient::new(None), // disabled in test
|
FaceClient::new(None), // disabled in test
|
||||||
|
|||||||
+1
-5
@@ -175,11 +175,7 @@ fn encode_large_jpeg(img: image::DynamicImage, dest: &Path) -> std::io::Result<(
|
|||||||
/// ffmpeg path for HEIC/HEIF (image crate can't decode these). Mirrors
|
/// ffmpeg path for HEIC/HEIF (image crate can't decode these). Mirrors
|
||||||
/// [`crate::video::actors::generate_image_thumbnail_ffmpeg`] but scales
|
/// [`crate::video::actors::generate_image_thumbnail_ffmpeg`] but scales
|
||||||
/// to the large-preview cap instead of 200.
|
/// to the large-preview cap instead of 200.
|
||||||
fn generate_large_preview_ffmpeg(
|
fn generate_large_preview_ffmpeg(src: &Path, dest: &Path, orientation: i32) -> std::io::Result<()> {
|
||||||
src: &Path,
|
|
||||||
dest: &Path,
|
|
||||||
orientation: i32,
|
|
||||||
) -> std::io::Result<()> {
|
|
||||||
// Rotation + scale + colorspace. HEIC sources use Display P3; without
|
// Rotation + scale + colorspace. HEIC sources use Display P3; without
|
||||||
// colorspace=bt709 the mjpeg encoder treats P3 values as sRGB, producing
|
// colorspace=bt709 the mjpeg encoder treats P3 values as sRGB, producing
|
||||||
// warm/oversaturated output. The min(iw,cap) trick caps the long edge
|
// warm/oversaturated output. The min(iw,cap) trick caps the long edge
|
||||||
|
|||||||
Reference in New Issue
Block a user