feat: chat sidebar and inbox, feat: saved chats, fix: wake jobs, fix: sandbox sends, ux: negotiation replies and draft box
This commit is contained in:
+195
-16
@@ -8,6 +8,9 @@ from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
|
||||
DEFAULT_THREAD_ID = "default"
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
@@ -65,8 +68,16 @@ class MemoryStore:
|
||||
with self._connect() as db:
|
||||
db.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS chat_threads (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
thread_id TEXT,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
@@ -129,27 +140,152 @@ class MemoryStore:
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
def add_conversation(self, role: str, content: str) -> None:
|
||||
with self._connect() as db:
|
||||
self._ensure_column(db, "conversations", "thread_id", "TEXT")
|
||||
now = iso_now()
|
||||
db.execute(
|
||||
"INSERT INTO conversations(role, content, created_at) VALUES (?, ?, ?)",
|
||||
(role, content, iso_now()),
|
||||
"""
|
||||
INSERT INTO chat_threads(id, title, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO NOTHING
|
||||
""",
|
||||
(DEFAULT_THREAD_ID, "New chat", now, now),
|
||||
)
|
||||
db.execute(
|
||||
"UPDATE conversations SET thread_id = ? WHERE thread_id IS NULL OR thread_id = ''",
|
||||
(DEFAULT_THREAD_ID,),
|
||||
)
|
||||
|
||||
def last_interaction(self) -> dict[str, Any] | None:
|
||||
@staticmethod
|
||||
def _ensure_column(db: sqlite3.Connection, table: str, column: str, definition: str) -> None:
|
||||
columns = {row["name"] for row in db.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||
if column not in columns:
|
||||
db.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
|
||||
|
||||
def ensure_thread(self, thread_id: str | None = None, title: str | None = None) -> dict[str, Any]:
|
||||
now = iso_now()
|
||||
resolved_id = (thread_id or DEFAULT_THREAD_ID).strip() or DEFAULT_THREAD_ID
|
||||
resolved_title = (title or "New chat").strip() or "New chat"
|
||||
with self._connect() as db:
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO chat_threads(id, title, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET updated_at=excluded.updated_at
|
||||
""",
|
||||
(resolved_id, resolved_title, now, now),
|
||||
)
|
||||
row = db.execute(
|
||||
"SELECT role, content, created_at FROM conversations ORDER BY id DESC LIMIT 1"
|
||||
"SELECT id, title, created_at, updated_at FROM chat_threads WHERE id = ?",
|
||||
(resolved_id,),
|
||||
).fetchone()
|
||||
return dict(row)
|
||||
|
||||
def create_thread(self, title: str | None = None) -> dict[str, Any]:
|
||||
import uuid
|
||||
|
||||
thread_id = f"chat-{uuid.uuid4()}"
|
||||
return self.ensure_thread(thread_id, title or "New chat")
|
||||
|
||||
def list_threads(self) -> list[dict[str, Any]]:
|
||||
with self._connect() as db:
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT
|
||||
t.id,
|
||||
t.title,
|
||||
t.created_at,
|
||||
t.updated_at,
|
||||
COUNT(c.id) AS message_count,
|
||||
MAX(c.created_at) AS last_message_at
|
||||
FROM chat_threads t
|
||||
LEFT JOIN conversations c ON c.thread_id = t.id
|
||||
GROUP BY t.id
|
||||
ORDER BY COALESCE(MAX(c.created_at), t.updated_at) DESC
|
||||
"""
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def delete_thread(self, thread_id: str) -> bool:
|
||||
with self._connect() as db:
|
||||
db.execute("DELETE FROM conversations WHERE thread_id = ?", (thread_id,))
|
||||
cursor = db.execute("DELETE FROM chat_threads WHERE id = ?", (thread_id,))
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def rename_thread(self, thread_id: str, title: str) -> dict[str, Any] | None:
|
||||
clean_title = self._clean_thread_title(title)
|
||||
if not clean_title:
|
||||
return None
|
||||
now = iso_now()
|
||||
with self._connect() as db:
|
||||
db.execute(
|
||||
"UPDATE chat_threads SET title = ?, updated_at = ? WHERE id = ?",
|
||||
(clean_title, now, thread_id),
|
||||
)
|
||||
row = db.execute(
|
||||
"SELECT id, title, created_at, updated_at FROM chat_threads WHERE id = ?",
|
||||
(thread_id,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def recent_conversation(self, limit: int = 8) -> list[dict[str, Any]]:
|
||||
def get_thread(self, thread_id: str) -> dict[str, Any] | None:
|
||||
with self._connect() as db:
|
||||
rows = db.execute(
|
||||
"SELECT role, content, created_at FROM conversations ORDER BY id DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
row = db.execute(
|
||||
"SELECT id, title, created_at, updated_at FROM chat_threads WHERE id = ?",
|
||||
(thread_id,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def add_conversation(self, role: str, content: str, thread_id: str | None = DEFAULT_THREAD_ID) -> None:
|
||||
resolved_thread_id = (thread_id or DEFAULT_THREAD_ID).strip() or DEFAULT_THREAD_ID
|
||||
self.ensure_thread(resolved_thread_id)
|
||||
now = iso_now()
|
||||
with self._connect() as db:
|
||||
db.execute(
|
||||
"INSERT INTO conversations(thread_id, role, content, created_at) VALUES (?, ?, ?, ?)",
|
||||
(resolved_thread_id, role, content, now),
|
||||
)
|
||||
db.execute(
|
||||
"UPDATE chat_threads SET updated_at = ? WHERE id = ?",
|
||||
(now, resolved_thread_id),
|
||||
)
|
||||
|
||||
def last_interaction(self, thread_id: str | None = None) -> dict[str, Any] | None:
|
||||
with self._connect() as db:
|
||||
if thread_id:
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT thread_id, role, content, created_at
|
||||
FROM conversations
|
||||
WHERE thread_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(thread_id,),
|
||||
).fetchone()
|
||||
else:
|
||||
row = db.execute(
|
||||
"SELECT thread_id, role, content, created_at FROM conversations ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def recent_conversation(self, limit: int = 8, thread_id: str | None = None) -> list[dict[str, Any]]:
|
||||
with self._connect() as db:
|
||||
if thread_id:
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT id, thread_id, role, content, created_at
|
||||
FROM conversations
|
||||
WHERE thread_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(thread_id, limit),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = db.execute(
|
||||
"SELECT id, thread_id, role, content, created_at FROM conversations ORDER BY id DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [dict(row) for row in reversed(rows)]
|
||||
|
||||
def remember(self, kind: str, content: str, importance: int = 3, metadata: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
@@ -222,13 +358,22 @@ class MemoryStore:
|
||||
).fetchall()
|
||||
conversations = db.execute(
|
||||
"""
|
||||
SELECT id, role, content, created_at
|
||||
SELECT id, thread_id, role, content, created_at
|
||||
FROM conversations
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
threads = db.execute(
|
||||
"""
|
||||
SELECT id, title, created_at, updated_at
|
||||
FROM chat_threads
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
profile_rows = db.execute(
|
||||
"SELECT key, value, updated_at FROM user_profile ORDER BY key"
|
||||
).fetchall()
|
||||
@@ -252,6 +397,7 @@ class MemoryStore:
|
||||
return {
|
||||
"path": str(self.path),
|
||||
"memories": [self._memory_row(row) for row in memories],
|
||||
"chat_threads": [dict(row) for row in threads],
|
||||
"conversations": [dict(row) for row in conversations],
|
||||
"profile": profile,
|
||||
"scheduled_jobs": [dict(row) for row in jobs],
|
||||
@@ -339,17 +485,38 @@ class MemoryStore:
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def mark_job_run(self, job_id: str, next_run_at: str | None = None) -> None:
|
||||
def mark_job_run(self, job_id: str, next_run_at: str | None = None, enabled: bool = True) -> None:
|
||||
with self._connect() as db:
|
||||
db.execute(
|
||||
"UPDATE scheduled_jobs SET last_run_at = ?, next_run_at = ? WHERE id = ?",
|
||||
(iso_now(), next_run_at, job_id),
|
||||
"UPDATE scheduled_jobs SET last_run_at = ?, next_run_at = ?, enabled = ? WHERE id = ?",
|
||||
(iso_now(), next_run_at, 1 if enabled else 0, job_id),
|
||||
)
|
||||
|
||||
def add_outbox(self, content: str) -> None:
|
||||
with self._connect() as db:
|
||||
db.execute("INSERT INTO outbox(content, created_at) VALUES (?, ?)", (content, iso_now()))
|
||||
|
||||
def list_outbox(self, limit: int = 100) -> list[dict[str, Any]]:
|
||||
with self._connect() as db:
|
||||
rows = db.execute(
|
||||
"SELECT id, content, created_at, delivered_at FROM outbox ORDER BY id DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def get_outbox(self, inbox_id: int) -> dict[str, Any] | None:
|
||||
with self._connect() as db:
|
||||
row = db.execute(
|
||||
"SELECT id, content, created_at, delivered_at FROM outbox WHERE id = ?",
|
||||
(inbox_id,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def delete_outbox(self, inbox_id: int) -> bool:
|
||||
with self._connect() as db:
|
||||
cursor = db.execute("DELETE FROM outbox WHERE id = ?", (inbox_id,))
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def undelivered_outbox(self) -> list[dict[str, Any]]:
|
||||
now = iso_now()
|
||||
with self._connect() as db:
|
||||
@@ -362,6 +529,18 @@ class MemoryStore:
|
||||
)
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
def _thread_title(content: str) -> str:
|
||||
text = " ".join(content.strip().split())
|
||||
if not text:
|
||||
return "New chat"
|
||||
return text[:42] + ("..." if len(text) > 42 else "")
|
||||
|
||||
@staticmethod
|
||||
def _clean_thread_title(title: str) -> str:
|
||||
text = " ".join(title.strip().strip('"').strip("'").split())
|
||||
return text[:64]
|
||||
|
||||
@staticmethod
|
||||
def _fts_query(query: str) -> str:
|
||||
tokens = [token.replace('"', "") for token in query.split() if token.strip()]
|
||||
|
||||
Reference in New Issue
Block a user