Implement chat and markdown views, enhance status bar, and add workflow panel
- Added `chat.rs` for rendering chat messages with timestamps and roles. - Introduced `markdown.rs` for rendering markdown content with styling. - Created `status.rs` to display the application status bar with session and message counts. - Developed `workflow.rs` to show the current workflow status, including tool calls and active jobs. - Established a `theme.rs` for centralized color management across the UI. - Updated `mod.rs` to include new modules and manage rendering logic.
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
use crate::app::subagent::spawn::AgentDefinition;
|
||||
|
||||
pub fn builtin_agents() -> Vec<AgentDefinition> {
|
||||
vec![
|
||||
AgentDefinition::new(
|
||||
"coder".to_string(),
|
||||
"coder".to_string(),
|
||||
).with_system_prompt(
|
||||
"You are a coding agent. Write correct, idiomatic Rust code.".to_string()
|
||||
).with_allowed_tools(
|
||||
vec![
|
||||
"read".to_string(),
|
||||
"write".to_string(),
|
||||
"edit".to_string(),
|
||||
"bash".to_string(),
|
||||
"grep".to_string(),
|
||||
"glob".to_string(),
|
||||
"git_operator".to_string(),
|
||||
]
|
||||
).with_max_steps(25),
|
||||
|
||||
AgentDefinition::new(
|
||||
"reviewer".to_string(),
|
||||
"reviewer".to_string(),
|
||||
).with_system_prompt(
|
||||
"You are a code reviewer. Focus on correctness, safety, and performance.".to_string()
|
||||
).with_allowed_tools(
|
||||
vec![
|
||||
"read".to_string(),
|
||||
"grep".to_string(),
|
||||
"glob".to_string(),
|
||||
"recall".to_string(),
|
||||
"remember".to_string(),
|
||||
]
|
||||
).with_max_steps(10),
|
||||
|
||||
AgentDefinition::new(
|
||||
"researcher".to_string(),
|
||||
"researcher".to_string(),
|
||||
).with_system_prompt(
|
||||
"You are a research agent. Search and synthesize information.".to_string()
|
||||
).with_allowed_tools(
|
||||
vec![
|
||||
"read".to_string(),
|
||||
"grep".to_string(),
|
||||
"glob".to_string(),
|
||||
"search".to_string(),
|
||||
"web_fetch".to_string(),
|
||||
]
|
||||
).with_max_steps(15),
|
||||
|
||||
AgentDefinition::new(
|
||||
"planner".to_string(),
|
||||
"planner".to_string(),
|
||||
).with_system_prompt(
|
||||
"You are a planning agent. Break down tasks into clear steps.".to_string()
|
||||
).with_allowed_tools(
|
||||
vec![
|
||||
"read".to_string(),
|
||||
"grep".to_string(),
|
||||
"glob".to_string(),
|
||||
"plan".to_string(),
|
||||
]
|
||||
).with_max_steps(20),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
use crate::app::subagent::spawn::AgentDefinition;
|
||||
|
||||
pub fn load_global_agents() -> Vec<AgentDefinition> {
|
||||
let store = crate::model::store::Store::new();
|
||||
let agents_dir = store.base_dir.join("agents");
|
||||
if !agents_dir.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut agents = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(&agents_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().is_some_and(|e| e == "json") {
|
||||
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||
if let Ok(def) = serde_json::from_str::<AgentDefinition>(&content) {
|
||||
agents.push(def);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
agents
|
||||
}
|
||||
|
||||
pub fn save_global_agent(def: &AgentDefinition) -> anyhow::Result<()> {
|
||||
let store = crate::model::store::Store::new();
|
||||
let agents_dir = store.base_dir.join("agents");
|
||||
std::fs::create_dir_all(&agents_dir)?;
|
||||
let path = agents_dir.join(format!("{}.json", def.name));
|
||||
let content = serde_json::to_string_pretty(def)?;
|
||||
std::fs::write(path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_global_agent(name: &str) -> anyhow::Result<()> {
|
||||
let store = crate::model::store::Store::new();
|
||||
let path = store.base_dir.join("agents").join(format!("{}.json", name));
|
||||
if path.exists() {
|
||||
std::fs::remove_file(path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod builtin;
|
||||
pub mod global;
|
||||
pub mod session;
|
||||
@@ -0,0 +1,35 @@
|
||||
use std::path::Path;
|
||||
use crate::app::subagent::spawn::AgentDefinition;
|
||||
|
||||
pub fn load_session_agents(session_dir: &Path) -> Vec<AgentDefinition> {
|
||||
let agents_file = session_dir.join("agents.json");
|
||||
if !agents_file.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
match std::fs::read_to_string(&agents_file) {
|
||||
Ok(content) => {
|
||||
serde_json::from_str(&content).unwrap_or_default()
|
||||
}
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_session_agents(session_dir: &Path, agents: &[AgentDefinition]) -> anyhow::Result<()> {
|
||||
let agents_file = session_dir.join("agents.json");
|
||||
let content = serde_json::to_string_pretty(agents)?;
|
||||
std::fs::write(agents_file, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_session_agent(session_dir: &Path, def: AgentDefinition) -> anyhow::Result<()> {
|
||||
let mut agents = load_session_agents(session_dir);
|
||||
agents.retain(|a| a.name != def.name);
|
||||
agents.push(def);
|
||||
save_session_agents(session_dir, &agents)
|
||||
}
|
||||
|
||||
pub fn remove_session_agent(session_dir: &Path, name: &str) -> anyhow::Result<()> {
|
||||
let mut agents = load_session_agents(session_dir);
|
||||
agents.retain(|a| a.name != name);
|
||||
save_session_agents(session_dir, &agents)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
pub providers: HashMap<String, ProviderConfig>,
|
||||
pub model_roles: HashMap<String, ModelRole>,
|
||||
pub default_provider: String,
|
||||
pub default_model: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderConfig {
|
||||
pub api_base: String,
|
||||
pub api_key_env: Option<String>,
|
||||
pub default_model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModelRole {
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
pub max_tokens: Option<u32>,
|
||||
pub temperature: Option<f32>,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
let mut providers = HashMap::new();
|
||||
providers.insert("openrouter".to_string(), ProviderConfig {
|
||||
api_base: "https://openrouter.ai/api/v1".to_string(),
|
||||
api_key_env: Some("OPENROUTER_API_KEY".to_string()),
|
||||
default_model: Some("anthropic/claude-opus-4-8".to_string()),
|
||||
});
|
||||
let mut model_roles = HashMap::new();
|
||||
model_roles.insert("default".to_string(), ModelRole {
|
||||
provider: "openrouter".to_string(),
|
||||
model: "anthropic/claude-opus-4-8".to_string(),
|
||||
max_tokens: Some(8192),
|
||||
temperature: Some(0.7),
|
||||
});
|
||||
AppConfig {
|
||||
providers,
|
||||
model_roles,
|
||||
default_provider: "openrouter".to_string(),
|
||||
default_model: "anthropic/claude-opus-4-8".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
pub fn load() -> Self {
|
||||
let store = super::store::Store::new();
|
||||
let path = store.base_dir.join("app_config.json");
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Conversation {
|
||||
pub messages: Vec<crate::dto::chat::message::ChatMessage>,
|
||||
pub system_prompt: String,
|
||||
pub session_id: String,
|
||||
pub model: String,
|
||||
pub max_tokens: u32,
|
||||
pub temperature: f32,
|
||||
}
|
||||
|
||||
impl Conversation {
|
||||
pub fn new(system_prompt: String, session_id: String) -> Self {
|
||||
Conversation {
|
||||
messages: Vec::new(),
|
||||
system_prompt,
|
||||
session_id,
|
||||
model: "anthropic/claude-opus-4-8".to_string(),
|
||||
max_tokens: 8192,
|
||||
temperature: 0.7,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push(&mut self, msg: crate::dto::chat::message::ChatMessage) {
|
||||
self.messages.push(msg);
|
||||
}
|
||||
|
||||
pub fn rebuild_system(&mut self, new_prompt: String) {
|
||||
self.system_prompt = new_prompt;
|
||||
self.messages.retain(|m| {
|
||||
!matches!(m.role, crate::dto::chat::message::Role::System)
|
||||
});
|
||||
}
|
||||
|
||||
pub fn to_api_messages(&self) -> Vec<crate::dto::chat::message::ChatMessage> {
|
||||
let mut msgs = Vec::with_capacity(self.messages.len() + 1);
|
||||
msgs.push(crate::dto::chat::message::ChatMessage::system(&self.system_prompt));
|
||||
msgs.extend(self.messages.iter().cloned());
|
||||
msgs
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.messages.len()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EditLogEntry {
|
||||
pub ts: i64,
|
||||
pub tool: String,
|
||||
pub path: String,
|
||||
pub reason: String,
|
||||
pub content_sha256: String,
|
||||
pub bytes_delta: i64,
|
||||
pub origin: String,
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EditLog {
|
||||
pub entries: Vec<EditLogEntry>,
|
||||
pub path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl EditLog {
|
||||
pub fn new(session_dir: &std::path::Path) -> Self {
|
||||
EditLog {
|
||||
entries: Vec::new(),
|
||||
path: session_dir.join("edits.jsonl"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn append(&mut self, entry: EditLogEntry) -> std::io::Result<()> {
|
||||
let line = serde_json::to_string(&entry)? + "\n";
|
||||
let parent = self.path.parent().unwrap();
|
||||
std::fs::create_dir_all(parent)?;
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&self.path)?;
|
||||
use std::io::Write;
|
||||
file.write_all(line.as_bytes())?;
|
||||
self.entries.push(entry);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load(path: &std::path::Path) -> std::io::Result<Self> {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
let entries: Vec<EditLogEntry> = content
|
||||
.lines()
|
||||
.filter_map(|l| serde_json::from_str(l).ok())
|
||||
.collect();
|
||||
Ok(EditLog {
|
||||
entries,
|
||||
path: path.to_path_buf(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn recent(&self, n: usize) -> &[EditLogEntry] {
|
||||
let len = self.entries.len();
|
||||
let start = len.saturating_sub(n);
|
||||
&self.entries[start..]
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::session::Session;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Memory {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub content: String,
|
||||
pub kind: String,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub outcome: Option<String>,
|
||||
pub lifecycle: String,
|
||||
}
|
||||
|
||||
impl Memory {
|
||||
pub fn slugify(s: &str) -> Option<String> {
|
||||
let slug: String = s
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||||
.collect();
|
||||
let slug: String = slug
|
||||
.split('-')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("-");
|
||||
if slug.is_empty() || slug.len() > 80 {
|
||||
return None;
|
||||
}
|
||||
Some(slug)
|
||||
}
|
||||
|
||||
pub fn path(memory_dir: &Path, name: &str) -> PathBuf {
|
||||
let slug = Self::slugify(name).unwrap_or_else(|| "memory".to_string());
|
||||
slug_path(memory_dir, &format!("{}.md", slug))
|
||||
}
|
||||
|
||||
pub fn write(&self, memory_dir: &Path) -> std::io::Result<()> {
|
||||
let path = Self::path(memory_dir, &self.name);
|
||||
let parent = path.parent().unwrap();
|
||||
std::fs::create_dir_all(parent)?;
|
||||
let outcome_line = self.outcome.as_ref().map(|o| format!("outcome: {}", o)).unwrap_or_default();
|
||||
let content = format!(
|
||||
"---\nname: {}\ndescription: {}\nkind: {}\ncreated_at: {}\nupdated_at: {}\nlifecycle: {}\n{}\n---\n\n{}",
|
||||
self.name, self.description, self.kind, self.created_at, self.updated_at, self.lifecycle, outcome_line, self.content
|
||||
);
|
||||
let tmp = parent.join(format!(".{}.tmp", std::process::id()));
|
||||
std::fs::write(&tmp, &content)?;
|
||||
std::fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn read(memory_dir: &Path, name: &str) -> std::io::Result<Self> {
|
||||
let path = Self::path(memory_dir, name);
|
||||
let content = std::fs::read_to_string(&path)?;
|
||||
Self::parse(&content)
|
||||
}
|
||||
|
||||
pub fn parse(content: &str) -> std::io::Result<Self> {
|
||||
let parts: Vec<&str> = content.splitn(2, "---\n").collect();
|
||||
if parts.len() < 2 {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "missing frontmatter"));
|
||||
}
|
||||
let front: std::collections::HashMap<String, String> = parts[0]
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
let mut it = l.splitn(2, ':');
|
||||
Some((it.next()?.trim().to_string(), it.next()?.trim().to_string()))
|
||||
})
|
||||
.collect();
|
||||
let body = parts.get(1).unwrap_or(&"").trim().to_string();
|
||||
Ok(Memory {
|
||||
name: front.get("name").cloned().unwrap_or_default(),
|
||||
description: front.get("description").cloned().unwrap_or_default(),
|
||||
content: body,
|
||||
kind: front.get("kind").cloned().unwrap_or_else(|| "reference".to_string()),
|
||||
created_at: front.get("created_at").and_then(|v| v.parse().ok()).unwrap_or(0),
|
||||
updated_at: front.get("updated_at").and_then(|v| v.parse().ok()).unwrap_or(0),
|
||||
outcome: front.get("outcome").cloned().filter(|s| !s.is_empty()),
|
||||
lifecycle: front.get("lifecycle").cloned().unwrap_or_else(|| "new".to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn remove(memory_dir: &Path, name: &str) -> std::io::Result<()> {
|
||||
let path = Self::path(memory_dir, name);
|
||||
if path.exists() {
|
||||
std::fs::remove_file(path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list(memory_dir: &Path) -> Vec<String> {
|
||||
let entries = match std::fs::read_dir(memory_dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().extension().map(|x| x == "md").unwrap_or(false))
|
||||
.filter_map(|e| {
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
if name == "MEMORY.md" { return None; }
|
||||
let slug = name.strip_suffix(".md")?.to_string();
|
||||
Some(slug)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn load_index(memory_dir: &Path) -> Vec<String> {
|
||||
let index_path = memory_dir.join("MEMORY.md");
|
||||
let content = std::fs::read_to_string(index_path).unwrap_or_default();
|
||||
content.lines().filter_map(|l| {
|
||||
let l = l.trim();
|
||||
if l.is_empty() || l.starts_with('#') { return None; }
|
||||
l.split(']').next().and_then(|s| s.split('[').nth(1)).map(|s| s.to_string())
|
||||
}).collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn slug_path(memory_dir: &Path, raw: &str) -> PathBuf {
|
||||
let clean: String = raw.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '.' || c == '-' { c } else { '-' })
|
||||
.collect();
|
||||
let clean = clean.trim_start_matches('.').to_string();
|
||||
memory_dir.join(if clean.is_empty() { "memory.md" } else { &clean })
|
||||
}
|
||||
|
||||
pub fn create_retrospective(session_dir: &Path, session: &Session, lessons: &[Memory]) -> std::io::Result<Memory> {
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
let lessons_content: String = lessons.iter()
|
||||
.map(|l| format!("- {}: {}", l.name, l.description))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let content = format!(
|
||||
"# Session Retrospective\n\nSession: {}\nCreated: {}\nLessons learned:\n{}\n",
|
||||
session.title, now, lessons_content,
|
||||
);
|
||||
let memory = Memory {
|
||||
name: format!("retrospective-{}", session.id),
|
||||
description: format!("End-of-session retrospective for {}", session.title),
|
||||
content,
|
||||
kind: "retrospective".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
outcome: None,
|
||||
lifecycle: "new".to_string(),
|
||||
};
|
||||
memory.write(session_dir)?;
|
||||
Ok(memory)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
pub mod agent_def;
|
||||
pub mod app_config;
|
||||
pub mod conversation;
|
||||
pub mod editlog;
|
||||
pub mod memory;
|
||||
pub mod msglog;
|
||||
pub mod session;
|
||||
pub mod session_lock;
|
||||
pub mod settings;
|
||||
pub mod store;
|
||||
@@ -0,0 +1,46 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use anyhow::Result;
|
||||
|
||||
pub fn store_blob(conn: &Connection, session_id: &str, blob_key: &str, data: &[u8], mime_type: Option<&str>) -> Result<()> {
|
||||
let created_at = chrono::Utc::now().timestamp_millis();
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO blobs (session_id, blob_key, data, mime_type, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![session_id, blob_key, data, mime_type, created_at],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn retrieve_blob(conn: &Connection, session_id: &str, blob_key: &str) -> Result<Option<Vec<u8>>> {
|
||||
let result = conn.query_row(
|
||||
"SELECT data FROM blobs WHERE session_id = ?1 AND blob_key = ?2",
|
||||
params![session_id, blob_key],
|
||||
|row| row.get(0),
|
||||
);
|
||||
match result {
|
||||
Ok(data) => Ok(Some(data)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_blob(conn: &Connection, session_id: &str, blob_key: &str) -> Result<bool> {
|
||||
let rows = conn.execute(
|
||||
"DELETE FROM blobs WHERE session_id = ?1 AND blob_key = ?2",
|
||||
params![session_id, blob_key],
|
||||
)?;
|
||||
Ok(rows > 0)
|
||||
}
|
||||
|
||||
pub fn list_blob_keys(conn: &Connection, session_id: &str) -> Result<Vec<String>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT blob_key FROM blobs WHERE session_id = ?1 ORDER BY created_at ASC"
|
||||
)?;
|
||||
let rows = stmt.query_map(params![session_id], |row| {
|
||||
row.get::<_, String>(0)
|
||||
})?;
|
||||
let mut keys = Vec::new();
|
||||
for row in rows {
|
||||
keys.push(row?);
|
||||
}
|
||||
Ok(keys)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod blobs;
|
||||
pub mod query;
|
||||
pub mod schema;
|
||||
pub mod summary;
|
||||
@@ -0,0 +1,68 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use anyhow::Result;
|
||||
use crate::dto::chat::message::{ChatMessage, Role};
|
||||
|
||||
pub fn insert_message(conn: &Connection, session_id: &str, msg: &ChatMessage) -> Result<i64> {
|
||||
let content = msg.content.as_deref();
|
||||
let tool_call_id = msg.tool_call_id.as_deref();
|
||||
let tool_name = msg.name.as_deref();
|
||||
let tool_arguments = msg.tool_calls.as_ref().map(|calls| {
|
||||
serde_json::to_string(calls).unwrap_or_default()
|
||||
});
|
||||
let created_at = chrono::Utc::now().timestamp_millis();
|
||||
let role_str = match msg.role {
|
||||
Role::User => "user",
|
||||
Role::Assistant => "assistant",
|
||||
Role::System => "system",
|
||||
Role::Tool => "tool",
|
||||
};
|
||||
conn.execute(
|
||||
"INSERT INTO messages (session_id, role, content, tool_call_id, tool_name, tool_arguments, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![session_id, role_str, content, tool_call_id, tool_name, tool_arguments, created_at],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
pub fn query_messages(conn: &Connection, session_id: &str, limit: usize, offset: usize) -> Result<Vec<ChatMessage>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT role, content, tool_call_id, tool_name, tool_arguments FROM messages WHERE session_id = ?1 ORDER BY id ASC LIMIT ?2 OFFSET ?3"
|
||||
)?;
|
||||
let rows = stmt.query_map(params![session_id, limit as i64, offset as i64], |row| {
|
||||
let role_str: String = row.get(0)?;
|
||||
let content: Option<String> = row.get(1)?;
|
||||
let tool_call_id: Option<String> = row.get(2)?;
|
||||
let tool_name: Option<String> = row.get(3)?;
|
||||
let tool_arguments: Option<String> = row.get(4)?;
|
||||
let role = match role_str.as_str() {
|
||||
"user" => Role::User,
|
||||
"assistant" => Role::Assistant,
|
||||
"system" => Role::System,
|
||||
"tool" => Role::Tool,
|
||||
_ => Role::User,
|
||||
};
|
||||
let tool_calls = tool_arguments.and_then(|args| {
|
||||
serde_json::from_str(&args).ok()
|
||||
});
|
||||
Ok(ChatMessage {
|
||||
role,
|
||||
content,
|
||||
tool_calls,
|
||||
tool_call_id,
|
||||
name: tool_name,
|
||||
})
|
||||
})?;
|
||||
let mut messages = Vec::new();
|
||||
for row in rows {
|
||||
messages.push(row?);
|
||||
}
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
pub fn count_messages(conn: &Connection, session_id: &str) -> Result<i64> {
|
||||
let count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM messages WHERE session_id = ?1",
|
||||
params![session_id],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok(count)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use rusqlite::Connection;
|
||||
use anyhow::Result;
|
||||
|
||||
pub fn init_schema(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
"
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT,
|
||||
tool_call_id TEXT,
|
||||
tool_name TEXT,
|
||||
tool_arguments TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (session_id) REFERENCES archives(session_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS archives (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL UNIQUE,
|
||||
title TEXT,
|
||||
model TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
message_count INTEGER DEFAULT 0,
|
||||
token_count INTEGER DEFAULT 0,
|
||||
summary TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_archives_created_at ON archives(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS blobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
blob_key TEXT NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
mime_type TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE(session_id, blob_key)
|
||||
);
|
||||
"
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SummaryRecord {
|
||||
pub session_id: String,
|
||||
pub title: String,
|
||||
pub model: String,
|
||||
pub message_count: usize,
|
||||
pub token_count: usize,
|
||||
pub summary: String,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
impl SummaryRecord {
|
||||
pub fn new(session_id: String, title: String, model: String) -> Self {
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
SummaryRecord {
|
||||
session_id,
|
||||
title,
|
||||
model,
|
||||
message_count: 0,
|
||||
token_count: 0,
|
||||
summary: String::new(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_summary(&mut self, summary: String) {
|
||||
self.summary = summary;
|
||||
self.updated_at = chrono::Utc::now().timestamp_millis();
|
||||
}
|
||||
|
||||
pub fn increment_counts(&mut self, messages: usize, tokens: usize) {
|
||||
self.message_count += messages;
|
||||
self.token_count += tokens;
|
||||
self.updated_at = chrono::Utc::now().timestamp_millis();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use chrono::Utc;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
pub id: String,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub title: String,
|
||||
pub model: String,
|
||||
pub workspace_roots: Vec<PathBuf>,
|
||||
pub message_count: u32,
|
||||
pub token_count: u32,
|
||||
pub archived: bool,
|
||||
pub summary: Option<String>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new(id: String, title: String) -> Self {
|
||||
let now = Utc::now().timestamp_millis();
|
||||
Session {
|
||||
id,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
title,
|
||||
model: "anthropic/claude-opus-4-8".to_string(),
|
||||
workspace_roots: vec![std::env::current_dir().unwrap_or_default()],
|
||||
message_count: 0,
|
||||
token_count: 0,
|
||||
archived: false,
|
||||
summary: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn session_dir(&self, base_dir: &Path) -> PathBuf {
|
||||
base_dir.join("sessions").join(&self.id)
|
||||
}
|
||||
|
||||
pub fn conversation_path(&self, base_dir: &Path) -> PathBuf {
|
||||
self.session_dir(base_dir).join("conversation.json")
|
||||
}
|
||||
|
||||
pub fn edit_log_path(&self, base_dir: &Path) -> PathBuf {
|
||||
self.session_dir(base_dir).join("edits.jsonl")
|
||||
}
|
||||
|
||||
pub fn msglog_path(&self, base_dir: &Path) -> PathBuf {
|
||||
self.session_dir(base_dir).join("msglog.db")
|
||||
}
|
||||
|
||||
pub fn save(&self, base_dir: &Path) -> std::io::Result<()> {
|
||||
let dir = self.session_dir(base_dir);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let path = dir.join("session.json");
|
||||
let data = serde_json::to_string_pretty(self)?;
|
||||
let tmp = dir.join("session.json.tmp");
|
||||
std::fs::write(&tmp, data)?;
|
||||
std::fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load(id: &str, base_dir: &Path) -> std::io::Result<Self> {
|
||||
let path = base_dir.join("sessions").join(id).join("session.json");
|
||||
let data = std::fs::read_to_string(path)?;
|
||||
let session: Session = serde_json::from_str(&data)?;
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
pub fn list(base_dir: &Path) -> Vec<Self> {
|
||||
let sessions_dir = base_dir.join("sessions");
|
||||
let entries = match std::fs::read_dir(&sessions_dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().is_dir())
|
||||
.filter_map(|e| {
|
||||
let id = e.file_name().to_string_lossy().to_string();
|
||||
Session::load(&id, base_dir).ok()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::fs;
|
||||
|
||||
pub struct SessionLock {
|
||||
path: PathBuf,
|
||||
pid: u32,
|
||||
}
|
||||
|
||||
impl SessionLock {
|
||||
pub fn new(session_dir: &Path) -> Self {
|
||||
SessionLock {
|
||||
path: session_dir.join(".lock"),
|
||||
pid: std::process::id(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn try_lock(&self) -> std::io::Result<bool> {
|
||||
if self.path.exists() {
|
||||
let content = fs::read_to_string(&self.path).unwrap_or_default();
|
||||
if let Ok(pid) = content.trim().parse::<u32>() {
|
||||
if self.is_alive(pid) {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
fs::write(&self.path, self.pid.to_string())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn unlock(&self) {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
|
||||
fn is_alive(&self, pid: u32) -> bool {
|
||||
unsafe { libc::kill(pid as i32, 0) == 0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SessionLock {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Default)]
|
||||
pub enum InternetMode {
|
||||
#[default]
|
||||
Off,
|
||||
ReadOnly,
|
||||
Full,
|
||||
}
|
||||
|
||||
impl InternetMode {
|
||||
pub fn can_fetch(&self) -> bool {
|
||||
matches!(self, InternetMode::Full)
|
||||
}
|
||||
|
||||
pub fn can_download(&self) -> bool {
|
||||
matches!(self, InternetMode::Full)
|
||||
}
|
||||
|
||||
pub fn can_search(&self) -> bool {
|
||||
matches!(self, InternetMode::Full)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
pub internet_mode: InternetMode,
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
pub api_key: Option<String>,
|
||||
pub max_tokens: u32,
|
||||
pub temperature: f32,
|
||||
pub review_enabled: bool,
|
||||
pub review_max_lessons_per_run: usize,
|
||||
pub adaptive_review_max_skip: u32,
|
||||
pub workflow_max_concurrency: usize,
|
||||
pub session_archive_enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Settings {
|
||||
internet_mode: InternetMode::Off,
|
||||
provider: "openrouter".to_string(),
|
||||
model: "anthropic/claude-opus-4-8".to_string(),
|
||||
api_key: None,
|
||||
max_tokens: 8192,
|
||||
temperature: 0.7,
|
||||
review_enabled: true,
|
||||
review_max_lessons_per_run: 5,
|
||||
adaptive_review_max_skip: 3,
|
||||
workflow_max_concurrency: 5,
|
||||
session_archive_enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
pub fn load() -> Self {
|
||||
let store = super::store::Store::new();
|
||||
let path = store.base_dir.join("settings.json");
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn save(&self) -> std::io::Result<()> {
|
||||
let store = super::store::Store::new();
|
||||
std::fs::create_dir_all(&store.base_dir)?;
|
||||
let path = store.base_dir.join("settings.json");
|
||||
let s = serde_json::to_string_pretty(self)?;
|
||||
std::fs::write(path, s)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
use std::path::PathBuf;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Store {
|
||||
pub base_dir: PathBuf,
|
||||
pub scratch_root: PathBuf,
|
||||
pub memory_dir: PathBuf,
|
||||
pub session_images_dir: PathBuf,
|
||||
pub download_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn new() -> Self {
|
||||
let base = dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from(".local/share"))
|
||||
.join("zesdex");
|
||||
let scratch = std::env::temp_dir().join("zesdex-scratch");
|
||||
Store {
|
||||
memory_dir: base.join("memory"),
|
||||
scratch_root: scratch,
|
||||
session_images_dir: base.join("session-images"),
|
||||
download_dir: base.join("downloads"),
|
||||
base_dir: base,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ensure_dirs(&self) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(&self.base_dir)?;
|
||||
std::fs::create_dir_all(&self.memory_dir)?;
|
||||
std::fs::create_dir_all(&self.scratch_root)?;
|
||||
std::fs::create_dir_all(&self.session_images_dir)?;
|
||||
std::fs::create_dir_all(&self.download_dir)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user