Skip to content

Commit 187a807

Browse files
committed
Merge branch 'better-sqlite3'
2 parents c3b2191 + f846e23 commit 187a807

27 files changed

Lines changed: 7392 additions & 3027 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { ipcMain } from 'electron'
2+
import {
3+
getConversations,
4+
getConversationById,
5+
createConversation,
6+
renameConversation,
7+
deleteConversation,
8+
getMessages,
9+
saveMessages,
10+
} from '../services/local-db'
11+
12+
export function registerDbHandlers(): void {
13+
// ─── Conversations ──────────────────────────────────────────
14+
15+
ipcMain.handle('db:getConversations', (_event, type?: string) => {
16+
return getConversations(type)
17+
})
18+
19+
ipcMain.handle('db:getConversationById', (_event, id: string) => {
20+
return getConversationById(id) || null
21+
})
22+
23+
ipcMain.handle(
24+
'db:createConversation',
25+
(_event, data: { id: string; title: string; type?: string; userId?: string }) => {
26+
return createConversation(data)
27+
}
28+
)
29+
30+
ipcMain.handle('db:renameConversation', (_event, id: string, title: string) => {
31+
renameConversation(id, title)
32+
})
33+
34+
ipcMain.handle('db:deleteConversation', (_event, id: string) => {
35+
deleteConversation(id)
36+
})
37+
38+
// ─── Messages ───────────────────────────────────────────────
39+
40+
ipcMain.handle('db:getMessages', (_event, conversationId: string) => {
41+
return getMessages(conversationId)
42+
})
43+
44+
ipcMain.handle(
45+
'db:saveMessages',
46+
(
47+
_event,
48+
messages: Array<{
49+
id: string
50+
conversation_id: string
51+
role: string
52+
parts: unknown
53+
metadata?: unknown
54+
}>
55+
) => {
56+
saveMessages(messages)
57+
}
58+
)
59+
60+
console.log('[IPC] Database handlers registered')
61+
}

frontend/electron/src/ipc/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { registerCaptureHandlers } from './capture-handlers'
55
import { registerTranscribeHandlers } from './transcribe-handlers'
66
import { registerVoiceAgentHandlers } from './voice-agent-handlers'
77
import { registerOnboardingHandlers } from './onboarding-handlers'
8+
import { registerDbHandlers } from './db-handlers'
9+
import { registerFileStorageHandlers } from '../services/local-file-storage'
810

