feat(tui): add usage overlay and sidebar for displaying usage statistics and tasks
feat(tui): implement status bar with connection and turn state indicators feat(tui): create workflow panel for agent status and progress visualization feat(web): introduce web frontend interface with static file serving feat(ws): add WebSocket interface for real-time communication and session management
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
//! Hardcoded built-in subagent definitions (coder, reviewer, researcher, planner).
|
||||
//!
|
||||
//! These agents are always available regardless of user or session config.
|
||||
//! They provide the default set of roles shipped with the application.
|
||||
//!
|
||||
//! ## Available agents
|
||||
//! | Agent | Purpose | Key tools |
|
||||
//! |-------|---------|-----------|
|
||||
//! | coder | Write/edit code | read, write, edit, bash, lsp_* |
|
||||
//! | reviewer | Review code for correctness/safety | read, grep, lsp_diagnostics |
|
||||
//! | researcher | Search and summarise | read, grep, bash, search_web |
|
||||
//! | planner | Break down tasks into steps | read, write, edit, bash, todo_* |
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Declarative specification for instantiating a subagent.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AgentDefinition {
|
||||
/// Human-readable name (e.g. `"quick-reviewer"`).
|
||||
pub name: String,
|
||||
/// Functional role (e.g. `"reviewer"`, `"coder"`).
|
||||
pub role: String,
|
||||
/// Optional system prompt override.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub system_prompt: Option<String>,
|
||||
/// Optional tool allowlist. `None` means role-based defaults.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub allowed_tools: Option<Vec<String>>,
|
||||
/// Optional step budget. `None` means no limit.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_steps: Option<usize>,
|
||||
/// Optional temperature override.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
}
|
||||
|
||||
impl AgentDefinition {
|
||||
/// Create an agent definition with the required name and role.
|
||||
pub fn new(name: String, role: String) -> Self {
|
||||
AgentDefinition {
|
||||
name,
|
||||
role,
|
||||
system_prompt: None,
|
||||
allowed_tools: None,
|
||||
max_steps: None,
|
||||
temperature: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder: set the system prompt.
|
||||
pub fn with_system_prompt(mut self, prompt: String) -> Self {
|
||||
self.system_prompt = Some(prompt);
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder: set the allowed tool list.
|
||||
pub fn with_allowed_tools(mut self, tools: Vec<String>) -> Self {
|
||||
self.allowed_tools = Some(tools);
|
||||
self
|
||||
}
|
||||
|
||||
/// Builder: set the maximum step count.
|
||||
pub fn with_max_steps(mut self, steps: usize) -> Self {
|
||||
self.max_steps = Some(steps);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the fixed list of built-in agent definitions shipped with zesdex.
|
||||
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(),
|
||||
"lsp_connect".to_string(),
|
||||
"lsp_diagnostics".to_string(),
|
||||
"lsp_hover".to_string(),
|
||||
"lsp_definition".to_string(),
|
||||
"lsp_references".to_string(),
|
||||
"lsp_completion".to_string(),
|
||||
"lsp_disconnect".to_string(),
|
||||
])
|
||||
.with_max_steps(usize::MAX),
|
||||
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(),
|
||||
"lsp_diagnostics".to_string(),
|
||||
"lsp_hover".to_string(),
|
||||
"lsp_definition".to_string(),
|
||||
"lsp_references".to_string(),
|
||||
])
|
||||
.with_max_steps(usize::MAX),
|
||||
AgentDefinition::new("researcher".to_string(), "researcher".to_string())
|
||||
.with_system_prompt(
|
||||
"You are a research agent. Search for information and summarize findings."
|
||||
.to_string(),
|
||||
)
|
||||
.with_allowed_tools(vec![
|
||||
"read".to_string(),
|
||||
"grep".to_string(),
|
||||
"glob".to_string(),
|
||||
"bash".to_string(),
|
||||
"search_web".to_string(),
|
||||
"fetch_url".to_string(),
|
||||
])
|
||||
.with_max_steps(usize::MAX),
|
||||
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(),
|
||||
"write".to_string(),
|
||||
"edit".to_string(),
|
||||
"bash".to_string(),
|
||||
"todo_write".to_string(),
|
||||
"todo_finish".to_string(),
|
||||
])
|
||||
.with_max_steps(usize::MAX),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Load, save, and remove user-defined agent definitions stored globally
|
||||
//! (under the store's `agents/` directory), independent of any session.
|
||||
use super::builtin::AgentDefinition;
|
||||
|
||||
/// Load all globally-registered agent definitions from disk.
|
||||
///
|
||||
/// Flow: resolve `<store>/agents/` -> read directory -> parse each `*.json`
|
||||
/// file into an `AgentDefinition`, skipping any that fail to read or parse.
|
||||
pub fn load_global_agents() -> Vec<AgentDefinition> {
|
||||
let store = crate::model::store::Store::new();
|
||||
let agents_dir = store.base_dir.join("agents");
|
||||
tracing::debug!(dir = %agents_dir.display(), "load_global_agents");
|
||||
|
||||
if !agents_dir.exists() {
|
||||
tracing::debug!("load_global_agents — agents dir does not exist");
|
||||
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) {
|
||||
tracing::debug!(agent = %def.name, "load_global_agents — loaded");
|
||||
agents.push(def);
|
||||
} else {
|
||||
tracing::warn!(file = %path.display(), "load_global_agents — failed to parse JSON");
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(file = %path.display(), "load_global_agents — failed to read file");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::info!(count = agents.len(), "load_global_agents — done");
|
||||
agents
|
||||
}
|
||||
|
||||
/// Persist a global agent definition as `<store>/agents/<name>.json`.
|
||||
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 tmp = agents_dir.join(format!("{}.json.tmp", def.name));
|
||||
let content = serde_json::to_string_pretty(def)?;
|
||||
tracing::debug!(agent = %def.name, "save_global_agent — writing");
|
||||
std::fs::write(&tmp, content)?;
|
||||
let f = std::fs::File::open(&tmp)?;
|
||||
f.sync_all()?;
|
||||
std::fs::rename(&tmp, path)?;
|
||||
if let Some(parent) = agents_dir.parent() {
|
||||
let _ = std::fs::File::open(parent).and_then(|d| d.sync_all());
|
||||
}
|
||||
tracing::info!(agent = %def.name, "save_global_agent — saved");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a global agent definition by name.
|
||||
pub fn remove_global_agent(name: &str) -> anyhow::Result<bool> {
|
||||
let store = crate::model::store::Store::new();
|
||||
let path = store.base_dir.join("agents").join(format!("{name}.json"));
|
||||
tracing::debug!(%name, path = %path.display(), "remove_global_agent");
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(_) => {
|
||||
tracing::info!(%name, "remove_global_agent — removed");
|
||||
Ok(true)
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
tracing::debug!(%name, "remove_global_agent — not found");
|
||||
Ok(false)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(%name, error = %e, "remove_global_agent — failed");
|
||||
Err(e.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//! Agent definition sources: built-in defaults, global (user-wide), and
|
||||
//! per-session overrides.
|
||||
//!
|
||||
//! Agent definitions control the system prompt, tool set, and configuration
|
||||
//! for each agent. The resolution order (lowest to highest priority) is:
|
||||
//!
|
||||
//! 1. `builtin` — hardcoded default agent shipped with the application.
|
||||
//! 2. `global` — user-wide overrides stored in the config directory.
|
||||
//! 3. `session` — per-session overrides stored in the session directory.
|
||||
pub mod builtin;
|
||||
pub mod global;
|
||||
pub mod session;
|
||||
@@ -0,0 +1,70 @@
|
||||
//! Load, save, add, and remove agent definitions scoped to a single
|
||||
//! session (`<session_dir>/agents.json`).
|
||||
use super::builtin::AgentDefinition;
|
||||
use std::path::Path;
|
||||
|
||||
/// Load agent definitions saved for a specific session.
|
||||
pub fn load_session_agents(session_dir: &Path) -> Vec<AgentDefinition> {
|
||||
let agents_file = session_dir.join("agents.json");
|
||||
tracing::debug!(file = %agents_file.display(), "load_session_agents");
|
||||
|
||||
if !agents_file.exists() {
|
||||
tracing::debug!("load_session_agents — file does not exist");
|
||||
return Vec::new();
|
||||
}
|
||||
match std::fs::read_to_string(&agents_file) {
|
||||
Ok(content) => {
|
||||
let agents: Vec<AgentDefinition> = serde_json::from_str(&content).unwrap_or_else(|e| {
|
||||
tracing::warn!("load_session_agents — failed to parse agents.json: {}", e);
|
||||
Vec::new()
|
||||
});
|
||||
tracing::debug!(count = agents.len(), "load_session_agents — loaded");
|
||||
agents
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "load_session_agents — failed to read");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Overwrite `<session_dir>/agents.json` with the given agent list.
|
||||
pub fn save_session_agents(session_dir: &Path, agents: &[AgentDefinition]) -> anyhow::Result<()> {
|
||||
let agents_file = session_dir.join("agents.json");
|
||||
let tmp = session_dir.join("agents.json.tmp");
|
||||
let content = serde_json::to_string_pretty(agents)?;
|
||||
tracing::debug!(count = agents.len(), "save_session_agents — writing");
|
||||
|
||||
std::fs::write(&tmp, content)?;
|
||||
let f = std::fs::File::open(&tmp)?;
|
||||
f.sync_all()?;
|
||||
std::fs::rename(&tmp, agents_file)?;
|
||||
let _ = std::fs::File::open(session_dir).and_then(|d| d.sync_all());
|
||||
|
||||
tracing::info!(count = agents.len(), "save_session_agents — saved");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add or replace a session agent definition by name.
|
||||
pub fn add_session_agent(session_dir: &Path, def: &AgentDefinition) -> anyhow::Result<()> {
|
||||
tracing::debug!(agent = %def.name, "add_session_agent");
|
||||
let mut agents = load_session_agents(session_dir);
|
||||
agents.retain(|a| a.name != def.name);
|
||||
agents.push(def.clone());
|
||||
save_session_agents(session_dir, &agents)
|
||||
}
|
||||
|
||||
/// Remove a session agent definition by name.
|
||||
pub fn remove_session_agent(session_dir: &Path, name: &str) -> anyhow::Result<bool> {
|
||||
tracing::debug!(%name, "remove_session_agent");
|
||||
let mut agents = load_session_agents(session_dir);
|
||||
let before = agents.len();
|
||||
agents.retain(|a| a.name != name);
|
||||
if agents.len() == before {
|
||||
tracing::debug!(%name, "remove_session_agent — not found");
|
||||
return Ok(false);
|
||||
}
|
||||
save_session_agents(session_dir, &agents)?;
|
||||
tracing::info!(%name, "remove_session_agent — removed");
|
||||
Ok(true)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Data-model layer for the TUI interface.
|
||||
//!
|
||||
//! This module contains:
|
||||
//! - `store` — Store path configuration
|
||||
//! - `agent_def` — Agent definition model (built-in, global, session scopes)
|
||||
//! - `msglog` — SQLite-backed message-log persistence (schema, insert, blobs)
|
||||
//!
|
||||
//! The `Store` type is re-exported from `zesdex_domain::core::store`.
|
||||
|
||||
pub mod store {
|
||||
//! Re-export `Store` from the domain layer for path resolution.
|
||||
pub use zesdex_domain::core::Store;
|
||||
}
|
||||
|
||||
pub mod agent_def;
|
||||
pub mod msglog;
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Binary blob storage in the message-log `SQLite` database (e.g. images,
|
||||
//! attachments), keyed by session id and an arbitrary blob key.
|
||||
use anyhow::Result;
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
/// Insert or overwrite a blob for a session under `blob_key`.
|
||||
///
|
||||
/// Flow: compute current timestamp -> `INSERT OR REPLACE` into `blobs`
|
||||
/// keyed on `(session_id, blob_key)`.
|
||||
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();
|
||||
tracing::debug!(%session_id, %blob_key, size = data.len(), "store_blob");
|
||||
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],
|
||||
)?;
|
||||
tracing::info!(%session_id, %blob_key, "store_blob — stored");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch a blob's bytes for a session by key.
|
||||
pub fn retrieve_blob(
|
||||
conn: &Connection,
|
||||
session_id: &str,
|
||||
blob_key: &str,
|
||||
) -> Result<Option<Vec<u8>>> {
|
||||
tracing::debug!(%session_id, %blob_key, "retrieve_blob");
|
||||
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::<_, Vec<u8>>(0),
|
||||
);
|
||||
match result {
|
||||
Ok(data) => {
|
||||
tracing::debug!(%session_id, %blob_key, size = data.len(), "retrieve_blob — found");
|
||||
Ok(Some(data))
|
||||
}
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
tracing::debug!(%session_id, %blob_key, "retrieve_blob — not found");
|
||||
Ok(None)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(%session_id, %blob_key, error = %e, "retrieve_blob — query failed");
|
||||
Err(e.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// List all blob keys stored for a session, oldest first.
|
||||
pub fn list_blob_keys(conn: &Connection, session_id: &str) -> Result<Vec<String>> {
|
||||
tracing::debug!(%session_id, "list_blob_keys");
|
||||
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?);
|
||||
}
|
||||
tracing::debug!(%session_id, count = keys.len(), "list_blob_keys — done");
|
||||
Ok(keys)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! Insert queries against the message log's `messages` table.
|
||||
use anyhow::Result;
|
||||
use rusqlite::{params, Connection};
|
||||
use zesdex_domain::core::{ChatMessage, Role};
|
||||
|
||||
/// Insert a chat message into the session's message log.
|
||||
///
|
||||
/// Flow: extract optional content/tool_call_id/tool_name -> serialize
|
||||
/// `tool_calls` to a JSON string if present -> map `Role` to its string
|
||||
/// column value -> `INSERT` the row with the current timestamp.
|
||||
///
|
||||
/// Return: the new row's `rowid` on success, or the underlying error.
|
||||
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",
|
||||
};
|
||||
|
||||
tracing::debug!(%session_id, %role_str, content_len = content.map_or(0, str::len), "insert_message");
|
||||
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],
|
||||
)?;
|
||||
let rowid = conn.last_insert_rowid();
|
||||
tracing::info!(%session_id, %role_str, rowid, "insert_message — inserted");
|
||||
Ok(rowid)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//! SQLite-backed message log: per-session `messages.sqlite` storing chat
|
||||
//! messages, blobs, and archive/summary metadata.
|
||||
//!
|
||||
//! ## Tables
|
||||
//! | Table | Purpose |
|
||||
//! |-------|---------|
|
||||
//! | `messages` | Individual chat messages (role, content, tool calls) |
|
||||
//! | `archives` | Session archive metadata (title, model, summary) |
|
||||
//! | `blobs` | Binary attachments keyed by `(session_id, blob_key)` |
|
||||
//!
|
||||
//! All writes use WAL mode for concurrent reads without blocking.
|
||||
pub mod blobs;
|
||||
pub mod insert;
|
||||
pub mod schema;
|
||||
|
||||
pub use blobs::store_blob;
|
||||
pub use insert::insert_message;
|
||||
|
||||
/// Open (creating if needed) a session's `messages.sqlite` and ensure its
|
||||
/// schema is initialized.
|
||||
///
|
||||
/// Flow: resolve `<session_dir>/messages.sqlite` -> create parent dirs ->
|
||||
/// open a `SQLite` connection -> run `schema::init_schema`.
|
||||
///
|
||||
/// Return: an open, schema-ready `Connection`, or an error if any step fails.
|
||||
pub fn open_or_create(session_dir: &std::path::Path) -> anyhow::Result<rusqlite::Connection> {
|
||||
let path = session_dir.join("messages.sqlite");
|
||||
tracing::debug!(?path, "open_or_create");
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let conn = rusqlite::Connection::open(&path)?;
|
||||
conn.execute_batch("PRAGMA journal_mode = WAL;")?;
|
||||
conn.execute_batch("PRAGMA busy_timeout = 5000;")?;
|
||||
schema::init_schema(&conn)?;
|
||||
tracing::info!("open_or_create — database ready");
|
||||
Ok(conn)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! `SQLite` schema definition for the message log database.
|
||||
use anyhow::Result;
|
||||
use rusqlite::Connection;
|
||||
|
||||
/// Create the message log's tables and indexes if they don't already
|
||||
/// exist (`messages`, `archives`, `blobs`).
|
||||
pub fn init_schema(conn: &Connection) -> Result<()> {
|
||||
tracing::debug!("init_schema — creating tables if not exists");
|
||||
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
|
||||
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)
|
||||
);
|
||||
",
|
||||
)?;
|
||||
tracing::info!("init_schema — schema ready");
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user