Root cause: assistant messages use temporary IDs (temp-assistant-{timestamp})
in the frontend, which don't match real DB message IDs. The PUT update by
message_id always found 0 rows, so results were never persisted.
Fix — fallback search by session + prompt:
- db.ts: updateMessageContent now returns changes count; new
updateMessageBySessionAndHint() finds the latest assistant message
containing image-gen-request with matching prompt text and updates it
- /api/chat/messages PUT: tries message_id first, falls back to
session_id + prompt_hint search; returns 404 if neither finds a match;
response includes updated_message_id and fallback_used for debugging
- ImageGenConfirmation: always sends session_id and prompt_hint in PUT
request, regardless of whether messageId is a real or temp ID
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1137 lines
41 KiB
TypeScript
1137 lines
41 KiB
TypeScript
import Database from 'better-sqlite3';
|
|
import path from 'path';
|
|
import crypto from 'crypto';
|
|
import fs from 'fs';
|
|
import os from 'os';
|
|
import type { ChatSession, Message, SettingsMap, TaskItem, TaskStatus, ApiProvider, CreateProviderRequest, UpdateProviderRequest, MediaJob, MediaJobStatus, MediaJobItem, MediaJobItemStatus, MediaContextEvent, BatchConfig } from '@/types';
|
|
|
|
const dataDir = process.env.CLAUDE_GUI_DATA_DIR || path.join(os.homedir(), '.codepilot');
|
|
const DB_PATH = path.join(dataDir, 'codepilot.db');
|
|
|
|
let db: Database.Database | null = null;
|
|
|
|
export function getDb(): Database.Database {
|
|
if (!db) {
|
|
const dir = path.dirname(DB_PATH);
|
|
if (!fs.existsSync(dir)) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
|
|
// Migrate from old locations if the new DB doesn't exist yet
|
|
if (!fs.existsSync(DB_PATH)) {
|
|
const home = os.homedir();
|
|
const oldPaths = [
|
|
// Old Electron userData paths (app.getPath('userData'))
|
|
path.join(home, 'Library', 'Application Support', 'CodePilot', 'codepilot.db'),
|
|
path.join(home, 'Library', 'Application Support', 'codepilot', 'codepilot.db'),
|
|
path.join(home, 'Library', 'Application Support', 'Claude GUI', 'codepilot.db'),
|
|
// Old dev-mode fallback
|
|
path.join(process.cwd(), 'data', 'codepilot.db'),
|
|
// Legacy name
|
|
path.join(home, 'Library', 'Application Support', 'CodePilot', 'claude-gui.db'),
|
|
path.join(home, 'Library', 'Application Support', 'codepilot', 'claude-gui.db'),
|
|
];
|
|
for (const oldPath of oldPaths) {
|
|
if (fs.existsSync(oldPath)) {
|
|
try {
|
|
fs.copyFileSync(oldPath, DB_PATH);
|
|
// Also copy WAL/SHM if they exist
|
|
if (fs.existsSync(oldPath + '-wal')) fs.copyFileSync(oldPath + '-wal', DB_PATH + '-wal');
|
|
if (fs.existsSync(oldPath + '-shm')) fs.copyFileSync(oldPath + '-shm', DB_PATH + '-shm');
|
|
console.log(`[db] Migrated database from ${oldPath}`);
|
|
break;
|
|
} catch (err) {
|
|
console.warn(`[db] Failed to migrate from ${oldPath}:`, err);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
db = new Database(DB_PATH);
|
|
db.pragma('journal_mode = WAL');
|
|
db.pragma('foreign_keys = ON');
|
|
initDb(db);
|
|
}
|
|
return db;
|
|
}
|
|
|
|
function initDb(db: Database.Database): void {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS chat_sessions (
|
|
id TEXT PRIMARY KEY,
|
|
title TEXT NOT NULL DEFAULT 'New Chat',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
model TEXT NOT NULL DEFAULT '',
|
|
system_prompt TEXT NOT NULL DEFAULT '',
|
|
working_directory TEXT NOT NULL DEFAULT '',
|
|
sdk_session_id TEXT NOT NULL DEFAULT ''
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS messages (
|
|
id TEXT PRIMARY KEY,
|
|
session_id TEXT NOT NULL,
|
|
role TEXT NOT NULL CHECK(role IN ('user', 'assistant')),
|
|
content TEXT NOT NULL,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
token_usage TEXT,
|
|
FOREIGN KEY (session_id) REFERENCES chat_sessions(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS settings (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
key TEXT NOT NULL UNIQUE,
|
|
value TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS tasks (
|
|
id TEXT PRIMARY KEY,
|
|
session_id TEXT NOT NULL,
|
|
title TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'in_progress', 'completed', 'failed')),
|
|
description TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
FOREIGN KEY (session_id) REFERENCES chat_sessions(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS api_providers (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
provider_type TEXT NOT NULL DEFAULT 'anthropic',
|
|
base_url TEXT NOT NULL DEFAULT '',
|
|
api_key TEXT NOT NULL DEFAULT '',
|
|
is_active INTEGER NOT NULL DEFAULT 0,
|
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
extra_env TEXT NOT NULL DEFAULT '{}',
|
|
notes TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS media_generations (
|
|
id TEXT PRIMARY KEY,
|
|
type TEXT NOT NULL DEFAULT 'image',
|
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'processing', 'completed', 'failed')),
|
|
provider TEXT NOT NULL DEFAULT 'gemini',
|
|
model TEXT NOT NULL DEFAULT '',
|
|
prompt TEXT NOT NULL DEFAULT '',
|
|
aspect_ratio TEXT NOT NULL DEFAULT '1:1',
|
|
image_size TEXT NOT NULL DEFAULT '1K',
|
|
local_path TEXT NOT NULL DEFAULT '',
|
|
thumbnail_path TEXT NOT NULL DEFAULT '',
|
|
session_id TEXT,
|
|
message_id TEXT,
|
|
tags TEXT NOT NULL DEFAULT '[]',
|
|
metadata TEXT NOT NULL DEFAULT '{}',
|
|
favorited INTEGER NOT NULL DEFAULT 0,
|
|
error TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
completed_at TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS media_tags (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL UNIQUE,
|
|
color TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS media_jobs (
|
|
id TEXT PRIMARY KEY,
|
|
session_id TEXT,
|
|
status TEXT NOT NULL DEFAULT 'draft'
|
|
CHECK(status IN ('draft','planning','planned','running','paused','completed','cancelled','failed')),
|
|
doc_paths TEXT NOT NULL DEFAULT '[]',
|
|
style_prompt TEXT NOT NULL DEFAULT '',
|
|
batch_config TEXT NOT NULL DEFAULT '{}',
|
|
total_items INTEGER NOT NULL DEFAULT 0,
|
|
completed_items INTEGER NOT NULL DEFAULT 0,
|
|
failed_items INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
completed_at TEXT,
|
|
FOREIGN KEY (session_id) REFERENCES chat_sessions(id) ON DELETE SET NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS media_job_items (
|
|
id TEXT PRIMARY KEY,
|
|
job_id TEXT NOT NULL,
|
|
idx INTEGER NOT NULL DEFAULT 0,
|
|
prompt TEXT NOT NULL DEFAULT '',
|
|
aspect_ratio TEXT NOT NULL DEFAULT '1:1',
|
|
image_size TEXT NOT NULL DEFAULT '1K',
|
|
model TEXT NOT NULL DEFAULT '',
|
|
tags TEXT NOT NULL DEFAULT '[]',
|
|
source_refs TEXT NOT NULL DEFAULT '[]',
|
|
status TEXT NOT NULL DEFAULT 'pending'
|
|
CHECK(status IN ('pending','processing','completed','failed','cancelled')),
|
|
retry_count INTEGER NOT NULL DEFAULT 0,
|
|
result_media_generation_id TEXT,
|
|
error TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
FOREIGN KEY (job_id) REFERENCES media_jobs(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (result_media_generation_id) REFERENCES media_generations(id) ON DELETE SET NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS media_context_events (
|
|
id TEXT PRIMARY KEY,
|
|
session_id TEXT NOT NULL,
|
|
job_id TEXT NOT NULL,
|
|
payload TEXT NOT NULL DEFAULT '{}',
|
|
sync_mode TEXT NOT NULL DEFAULT 'manual'
|
|
CHECK(sync_mode IN ('manual','auto_batch')),
|
|
synced_at TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
FOREIGN KEY (session_id) REFERENCES chat_sessions(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (job_id) REFERENCES media_jobs(id) ON DELETE CASCADE
|
|
);
|
|
|
|
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_sessions_updated_at ON chat_sessions(updated_at);
|
|
CREATE INDEX IF NOT EXISTS idx_tasks_session_id ON tasks(session_id);
|
|
CREATE INDEX IF NOT EXISTS idx_media_created_at ON media_generations(created_at);
|
|
CREATE INDEX IF NOT EXISTS idx_media_session_id ON media_generations(session_id);
|
|
CREATE INDEX IF NOT EXISTS idx_media_status ON media_generations(status);
|
|
CREATE INDEX IF NOT EXISTS idx_media_jobs_session_id ON media_jobs(session_id);
|
|
CREATE INDEX IF NOT EXISTS idx_media_jobs_status ON media_jobs(status);
|
|
CREATE INDEX IF NOT EXISTS idx_media_job_items_job_id ON media_job_items(job_id);
|
|
CREATE INDEX IF NOT EXISTS idx_media_job_items_status ON media_job_items(status);
|
|
CREATE INDEX IF NOT EXISTS idx_media_context_events_job_id ON media_context_events(job_id);
|
|
`);
|
|
|
|
// Run migrations for existing databases
|
|
migrateDb(db);
|
|
}
|
|
|
|
function migrateDb(db: Database.Database): void {
|
|
const columns = db.prepare("PRAGMA table_info(chat_sessions)").all() as { name: string }[];
|
|
const colNames = columns.map(c => c.name);
|
|
|
|
if (!colNames.includes('model')) {
|
|
db.exec("ALTER TABLE chat_sessions ADD COLUMN model TEXT NOT NULL DEFAULT ''");
|
|
}
|
|
if (!colNames.includes('system_prompt')) {
|
|
db.exec("ALTER TABLE chat_sessions ADD COLUMN system_prompt TEXT NOT NULL DEFAULT ''");
|
|
}
|
|
if (!colNames.includes('sdk_session_id')) {
|
|
db.exec("ALTER TABLE chat_sessions ADD COLUMN sdk_session_id TEXT NOT NULL DEFAULT ''");
|
|
}
|
|
if (!colNames.includes('project_name')) {
|
|
db.exec("ALTER TABLE chat_sessions ADD COLUMN project_name TEXT NOT NULL DEFAULT ''");
|
|
// Backfill project_name from working_directory for existing rows
|
|
db.exec(`
|
|
UPDATE chat_sessions
|
|
SET project_name = CASE
|
|
WHEN working_directory != '' THEN REPLACE(REPLACE(working_directory, RTRIM(working_directory, REPLACE(working_directory, '/', '')), ''), '/', '')
|
|
ELSE ''
|
|
END
|
|
WHERE project_name = ''
|
|
`);
|
|
}
|
|
if (!colNames.includes('status')) {
|
|
db.exec("ALTER TABLE chat_sessions ADD COLUMN status TEXT NOT NULL DEFAULT 'active'");
|
|
}
|
|
if (!colNames.includes('mode')) {
|
|
db.exec("ALTER TABLE chat_sessions ADD COLUMN mode TEXT NOT NULL DEFAULT 'code'");
|
|
}
|
|
if (!colNames.includes('provider_name')) {
|
|
db.exec("ALTER TABLE chat_sessions ADD COLUMN provider_name TEXT NOT NULL DEFAULT ''");
|
|
}
|
|
if (!colNames.includes('provider_id')) {
|
|
db.exec("ALTER TABLE chat_sessions ADD COLUMN provider_id TEXT NOT NULL DEFAULT ''");
|
|
}
|
|
|
|
// Migrate is_active provider to default_provider_id setting
|
|
const defaultProviderSetting = db.prepare("SELECT value FROM settings WHERE key = 'default_provider_id'").get() as { value: string } | undefined;
|
|
if (!defaultProviderSetting) {
|
|
const activeProvider = db.prepare('SELECT id FROM api_providers WHERE is_active = 1 LIMIT 1').get() as { id: string } | undefined;
|
|
if (activeProvider) {
|
|
db.prepare(
|
|
"INSERT INTO settings (key, value) VALUES ('default_provider_id', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value"
|
|
).run(activeProvider.id);
|
|
}
|
|
}
|
|
|
|
const msgColumns = db.prepare("PRAGMA table_info(messages)").all() as { name: string }[];
|
|
const msgColNames = msgColumns.map(c => c.name);
|
|
|
|
if (!msgColNames.includes('token_usage')) {
|
|
db.exec("ALTER TABLE messages ADD COLUMN token_usage TEXT");
|
|
}
|
|
|
|
// Ensure tasks table exists for databases created before this migration
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS tasks (
|
|
id TEXT PRIMARY KEY,
|
|
session_id TEXT NOT NULL,
|
|
title TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'in_progress', 'completed', 'failed')),
|
|
description TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
FOREIGN KEY (session_id) REFERENCES chat_sessions(id) ON DELETE CASCADE
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_tasks_session_id ON tasks(session_id);
|
|
`);
|
|
|
|
// Ensure api_providers table exists for databases created before this migration
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS api_providers (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
provider_type TEXT NOT NULL DEFAULT 'anthropic',
|
|
base_url TEXT NOT NULL DEFAULT '',
|
|
api_key TEXT NOT NULL DEFAULT '',
|
|
is_active INTEGER NOT NULL DEFAULT 0,
|
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
extra_env TEXT NOT NULL DEFAULT '{}',
|
|
notes TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
`);
|
|
|
|
// Ensure media_generations table exists for databases created before this migration
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS media_generations (
|
|
id TEXT PRIMARY KEY,
|
|
type TEXT NOT NULL DEFAULT 'image',
|
|
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'processing', 'completed', 'failed')),
|
|
provider TEXT NOT NULL DEFAULT 'gemini',
|
|
model TEXT NOT NULL DEFAULT '',
|
|
prompt TEXT NOT NULL DEFAULT '',
|
|
aspect_ratio TEXT NOT NULL DEFAULT '1:1',
|
|
image_size TEXT NOT NULL DEFAULT '1K',
|
|
local_path TEXT NOT NULL DEFAULT '',
|
|
thumbnail_path TEXT NOT NULL DEFAULT '',
|
|
session_id TEXT,
|
|
message_id TEXT,
|
|
tags TEXT NOT NULL DEFAULT '[]',
|
|
metadata TEXT NOT NULL DEFAULT '{}',
|
|
favorited INTEGER NOT NULL DEFAULT 0,
|
|
error TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
completed_at TEXT
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS media_tags (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL UNIQUE,
|
|
color TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_media_created_at ON media_generations(created_at);
|
|
CREATE INDEX IF NOT EXISTS idx_media_session_id ON media_generations(session_id);
|
|
CREATE INDEX IF NOT EXISTS idx_media_status ON media_generations(status);
|
|
`);
|
|
|
|
// Ensure media_jobs tables exist for databases created before this migration
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS media_jobs (
|
|
id TEXT PRIMARY KEY,
|
|
session_id TEXT,
|
|
status TEXT NOT NULL DEFAULT 'draft'
|
|
CHECK(status IN ('draft','planning','planned','running','paused','completed','cancelled','failed')),
|
|
doc_paths TEXT NOT NULL DEFAULT '[]',
|
|
style_prompt TEXT NOT NULL DEFAULT '',
|
|
batch_config TEXT NOT NULL DEFAULT '{}',
|
|
total_items INTEGER NOT NULL DEFAULT 0,
|
|
completed_items INTEGER NOT NULL DEFAULT 0,
|
|
failed_items INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
completed_at TEXT,
|
|
FOREIGN KEY (session_id) REFERENCES chat_sessions(id) ON DELETE SET NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS media_job_items (
|
|
id TEXT PRIMARY KEY,
|
|
job_id TEXT NOT NULL,
|
|
idx INTEGER NOT NULL DEFAULT 0,
|
|
prompt TEXT NOT NULL DEFAULT '',
|
|
aspect_ratio TEXT NOT NULL DEFAULT '1:1',
|
|
image_size TEXT NOT NULL DEFAULT '1K',
|
|
model TEXT NOT NULL DEFAULT '',
|
|
tags TEXT NOT NULL DEFAULT '[]',
|
|
source_refs TEXT NOT NULL DEFAULT '[]',
|
|
status TEXT NOT NULL DEFAULT 'pending'
|
|
CHECK(status IN ('pending','processing','completed','failed','cancelled')),
|
|
retry_count INTEGER NOT NULL DEFAULT 0,
|
|
result_media_generation_id TEXT,
|
|
error TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
FOREIGN KEY (job_id) REFERENCES media_jobs(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (result_media_generation_id) REFERENCES media_generations(id) ON DELETE SET NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS media_context_events (
|
|
id TEXT PRIMARY KEY,
|
|
session_id TEXT NOT NULL,
|
|
job_id TEXT NOT NULL,
|
|
payload TEXT NOT NULL DEFAULT '{}',
|
|
sync_mode TEXT NOT NULL DEFAULT 'manual'
|
|
CHECK(sync_mode IN ('manual','auto_batch')),
|
|
synced_at TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
FOREIGN KEY (session_id) REFERENCES chat_sessions(id) ON DELETE CASCADE,
|
|
FOREIGN KEY (job_id) REFERENCES media_jobs(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_media_jobs_session_id ON media_jobs(session_id);
|
|
CREATE INDEX IF NOT EXISTS idx_media_jobs_status ON media_jobs(status);
|
|
CREATE INDEX IF NOT EXISTS idx_media_job_items_job_id ON media_job_items(job_id);
|
|
CREATE INDEX IF NOT EXISTS idx_media_job_items_status ON media_job_items(status);
|
|
CREATE INDEX IF NOT EXISTS idx_media_context_events_job_id ON media_context_events(job_id);
|
|
`);
|
|
|
|
// Add favorited column to media_generations if missing
|
|
try {
|
|
db.exec("ALTER TABLE media_generations ADD COLUMN favorited INTEGER NOT NULL DEFAULT 0");
|
|
} catch {
|
|
// Column already exists
|
|
}
|
|
|
|
// Recover stale jobs: mark 'running' jobs as 'paused' after process restart
|
|
db.exec(`
|
|
UPDATE media_jobs SET status = 'paused', updated_at = datetime('now')
|
|
WHERE status = 'running'
|
|
`);
|
|
db.exec(`
|
|
UPDATE media_job_items SET status = 'pending', updated_at = datetime('now')
|
|
WHERE status = 'processing'
|
|
`);
|
|
|
|
// Migrate existing settings to a default provider if api_providers is empty
|
|
const providerCount = db.prepare('SELECT COUNT(*) as count FROM api_providers').get() as { count: number };
|
|
if (providerCount.count === 0) {
|
|
const tokenRow = db.prepare("SELECT value FROM settings WHERE key = 'anthropic_auth_token'").get() as { value: string } | undefined;
|
|
const baseUrlRow = db.prepare("SELECT value FROM settings WHERE key = 'anthropic_base_url'").get() as { value: string } | undefined;
|
|
if (tokenRow || baseUrlRow) {
|
|
const id = crypto.randomBytes(16).toString('hex');
|
|
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
|
|
db.prepare(
|
|
'INSERT INTO api_providers (id, name, provider_type, base_url, api_key, is_active, sort_order, extra_env, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
|
).run(id, 'Default', 'anthropic', baseUrlRow?.value || '', tokenRow?.value || '', 1, 0, '{}', 'Migrated from settings', now, now);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ==========================================
|
|
// Session Operations
|
|
// ==========================================
|
|
|
|
export function getAllSessions(): ChatSession[] {
|
|
const db = getDb();
|
|
return db.prepare('SELECT * FROM chat_sessions ORDER BY updated_at DESC').all() as ChatSession[];
|
|
}
|
|
|
|
export function getSession(id: string): ChatSession | undefined {
|
|
const db = getDb();
|
|
return db.prepare('SELECT * FROM chat_sessions WHERE id = ?').get(id) as ChatSession | undefined;
|
|
}
|
|
|
|
export function createSession(
|
|
title?: string,
|
|
model?: string,
|
|
systemPrompt?: string,
|
|
workingDirectory?: string,
|
|
mode?: string,
|
|
): ChatSession {
|
|
const db = getDb();
|
|
const id = crypto.randomBytes(16).toString('hex');
|
|
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
|
|
const wd = workingDirectory || '';
|
|
const projectName = path.basename(wd);
|
|
|
|
db.prepare(
|
|
'INSERT INTO chat_sessions (id, title, created_at, updated_at, model, system_prompt, working_directory, sdk_session_id, project_name, status, mode) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
|
).run(id, title || 'New Chat', now, now, model || '', systemPrompt || '', wd, '', projectName, 'active', mode || 'code');
|
|
|
|
return getSession(id)!;
|
|
}
|
|
|
|
export function deleteSession(id: string): boolean {
|
|
const db = getDb();
|
|
const result = db.prepare('DELETE FROM chat_sessions WHERE id = ?').run(id);
|
|
return result.changes > 0;
|
|
}
|
|
|
|
export function updateSessionTimestamp(id: string): void {
|
|
const db = getDb();
|
|
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
|
|
db.prepare('UPDATE chat_sessions SET updated_at = ? WHERE id = ?').run(now, id);
|
|
}
|
|
|
|
export function updateSessionTitle(id: string, title: string): void {
|
|
const db = getDb();
|
|
db.prepare('UPDATE chat_sessions SET title = ? WHERE id = ?').run(title, id);
|
|
}
|
|
|
|
export function updateSdkSessionId(id: string, sdkSessionId: string): void {
|
|
const db = getDb();
|
|
db.prepare('UPDATE chat_sessions SET sdk_session_id = ? WHERE id = ?').run(sdkSessionId, id);
|
|
}
|
|
|
|
export function updateSessionModel(id: string, model: string): void {
|
|
const db = getDb();
|
|
db.prepare('UPDATE chat_sessions SET model = ? WHERE id = ?').run(model, id);
|
|
}
|
|
|
|
export function updateSessionProvider(id: string, providerName: string): void {
|
|
const db = getDb();
|
|
db.prepare('UPDATE chat_sessions SET provider_name = ? WHERE id = ?').run(providerName, id);
|
|
}
|
|
|
|
export function updateSessionProviderId(id: string, providerId: string): void {
|
|
const db = getDb();
|
|
db.prepare('UPDATE chat_sessions SET provider_id = ? WHERE id = ?').run(providerId, id);
|
|
}
|
|
|
|
export function getDefaultProviderId(): string | undefined {
|
|
return getSetting('default_provider_id') || undefined;
|
|
}
|
|
|
|
export function setDefaultProviderId(id: string): void {
|
|
setSetting('default_provider_id', id);
|
|
}
|
|
|
|
export function updateSessionWorkingDirectory(id: string, workingDirectory: string): void {
|
|
const db = getDb();
|
|
const projectName = path.basename(workingDirectory);
|
|
db.prepare('UPDATE chat_sessions SET working_directory = ?, project_name = ? WHERE id = ?').run(workingDirectory, projectName, id);
|
|
}
|
|
|
|
export function updateSessionMode(id: string, mode: string): void {
|
|
const db = getDb();
|
|
db.prepare('UPDATE chat_sessions SET mode = ? WHERE id = ?').run(mode, id);
|
|
}
|
|
|
|
// ==========================================
|
|
// Message Operations
|
|
// ==========================================
|
|
|
|
export function getMessages(
|
|
sessionId: string,
|
|
options?: { limit?: number; beforeRowId?: number },
|
|
): { messages: Message[]; hasMore: boolean } {
|
|
const db = getDb();
|
|
const limit = options?.limit ?? 100;
|
|
const beforeRowId = options?.beforeRowId;
|
|
|
|
let rows: Message[];
|
|
if (beforeRowId) {
|
|
// Fetch `limit + 1` rows before the cursor to detect if there are more
|
|
rows = db.prepare(
|
|
'SELECT *, rowid as _rowid FROM messages WHERE session_id = ? AND rowid < ? ORDER BY rowid DESC LIMIT ?'
|
|
).all(sessionId, beforeRowId, limit + 1) as Message[];
|
|
} else {
|
|
// Fetch the most recent `limit + 1` messages
|
|
rows = db.prepare(
|
|
'SELECT *, rowid as _rowid FROM messages WHERE session_id = ? ORDER BY rowid DESC LIMIT ?'
|
|
).all(sessionId, limit + 1) as Message[];
|
|
}
|
|
|
|
const hasMore = rows.length > limit;
|
|
if (hasMore) {
|
|
rows = rows.slice(0, limit);
|
|
}
|
|
|
|
// Reverse to chronological order (ASC)
|
|
rows.reverse();
|
|
return { messages: rows, hasMore };
|
|
}
|
|
|
|
export function addMessage(
|
|
sessionId: string,
|
|
role: 'user' | 'assistant',
|
|
content: string,
|
|
tokenUsage?: string | null,
|
|
): Message {
|
|
const db = getDb();
|
|
const id = crypto.randomBytes(16).toString('hex');
|
|
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
|
|
|
|
db.prepare(
|
|
'INSERT INTO messages (id, session_id, role, content, created_at, token_usage) VALUES (?, ?, ?, ?, ?, ?)'
|
|
).run(id, sessionId, role, content, now, tokenUsage || null);
|
|
|
|
updateSessionTimestamp(sessionId);
|
|
|
|
return db.prepare('SELECT * FROM messages WHERE id = ?').get(id) as Message;
|
|
}
|
|
|
|
export function updateMessageContent(messageId: string, content: string): number {
|
|
const db = getDb();
|
|
const result = db.prepare('UPDATE messages SET content = ? WHERE id = ?').run(content, messageId);
|
|
return result.changes;
|
|
}
|
|
|
|
/**
|
|
* Find the most recent assistant message in a session that contains a given text snippet,
|
|
* update its content, and return the real message ID. Used as fallback when the frontend
|
|
* only has a temporary message ID.
|
|
*/
|
|
export function updateMessageBySessionAndHint(
|
|
sessionId: string,
|
|
promptHint: string,
|
|
content: string,
|
|
): { changes: number; messageId?: string } {
|
|
const db = getDb();
|
|
// Find the latest assistant message containing the prompt hint within an image-gen-request block
|
|
const row = db.prepare(
|
|
"SELECT id FROM messages WHERE session_id = ? AND role = 'assistant' AND content LIKE '%image-gen-request%' AND content LIKE ? ORDER BY created_at DESC LIMIT 1"
|
|
).get(sessionId, `%${promptHint.slice(0, 60)}%`) as { id: string } | undefined;
|
|
|
|
if (!row) return { changes: 0 };
|
|
|
|
const result = db.prepare('UPDATE messages SET content = ? WHERE id = ?').run(content, row.id);
|
|
return { changes: result.changes, messageId: row.id };
|
|
}
|
|
|
|
export function clearSessionMessages(sessionId: string): void {
|
|
const db = getDb();
|
|
db.prepare('DELETE FROM messages WHERE session_id = ?').run(sessionId);
|
|
// Reset SDK session ID so next message starts fresh
|
|
db.prepare('UPDATE chat_sessions SET sdk_session_id = ? WHERE id = ?').run('', sessionId);
|
|
}
|
|
|
|
// ==========================================
|
|
// Settings Operations
|
|
// ==========================================
|
|
|
|
export function getSetting(key: string): string | undefined {
|
|
const db = getDb();
|
|
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined;
|
|
return row?.value;
|
|
}
|
|
|
|
export function setSetting(key: string, value: string): void {
|
|
const db = getDb();
|
|
db.prepare(
|
|
'INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
|
).run(key, value);
|
|
}
|
|
|
|
export function getAllSettings(): SettingsMap {
|
|
const db = getDb();
|
|
const rows = db.prepare('SELECT key, value FROM settings').all() as { key: string; value: string }[];
|
|
const settings: SettingsMap = {};
|
|
for (const row of rows) {
|
|
settings[row.key] = row.value;
|
|
}
|
|
return settings;
|
|
}
|
|
|
|
// ==========================================
|
|
// Session Status Operations
|
|
// ==========================================
|
|
|
|
export function updateSessionStatus(id: string, status: 'active' | 'archived'): void {
|
|
const db = getDb();
|
|
db.prepare('UPDATE chat_sessions SET status = ? WHERE id = ?').run(status, id);
|
|
}
|
|
|
|
// ==========================================
|
|
// Task Operations
|
|
// ==========================================
|
|
|
|
export function getTasksBySession(sessionId: string): TaskItem[] {
|
|
const db = getDb();
|
|
return db.prepare('SELECT * FROM tasks WHERE session_id = ? ORDER BY created_at ASC').all(sessionId) as TaskItem[];
|
|
}
|
|
|
|
export function getTask(id: string): TaskItem | undefined {
|
|
const db = getDb();
|
|
return db.prepare('SELECT * FROM tasks WHERE id = ?').get(id) as TaskItem | undefined;
|
|
}
|
|
|
|
export function createTask(sessionId: string, title: string, description?: string): TaskItem {
|
|
const db = getDb();
|
|
const id = crypto.randomBytes(16).toString('hex');
|
|
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
|
|
|
|
db.prepare(
|
|
'INSERT INTO tasks (id, session_id, title, status, description, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
|
).run(id, sessionId, title, 'pending', description || null, now, now);
|
|
|
|
return getTask(id)!;
|
|
}
|
|
|
|
export function updateTask(id: string, updates: { title?: string; status?: TaskStatus; description?: string }): TaskItem | undefined {
|
|
const db = getDb();
|
|
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
|
|
const existing = getTask(id);
|
|
if (!existing) return undefined;
|
|
|
|
const title = updates.title ?? existing.title;
|
|
const status = updates.status ?? existing.status;
|
|
const description = updates.description !== undefined ? updates.description : existing.description;
|
|
|
|
db.prepare(
|
|
'UPDATE tasks SET title = ?, status = ?, description = ?, updated_at = ? WHERE id = ?'
|
|
).run(title, status, description, now, id);
|
|
|
|
return getTask(id);
|
|
}
|
|
|
|
export function deleteTask(id: string): boolean {
|
|
const db = getDb();
|
|
const result = db.prepare('DELETE FROM tasks WHERE id = ?').run(id);
|
|
return result.changes > 0;
|
|
}
|
|
|
|
// ==========================================
|
|
// API Provider Operations
|
|
// ==========================================
|
|
|
|
export function getAllProviders(): ApiProvider[] {
|
|
const db = getDb();
|
|
return db.prepare('SELECT * FROM api_providers ORDER BY sort_order ASC, created_at ASC').all() as ApiProvider[];
|
|
}
|
|
|
|
export function getProvider(id: string): ApiProvider | undefined {
|
|
const db = getDb();
|
|
return db.prepare('SELECT * FROM api_providers WHERE id = ?').get(id) as ApiProvider | undefined;
|
|
}
|
|
|
|
export function getActiveProvider(): ApiProvider | undefined {
|
|
const db = getDb();
|
|
return db.prepare('SELECT * FROM api_providers WHERE is_active = 1 LIMIT 1').get() as ApiProvider | undefined;
|
|
}
|
|
|
|
export function createProvider(data: CreateProviderRequest): ApiProvider {
|
|
const db = getDb();
|
|
const id = crypto.randomBytes(16).toString('hex');
|
|
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
|
|
|
|
// Get max sort_order to append at end
|
|
const maxRow = db.prepare('SELECT MAX(sort_order) as max_order FROM api_providers').get() as { max_order: number | null };
|
|
const sortOrder = (maxRow.max_order ?? -1) + 1;
|
|
|
|
db.prepare(
|
|
'INSERT INTO api_providers (id, name, provider_type, base_url, api_key, is_active, sort_order, extra_env, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
|
).run(
|
|
id,
|
|
data.name,
|
|
data.provider_type || 'anthropic',
|
|
data.base_url || '',
|
|
data.api_key || '',
|
|
0,
|
|
sortOrder,
|
|
data.extra_env || '{}',
|
|
data.notes || '',
|
|
now,
|
|
now,
|
|
);
|
|
|
|
return getProvider(id)!;
|
|
}
|
|
|
|
export function updateProvider(id: string, data: UpdateProviderRequest): ApiProvider | undefined {
|
|
const db = getDb();
|
|
const existing = getProvider(id);
|
|
if (!existing) return undefined;
|
|
|
|
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
|
|
const name = data.name ?? existing.name;
|
|
const providerType = data.provider_type ?? existing.provider_type;
|
|
const baseUrl = data.base_url ?? existing.base_url;
|
|
const apiKey = data.api_key ?? existing.api_key;
|
|
const extraEnv = data.extra_env ?? existing.extra_env;
|
|
const notes = data.notes ?? existing.notes;
|
|
const sortOrder = data.sort_order ?? existing.sort_order;
|
|
|
|
db.prepare(
|
|
'UPDATE api_providers SET name = ?, provider_type = ?, base_url = ?, api_key = ?, extra_env = ?, notes = ?, sort_order = ?, updated_at = ? WHERE id = ?'
|
|
).run(name, providerType, baseUrl, apiKey, extraEnv, notes, sortOrder, now, id);
|
|
|
|
return getProvider(id);
|
|
}
|
|
|
|
export function deleteProvider(id: string): boolean {
|
|
const db = getDb();
|
|
const result = db.prepare('DELETE FROM api_providers WHERE id = ?').run(id);
|
|
return result.changes > 0;
|
|
}
|
|
|
|
export function activateProvider(id: string): boolean {
|
|
const db = getDb();
|
|
const existing = getProvider(id);
|
|
if (!existing) return false;
|
|
|
|
const transaction = db.transaction(() => {
|
|
db.prepare('UPDATE api_providers SET is_active = 0').run();
|
|
db.prepare('UPDATE api_providers SET is_active = 1 WHERE id = ?').run(id);
|
|
});
|
|
transaction();
|
|
return true;
|
|
}
|
|
|
|
export function deactivateAllProviders(): void {
|
|
const db = getDb();
|
|
db.prepare('UPDATE api_providers SET is_active = 0').run();
|
|
}
|
|
|
|
// ==========================================
|
|
// Token Usage Statistics
|
|
// ==========================================
|
|
|
|
export function getTokenUsageStats(days: number = 30): {
|
|
summary: {
|
|
total_input_tokens: number;
|
|
total_output_tokens: number;
|
|
total_cost: number;
|
|
total_sessions: number;
|
|
cache_read_tokens: number;
|
|
cache_creation_tokens: number;
|
|
};
|
|
daily: Array<{
|
|
date: string;
|
|
model: string;
|
|
input_tokens: number;
|
|
output_tokens: number;
|
|
cost: number;
|
|
}>;
|
|
} {
|
|
const db = getDb();
|
|
|
|
const summary = db.prepare(`
|
|
SELECT
|
|
COALESCE(SUM(json_extract(m.token_usage, '$.input_tokens')), 0) AS total_input_tokens,
|
|
COALESCE(SUM(json_extract(m.token_usage, '$.output_tokens')), 0) AS total_output_tokens,
|
|
COALESCE(SUM(json_extract(m.token_usage, '$.cost_usd')), 0) AS total_cost,
|
|
COUNT(DISTINCT m.session_id) AS total_sessions,
|
|
COALESCE(SUM(json_extract(m.token_usage, '$.cache_read_input_tokens')), 0) AS cache_read_tokens,
|
|
COALESCE(SUM(json_extract(m.token_usage, '$.cache_creation_input_tokens')), 0) AS cache_creation_tokens
|
|
FROM messages m
|
|
WHERE m.token_usage IS NOT NULL
|
|
AND json_valid(m.token_usage) = 1
|
|
AND m.created_at >= date('now', '-' || (? - 1) || ' days')
|
|
`).get(days) as {
|
|
total_input_tokens: number;
|
|
total_output_tokens: number;
|
|
total_cost: number;
|
|
total_sessions: number;
|
|
cache_read_tokens: number;
|
|
cache_creation_tokens: number;
|
|
};
|
|
|
|
const daily = db.prepare(`
|
|
SELECT
|
|
DATE(m.created_at) AS date,
|
|
CASE
|
|
WHEN COALESCE(NULLIF(s.provider_name, ''), '') != ''
|
|
THEN s.provider_name
|
|
ELSE COALESCE(NULLIF(s.model, ''), 'unknown')
|
|
END AS model,
|
|
COALESCE(SUM(json_extract(m.token_usage, '$.input_tokens')), 0) AS input_tokens,
|
|
COALESCE(SUM(json_extract(m.token_usage, '$.output_tokens')), 0) AS output_tokens,
|
|
COALESCE(SUM(json_extract(m.token_usage, '$.cost_usd')), 0) AS cost
|
|
FROM messages m
|
|
LEFT JOIN chat_sessions s ON m.session_id = s.id
|
|
WHERE m.token_usage IS NOT NULL
|
|
AND json_valid(m.token_usage) = 1
|
|
AND m.created_at >= date('now', '-' || (? - 1) || ' days')
|
|
GROUP BY DATE(m.created_at),
|
|
CASE
|
|
WHEN COALESCE(NULLIF(s.provider_name, ''), '') != ''
|
|
THEN s.provider_name
|
|
ELSE COALESCE(NULLIF(s.model, ''), 'unknown')
|
|
END
|
|
ORDER BY date ASC
|
|
`).all(days) as Array<{
|
|
date: string;
|
|
model: string;
|
|
input_tokens: number;
|
|
output_tokens: number;
|
|
cost: number;
|
|
}>;
|
|
|
|
return { summary, daily };
|
|
}
|
|
|
|
// ==========================================
|
|
// Media Job Operations
|
|
// ==========================================
|
|
|
|
const DEFAULT_BATCH_CONFIG: BatchConfig = {
|
|
concurrency: 2,
|
|
maxRetries: 2,
|
|
retryDelayMs: 2000,
|
|
};
|
|
|
|
export function createMediaJob(params: {
|
|
sessionId?: string;
|
|
docPaths?: string[];
|
|
stylePrompt?: string;
|
|
batchConfig?: Partial<BatchConfig>;
|
|
totalItems: number;
|
|
}): MediaJob {
|
|
const db = getDb();
|
|
const id = crypto.randomBytes(16).toString('hex');
|
|
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
|
|
const config = { ...DEFAULT_BATCH_CONFIG, ...params.batchConfig };
|
|
|
|
db.prepare(
|
|
`INSERT INTO media_jobs (id, session_id, status, doc_paths, style_prompt, batch_config, total_items, completed_items, failed_items, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)`
|
|
).run(
|
|
id,
|
|
params.sessionId || null,
|
|
'planned',
|
|
JSON.stringify(params.docPaths || []),
|
|
params.stylePrompt || '',
|
|
JSON.stringify(config),
|
|
params.totalItems,
|
|
now,
|
|
now,
|
|
);
|
|
|
|
return getMediaJob(id)!;
|
|
}
|
|
|
|
export function getMediaJob(id: string): MediaJob | undefined {
|
|
const db = getDb();
|
|
return db.prepare('SELECT * FROM media_jobs WHERE id = ?').get(id) as MediaJob | undefined;
|
|
}
|
|
|
|
export function getMediaJobsBySession(sessionId: string): MediaJob[] {
|
|
const db = getDb();
|
|
return db.prepare('SELECT * FROM media_jobs WHERE session_id = ? ORDER BY created_at DESC').all(sessionId) as MediaJob[];
|
|
}
|
|
|
|
export function getAllMediaJobs(limit = 50, offset = 0): MediaJob[] {
|
|
const db = getDb();
|
|
return db.prepare('SELECT * FROM media_jobs ORDER BY created_at DESC LIMIT ? OFFSET ?').all(limit, offset) as MediaJob[];
|
|
}
|
|
|
|
export function updateMediaJobStatus(id: string, status: MediaJobStatus): void {
|
|
const db = getDb();
|
|
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
|
|
const completedAt = (status === 'completed' || status === 'cancelled' || status === 'failed') ? now : null;
|
|
|
|
db.prepare(
|
|
'UPDATE media_jobs SET status = ?, updated_at = ?, completed_at = COALESCE(?, completed_at) WHERE id = ?'
|
|
).run(status, now, completedAt, id);
|
|
}
|
|
|
|
export function updateMediaJobCounters(id: string): void {
|
|
const db = getDb();
|
|
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
|
|
db.prepare(`
|
|
UPDATE media_jobs SET
|
|
completed_items = (SELECT COUNT(*) FROM media_job_items WHERE job_id = ? AND status = 'completed'),
|
|
failed_items = (SELECT COUNT(*) FROM media_job_items WHERE job_id = ? AND status = 'failed'),
|
|
updated_at = ?
|
|
WHERE id = ?
|
|
`).run(id, id, now, id);
|
|
}
|
|
|
|
export function deleteMediaJob(id: string): boolean {
|
|
const db = getDb();
|
|
const result = db.prepare('DELETE FROM media_jobs WHERE id = ?').run(id);
|
|
return result.changes > 0;
|
|
}
|
|
|
|
// ==========================================
|
|
// Media Job Item Operations
|
|
// ==========================================
|
|
|
|
export function createMediaJobItems(jobId: string, items: Array<{
|
|
prompt: string;
|
|
aspectRatio?: string;
|
|
imageSize?: string;
|
|
model?: string;
|
|
tags?: string[];
|
|
sourceRefs?: string[];
|
|
}>): MediaJobItem[] {
|
|
const db = getDb();
|
|
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
|
|
const insertStmt = db.prepare(
|
|
`INSERT INTO media_job_items (id, job_id, idx, prompt, aspect_ratio, image_size, model, tags, source_refs, status, retry_count, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?)`
|
|
);
|
|
|
|
const ids: string[] = [];
|
|
const transaction = db.transaction(() => {
|
|
for (let i = 0; i < items.length; i++) {
|
|
const item = items[i];
|
|
const id = crypto.randomBytes(16).toString('hex');
|
|
ids.push(id);
|
|
insertStmt.run(
|
|
id, jobId, i,
|
|
item.prompt,
|
|
item.aspectRatio || '1:1',
|
|
item.imageSize || '1K',
|
|
item.model || '',
|
|
JSON.stringify(item.tags || []),
|
|
JSON.stringify(item.sourceRefs || []),
|
|
now, now,
|
|
);
|
|
}
|
|
});
|
|
transaction();
|
|
|
|
return ids.map(id => db.prepare('SELECT * FROM media_job_items WHERE id = ?').get(id) as MediaJobItem);
|
|
}
|
|
|
|
export function getMediaJobItems(jobId: string): MediaJobItem[] {
|
|
const db = getDb();
|
|
return db.prepare('SELECT * FROM media_job_items WHERE job_id = ? ORDER BY idx ASC').all(jobId) as MediaJobItem[];
|
|
}
|
|
|
|
export function getMediaJobItem(id: string): MediaJobItem | undefined {
|
|
const db = getDb();
|
|
return db.prepare('SELECT * FROM media_job_items WHERE id = ?').get(id) as MediaJobItem | undefined;
|
|
}
|
|
|
|
export function getPendingJobItems(jobId: string, maxRetries: number): MediaJobItem[] {
|
|
const db = getDb();
|
|
return db.prepare(
|
|
`SELECT * FROM media_job_items
|
|
WHERE job_id = ? AND (status = 'pending' OR (status = 'failed' AND retry_count < ?))
|
|
ORDER BY idx ASC`
|
|
).all(jobId, maxRetries) as MediaJobItem[];
|
|
}
|
|
|
|
export function updateMediaJobItem(id: string, updates: {
|
|
status?: MediaJobItemStatus;
|
|
retryCount?: number;
|
|
resultMediaGenerationId?: string | null;
|
|
error?: string | null;
|
|
prompt?: string;
|
|
aspectRatio?: string;
|
|
imageSize?: string;
|
|
tags?: string[];
|
|
}): MediaJobItem | undefined {
|
|
const db = getDb();
|
|
const existing = getMediaJobItem(id);
|
|
if (!existing) return undefined;
|
|
|
|
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
|
|
db.prepare(`
|
|
UPDATE media_job_items SET
|
|
status = ?,
|
|
retry_count = ?,
|
|
result_media_generation_id = ?,
|
|
error = ?,
|
|
prompt = ?,
|
|
aspect_ratio = ?,
|
|
image_size = ?,
|
|
tags = ?,
|
|
updated_at = ?
|
|
WHERE id = ?
|
|
`).run(
|
|
updates.status ?? existing.status,
|
|
updates.retryCount ?? existing.retry_count,
|
|
updates.resultMediaGenerationId !== undefined ? updates.resultMediaGenerationId : existing.result_media_generation_id,
|
|
updates.error !== undefined ? updates.error : existing.error,
|
|
updates.prompt ?? existing.prompt,
|
|
updates.aspectRatio ?? existing.aspect_ratio,
|
|
updates.imageSize ?? existing.image_size,
|
|
updates.tags ? JSON.stringify(updates.tags) : existing.tags,
|
|
now,
|
|
id,
|
|
);
|
|
|
|
return getMediaJobItem(id);
|
|
}
|
|
|
|
export function cancelPendingJobItems(jobId: string): void {
|
|
const db = getDb();
|
|
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
|
|
db.prepare(
|
|
"UPDATE media_job_items SET status = 'cancelled', updated_at = ? WHERE job_id = ? AND status IN ('pending', 'failed')"
|
|
).run(now, jobId);
|
|
}
|
|
|
|
// ==========================================
|
|
// Media Context Event Operations
|
|
// ==========================================
|
|
|
|
export function createContextEvent(params: {
|
|
sessionId: string;
|
|
jobId: string;
|
|
payload: Record<string, unknown>;
|
|
syncMode?: 'manual' | 'auto_batch';
|
|
}): MediaContextEvent {
|
|
const db = getDb();
|
|
const id = crypto.randomBytes(16).toString('hex');
|
|
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
|
|
|
|
db.prepare(
|
|
`INSERT INTO media_context_events (id, session_id, job_id, payload, sync_mode, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)`
|
|
).run(id, params.sessionId, params.jobId, JSON.stringify(params.payload), params.syncMode || 'manual', now);
|
|
|
|
return db.prepare('SELECT * FROM media_context_events WHERE id = ?').get(id) as MediaContextEvent;
|
|
}
|
|
|
|
export function markContextEventSynced(id: string): void {
|
|
const db = getDb();
|
|
const now = new Date().toISOString().replace('T', ' ').split('.')[0];
|
|
db.prepare('UPDATE media_context_events SET synced_at = ? WHERE id = ?').run(now, id);
|
|
}
|
|
|
|
// ==========================================
|
|
// Graceful Shutdown
|
|
// ==========================================
|
|
|
|
/**
|
|
* Close the database connection gracefully.
|
|
* In WAL mode, this ensures the WAL is checkpointed and the
|
|
* -wal/-shm files are cleaned up properly.
|
|
*/
|
|
export function closeDb(): void {
|
|
if (db) {
|
|
try {
|
|
db.close();
|
|
console.log('[db] Database closed gracefully');
|
|
} catch (err) {
|
|
console.warn('[db] Error closing database:', err);
|
|
}
|
|
db = null;
|
|
}
|
|
}
|
|
|
|
// Register shutdown handlers to close the database when the process exits.
|
|
// This prevents WAL file accumulation and potential data loss.
|
|
function registerShutdownHandlers(): void {
|
|
let shuttingDown = false;
|
|
|
|
const shutdown = (signal: string) => {
|
|
if (shuttingDown) return;
|
|
shuttingDown = true;
|
|
console.log(`[db] Received ${signal}, closing database...`);
|
|
closeDb();
|
|
};
|
|
|
|
// 'exit' fires synchronously when the process is about to exit
|
|
process.on('exit', () => shutdown('exit'));
|
|
|
|
// Handle termination signals (Docker stop, systemd, Ctrl+C, etc.)
|
|
process.on('SIGTERM', () => {
|
|
shutdown('SIGTERM');
|
|
process.exit(0);
|
|
});
|
|
process.on('SIGINT', () => {
|
|
shutdown('SIGINT');
|
|
process.exit(0);
|
|
});
|
|
|
|
// Handle Windows-specific close events
|
|
if (process.platform === 'win32') {
|
|
process.on('SIGHUP', () => {
|
|
shutdown('SIGHUP');
|
|
process.exit(0);
|
|
});
|
|
}
|
|
}
|
|
|
|
registerShutdownHandlers();
|