feat: globalize Magic Notes and improve runtime tools

This commit is contained in:
lofyer
2026-08-10 10:27:14 +08:00
parent 1a8e110866
commit 5ea022ad5c
47 changed files with 2442 additions and 1177 deletions
+172 -152
View File
@@ -49,6 +49,7 @@ import {
type MagicNoteDetail,
type MagicNoteEntry,
type MagicNoteRichContent,
type MagicNoteSearchResult,
type MagicNoteSummary,
type MagicTodoItem
} from '../../shared/magic-notes-contracts'
@@ -56,8 +57,7 @@ import type { ComputerControlAuditEvent } from '../computer-control/audit'
import {
magicNoteChecklistItems,
magicNoteImageBytes,
magicNotePreview,
setMagicNoteChecklistCompletion
magicNotePreview
} from '../magic-notes/rich-content'
import { computeNextHeartbeatRun } from './heartbeat-recurrence'
@@ -409,14 +409,22 @@ function toMagicNoteEntry(row: MagicNoteEntryRow): MagicNoteEntry {
}
function toMagicTodo(row: MagicTodoRow): MagicTodoItem {
if (
row.source !== 'note' ||
!row.note_id ||
!row.entry_id ||
row.source_index === null ||
!row.note_title
) {
throw new Error('待办来源数据无效')
}
return {
id: row.id,
projectId: row.project_id ?? undefined,
noteId: row.note_id ?? undefined,
entryId: row.entry_id ?? undefined,
noteTitle: row.note_title ?? undefined,
sourceIndex: row.source_index ?? undefined,
source: row.source,
noteId: row.note_id,
entryId: row.entry_id,
noteTitle: row.note_title,
sourceIndex: row.source_index,
source: 'note',
title: row.title,
instructions: row.instructions,
completed: row.completed === 1,
@@ -431,7 +439,6 @@ function toMagicTodo(row: MagicTodoRow): MagicTodoItem {
function toMagicNoteSummary(row: MagicNoteRow): MagicNoteSummary {
return {
id: row.id,
projectId: row.project_id ?? undefined,
title: row.title,
preview: magicNotePreview(row.latest_plain_text ?? ''),
entryCount: row.entry_count,
@@ -1774,7 +1781,7 @@ export class AssistantDatabase {
}))
}
listMagicNotes(projectId?: string): MagicNoteSummary[] {
listMagicNotes(): MagicNoteSummary[] {
const database = this.requireDatabase()
const rows = database
.prepare(
@@ -1786,11 +1793,10 @@ export class AssistantDatabase {
ORDER BY e.created_at DESC, e.rowid DESC LIMIT 1)
AS latest_plain_text
FROM magic_notes n
WHERE n.project_id IS ?
ORDER BY n.pinned DESC, n.updated_at DESC, n.rowid DESC
LIMIT 200`
)
.all(projectId ?? null) as MagicNoteRow[]
.all() as MagicNoteRow[]
return rows.map(toMagicNoteSummary)
}
@@ -1827,7 +1833,6 @@ export class AssistantDatabase {
getMagicNoteContext(noteId: string): {
id: string
projectId?: string
title: string
} {
const row = this.requireDatabase()
@@ -1844,15 +1849,11 @@ export class AssistantDatabase {
}
return {
id: row.id,
projectId: row.project_id ?? undefined,
title: row.title
}
}
createMagicNote(input: {
projectId?: string
title: string
}): MagicNoteDetail {
createMagicNote(input: { title: string }): MagicNoteDetail {
const id = randomUUID()
const now = new Date().toISOString()
this.requireDatabase()
@@ -1861,7 +1862,7 @@ export class AssistantDatabase {
(id, project_id, title, pinned, revision, created_at, updated_at)
VALUES (?, ?, ?, 0, 0, ?, ?)`
)
.run(id, input.projectId ?? null, input.title, now, now)
.run(id, null, input.title, now, now)
return this.getMagicNote(id)
}
@@ -2090,21 +2091,51 @@ export class AssistantDatabase {
return this.getMagicNote(existing.note_id)
}
listMagicTodos(projectId?: string): MagicTodoItem[] {
listMagicTodos(): MagicTodoItem[] {
return (
this.requireDatabase()
.prepare(
`SELECT t.*, n.title AS note_title
FROM magic_todos t
LEFT JOIN magic_notes n ON n.id = t.note_id
WHERE t.project_id IS ?
WHERE t.source = 'note'
ORDER BY t.completed ASC, t.updated_at DESC, t.rowid DESC
LIMIT 500`
)
.all(projectId ?? null) as MagicTodoRow[]
.all() as MagicTodoRow[]
).map(toMagicTodo)
}
searchMagicNotes(query: string, limit: number): MagicNoteSearchResult[] {
const pattern = `%${query.replace(/[\\%_]/gu, '\\$&')}%`
return (
this.requireDatabase()
.prepare(
`SELECT n.id AS note_id, n.title AS note_title,
e.id AS entry_id, e.plain_text, e.updated_at
FROM magic_note_entries e
INNER JOIN magic_notes n ON n.id = e.note_id
WHERE n.title LIKE ? ESCAPE '\\'
OR e.plain_text LIKE ? ESCAPE '\\'
ORDER BY e.updated_at DESC, e.rowid DESC
LIMIT ?`
)
.all(pattern, pattern, limit) as Array<{
note_id: string
note_title: string
entry_id: string
plain_text: string
updated_at: string
}>
).map((row) => ({
noteId: row.note_id,
noteTitle: row.note_title.slice(0, 100),
entryId: row.entry_id,
content: row.plain_text.slice(0, 12_000),
updatedAt: row.updated_at
}))
}
getMagicTodo(todoId: string): MagicTodoItem {
const row = this.requireDatabase()
.prepare(
@@ -2120,129 +2151,6 @@ export class AssistantDatabase {
return toMagicTodo(row)
}
createMagicTodo(input: {
projectId?: string
title: string
instructions: string
}): MagicTodoItem {
const id = randomUUID()
const now = new Date().toISOString()
this.requireDatabase()
.prepare(
`INSERT INTO magic_todos
(id, project_id, note_id, entry_id, source_index, source,
title, instructions, completed, comments_json, analyzed_at,
revision, created_at, updated_at)
VALUES (?, ?, NULL, NULL, NULL, 'manual', ?, ?, 0, '[]',
NULL, 0, ?, ?)`
)
.run(
id,
input.projectId ?? null,
input.title,
input.instructions,
now,
now
)
return this.getMagicTodo(id)
}
updateMagicTodo(input: {
todoId: string
title?: string
instructions?: string
completed?: boolean
expectedRevision: number
}): MagicTodoItem {
const database = this.requireDatabase()
const existing = database
.prepare('SELECT * FROM magic_todos WHERE id = ?')
.get(input.todoId) as Omit<MagicTodoRow, 'note_title'> | undefined
if (!existing) {
throw new Error('待办不存在')
}
if (
existing.source === 'note' &&
(input.title !== undefined || input.instructions !== undefined)
) {
throw new Error('笔记待办的内容需要在原笔记中编辑')
}
const now = new Date().toISOString()
database.exec('BEGIN IMMEDIATE')
try {
const result = database
.prepare(
`UPDATE magic_todos
SET title = COALESCE(?, title),
instructions = COALESCE(?, instructions),
completed = COALESCE(?, completed),
revision = revision + 1,
updated_at = ?
WHERE id = ? AND revision = ?`
)
.run(
input.title ?? null,
input.instructions ?? null,
input.completed === undefined ? null : Number(input.completed),
now,
input.todoId,
input.expectedRevision
)
if (result.changes !== 1) {
throw new Error('待办已被更新,请刷新后重试')
}
if (
existing.source === 'note' &&
input.completed !== undefined &&
existing.entry_id &&
existing.note_id &&
existing.source_index !== null
) {
const entry = database
.prepare(
'SELECT content_json FROM magic_note_entries WHERE id = ?'
)
.get(existing.entry_id) as { content_json: string } | undefined
if (!entry) {
throw new Error('待办来源记录不存在')
}
const content = setMagicNoteChecklistCompletion(
JSON.parse(entry.content_json) as MagicNoteRichContent,
existing.source_index,
input.completed
)
database
.prepare(
`UPDATE magic_note_entries
SET content_json = ?, revision = revision + 1, updated_at = ?
WHERE id = ?`
)
.run(JSON.stringify(content), now, existing.entry_id)
database
.prepare(
`UPDATE magic_notes
SET revision = revision + 1, updated_at = ?
WHERE id = ?`
)
.run(now, existing.note_id)
}
database.exec('COMMIT')
} catch (error) {
database.exec('ROLLBACK')
throw error
}
return this.getMagicTodo(input.todoId)
}
deleteMagicTodo(todoId: string): void {
const result = this.requireDatabase()
.prepare("DELETE FROM magic_todos WHERE id = ? AND source = 'manual'")
.run(todoId)
if (result.changes !== 1) {
throw new Error('手动待办不存在')
}
}
saveMagicTodoAnalysis(input: {
todoId: string
expectedRevision: number
@@ -4082,8 +3990,8 @@ export class AssistantDatabase {
now: string
): void {
const note = database
.prepare('SELECT project_id FROM magic_notes WHERE id = ?')
.get(noteId) as { project_id: string | null } | undefined
.prepare('SELECT id FROM magic_notes WHERE id = ?')
.get(noteId) as { id: string } | undefined
if (!note) {
throw new Error('笔记不存在')
}
@@ -4197,7 +4105,7 @@ export class AssistantDatabase {
if (!matched) {
insertTodo.run(
randomUUID(),
note.project_id,
null,
noteId,
entryId,
item.sourceIndex,
@@ -4214,7 +4122,7 @@ export class AssistantDatabase {
const positionChanged =
matched.source_index !== item.sourceIndex
const scopeChanged =
matched.project_id !== note.project_id ||
matched.project_id !== null ||
matched.note_id !== noteId
if (
!titleChanged &&
@@ -4225,7 +4133,7 @@ export class AssistantDatabase {
continue
}
updateTodo.run(
note.project_id,
null,
noteId,
item.sourceIndex,
item.title,
@@ -4262,12 +4170,12 @@ export class AssistantDatabase {
const version = database
.prepare('PRAGMA user_version')
.get() as { user_version: number }
if (version.user_version > 16) {
if (version.user_version > 17) {
throw new Error(
` GoodBuddy ${version.user_version}`
)
}
if (version.user_version === 16) {
if (version.user_version === 17) {
return
}
if (version.user_version < 1) {
@@ -4997,6 +4905,118 @@ export class AssistantDatabase {
throw error
}
}
if (version.user_version < 17) {
database.exec('BEGIN IMMEDIATE')
try {
database.exec(`
UPDATE magic_notes SET project_id = NULL
WHERE project_id IS NOT NULL;
UPDATE magic_todos SET project_id = NULL
WHERE source = 'note' AND project_id IS NOT NULL;
`)
const manualTodos = database
.prepare(
`SELECT id, title, instructions, completed, comments_json,
analyzed_at, revision, created_at, updated_at
FROM magic_todos
WHERE source = 'manual'
ORDER BY created_at ASC, rowid ASC`
)
.all() as Array<{
id: string
title: string
instructions: string
completed: number
comments_json: string
analyzed_at: string | null
revision: number
created_at: string
updated_at: string
}>
if (manualTodos.length > 0) {
const noteId = randomUUID()
const createdAt = manualTodos[0]!.created_at
const updatedAt = manualTodos.at(-1)!.updated_at
database
.prepare(
`INSERT INTO magic_notes
(id, project_id, title, pinned, revision,
created_at, updated_at)
VALUES (?, NULL, '迁入的待办', 0, 0, ?, ?)`
)
.run(noteId, createdAt, updatedAt)
const insertEntry = database.prepare(
`INSERT INTO magic_note_entries
(id, note_id, content_json, plain_text, comments_json,
actions_json, analyzed_at, revision, created_at, updated_at,
image_bytes)
VALUES (?, ?, ?, ?, ?, '[]', ?, ?, ?, ?, 0)`
)
const updateMigratedTodo = database.prepare(
`UPDATE magic_todos
SET instructions = ?, comments_json = ?, analyzed_at = ?,
revision = ?
WHERE entry_id = ? AND source = 'note'`
)
const deleteManualTodo = database.prepare(
`DELETE FROM magic_todos
WHERE id = ? AND source = 'manual'`
)
for (const todo of manualTodos) {
const entryId = randomUUID()
const content: MagicNoteRichContent = {
version: 1,
ops: [
{ insert: todo.title },
{
insert: '\n',
attributes: {
list: todo.completed ? 'checked' : 'unchecked'
}
},
...(todo.instructions
? [
{ insert: todo.instructions },
{ insert: '\n' }
]
: [])
]
}
insertEntry.run(
entryId,
noteId,
JSON.stringify(content),
[todo.title, todo.instructions].filter(Boolean).join('\n'),
todo.comments_json,
todo.analyzed_at,
todo.revision,
todo.created_at,
todo.updated_at
)
this.syncMagicNoteTodos(
database,
noteId,
entryId,
content,
todo.updated_at
)
updateMigratedTodo.run(
todo.instructions,
todo.comments_json,
todo.analyzed_at,
todo.revision,
entryId
)
deleteManualTodo.run(todo.id)
}
}
database.exec('PRAGMA user_version = 17')
database.exec('COMMIT')
} catch (error) {
database.exec('ROLLBACK')
throw error
}
}
}
private requireDatabase(): DatabaseSync {