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,220 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { BrowserWindow, ipcMain } from 'electron'
|
||||
import { IPC } from '../../../shared/ipc-channels'
|
||||
import type { AiConfig, AiContextBinding, NamingConflict } from '../../../shared/types'
|
||||
import { BookRegistryService } from '../../services/book-registry'
|
||||
import { GlobalSettingsService } from '../../services/global-settings'
|
||||
import { AiClientService } from '../../services/ai-client.service'
|
||||
import { aiContextBuilder } from '../../services/ai-context-builder.service'
|
||||
import {
|
||||
buildNamingPrompt,
|
||||
buildNamingCorpus,
|
||||
collectChaptersPlain,
|
||||
findLocalNamingConflicts,
|
||||
mergeNamingConflicts,
|
||||
parseNamingJson
|
||||
} from '../../services/naming-check.service'
|
||||
import type { ChatMessage } from '../../services/ai-client.service'
|
||||
import { wrap } from '../result'
|
||||
|
||||
const activeChats = new Map<string, AbortController>()
|
||||
|
||||
export function registerAiHandlers(
|
||||
registry: BookRegistryService,
|
||||
settings: GlobalSettingsService,
|
||||
aiClient: AiClientService
|
||||
): void {
|
||||
ipcMain.handle(
|
||||
IPC.AI_SESSION_LIST,
|
||||
(_e, { bookId, includeArchived }: { bookId: string; includeArchived?: boolean }) =>
|
||||
wrap(() => registry.getAiSessionRepo(bookId).list(includeArchived))
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.AI_SESSION_CREATE,
|
||||
(_e, { bookId, title, model }: { bookId: string; title?: string; model?: string }) =>
|
||||
wrap(() => {
|
||||
const config = settings.get().aiConfig
|
||||
return registry.getAiSessionRepo(bookId).create(title, model ?? config.model)
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.AI_SESSION_UPDATE,
|
||||
(
|
||||
_e,
|
||||
{
|
||||
bookId,
|
||||
sessionId,
|
||||
patch
|
||||
}: {
|
||||
bookId: string
|
||||
sessionId: string
|
||||
patch: Partial<{ title: string; model: string; archived: boolean; context: AiContextBinding }>
|
||||
}
|
||||
) => wrap(() => registry.getAiSessionRepo(bookId).update(sessionId, patch))
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.AI_SESSION_DELETE,
|
||||
(_e, { bookId, sessionId }: { bookId: string; sessionId: string }) =>
|
||||
wrap(() => registry.getAiSessionRepo(bookId).delete(sessionId))
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.AI_MESSAGE_LIST,
|
||||
(_e, { bookId, sessionId }: { bookId: string; sessionId: string }) =>
|
||||
wrap(() => registry.getAiSessionRepo(bookId).listMessages(sessionId))
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.AI_BUILD_CONTEXT,
|
||||
(_e, { bookId, binding }: { bookId: string; binding: AiContextBinding }) =>
|
||||
wrap(() => aiContextBuilder.build(binding, registry, bookId, settings.get().penName))
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC.AI_TEST_CONNECTION, (_e, { config }: { config?: AiConfig }) =>
|
||||
wrap(async () => {
|
||||
const effective = config ?? settings.get().aiConfig
|
||||
const probe = new AiClientService(() => effective)
|
||||
const content = await probe.chat([{ role: 'user', content: '回复一个字:好' }])
|
||||
if (!content.trim()) throw new Error('AI returned empty response')
|
||||
return { ok: true as const }
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC.AI_NAMING_CHECK, (_e, { bookId }: { bookId: string }) =>
|
||||
wrap(async () => {
|
||||
const settingsList = registry.getSettingRepo(bookId).list()
|
||||
const chapters = registry.getChapterRepo(bookId).list()
|
||||
const ignoreWords = settings.get().namingIgnoreWords
|
||||
const settingNames = settingsList.map((s) => s.name)
|
||||
const corpus = buildNamingCorpus(settingsList, chapters)
|
||||
const prompt = buildNamingPrompt(settingNames, corpus, ignoreWords)
|
||||
const local = findLocalNamingConflicts(settingNames, corpus, ignoreWords)
|
||||
if (local.length > 0) {
|
||||
try {
|
||||
const response = await Promise.race([
|
||||
aiClient.chat([{ role: 'user', content: prompt }]),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('ai-timeout')), 8_000)
|
||||
)
|
||||
])
|
||||
return mergeNamingConflicts(local, parseNamingJson(response))
|
||||
} catch {
|
||||
return local
|
||||
}
|
||||
}
|
||||
|
||||
const fetchAi = async (): Promise<NamingConflict[]> => {
|
||||
const response = await aiClient.chat([{ role: 'user', content: prompt }])
|
||||
return parseNamingJson(response)
|
||||
}
|
||||
|
||||
try {
|
||||
const ai = await fetchAi()
|
||||
return mergeNamingConflicts(local, ai)
|
||||
} catch {
|
||||
try {
|
||||
const response = await aiClient.chat([
|
||||
{ role: 'user', content: `${prompt}\n\n仅返回 JSON 数组,不要 markdown 或其它说明。` }
|
||||
])
|
||||
return mergeNamingConflicts(local, parseNamingJson(response))
|
||||
} catch {
|
||||
if (local.length > 0) return local
|
||||
throw new Error('命名检查失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC.AI_ABORT, (_e, { messageId }: { messageId: string }) =>
|
||||
wrap(() => {
|
||||
activeChats.get(messageId)?.abort()
|
||||
activeChats.delete(messageId)
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.AI_CHAT,
|
||||
(
|
||||
event,
|
||||
{
|
||||
bookId,
|
||||
sessionId,
|
||||
content,
|
||||
prompt
|
||||
}: { bookId: string; sessionId: string; content: string; prompt?: string; slashCommand?: string }
|
||||
) =>
|
||||
wrap(async () => {
|
||||
const repo = registry.getAiSessionRepo(bookId)
|
||||
const session = repo.get(sessionId)
|
||||
if (!session) throw new Error('AI session not found')
|
||||
|
||||
const actualPrompt = prompt ?? content
|
||||
repo.addMessage(sessionId, 'user', content)
|
||||
const history = repo.listMessages(sessionId)
|
||||
|
||||
const { systemPrompt } = aiContextBuilder.build(
|
||||
session.context,
|
||||
registry,
|
||||
bookId,
|
||||
settings.get().penName
|
||||
)
|
||||
|
||||
const apiMessages: ChatMessage[] = [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...history.map((m, index) => ({
|
||||
role: m.role,
|
||||
content:
|
||||
index === history.length - 1 && m.role === 'user' ? actualPrompt : m.content
|
||||
}))
|
||||
]
|
||||
|
||||
const assistantId = randomUUID()
|
||||
const controller = new AbortController()
|
||||
activeChats.set(assistantId, controller)
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
|
||||
let partial = ''
|
||||
try {
|
||||
const full = await aiClient.chat(
|
||||
apiMessages,
|
||||
(delta) => {
|
||||
partial += delta
|
||||
win?.webContents.send(IPC.AI_STREAM_CHUNK, {
|
||||
messageId: assistantId,
|
||||
delta,
|
||||
done: false
|
||||
})
|
||||
},
|
||||
controller.signal
|
||||
)
|
||||
win?.webContents.send(IPC.AI_STREAM_CHUNK, {
|
||||
messageId: assistantId,
|
||||
delta: '',
|
||||
done: true
|
||||
})
|
||||
repo.addMessage(sessionId, 'assistant', full, assistantId)
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted && partial) {
|
||||
win?.webContents.send(IPC.AI_STREAM_CHUNK, {
|
||||
messageId: assistantId,
|
||||
delta: '',
|
||||
done: true
|
||||
})
|
||||
repo.addMessage(sessionId, 'assistant', partial, assistantId)
|
||||
return { messageId: assistantId }
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
activeChats.delete(assistantId)
|
||||
}
|
||||
|
||||
if (!session.title.trim()) {
|
||||
repo.update(sessionId, { title: content.slice(0, 40) })
|
||||
}
|
||||
return { messageId: assistantId }
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -104,4 +104,24 @@ export function registerChapterHandlers(registry: BookRegistryService): void {
|
||||
ftsSync.remove(registry.getDb(bookId), 'chapter', chapterId)
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.CHAPTER_REORDER,
|
||||
(_event, { bookId, volumeId, orderedIds }: { bookId: string; volumeId: string; orderedIds: string[] }) =>
|
||||
wrap(() => registry.getChapterRepo(bookId).reorderVolume(volumeId, orderedIds))
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.CHAPTER_MOVE_TO_VOLUME,
|
||||
(
|
||||
_event,
|
||||
{
|
||||
bookId,
|
||||
chapterId,
|
||||
targetVolumeId,
|
||||
targetIndex
|
||||
}: { bookId: string; chapterId: string; targetVolumeId: string; targetIndex: number }
|
||||
) =>
|
||||
wrap(() => registry.getChapterRepo(bookId).moveToVolume(chapterId, targetVolumeId, targetIndex))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,9 +14,13 @@ import { registerInspirationHandlers } from './handlers/inspiration.handler'
|
||||
import { registerSnapshotHandlers } from './handlers/snapshot.handler'
|
||||
import { registerBookmarkHandlers } from './handlers/bookmark.handler'
|
||||
import { registerSearchHandlers } from './handlers/search.handler'
|
||||
import { registerAiHandlers } from './handlers/ai.handler'
|
||||
import { AiClientService } from '../services/ai-client.service'
|
||||
import { NetworkMonitorService } from '../services/network-monitor.service'
|
||||
|
||||
let shortcutManager: ShortcutManager | null = null
|
||||
let snapshotService: SnapshotService | null = null
|
||||
let networkMonitor: NetworkMonitorService | null = null
|
||||
|
||||
export function registerIpc(): { settings: GlobalSettingsService; books: BookRegistryService; shortcuts: ShortcutManager } {
|
||||
const userData = app.getPath('userData')
|
||||
@@ -25,6 +29,7 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
|
||||
const books = new BookRegistryService(userData)
|
||||
shortcutManager = new ShortcutManager(settings)
|
||||
snapshotService = new SnapshotService(() => settings.get())
|
||||
networkMonitor = new NetworkMonitorService()
|
||||
|
||||
registerSettingsHandlers(settings)
|
||||
registerBookHandlers(books)
|
||||
@@ -36,6 +41,7 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
|
||||
registerSnapshotHandlers(books, snapshotService!)
|
||||
registerBookmarkHandlers(books)
|
||||
registerSearchHandlers(books, settings)
|
||||
registerAiHandlers(books, settings, new AiClientService(() => settings.get().aiConfig))
|
||||
|
||||
return { settings, books, shortcuts: shortcutManager }
|
||||
}
|
||||
@@ -44,6 +50,10 @@ export function getShortcutManager(): ShortcutManager | null {
|
||||
return shortcutManager
|
||||
}
|
||||
|
||||
export function getNetworkMonitor(): NetworkMonitorService | null {
|
||||
return networkMonitor
|
||||
}
|
||||
|
||||
export function getSnapshotService(): SnapshotService | null {
|
||||
return snapshotService
|
||||
}
|
||||
|
||||
+11
-2
@@ -8,9 +8,18 @@ export function fail(code: ErrorCode, message: string, recoverable: boolean): Ip
|
||||
return { ok: false, error: { code, message, recoverable } }
|
||||
}
|
||||
|
||||
export function wrap<T>(fn: () => T, recoverable = true): IpcResult<T> {
|
||||
export function wrap<T>(fn: () => T | Promise<T>, recoverable = true): IpcResult<T> | Promise<IpcResult<T>> {
|
||||
try {
|
||||
return ok(fn())
|
||||
const out = fn()
|
||||
if (out && typeof (out as Promise<T>).then === 'function') {
|
||||
return (out as Promise<T>)
|
||||
.then((data) => ok(data))
|
||||
.catch((e) => {
|
||||
const message = e instanceof Error ? e.message : String(e)
|
||||
return fail(ErrorCode.UNKNOWN, message, recoverable)
|
||||
})
|
||||
}
|
||||
return ok(out as T)
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e)
|
||||
return fail(ErrorCode.UNKNOWN, message, recoverable)
|
||||
|
||||
Reference in New Issue
Block a user