Add initial project structure with essential files
- Created .gitignore to exclude build artifacts and dependencies. - Added index.html as the main entry point for the application. - Included LICENSE file with Apache 2.0 terms. - Initialized package.json and package-lock.json for project dependencies. - Added pnpm-lock.yaml for package management. - Created QUICKSTART.md for setup instructions. - Added README.md and README.zh-CN.md for project documentation in English and Chinese.
This commit is contained in:
+465
@@ -0,0 +1,465 @@
|
||||
// SQLite-backed persistence for projects, conversations, messages, and the
|
||||
// per-project set of open file tabs. The on-disk project folder under
|
||||
// .ocd/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;
|
||||
|
||||
export function openDatabase(projectRoot) {
|
||||
if (dbInstance) return dbInstance;
|
||||
const dir = path.join(projectRoot, '.ocd');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const file = path.join(dir, 'app.sqlite');
|
||||
const db = new Database(file);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
migrate(db);
|
||||
dbInstance = db;
|
||||
return db;
|
||||
}
|
||||
|
||||
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,
|
||||
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`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 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, 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 = ?, events_json = ?, attachments_json = ?,
|
||||
produced_files_json = ?, started_at = ?, ended_at = ?
|
||||
WHERE id = ?`,
|
||||
).run(
|
||||
m.role,
|
||||
m.content,
|
||||
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;
|
||||
db.prepare(
|
||||
`INSERT INTO messages
|
||||
(id, conversation_id, role, content, events_json,
|
||||
attachments_json, produced_files_json,
|
||||
started_at, ended_at, position, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
m.id,
|
||||
conversationId,
|
||||
m.role,
|
||||
m.content,
|
||||
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, 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,
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user