Users, file manager, image generation, budgets and agent workflow
- User management: local accounts (Argon2) and OpenID Connect, admin/user roles, per-user folders plus admin-shared folders, read-only session sharing, event filtering per user - File manager with CodeMirror editor and git client (status, diff, stage, commit, branches, fetch/pull/push, log, clone/init) - Image generation via fal.ai: generate_image tool and image page - Budgets: monthly limits per provider, provider balances/costs (OpenRouter, Anthropic admin cost report, fal.ai), price discovery - Subagents on other models, background tasks, concurrency limits per host/model/subagent - MCP tools loaded on demand (mcp_search/mcp_call), read-only hints - Permission mode "auto", safe read-only commands always allowed - Persistent message queue with attachments, deletable - Live reasoning incl. <think> tags, activity animations, CSS themes, mobile layout, statistics per user, upload size handling - Docker: named volume for state, localhost-bound port Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f3a2fe3816
commit
49262c612c
70 files changed
+8495
-714
No files matched your search
+473
-18
@@ -50,7 +50,74 @@ impl Store {
|
||||
error INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS usage_ts ON usage(ts);
|
||||
CREATE INDEX IF NOT EXISTS usage_root ON usage(root_session_id);",
|
||||
CREATE INDEX IF NOT EXISTS usage_root ON usage(root_session_id);
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
password_hash TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
oidc_sub TEXT UNIQUE,
|
||||
disabled INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS auth_tokens (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
user_agent TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id TEXT PRIMARY KEY,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS folder_access (
|
||||
folder_id TEXT NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (folder_id, user_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS images (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT,
|
||||
session_id TEXT,
|
||||
prompt TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
width INTEGER NOT NULL DEFAULT 0,
|
||||
height INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS images_user ON images(user_id, created_at);
|
||||
CREATE TABLE IF NOT EXISTS queue (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
data TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS queue_session ON queue(session_id, created_at);
|
||||
CREATE TABLE IF NOT EXISTS session_shares (
|
||||
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (session_id, user_id)
|
||||
);",
|
||||
)?;
|
||||
// Columns added after the first release.
|
||||
for (table, column, ddl) in [
|
||||
("sessions", "owner_id", "ALTER TABLE sessions ADD COLUMN owner_id TEXT"),
|
||||
("usage", "user_id", "ALTER TABLE usage ADD COLUMN user_id TEXT"),
|
||||
] {
|
||||
let exists: bool = conn
|
||||
.prepare(&format!("SELECT 1 FROM pragma_table_info('{table}') WHERE name = ?1"))?
|
||||
.exists([column])?;
|
||||
if !exists {
|
||||
conn.execute(ddl, [])?;
|
||||
}
|
||||
}
|
||||
conn.execute_batch(
|
||||
"CREATE INDEX IF NOT EXISTS sessions_owner ON sessions(owner_id);
|
||||
CREATE INDEX IF NOT EXISTS usage_user ON usage(user_id);",
|
||||
)?;
|
||||
Ok(Self { conn: Mutex::new(conn) })
|
||||
}
|
||||
@@ -58,9 +125,10 @@ impl Store {
|
||||
pub fn save_session(&self, s: &Session) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (id, data, parent_id, updated_at) VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at",
|
||||
params![s.id, serde_json::to_string(s)?, s.parent_id, s.updated_at],
|
||||
"INSERT INTO sessions (id, data, parent_id, updated_at, owner_id) VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT(id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at,
|
||||
owner_id = excluded.owner_id",
|
||||
params![s.id, serde_json::to_string(s)?, s.parent_id, s.updated_at, s.owner_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -74,12 +142,15 @@ impl Store {
|
||||
}
|
||||
|
||||
/// Top-level sessions (subagent sessions are reachable through their parent).
|
||||
pub fn list_sessions(&self) -> Result<Vec<Session>> {
|
||||
/// `user = None` lists all sessions (admins); otherwise own and shared ones.
|
||||
pub fn list_sessions(&self, user: Option<&str>) -> Result<Vec<Session>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT data FROM sessions WHERE parent_id IS NULL ORDER BY updated_at DESC LIMIT 500",
|
||||
"SELECT data FROM sessions WHERE parent_id IS NULL AND (?1 IS NULL OR owner_id = ?1
|
||||
OR id IN (SELECT session_id FROM session_shares WHERE user_id = ?1))
|
||||
ORDER BY updated_at DESC LIMIT 500",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
|
||||
let rows = stmt.query_map([user], |r| r.get::<_, String>(0))?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
out.push(serde_json::from_str(&row?)?);
|
||||
@@ -197,8 +268,9 @@ impl Store {
|
||||
conn.execute(
|
||||
"INSERT INTO usage (ts, session_id, root_session_id, provider, model, agent,
|
||||
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
|
||||
ttft_ms, duration_ms, cost, error)
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)",
|
||||
ttft_ms, duration_ms, cost, error, user_id)
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,
|
||||
(SELECT owner_id FROM sessions WHERE id = ?3))",
|
||||
params![
|
||||
u.ts, u.session_id, u.root_session_id, u.provider, u.model, u.agent,
|
||||
u.input_tokens as i64, u.output_tokens as i64, u.cache_read_tokens as i64,
|
||||
@@ -232,21 +304,22 @@ impl Store {
|
||||
|
||||
/// Global statistics in [from, to) (ms). `tz_offset_min` shifts day/hour buckets
|
||||
/// into the viewer's local time.
|
||||
pub fn global_stats(&self, from: i64, to: i64, tz_offset_min: i64) -> Result<Value> {
|
||||
pub fn global_stats(&self, from: i64, to: i64, tz_offset_min: i64, user: Option<&str>) -> Result<Value> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let shift = tz_offset_min * 60_000;
|
||||
let total = conn.query_row(
|
||||
&format!("SELECT {AGG} FROM usage WHERE ts >= ?1 AND ts < ?2"),
|
||||
params![from, to],
|
||||
&format!("SELECT {AGG} FROM usage WHERE ts >= ?1 AND ts < ?2 AND (?3 IS NULL OR user_id = ?3)"),
|
||||
params![from, to, user],
|
||||
|r| agg_row(r, 0),
|
||||
)?;
|
||||
|
||||
let grouped = |key_sql: &str, key_name: &str| -> Result<Vec<Value>> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {key_sql}, {AGG} FROM usage WHERE ts >= ?1 AND ts < ?2 AND ?3 = ?3 GROUP BY 1 ORDER BY 1"
|
||||
"SELECT {key_sql}, {AGG} FROM usage WHERE ts >= ?1 AND ts < ?2 AND ?3 = ?3
|
||||
AND (?4 IS NULL OR user_id = ?4) GROUP BY 1 ORDER BY 1"
|
||||
))?;
|
||||
let rows = stmt
|
||||
.query_map(params![from, to, shift], |r| {
|
||||
.query_map(params![from, to, shift, user], |r| {
|
||||
let mut v = agg_row(r, 1)?;
|
||||
v[key_name] = match r.get_ref(0)? {
|
||||
rusqlite::types::ValueRef::Integer(i) => json!(i),
|
||||
@@ -264,6 +337,10 @@ impl Store {
|
||||
let by_model = grouped("provider || '/' || model", "model")?;
|
||||
let by_provider = grouped("provider", "provider")?;
|
||||
let by_agent = grouped("agent", "agent")?;
|
||||
let by_user = grouped(
|
||||
"COALESCE((SELECT username FROM users WHERE users.id = usage.user_id), '(none)')",
|
||||
"user",
|
||||
)?;
|
||||
// Heatmap: weekday (0 = Sunday) x hour of day.
|
||||
let heatmap = grouped(
|
||||
"CAST(strftime('%w', (ts + ?3) / 1000, 'unixepoch') AS INTEGER) * 24 + \
|
||||
@@ -274,9 +351,10 @@ impl Store {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {day_sql}, provider || '/' || model, SUM(cost),
|
||||
SUM(input_tokens + cache_read_tokens + cache_write_tokens), SUM(output_tokens)
|
||||
FROM usage WHERE ts >= ?1 AND ts < ?2 GROUP BY 1, 2 ORDER BY 1"
|
||||
FROM usage WHERE ts >= ?1 AND ts < ?2 AND (?4 IS NULL OR user_id = ?4)
|
||||
GROUP BY 1, 2 ORDER BY 1"
|
||||
))?;
|
||||
stmt.query_map(params![from, to, shift], |r| {
|
||||
stmt.query_map(params![from, to, shift, user], |r| {
|
||||
Ok(json!({
|
||||
"day": r.get::<_, String>(0)?,
|
||||
"model": r.get::<_, String>(1)?,
|
||||
@@ -292,10 +370,10 @@ impl Store {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT u.root_session_id, COALESCE(json_extract(s.data, '$.title'), '(deleted)'), {AGG}
|
||||
FROM usage u LEFT JOIN sessions s ON s.id = u.root_session_id
|
||||
WHERE u.ts >= ?1 AND u.ts < ?2
|
||||
WHERE u.ts >= ?1 AND u.ts < ?2 AND (?3 IS NULL OR u.user_id = ?3)
|
||||
GROUP BY 1 ORDER BY SUM(u.cost) DESC, COUNT(*) DESC LIMIT 15"
|
||||
))?;
|
||||
stmt.query_map(params![from, to], |r| {
|
||||
stmt.query_map(params![from, to, user], |r| {
|
||||
let mut v = agg_row(r, 2)?;
|
||||
v["session_id"] = json!(r.get::<_, String>(0)?);
|
||||
v["title"] = json!(r.get::<_, String>(1)?);
|
||||
@@ -310,8 +388,385 @@ impl Store {
|
||||
"by_model": by_model,
|
||||
"by_provider": by_provider,
|
||||
"by_agent": by_agent,
|
||||
"by_user": by_user,
|
||||
"heatmap": heatmap,
|
||||
"top_sessions": top_sessions,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// --- users & auth -------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct User {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub role: String,
|
||||
pub has_password: bool,
|
||||
pub oidc: bool,
|
||||
pub disabled: bool,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
impl User {
|
||||
pub fn is_admin(&self) -> bool {
|
||||
self.role == "admin"
|
||||
}
|
||||
}
|
||||
|
||||
const USER_COLS: &str =
|
||||
"id, username, display_name, role, password_hash IS NOT NULL, oidc_sub IS NOT NULL, disabled, created_at";
|
||||
|
||||
fn user_row(r: &rusqlite::Row) -> rusqlite::Result<User> {
|
||||
Ok(User {
|
||||
id: r.get(0)?,
|
||||
username: r.get(1)?,
|
||||
display_name: r.get(2)?,
|
||||
role: r.get(3)?,
|
||||
has_password: r.get(4)?,
|
||||
oidc: r.get(5)?,
|
||||
disabled: r.get::<_, i64>(6)? != 0,
|
||||
created_at: r.get(7)?,
|
||||
})
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn user_count(&self) -> Result<i64> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn.query_row("SELECT COUNT(*) FROM users", [], |r| r.get(0))?)
|
||||
}
|
||||
|
||||
pub fn create_user(&self, username: &str, display_name: &str, role: &str, password_hash: Option<&str>, oidc_sub: Option<&str>) -> Result<User> {
|
||||
let id = crate::types::new_id();
|
||||
{
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO users (id, username, display_name, password_hash, role, oidc_sub, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![id, username, display_name, password_hash, role, oidc_sub, now_ms()],
|
||||
)?;
|
||||
}
|
||||
self.user(&id)?.ok_or_else(|| anyhow::anyhow!("user vanished"))
|
||||
}
|
||||
|
||||
pub fn user(&self, id: &str) -> Result<Option<User>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn.query_row(&format!("SELECT {USER_COLS} FROM users WHERE id = ?1"), [id], user_row).optional()?)
|
||||
}
|
||||
|
||||
pub fn user_by_name(&self, username: &str) -> Result<Option<(User, Option<String>)>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn
|
||||
.query_row(
|
||||
&format!("SELECT {USER_COLS}, password_hash FROM users WHERE username = ?1"),
|
||||
[username],
|
||||
|r| Ok((user_row(r)?, r.get::<_, Option<String>>(8)?)),
|
||||
)
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
pub fn user_by_oidc(&self, sub: &str) -> Result<Option<User>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn.query_row(&format!("SELECT {USER_COLS} FROM users WHERE oidc_sub = ?1"), [sub], user_row).optional()?)
|
||||
}
|
||||
|
||||
pub fn users(&self) -> Result<Vec<User>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(&format!("SELECT {USER_COLS} FROM users ORDER BY username"))?;
|
||||
let rows = stmt.query_map([], user_row)?.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Update selected fields; None leaves a field unchanged.
|
||||
pub fn update_user(&self, id: &str, display_name: Option<&str>, role: Option<&str>, disabled: Option<bool>, password_hash: Option<&str>, oidc_sub: Option<&str>) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"UPDATE users SET display_name = COALESCE(?2, display_name), role = COALESCE(?3, role),
|
||||
disabled = COALESCE(?4, disabled), password_hash = COALESCE(?5, password_hash),
|
||||
oidc_sub = COALESCE(?6, oidc_sub) WHERE id = ?1",
|
||||
params![id, display_name, role, disabled.map(|d| d as i64), password_hash, oidc_sub],
|
||||
)?;
|
||||
if disabled == Some(true) {
|
||||
conn.execute("DELETE FROM auth_tokens WHERE user_id = ?1", [id])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_user(&self, id: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute("DELETE FROM users WHERE id = ?1", [id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn admin_count(&self) -> Result<i64> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn.query_row("SELECT COUNT(*) FROM users WHERE role = 'admin' AND disabled = 0", [], |r| r.get(0))?)
|
||||
}
|
||||
|
||||
pub fn add_token(&self, token_hash: &str, user_id: &str, expires_at: i64, user_agent: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute("DELETE FROM auth_tokens WHERE expires_at < ?1", [now_ms()])?;
|
||||
conn.execute(
|
||||
"INSERT INTO auth_tokens (token_hash, user_id, created_at, expires_at, user_agent) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![token_hash, user_id, now_ms(), expires_at, user_agent],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The (enabled) user behind a valid login token.
|
||||
pub fn user_by_token(&self, token_hash: &str) -> Result<Option<User>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn
|
||||
.query_row(
|
||||
&format!(
|
||||
"SELECT {} FROM auth_tokens t JOIN users u ON u.id = t.user_id
|
||||
WHERE t.token_hash = ?1 AND t.expires_at > ?2 AND u.disabled = 0",
|
||||
USER_COLS.split(", ").map(|c| format!("u.{c}")).collect::<Vec<_>>().join(", ")
|
||||
),
|
||||
params![token_hash, now_ms()],
|
||||
user_row,
|
||||
)
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
pub fn delete_token(&self, token_hash: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute("DELETE FROM auth_tokens WHERE token_hash = ?1", [token_hash])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_user_tokens(&self, user_id: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute("DELETE FROM auth_tokens WHERE user_id = ?1", [user_id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn shares(&self, session_id: &str) -> Result<Vec<String>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT user_id FROM session_shares WHERE session_id = ?1")?;
|
||||
let rows = stmt.query_map([session_id], |r| r.get(0))?.collect::<rusqlite::Result<Vec<String>>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub fn set_shares(&self, session_id: &str, users: &[String]) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute("DELETE FROM session_shares WHERE session_id = ?1", [session_id])?;
|
||||
for u in users {
|
||||
conn.execute("INSERT OR IGNORE INTO session_shares (session_id, user_id) VALUES (?1, ?2)", params![session_id, u])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Assign sessions without owner (created before user management) to a user.
|
||||
pub fn adopt_orphans(&self, user_id: &str) -> Result<usize> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let ids: Vec<(String, String)> = {
|
||||
let mut stmt = conn.prepare("SELECT id, data FROM sessions WHERE owner_id IS NULL")?;
|
||||
stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?.collect::<rusqlite::Result<Vec<_>>>()?
|
||||
};
|
||||
for (id, data) in &ids {
|
||||
let mut v: Value = serde_json::from_str(data)?;
|
||||
v["owner_id"] = json!(user_id);
|
||||
conn.execute("UPDATE sessions SET owner_id = ?2, data = ?3 WHERE id = ?1", params![id, user_id, v.to_string()])?;
|
||||
}
|
||||
conn.execute(
|
||||
"UPDATE usage SET user_id = ?1 WHERE user_id IS NULL",
|
||||
[user_id],
|
||||
)?;
|
||||
Ok(ids.len())
|
||||
}
|
||||
}
|
||||
|
||||
// --- shared folders -----------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Folder {
|
||||
pub id: String,
|
||||
/// Absolute path inside the workspace.
|
||||
pub path: String,
|
||||
pub users: Vec<String>,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn folders(&self) -> Result<Vec<Folder>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT f.id, f.path, f.created_at, COALESCE(group_concat(a.user_id), '')
|
||||
FROM folders f LEFT JOIN folder_access a ON a.folder_id = f.id GROUP BY f.id ORDER BY f.path",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map([], |r| {
|
||||
let users: String = r.get(3)?;
|
||||
Ok(Folder {
|
||||
id: r.get(0)?,
|
||||
path: r.get(1)?,
|
||||
created_at: r.get(2)?,
|
||||
users: users.split(',').filter(|u| !u.is_empty()).map(String::from).collect(),
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub fn add_folder(&self, path: &str) -> Result<String> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let id = crate::types::new_id();
|
||||
conn.execute("INSERT INTO folders (id, path, created_at) VALUES (?1, ?2, ?3)", params![id, path, now_ms()])?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub fn delete_folder(&self, id: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute("DELETE FROM folders WHERE id = ?1", [id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_folder_access(&self, id: &str, users: &[String]) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute("DELETE FROM folder_access WHERE folder_id = ?1", [id])?;
|
||||
for u in users {
|
||||
conn.execute("INSERT OR IGNORE INTO folder_access (folder_id, user_id) VALUES (?1, ?2)", params![id, u])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Paths of folders shared with a user.
|
||||
pub fn folders_for_user(&self, user_id: &str) -> Result<Vec<String>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT f.path FROM folders f JOIN folder_access a ON a.folder_id = f.id WHERE a.user_id = ?1 ORDER BY f.path",
|
||||
)?;
|
||||
let rows = stmt.query_map([user_id], |r| r.get(0))?.collect::<rusqlite::Result<Vec<String>>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
}
|
||||
|
||||
// --- generated images ---------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ImageRecord {
|
||||
pub id: String,
|
||||
pub user_id: Option<String>,
|
||||
pub session_id: Option<String>,
|
||||
pub prompt: String,
|
||||
pub model: String,
|
||||
pub path: String,
|
||||
pub width: i64,
|
||||
pub height: i64,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
fn image_row(r: &rusqlite::Row) -> rusqlite::Result<ImageRecord> {
|
||||
Ok(ImageRecord {
|
||||
id: r.get(0)?,
|
||||
user_id: r.get(1)?,
|
||||
session_id: r.get(2)?,
|
||||
prompt: r.get(3)?,
|
||||
model: r.get(4)?,
|
||||
path: r.get(5)?,
|
||||
width: r.get(6)?,
|
||||
height: r.get(7)?,
|
||||
created_at: r.get(8)?,
|
||||
})
|
||||
}
|
||||
|
||||
const IMAGE_COLS: &str = "id, user_id, session_id, prompt, model, path, width, height, created_at";
|
||||
|
||||
impl Store {
|
||||
pub fn add_image(&self, img: &ImageRecord) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
&format!("INSERT INTO images ({IMAGE_COLS}) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)"),
|
||||
params![img.id, img.user_id, img.session_id, img.prompt, img.model, img.path, img.width, img.height, img.created_at],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Latest images; `user = None` lists everybody's (admins).
|
||||
pub fn images(&self, user: Option<&str>, limit: i64) -> Result<Vec<ImageRecord>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {IMAGE_COLS} FROM images WHERE ?1 IS NULL OR user_id = ?1 ORDER BY created_at DESC LIMIT ?2"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![user, limit], image_row)?.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub fn image(&self, id: &str) -> Result<Option<ImageRecord>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn.query_row(&format!("SELECT {IMAGE_COLS} FROM images WHERE id = ?1"), [id], image_row).optional()?)
|
||||
}
|
||||
|
||||
pub fn delete_image(&self, id: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute("DELETE FROM images WHERE id = ?1", [id])?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// Cost per provider since `from` (ms).
|
||||
pub fn cost_by_provider(&self, from: i64) -> Result<std::collections::BTreeMap<String, f64>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT provider, COALESCE(SUM(cost), 0) FROM usage WHERE ts >= ?1 GROUP BY provider")?;
|
||||
let rows = stmt.query_map([from], |r| Ok((r.get::<_, String>(0)?, r.get::<_, f64>(1)?)))?;
|
||||
let mut out = std::collections::BTreeMap::new();
|
||||
for r in rows {
|
||||
let (p, c) = r?;
|
||||
out.insert(p, c);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
// --- message queue -------------------------------------------------------------
|
||||
|
||||
/// A message waiting to be delivered to a busy session.
|
||||
#[derive(Debug, Clone, Serialize, serde::Deserialize)]
|
||||
pub struct QueueItem {
|
||||
pub id: String,
|
||||
pub parts: Vec<crate::types::Part>,
|
||||
/// "user" or "background" (results of background subagents).
|
||||
pub source: String,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn queue_add(&self, session_id: &str, item: &QueueItem) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO queue (id, session_id, data, created_at) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![item.id, session_id, serde_json::to_string(item)?, item.created_at],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn queue_list(&self, session_id: &str) -> Result<Vec<QueueItem>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT data FROM queue WHERE session_id = ?1 ORDER BY created_at, rowid")?;
|
||||
let rows = stmt.query_map([session_id], |r| r.get::<_, String>(0))?;
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
out.push(serde_json::from_str(&r?)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Remove one item; false if it was already delivered.
|
||||
pub fn queue_remove(&self, session_id: &str, id: &str) -> Result<bool> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
Ok(conn.execute("DELETE FROM queue WHERE session_id = ?1 AND id = ?2", params![session_id, id])? > 0)
|
||||
}
|
||||
|
||||
/// Take all items for delivery.
|
||||
pub fn queue_take(&self, session_id: &str) -> Result<Vec<QueueItem>> {
|
||||
let items = self.queue_list(session_id)?;
|
||||
let conn = self.conn.lock().unwrap();
|
||||
for it in &items {
|
||||
conn.execute("DELETE FROM queue WHERE id = ?1", [&it.id])?;
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user