feat: ship v0.3.0 with P2.1 editor polish and P3 AI collaboration
Add chapter reorder, search replace, inspiration capture, and AI chat with context editing, settings, offline handling, and naming checks. Fix dev black screen by keeping process.env reads in the main process only. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import type { AiContextBinding, AiMessage, AiMessageRole, AiSession } from '../../../shared/types'
|
||||
import type { SqliteDb } from '../types'
|
||||
|
||||
const EMPTY_CONTEXT: AiContextBinding = {
|
||||
chapterIds: [],
|
||||
outlineIds: [],
|
||||
settingIds: [],
|
||||
inspirationIds: []
|
||||
}
|
||||
|
||||
function parseContext(json: string): AiContextBinding {
|
||||
try {
|
||||
const parsed = JSON.parse(json) as Partial<AiContextBinding>
|
||||
return {
|
||||
chapterIds: parsed.chapterIds ?? [],
|
||||
outlineIds: parsed.outlineIds ?? [],
|
||||
settingIds: parsed.settingIds ?? [],
|
||||
inspirationIds: parsed.inspirationIds ?? []
|
||||
}
|
||||
} catch {
|
||||
return { ...EMPTY_CONTEXT }
|
||||
}
|
||||
}
|
||||
|
||||
function mapSession(row: Record<string, unknown>): AiSession {
|
||||
return {
|
||||
id: row.id as string,
|
||||
title: row.title as string,
|
||||
model: row.model as string,
|
||||
archived: Boolean(row.archived),
|
||||
context: parseContext((row.context_json as string) || '{}'),
|
||||
createdAt: row.created_at as string,
|
||||
updatedAt: row.updated_at as string
|
||||
}
|
||||
}
|
||||
|
||||
function mapMessage(row: Record<string, unknown>): AiMessage {
|
||||
return {
|
||||
id: row.id as string,
|
||||
sessionId: row.session_id as string,
|
||||
role: row.role as AiMessageRole,
|
||||
content: row.content as string,
|
||||
createdAt: row.created_at as string
|
||||
}
|
||||
}
|
||||
|
||||
export class AiSessionRepository {
|
||||
constructor(private db: SqliteDb) {}
|
||||
|
||||
list(includeArchived = false): AiSession[] {
|
||||
const rows = includeArchived
|
||||
? (this.db.prepare('SELECT * FROM ai_sessions ORDER BY updated_at DESC').all() as Record<string, unknown>[])
|
||||
: (this.db
|
||||
.prepare('SELECT * FROM ai_sessions WHERE archived = 0 ORDER BY updated_at DESC')
|
||||
.all() as Record<string, unknown>[])
|
||||
return rows.map(mapSession)
|
||||
}
|
||||
|
||||
get(id: string): AiSession | null {
|
||||
const row = this.db.prepare('SELECT * FROM ai_sessions WHERE id = ?').get(id) as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
return row ? mapSession(row) : null
|
||||
}
|
||||
|
||||
create(title = '', model = ''): AiSession {
|
||||
const id = randomUUID()
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO ai_sessions (id, title, model, archived, context_json) VALUES (?, ?, ?, 0, ?)`
|
||||
)
|
||||
.run(id, title, model, JSON.stringify(EMPTY_CONTEXT))
|
||||
return this.get(id)!
|
||||
}
|
||||
|
||||
update(
|
||||
id: string,
|
||||
patch: Partial<{ title: string; model: string; archived: boolean; context: AiContextBinding }>
|
||||
): AiSession {
|
||||
const existing = this.get(id)
|
||||
if (!existing) throw new Error('AI session not found')
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE ai_sessions SET title = ?, model = ?, archived = ?, context_json = ?, updated_at = datetime('now') WHERE id = ?`
|
||||
)
|
||||
.run(
|
||||
patch.title ?? existing.title,
|
||||
patch.model ?? existing.model,
|
||||
patch.archived !== undefined ? (patch.archived ? 1 : 0) : existing.archived ? 1 : 0,
|
||||
JSON.stringify(patch.context ?? existing.context),
|
||||
id
|
||||
)
|
||||
return this.get(id)!
|
||||
}
|
||||
|
||||
delete(id: string): void {
|
||||
this.db.prepare('DELETE FROM ai_messages WHERE session_id = ?').run(id)
|
||||
this.db.prepare('DELETE FROM ai_sessions WHERE id = ?').run(id)
|
||||
}
|
||||
|
||||
listMessages(sessionId: string): AiMessage[] {
|
||||
return (
|
||||
this.db
|
||||
.prepare('SELECT * FROM ai_messages WHERE session_id = ? ORDER BY created_at ASC')
|
||||
.all(sessionId) as Record<string, unknown>[]
|
||||
).map(mapMessage)
|
||||
}
|
||||
|
||||
addMessage(sessionId: string, role: AiMessageRole, content: string, id?: string): AiMessage {
|
||||
const messageId = id ?? randomUUID()
|
||||
this.db
|
||||
.prepare(`INSERT INTO ai_messages (id, session_id, role, content) VALUES (?, ?, ?, ?)`)
|
||||
.run(messageId, sessionId, role, content)
|
||||
this.db
|
||||
.prepare(`UPDATE ai_sessions SET updated_at = datetime('now') WHERE id = ?`)
|
||||
.run(sessionId)
|
||||
const row = this.db.prepare('SELECT * FROM ai_messages WHERE id = ?').get(messageId) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
return mapMessage(row)
|
||||
}
|
||||
}
|
||||
@@ -88,4 +88,45 @@ export class ChapterRepository {
|
||||
.get(volumeId) as { maxOrder: number | null }
|
||||
return (row?.maxOrder ?? -1) + 1
|
||||
}
|
||||
|
||||
reorderVolume(volumeId: string, orderedIds: string[]): Chapter[] {
|
||||
const stmt = this.db.prepare(
|
||||
`UPDATE chapters SET sort_order = ?, updated_at = datetime('now') WHERE id = ? AND volume_id = ?`
|
||||
)
|
||||
orderedIds.forEach((id, idx) => stmt.run(idx, id, volumeId))
|
||||
return this.listByVolume(volumeId)
|
||||
}
|
||||
|
||||
moveToVolume(chapterId: string, targetVolumeId: string, targetIndex: number): Chapter {
|
||||
const chapter = this.get(chapterId)
|
||||
if (!chapter) throw new Error('Chapter not found')
|
||||
const sourceVolumeId = chapter.volumeId
|
||||
if (!sourceVolumeId) throw new Error('Chapter has no volume')
|
||||
|
||||
if (sourceVolumeId === targetVolumeId) {
|
||||
const ids = this.listByVolume(sourceVolumeId).map((c) => c.id)
|
||||
const from = ids.indexOf(chapterId)
|
||||
if (from < 0) throw new Error('Chapter not in volume')
|
||||
ids.splice(from, 1)
|
||||
ids.splice(Math.max(0, Math.min(targetIndex, ids.length)), 0, chapterId)
|
||||
this.reorderVolume(sourceVolumeId, ids)
|
||||
return this.get(chapterId)!
|
||||
}
|
||||
|
||||
const sourceIds = this.listByVolume(sourceVolumeId)
|
||||
.map((c) => c.id)
|
||||
.filter((id) => id !== chapterId)
|
||||
this.reorderVolume(sourceVolumeId, sourceIds)
|
||||
|
||||
const targetList = this.listByVolume(targetVolumeId)
|
||||
const insertAt = Math.max(0, Math.min(targetIndex, targetList.length))
|
||||
const targetIds = targetList.map((c) => c.id)
|
||||
targetIds.splice(insertAt, 0, chapterId)
|
||||
|
||||
this.db
|
||||
.prepare(`UPDATE chapters SET volume_id = ?, updated_at = datetime('now') WHERE id = ?`)
|
||||
.run(targetVolumeId, chapterId)
|
||||
this.reorderVolume(targetVolumeId, targetIds)
|
||||
return this.get(chapterId)!
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user