mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
497 lines
14 KiB
TypeScript
497 lines
14 KiB
TypeScript
// @ts-nocheck
|
|
// SQLite-backed persistence for projects, conversations, messages, and the
|
|
// per-project set of open file tabs. The on-disk project folder under
|
|
// .od/projects/<id>/ is still the single owner of the user's actual files
|
|
// (HTML artifacts, sketches, uploads); this database tracks the metadata
|
|
// that used to live in localStorage.
|
|
|
|
import Database from 'better-sqlite3';
|
|
import path from 'node:path';
|
|
import fs from 'node:fs';
|
|
|
|
let dbInstance = null;
|
|
let dbFile = null;
|
|
|
|
export function openDatabase(projectRoot, { dataDir } = {}) {
|
|
const dir = dataDir ? path.resolve(dataDir) : path.join(projectRoot, '.od');
|
|
const file = path.join(dir, 'app.sqlite');
|
|
if (dbInstance && dbFile === file) return dbInstance;
|
|
if (dbInstance) closeDatabase();
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
const db = new Database(file);
|
|
db.pragma('journal_mode = WAL');
|
|
db.pragma('foreign_keys = ON');
|
|
migrate(db);
|
|
dbInstance = db;
|
|
dbFile = file;
|
|
return db;
|
|
}
|
|
|
|
export function closeDatabase() {
|
|
if (!dbInstance) return;
|
|
dbInstance.close();
|
|
dbInstance = null;
|
|
dbFile = null;
|
|
}
|
|
|
|
function migrate(db) {
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS projects (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
skill_id TEXT,
|
|
design_system_id TEXT,
|
|
pending_prompt TEXT,
|
|
metadata_json TEXT,
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS templates (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
description TEXT,
|
|
source_project_id TEXT,
|
|
files_json TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS conversations (
|
|
id TEXT PRIMARY KEY,
|
|
project_id TEXT NOT NULL,
|
|
title TEXT,
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL,
|
|
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_conv_project
|
|
ON conversations(project_id, updated_at DESC);
|
|
|
|
CREATE TABLE IF NOT EXISTS messages (
|
|
id TEXT PRIMARY KEY,
|
|
conversation_id TEXT NOT NULL,
|
|
role TEXT NOT NULL,
|
|
content TEXT NOT NULL,
|
|
agent_id TEXT,
|
|
agent_name TEXT,
|
|
events_json TEXT,
|
|
attachments_json TEXT,
|
|
produced_files_json TEXT,
|
|
started_at INTEGER,
|
|
ended_at INTEGER,
|
|
position INTEGER NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
FOREIGN KEY(conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_messages_conv
|
|
ON messages(conversation_id, position);
|
|
|
|
CREATE TABLE IF NOT EXISTS tabs (
|
|
project_id TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
position INTEGER NOT NULL,
|
|
is_active INTEGER NOT NULL DEFAULT 0,
|
|
PRIMARY KEY(project_id, name),
|
|
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_tabs_project
|
|
ON tabs(project_id, position);
|
|
`);
|
|
// Forward-compatible column add for databases created before metadata_json.
|
|
// SQLite has no IF NOT EXISTS for ALTER, so we check pragma_table_info.
|
|
const cols = db.prepare(`PRAGMA table_info(projects)`).all();
|
|
if (!cols.some((c) => c.name === 'metadata_json')) {
|
|
db.exec(`ALTER TABLE projects ADD COLUMN metadata_json TEXT`);
|
|
}
|
|
const messageCols = db.prepare(`PRAGMA table_info(messages)`).all();
|
|
if (!messageCols.some((c) => c.name === 'agent_id')) {
|
|
db.exec(`ALTER TABLE messages ADD COLUMN agent_id TEXT`);
|
|
}
|
|
if (!messageCols.some((c) => c.name === 'agent_name')) {
|
|
db.exec(`ALTER TABLE messages ADD COLUMN agent_name TEXT`);
|
|
}
|
|
}
|
|
|
|
// ---------- projects ----------
|
|
|
|
const PROJECT_COLS = `id, name, skill_id AS skillId,
|
|
design_system_id AS designSystemId,
|
|
pending_prompt AS pendingPrompt,
|
|
metadata_json AS metadataJson,
|
|
created_at AS createdAt,
|
|
updated_at AS updatedAt`;
|
|
|
|
export function listProjects(db) {
|
|
const rows = db
|
|
.prepare(
|
|
`SELECT ${PROJECT_COLS}
|
|
FROM projects
|
|
ORDER BY updated_at DESC`,
|
|
)
|
|
.all();
|
|
return rows.map(normalizeProject);
|
|
}
|
|
|
|
export function getProject(db, id) {
|
|
const row = db
|
|
.prepare(`SELECT ${PROJECT_COLS} FROM projects WHERE id = ?`)
|
|
.get(id);
|
|
return row ? normalizeProject(row) : null;
|
|
}
|
|
|
|
export function insertProject(db, p) {
|
|
db.prepare(
|
|
`INSERT INTO projects
|
|
(id, name, skill_id, design_system_id, pending_prompt,
|
|
metadata_json, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
).run(
|
|
p.id,
|
|
p.name,
|
|
p.skillId ?? null,
|
|
p.designSystemId ?? null,
|
|
p.pendingPrompt ?? null,
|
|
p.metadata ? JSON.stringify(p.metadata) : null,
|
|
p.createdAt,
|
|
p.updatedAt,
|
|
);
|
|
return getProject(db, p.id);
|
|
}
|
|
|
|
export function updateProject(db, id, patch) {
|
|
const existing = getProject(db, id);
|
|
if (!existing) return null;
|
|
const merged = {
|
|
...existing,
|
|
...patch,
|
|
updatedAt: typeof patch.updatedAt === 'number' ? patch.updatedAt : Date.now(),
|
|
};
|
|
db.prepare(
|
|
`UPDATE projects
|
|
SET name = ?,
|
|
skill_id = ?,
|
|
design_system_id = ?,
|
|
pending_prompt = ?,
|
|
metadata_json = ?,
|
|
updated_at = ?
|
|
WHERE id = ?`,
|
|
).run(
|
|
merged.name,
|
|
merged.skillId ?? null,
|
|
merged.designSystemId ?? null,
|
|
merged.pendingPrompt ?? null,
|
|
merged.metadata ? JSON.stringify(merged.metadata) : null,
|
|
merged.updatedAt,
|
|
id,
|
|
);
|
|
return getProject(db, id);
|
|
}
|
|
|
|
export function deleteProject(db, id) {
|
|
db.prepare(`DELETE FROM projects WHERE id = ?`).run(id);
|
|
}
|
|
|
|
function normalizeProject(row) {
|
|
let metadata;
|
|
if (row.metadataJson) {
|
|
try {
|
|
metadata = JSON.parse(row.metadataJson);
|
|
} catch {
|
|
metadata = undefined;
|
|
}
|
|
}
|
|
return {
|
|
id: row.id,
|
|
name: row.name,
|
|
skillId: row.skillId,
|
|
designSystemId: row.designSystemId,
|
|
pendingPrompt: row.pendingPrompt ?? undefined,
|
|
metadata,
|
|
createdAt: Number(row.createdAt),
|
|
updatedAt: Number(row.updatedAt),
|
|
};
|
|
}
|
|
|
|
// ---------- templates ----------
|
|
|
|
export function listTemplates(db) {
|
|
return db
|
|
.prepare(
|
|
`SELECT id, name, description, source_project_id AS sourceProjectId,
|
|
files_json AS filesJson, created_at AS createdAt
|
|
FROM templates
|
|
ORDER BY created_at DESC`,
|
|
)
|
|
.all()
|
|
.map(normalizeTemplate);
|
|
}
|
|
|
|
export function getTemplate(db, id) {
|
|
const row = db
|
|
.prepare(
|
|
`SELECT id, name, description, source_project_id AS sourceProjectId,
|
|
files_json AS filesJson, created_at AS createdAt
|
|
FROM templates WHERE id = ?`,
|
|
)
|
|
.get(id);
|
|
return row ? normalizeTemplate(row) : null;
|
|
}
|
|
|
|
export function insertTemplate(db, t) {
|
|
db.prepare(
|
|
`INSERT INTO templates (id, name, description, source_project_id, files_json, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
).run(
|
|
t.id,
|
|
t.name,
|
|
t.description ?? null,
|
|
t.sourceProjectId ?? null,
|
|
JSON.stringify(t.files ?? []),
|
|
t.createdAt,
|
|
);
|
|
return getTemplate(db, t.id);
|
|
}
|
|
|
|
export function deleteTemplate(db, id) {
|
|
db.prepare(`DELETE FROM templates WHERE id = ?`).run(id);
|
|
}
|
|
|
|
function normalizeTemplate(row) {
|
|
let files = [];
|
|
try {
|
|
files = JSON.parse(row.filesJson || '[]');
|
|
} catch {
|
|
files = [];
|
|
}
|
|
return {
|
|
id: row.id,
|
|
name: row.name,
|
|
description: row.description ?? undefined,
|
|
sourceProjectId: row.sourceProjectId ?? undefined,
|
|
files,
|
|
createdAt: Number(row.createdAt),
|
|
};
|
|
}
|
|
|
|
// ---------- conversations ----------
|
|
|
|
export function listConversations(db, projectId) {
|
|
return db
|
|
.prepare(
|
|
`SELECT id, project_id AS projectId, title,
|
|
created_at AS createdAt, updated_at AS updatedAt
|
|
FROM conversations
|
|
WHERE project_id = ?
|
|
ORDER BY updated_at DESC`,
|
|
)
|
|
.all(projectId)
|
|
.map((r) => ({
|
|
id: r.id,
|
|
projectId: r.projectId,
|
|
title: r.title ?? null,
|
|
createdAt: Number(r.createdAt),
|
|
updatedAt: Number(r.updatedAt),
|
|
}));
|
|
}
|
|
|
|
export function getConversation(db, id) {
|
|
const r = db
|
|
.prepare(
|
|
`SELECT id, project_id AS projectId, title,
|
|
created_at AS createdAt, updated_at AS updatedAt
|
|
FROM conversations WHERE id = ?`,
|
|
)
|
|
.get(id);
|
|
if (!r) return null;
|
|
return {
|
|
id: r.id,
|
|
projectId: r.projectId,
|
|
title: r.title ?? null,
|
|
createdAt: Number(r.createdAt),
|
|
updatedAt: Number(r.updatedAt),
|
|
};
|
|
}
|
|
|
|
export function insertConversation(db, c) {
|
|
db.prepare(
|
|
`INSERT INTO conversations
|
|
(id, project_id, title, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?)`,
|
|
).run(c.id, c.projectId, c.title ?? null, c.createdAt, c.updatedAt);
|
|
return getConversation(db, c.id);
|
|
}
|
|
|
|
export function updateConversation(db, id, patch) {
|
|
const existing = getConversation(db, id);
|
|
if (!existing) return null;
|
|
const merged = {
|
|
...existing,
|
|
...patch,
|
|
updatedAt: typeof patch.updatedAt === 'number' ? patch.updatedAt : Date.now(),
|
|
};
|
|
db.prepare(
|
|
`UPDATE conversations
|
|
SET title = ?, updated_at = ? WHERE id = ?`,
|
|
).run(merged.title ?? null, merged.updatedAt, id);
|
|
return getConversation(db, id);
|
|
}
|
|
|
|
export function deleteConversation(db, id) {
|
|
db.prepare(`DELETE FROM conversations WHERE id = ?`).run(id);
|
|
}
|
|
|
|
// ---------- messages ----------
|
|
|
|
export function listMessages(db, conversationId) {
|
|
return db
|
|
.prepare(
|
|
`SELECT id, role, content, agent_id AS agentId, agent_name AS agentName,
|
|
events_json AS eventsJson,
|
|
attachments_json AS attachmentsJson,
|
|
produced_files_json AS producedFilesJson,
|
|
started_at AS startedAt, ended_at AS endedAt,
|
|
position
|
|
FROM messages
|
|
WHERE conversation_id = ?
|
|
ORDER BY position ASC`,
|
|
)
|
|
.all(conversationId)
|
|
.map(normalizeMessage);
|
|
}
|
|
|
|
export function upsertMessage(db, conversationId, m) {
|
|
const existing = db
|
|
.prepare(`SELECT position FROM messages WHERE id = ?`)
|
|
.get(m.id);
|
|
const now = Date.now();
|
|
if (existing) {
|
|
db.prepare(
|
|
`UPDATE messages
|
|
SET role = ?, content = ?, agent_id = ?, agent_name = ?,
|
|
events_json = ?, attachments_json = ?,
|
|
produced_files_json = ?, started_at = ?, ended_at = ?
|
|
WHERE id = ?`,
|
|
).run(
|
|
m.role,
|
|
m.content,
|
|
m.agentId ?? null,
|
|
m.agentName ?? null,
|
|
m.events ? JSON.stringify(m.events) : null,
|
|
m.attachments ? JSON.stringify(m.attachments) : null,
|
|
m.producedFiles ? JSON.stringify(m.producedFiles) : null,
|
|
m.startedAt ?? null,
|
|
m.endedAt ?? null,
|
|
m.id,
|
|
);
|
|
} else {
|
|
const max = db
|
|
.prepare(
|
|
`SELECT COALESCE(MAX(position), -1) AS m FROM messages WHERE conversation_id = ?`,
|
|
)
|
|
.get(conversationId);
|
|
const position = (max?.m ?? -1) + 1;
|
|
// 13 values: id, conversation_id, role, content, agent_id, agent_name,
|
|
// events_json, attachments_json, produced_files_json, started_at,
|
|
// ended_at, position, created_at.
|
|
db.prepare(
|
|
`INSERT INTO messages
|
|
(id, conversation_id, role, content, agent_id, agent_name, events_json,
|
|
attachments_json, produced_files_json,
|
|
started_at, ended_at, position, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
).run(
|
|
m.id,
|
|
conversationId,
|
|
m.role,
|
|
m.content,
|
|
m.agentId ?? null,
|
|
m.agentName ?? null,
|
|
m.events ? JSON.stringify(m.events) : null,
|
|
m.attachments ? JSON.stringify(m.attachments) : null,
|
|
m.producedFiles ? JSON.stringify(m.producedFiles) : null,
|
|
m.startedAt ?? null,
|
|
m.endedAt ?? null,
|
|
position,
|
|
now,
|
|
);
|
|
}
|
|
// Bump conversation activity so the sidebar's recency sort works.
|
|
db.prepare(`UPDATE conversations SET updated_at = ? WHERE id = ?`).run(
|
|
now,
|
|
conversationId,
|
|
);
|
|
const row = db
|
|
.prepare(
|
|
`SELECT id, role, content, agent_id AS agentId, agent_name AS agentName,
|
|
events_json AS eventsJson,
|
|
attachments_json AS attachmentsJson,
|
|
produced_files_json AS producedFilesJson,
|
|
started_at AS startedAt, ended_at AS endedAt,
|
|
position
|
|
FROM messages WHERE id = ?`,
|
|
)
|
|
.get(m.id);
|
|
return row ? normalizeMessage(row) : null;
|
|
}
|
|
|
|
export function deleteMessage(db, id) {
|
|
db.prepare(`DELETE FROM messages WHERE id = ?`).run(id);
|
|
}
|
|
|
|
function normalizeMessage(row) {
|
|
return {
|
|
id: row.id,
|
|
role: row.role,
|
|
content: row.content,
|
|
agentId: row.agentId ?? undefined,
|
|
agentName: row.agentName ?? undefined,
|
|
events: parseJsonOrUndef(row.eventsJson),
|
|
attachments: parseJsonOrUndef(row.attachmentsJson),
|
|
producedFiles: parseJsonOrUndef(row.producedFilesJson),
|
|
startedAt: row.startedAt ?? undefined,
|
|
endedAt: row.endedAt ?? undefined,
|
|
};
|
|
}
|
|
|
|
function parseJsonOrUndef(s) {
|
|
if (!s) return undefined;
|
|
try {
|
|
return JSON.parse(s);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
// ---------- tabs ----------
|
|
|
|
export function listTabs(db, projectId) {
|
|
const rows = db
|
|
.prepare(
|
|
`SELECT name, position, is_active AS isActive
|
|
FROM tabs WHERE project_id = ? ORDER BY position ASC`,
|
|
)
|
|
.all(projectId);
|
|
const active = rows.find((r) => r.isActive) ?? null;
|
|
return {
|
|
tabs: rows.map((r) => r.name),
|
|
active: active ? active.name : null,
|
|
};
|
|
}
|
|
|
|
export function setTabs(db, projectId, names, activeName) {
|
|
const tx = db.transaction(() => {
|
|
db.prepare(`DELETE FROM tabs WHERE project_id = ?`).run(projectId);
|
|
const ins = db.prepare(
|
|
`INSERT INTO tabs (project_id, name, position, is_active)
|
|
VALUES (?, ?, ?, ?)`,
|
|
);
|
|
names.forEach((name, i) => {
|
|
ins.run(projectId, name, i, name === activeName ? 1 : 0);
|
|
});
|
|
});
|
|
tx();
|
|
return listTabs(db, projectId);
|
|
}
|