911
export const registerAllIpcHandlers = (): void => {
1012
registerTextHandlers()
@@ -14,6 +16,8 @@ export const registerAllIpcHandlers = (): void => {
1416
registerTranscribeHandlers()
1517
registerVoiceAgentHandlers()
1618
registerOnboardingHandlers()
19+
registerDbHandlers()
20+
registerFileStorageHandlers()
1721
}
1822

1923
export { registerTextHandlers } from './text-handlers'
@@ -23,3 +27,4 @@ export { registerCaptureHandlers } from './capture-handlers'
2327
export { registerTranscribeHandlers } from './transcribe-handlers'
2428
export { registerVoiceAgentHandlers, toggleVoiceAgentPanel } from './voice-agent-handlers'
2529
export { registerOnboardingHandlers } from './onboarding-handlers'
30+
export { registerDbHandlers } from './db-handlers'

frontend/electron/src/preload.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,4 +160,47 @@ contextBridge.exposeInMainWorld('electron', {
160160
getOnboardingComplete: () => ipcRenderer.invoke('get-onboarding-complete'),
161161
setOnboardingComplete: (complete: boolean) =>
162162
ipcRenderer.send('set-onboarding-complete', complete),
163+
164+
// Local Database
165+
db: {
166+
getConversations: (type?: string) => ipcRenderer.invoke('db:getConversations', type),
167+
getConversationById: (id: string) => ipcRenderer.invoke('db:getConversationById', id),
168+
createConversation: (data: { id: string; title: string; type?: string; userId?: string }) =>
169+
ipcRenderer.invoke('db:createConversation', data),
170+
renameConversation: (id: string, title: string) =>
171+
ipcRenderer.invoke('db:renameConversation', id, title),
172+
deleteConversation: (id: string) => ipcRenderer.invoke('db:deleteConversation', id),
173+
getMessages: (conversationId: string) => ipcRenderer.invoke('db:getMessages', conversationId),
174+
saveMessages: (
175+
messages: Array<{
176+
id: string
177+
conversation_id: string
178+
role: string
179+
parts: unknown
180+
metadata?: unknown
181+
}>
182+
) => ipcRenderer.invoke('db:saveMessages', messages),
183+
},
184+
185+
// Local File Storage
186+
fileStorage: {
187+
saveScreenshot: (imageDataUrl: string, userId: string) =>
188+
ipcRenderer.invoke('fileStorage:saveScreenshot', imageDataUrl, userId),
189+
saveChatAttachment: (
190+
projectId: string,
191+
fileBuffer: Buffer,
192+
fileName: string,
193+
mimeType: string
194+
) =>
195+
ipcRenderer.invoke(
196+
'fileStorage:saveChatAttachment',
197+
projectId,
198+
fileBuffer,
199+
fileName,
200+
mimeType
201+
),
202+
deleteFile: (filePath: string) => ipcRenderer.invoke('fileStorage:deleteFile', filePath),
203+
cleanupOldScreenshots: (userId: string, maxAgeHours?: number) =>
204+
ipcRenderer.invoke('fileStorage:cleanupOldScreenshots', userId, maxAgeHours),
205+
},
163206
})
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import Database from 'better-sqlite3'
2+
import { app } from 'electron'
3+
import path from 'path'
4+
5+
let db: Database.Database | null = null
6+
7+
/**
8+
* Get or initialize the SQLite database.
9+
* Creates tables if they don't exist.
10+
*/
11+
export function getDatabase(): Database.Database {
12+
if (db) return db
13+
14+
const dbPath = path.join(app.getPath('userData'), 'tabby.db')
15+
console.log('[LocalDB] Opening database at:', dbPath)
16+
17+
db = new Database(dbPath)
18+
19+
// Enable WAL mode for better performance
20+
db.pragma('journal_mode = WAL')
21+
db.pragma('foreign_keys = ON')
22+
23+
// Create tables
24+
db.exec(`
25+
CREATE TABLE IF NOT EXISTS conversations (
26+
id TEXT PRIMARY KEY,
27+
user_id TEXT,
28+
title TEXT NOT NULL DEFAULT 'New Chat',
29+
type TEXT NOT NULL DEFAULT 'chat',
30+
lastContext TEXT,
31+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
32+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
33+
);
34+
35+
CREATE TABLE IF NOT EXISTS messages (
36+
id TEXT PRIMARY KEY,
37+
conversation_id TEXT NOT NULL,
38+
role TEXT NOT NULL,
39+
parts TEXT NOT NULL DEFAULT '[]',
40+
metadata TEXT,
41+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
42+
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
43+
);
44+
45+
CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON messages(conversation_id);
46+
CREATE INDEX IF NOT EXISTS idx_conversations_type ON conversations(type);
47+
CREATE INDEX IF NOT EXISTS idx_conversations_updated_at ON conversations(updated_at);
48+
`)
49+
50+
console.log('[LocalDB] Database initialized successfully')
51+
return db
52+
}
53+
54+
/**
55+
* Close the database connection gracefully.
56+
*/
57+
export function closeDatabase(): void {
58+
if (db) {
59+
db.close()
60+
db = null
61+
console.log('[LocalDB] Database closed')
62+
}
63+
}
64+
65+
// ─── Conversation CRUD ───────────────────────────────────────────
66+
67+
export interface ConversationRow {
68+
id: string
69+
user_id: string | null
70+
title: string
71+
type: string
72+
lastContext: string | null
73+
created_at: string
74+
updated_at: string
75+
}
76+
77+
export interface MessageRow {
78+
id: string
79+
conversation_id: string
80+
role: string
81+
parts: string // JSON string
82+
metadata: string | null // JSON string
83+
created_at: string
84+
}
85+
86+
export function getConversations(type?: string): ConversationRow[] {
87+
const database = getDatabase()
88+
if (type) {
89+
return database
90+
.prepare('SELECT * FROM conversations WHERE type = ? ORDER BY updated_at DESC')
91+
.all(type) as ConversationRow[]
92+
}
93+
return database
94+
.prepare('SELECT * FROM conversations ORDER BY updated_at DESC')
95+
.all() as ConversationRow[]
96+
}
97+
98+
export function getConversationById(id: string): ConversationRow | undefined {
99+
const database = getDatabase()
100+
return database.prepare('SELECT * FROM conversations WHERE id = ?').get(id) as
101+
| ConversationRow
102+
| undefined
103+
}
104+
105+
export function createConversation(conversation: {
106+
id: string
107+
title: string
108+
type?: string
109+
userId?: string
110+
}): ConversationRow {
111+
const database = getDatabase()
112+
const now = new Date().toISOString()
113+
database
114+
.prepare(
115+
`INSERT INTO conversations (id, user_id, title, type, created_at, updated_at)
116+
VALUES (?, ?, ?, ?, ?, ?)`
117+
)
118+
.run(
119+
conversation.id,
120+
conversation.userId || null,
121+
conversation.title,
122+
conversation.type || 'chat',
123+
now,
124+
now
125+
)
126+
return getConversationById(conversation.id)!
127+
}
128+
129+
export function renameConversation(id: string, title: string): void {
130+
const database = getDatabase()
131+
database
132+
.prepare('UPDATE conversations SET title = ?, updated_at = ? WHERE id = ?')
133+
.run(title, new Date().toISOString(), id)
134+
}
135+
136+
export function deleteConversation(id: string): void {
137+
const database = getDatabase()
138+
// Messages are cascade-deleted via FK
139+
database.prepare('DELETE FROM conversations WHERE id = ?').run(id)
140+
}
141+
142+
// ─── Message CRUD ────────────────────────────────────────────────
143+
144+
export function getMessages(conversationId: string): MessageRow[] {
145+
const database = getDatabase()
146+
return database
147+
.prepare('SELECT * FROM messages WHERE conversation_id = ? ORDER BY created_at ASC')
148+
.all(conversationId) as MessageRow[]
149+
}
150+
151+
export function saveMessages(
152+
messages: Array<{
153+
id: string
154+
conversation_id: string
155+
role: string
156+
parts: unknown
157+
metadata?: unknown
158+
}>
159+
): void {
160+
const database = getDatabase()
161+
const upsert = database.prepare(
162+
`INSERT INTO messages (id, conversation_id, role, parts, metadata, created_at)
163+
VALUES (?, ?, ?, ?, ?, ?)
164+
ON CONFLICT(id) DO UPDATE SET
165+
parts = excluded.parts,
166+
metadata = excluded.metadata`
167+
)
168+
169+
const now = new Date().toISOString()
170+
const transaction = database.transaction(() => {
171+
for (const msg of messages) {
172+
upsert.run(
173+
msg.id,
174+
msg.conversation_id,
175+
msg.role,
176+
JSON.stringify(msg.parts),
177+
msg.metadata ? JSON.stringify(msg.metadata) : null,
178+
now
179+
)
180+
}
181+
})
182+
transaction()
183+
184+
// Update conversation timestamp
185+
if (messages.length > 0) {
186+
database
187+
.prepare('UPDATE conversations SET updated_at = ? WHERE id = ?')
188+
.run(now, messages[0].conversation_id)
189+
}
190+
}

0 commit comments

Comments
 (0)