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:
@@ -1,8 +1,9 @@
|
||||
import type { SqliteDb } from './types'
|
||||
import schemaV1 from './schema-v1.sql?raw'
|
||||
import schemaV2 from './schema-v2.sql?raw'
|
||||
import schemaV3 from './schema-v3.sql?raw'
|
||||
|
||||
const CURRENT_VERSION = 2
|
||||
const CURRENT_VERSION = 3
|
||||
|
||||
function tryAlter(db: SqliteDb, sql: string): void {
|
||||
try {
|
||||
@@ -41,5 +42,11 @@ export function migrate(db: SqliteDb): void {
|
||||
if (current < 2) {
|
||||
applyV2(db)
|
||||
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(2, 'P2 schema')
|
||||
current = 2
|
||||
}
|
||||
|
||||
if (current < 3) {
|
||||
db.exec(schemaV3)
|
||||
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(3, 'P3 AI schema')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)!
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
CREATE TABLE IF NOT EXISTS ai_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
model TEXT NOT NULL DEFAULT '',
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
context_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')),
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (session_id) REFERENCES ai_sessions(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_messages_session ON ai_messages(session_id, created_at);
|
||||
+8
-1
@@ -2,7 +2,7 @@ import { app, BrowserWindow, ipcMain } from 'electron'
|
||||
import { existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { IPC } from '../shared/ipc-channels'
|
||||
import { getShortcutManager, getSnapshotService, registerIpc } from './ipc/register'
|
||||
import { getShortcutManager, getSnapshotService, getNetworkMonitor, registerIpc } from './ipc/register'
|
||||
import { closeAllBookDbs } from './db/connection'
|
||||
|
||||
if (process.env.BILIN_E2E === '1' && process.env.BILIN_E2E_USER_DATA) {
|
||||
@@ -38,11 +38,16 @@ function createWindow(): void { mainWindow = new BrowserWindow({
|
||||
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
|
||||
}
|
||||
|
||||
if (process.env.BILIN_E2E === '1') {
|
||||
mainWindow.webContents.setDevToolsEnabled(false)
|
||||
}
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null
|
||||
})
|
||||
|
||||
getShortcutManager()?.setWindow(mainWindow)
|
||||
getNetworkMonitor()?.start(() => mainWindow)
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
@@ -66,6 +71,7 @@ app.whenReady().then(() => {
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
getShortcutManager()?.unregisterAll()
|
||||
getNetworkMonitor()?.stop()
|
||||
getSnapshotService()?.stopAll()
|
||||
closeAllBookDbs()
|
||||
if (process.platform !== 'darwin') app.quit()
|
||||
@@ -81,4 +87,5 @@ app.on('before-quit', () => {
|
||||
|
||||
app.on('will-quit', () => {
|
||||
getShortcutManager()?.unregisterAll()
|
||||
getNetworkMonitor()?.stop()
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { AiConfig } from '../../shared/types'
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'system' | 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
export class AiClientService {
|
||||
constructor(private getConfig: () => AiConfig) {}
|
||||
|
||||
private endpoint(): string {
|
||||
const { baseUrl } = this.getConfig()
|
||||
return `${baseUrl.replace(/\/$/, '')}/v1/chat/completions`
|
||||
}
|
||||
|
||||
private mergeAbortSignals(timeoutMs: number, external?: AbortSignal): {
|
||||
signal: AbortSignal
|
||||
dispose: () => void
|
||||
} {
|
||||
const timeoutController = new AbortController()
|
||||
const timer = setTimeout(() => timeoutController.abort(), timeoutMs)
|
||||
const dispose = (): void => clearTimeout(timer)
|
||||
|
||||
if (!external) {
|
||||
return { signal: timeoutController.signal, dispose }
|
||||
}
|
||||
|
||||
const linked = new AbortController()
|
||||
const abort = (): void => linked.abort()
|
||||
if (timeoutController.signal.aborted || external.aborted) abort()
|
||||
timeoutController.signal.addEventListener('abort', abort)
|
||||
external.addEventListener('abort', abort)
|
||||
return {
|
||||
signal: linked.signal,
|
||||
dispose: () => {
|
||||
clearTimeout(timer)
|
||||
timeoutController.signal.removeEventListener('abort', abort)
|
||||
external.removeEventListener('abort', abort)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async chat(
|
||||
messages: ChatMessage[],
|
||||
onChunk?: (delta: string) => void,
|
||||
abortSignal?: AbortSignal
|
||||
): Promise<string> {
|
||||
const config = this.getConfig()
|
||||
const { signal, dispose } = this.mergeAbortSignals(config.timeoutMs, abortSignal)
|
||||
try {
|
||||
const res = await fetch(this.endpoint(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {})
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: config.model,
|
||||
messages,
|
||||
max_tokens: config.maxTokens,
|
||||
stream: Boolean(onChunk)
|
||||
}),
|
||||
signal
|
||||
})
|
||||
if (!res.ok) throw new Error(`AI ${res.status}: ${await res.text()}`)
|
||||
|
||||
if (!onChunk) {
|
||||
const json = (await res.json()) as {
|
||||
choices?: { message?: { content?: string } }[]
|
||||
}
|
||||
return json.choices?.[0]?.message?.content ?? ''
|
||||
}
|
||||
|
||||
if (!res.body) throw new Error('AI stream: empty response body')
|
||||
|
||||
let full = ''
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buf = ''
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buf += decoder.decode(value, { stream: true })
|
||||
const lines = buf.split('\n')
|
||||
buf = lines.pop() ?? ''
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue
|
||||
const payload = line.slice(6).trim()
|
||||
if (payload === '[DONE]') continue
|
||||
const parsed = JSON.parse(payload) as {
|
||||
choices?: { delta?: { content?: string } }[]
|
||||
}
|
||||
const delta = parsed.choices?.[0]?.delta?.content ?? ''
|
||||
if (delta) {
|
||||
full += delta
|
||||
onChunk(delta)
|
||||
}
|
||||
}
|
||||
}
|
||||
return full
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { stripHtml } from './word-count'
|
||||
import type { AiContextBinding, AiContextBuildResult, ContextPreviewItem } from '../../shared/types'
|
||||
import type { BookRegistryService } from './book-registry'
|
||||
|
||||
const CHAPTER_CHAR_LIMIT = 2000
|
||||
const OTHER_CHAR_LIMIT = 500
|
||||
const TOTAL_CHAR_LIMIT = 16 * 1024
|
||||
|
||||
interface BlockCandidate {
|
||||
sortKey: number
|
||||
text: string
|
||||
preview: ContextPreviewItem
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
if (text.length <= max) return text
|
||||
return `${text.slice(0, max)}…`
|
||||
}
|
||||
|
||||
function formatBlock(kind: ContextPreviewItem['kind'], title: string, body: string): string {
|
||||
const label =
|
||||
kind === 'chapter' ? '章节' : kind === 'outline' ? '大纲' : kind === 'setting' ? '设定' : '灵感'
|
||||
return `【${label}】${title}\n${body}`
|
||||
}
|
||||
|
||||
export class AiContextBuilderService {
|
||||
build(
|
||||
binding: AiContextBinding,
|
||||
registry: BookRegistryService,
|
||||
bookId: string,
|
||||
penName: string
|
||||
): AiContextBuildResult {
|
||||
const meta = registry.getMeta(bookId)
|
||||
if (!meta) throw new Error('Book not found')
|
||||
|
||||
const candidates: BlockCandidate[] = []
|
||||
|
||||
for (const id of binding.chapterIds) {
|
||||
const chapter = registry.getChapterRepo(bookId).get(id)
|
||||
if (!chapter) continue
|
||||
const plain = stripHtml(chapter.content)
|
||||
const body = truncate(plain, CHAPTER_CHAR_LIMIT)
|
||||
candidates.push({
|
||||
sortKey: chapter.sortOrder,
|
||||
text: formatBlock('chapter', chapter.title, body),
|
||||
preview: {
|
||||
kind: 'chapter',
|
||||
id,
|
||||
title: chapter.title,
|
||||
excerpt: body,
|
||||
charCount: body.length
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for (const id of binding.outlineIds) {
|
||||
const item = registry.getOutlineRepo(bookId).get(id)
|
||||
if (!item) continue
|
||||
const body = truncate(stripHtml(item.description), OTHER_CHAR_LIMIT)
|
||||
candidates.push({
|
||||
sortKey: item.sortOrder,
|
||||
text: formatBlock('outline', item.title, body),
|
||||
preview: {
|
||||
kind: 'outline',
|
||||
id,
|
||||
title: item.title,
|
||||
excerpt: body,
|
||||
charCount: body.length
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for (const id of binding.settingIds) {
|
||||
const item = registry.getSettingRepo(bookId).get(id)
|
||||
if (!item) continue
|
||||
const body = truncate(stripHtml(item.description), OTHER_CHAR_LIMIT)
|
||||
candidates.push({
|
||||
sortKey: 0,
|
||||
text: formatBlock('setting', item.name, body),
|
||||
preview: {
|
||||
kind: 'setting',
|
||||
id,
|
||||
title: item.name,
|
||||
excerpt: body,
|
||||
charCount: body.length
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for (const id of binding.inspirationIds) {
|
||||
const item = registry.getInspirationRepo(bookId).get(id)
|
||||
if (!item) continue
|
||||
const body = truncate(stripHtml(item.content), OTHER_CHAR_LIMIT)
|
||||
candidates.push({
|
||||
sortKey: Date.parse(item.updatedAt) || 0,
|
||||
text: formatBlock('inspiration', item.title, body),
|
||||
preview: {
|
||||
kind: 'inspiration',
|
||||
id,
|
||||
title: item.title,
|
||||
excerpt: body,
|
||||
charCount: body.length
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => b.sortKey - a.sortKey)
|
||||
|
||||
const selected: BlockCandidate[] = []
|
||||
let totalChars = 0
|
||||
for (const candidate of candidates) {
|
||||
const nextTotal = totalChars + candidate.text.length + (selected.length > 0 ? 2 : 0)
|
||||
if (nextTotal > TOTAL_CHAR_LIMIT) break
|
||||
selected.push(candidate)
|
||||
totalChars = nextTotal
|
||||
}
|
||||
|
||||
const contextBlocks = selected.map((x) => x.text).join('\n\n')
|
||||
const systemPrompt = `你是笔临 AI 写作助手。作者笔名:${penName}。书籍:${meta.name}。
|
||||
以下是与本书相关的上下文(由作者勾选):
|
||||
${contextBlocks || '(无)'}
|
||||
请用简体中文回答,尊重作者的创作主权。`
|
||||
|
||||
return {
|
||||
systemPrompt,
|
||||
preview: selected.map((x) => x.preview)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const aiContextBuilder = new AiContextBuilderService()
|
||||
@@ -7,6 +7,7 @@ import { ChapterRepository } from '../db/repositories/chapter.repo'
|
||||
import { OutlineRepository } from '../db/repositories/outline.repo'
|
||||
import { SettingRepository } from '../db/repositories/setting.repo'
|
||||
import { InspirationRepository } from '../db/repositories/inspiration.repo'
|
||||
import { AiSessionRepository } from '../db/repositories/ai-session.repo'
|
||||
import { SnapshotRepository } from '../db/repositories/snapshot.repo'
|
||||
import { BookmarkRepository } from '../db/repositories/bookmark.repo'
|
||||
import type { BookMeta, BookOpenResult, CreateBookParams, Chapter, Volume } from '../../shared/types'
|
||||
@@ -145,6 +146,10 @@ export class BookRegistryService {
|
||||
return new InspirationRepository(this.getDb(bookId))
|
||||
}
|
||||
|
||||
getAiSessionRepo(bookId: string): AiSessionRepository {
|
||||
return new AiSessionRepository(this.getDb(bookId))
|
||||
}
|
||||
|
||||
getSnapshotRepo(bookId: string): SnapshotRepository {
|
||||
return new SnapshotRepository(this.getDb(bookId))
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { DEFAULT_SHORTCUTS } from '../../shared/default-shortcuts'
|
||||
import type { GlobalSettings } from '../../shared/types'
|
||||
import { DEFAULT_AI_CONFIG, type GlobalSettings } from '../../shared/types'
|
||||
|
||||
const FILE = 'global_settings.json'
|
||||
|
||||
function defaultAiConfig() {
|
||||
return {
|
||||
...DEFAULT_AI_CONFIG,
|
||||
baseUrl: process.env.BILIN_AI_BASE_URL ?? DEFAULT_AI_CONFIG.baseUrl,
|
||||
model: process.env.BILIN_AI_MODEL ?? DEFAULT_AI_CONFIG.model
|
||||
}
|
||||
}
|
||||
|
||||
function defaults(): GlobalSettings {
|
||||
return {
|
||||
penName: '未命名作者',
|
||||
@@ -16,7 +24,9 @@ function defaults(): GlobalSettings {
|
||||
snapshotIntervalMs: 300_000,
|
||||
snapshotMaxAuto: 20,
|
||||
snapshotMaxPersist: 3,
|
||||
searchHistory: []
|
||||
searchHistory: [],
|
||||
aiConfig: defaultAiConfig(),
|
||||
namingIgnoreWords: []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +45,8 @@ export class GlobalSettingsService {
|
||||
return {
|
||||
...defaults(),
|
||||
...raw,
|
||||
shortcuts: { ...DEFAULT_SHORTCUTS, ...raw.shortcuts }
|
||||
shortcuts: { ...DEFAULT_SHORTCUTS, ...raw.shortcuts },
|
||||
aiConfig: { ...DEFAULT_AI_CONFIG, ...raw.aiConfig }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +57,8 @@ export class GlobalSettingsService {
|
||||
...partial,
|
||||
shortcuts: partial.shortcuts
|
||||
? { ...current.shortcuts, ...partial.shortcuts }
|
||||
: current.shortcuts
|
||||
: current.shortcuts,
|
||||
aiConfig: partial.aiConfig ? { ...current.aiConfig, ...partial.aiConfig } : current.aiConfig
|
||||
}
|
||||
writeFileSync(this.filePath(), JSON.stringify(next, null, 2), 'utf-8')
|
||||
return next
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { NamingConflict } from '../../shared/types'
|
||||
|
||||
export function parseNamingJson(response: string): NamingConflict[] {
|
||||
const trimmed = response.trim()
|
||||
const start = trimmed.indexOf('[')
|
||||
const end = trimmed.lastIndexOf(']')
|
||||
if (start < 0 || end <= start) {
|
||||
throw new Error('命名检查响应中未找到 JSON 数组')
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(trimmed.slice(start, end + 1)) as unknown
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error('命名检查响应不是 JSON 数组')
|
||||
}
|
||||
|
||||
const conflicts: NamingConflict[] = []
|
||||
for (const item of parsed) {
|
||||
if (!item || typeof item !== 'object') continue
|
||||
const row = item as Record<string, unknown>
|
||||
const termA = String(row.termA ?? '').trim()
|
||||
const termB = String(row.termB ?? '').trim()
|
||||
const reason = String(row.reason ?? '').trim()
|
||||
const confidence = Number(row.confidence)
|
||||
if (!termA || !termB || !Number.isFinite(confidence)) continue
|
||||
conflicts.push({
|
||||
termA,
|
||||
termB,
|
||||
confidence,
|
||||
reason,
|
||||
suggestedCanonical:
|
||||
typeof row.suggestedCanonical === 'string' ? row.suggestedCanonical.trim() : undefined
|
||||
})
|
||||
}
|
||||
|
||||
return conflicts.sort((a, b) => b.confidence - a.confidence)
|
||||
}
|
||||
|
||||
export function buildNamingCorpus(
|
||||
settings: { name: string; description: string }[],
|
||||
chapters: { title: string; content: string }[]
|
||||
): string {
|
||||
const chapterText = collectChaptersPlain(chapters)
|
||||
const settingText = settings
|
||||
.map((s) => stripHtml(s.description))
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
return [chapterText, settingText].filter(Boolean).join('\n\n')
|
||||
}
|
||||
|
||||
export function buildNamingPrompt(
|
||||
settingNames: string[],
|
||||
corpusPlain: string,
|
||||
ignoreWords: string[]
|
||||
): string {
|
||||
const names = settingNames.filter(Boolean).join('、') || '(无)'
|
||||
const ignore = ignoreWords.filter(Boolean).join('、') || '(无)'
|
||||
const excerpt = corpusPlain.slice(0, 12_000)
|
||||
|
||||
return `你是小说编辑助手。请检查下列设定名称与正文片段中是否存在**同一实体/概念的不同写法**(如「林远/林悦」「玄火掌/焰火掌」)。
|
||||
|
||||
设定名称:${names}
|
||||
忽略词:${ignore}
|
||||
|
||||
正文片段:
|
||||
${excerpt}
|
||||
|
||||
仅返回 JSON 数组,不要 markdown 或其它说明。每项格式:
|
||||
{"termA":"写法A","termB":"写法B","confidence":0.0-1.0,"reason":"简短原因","suggestedCanonical":"建议统一用名"}
|
||||
|
||||
若无冲突返回 []。`
|
||||
}
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function extractBodyTokens(text: string): Set<string> {
|
||||
const tokens = new Set<string>()
|
||||
const segments = text.match(/[\u4e00-\u9fff]+/g) ?? []
|
||||
for (const segment of segments) {
|
||||
for (let len = 2; len <= Math.min(4, segment.length); len++) {
|
||||
for (let i = 0; i <= segment.length - len; i++) {
|
||||
tokens.add(segment.slice(i, i + len))
|
||||
}
|
||||
}
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
export function findLocalNamingConflicts(
|
||||
settingNames: string[],
|
||||
corpusPlain: string,
|
||||
ignoreWords: string[]
|
||||
): NamingConflict[] {
|
||||
const ignore = new Set(ignoreWords)
|
||||
const tokens = extractBodyTokens(corpusPlain)
|
||||
const conflicts: NamingConflict[] = []
|
||||
|
||||
for (const name of settingNames) {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed || ignore.has(trimmed)) continue
|
||||
|
||||
for (const token of tokens) {
|
||||
if (token === trimmed || ignore.has(token)) continue
|
||||
if (trimmed.length !== token.length || trimmed[0] !== token[0]) continue
|
||||
|
||||
const diffCount = [...trimmed].filter((ch, index) => ch !== token[index]).length
|
||||
if (diffCount === 0 || diffCount > 2) continue
|
||||
|
||||
conflicts.push({
|
||||
termA: trimmed,
|
||||
termB: token,
|
||||
confidence: 0.88,
|
||||
reason: '设定名与正文用词仅一字之差',
|
||||
suggestedCanonical: trimmed
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const seen = new Set<string>()
|
||||
return conflicts.filter((row) => {
|
||||
const key = [row.termA, row.termB].sort().join('|')
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function mergeNamingConflicts(...groups: NamingConflict[][]): NamingConflict[] {
|
||||
const seen = new Set<string>()
|
||||
const merged: NamingConflict[] = []
|
||||
for (const group of groups) {
|
||||
for (const row of group) {
|
||||
const key = [row.termA, row.termB].sort().join('|')
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
merged.push(row)
|
||||
}
|
||||
}
|
||||
return merged.sort((a, b) => b.confidence - a.confidence)
|
||||
}
|
||||
|
||||
export function collectChaptersPlain(
|
||||
chapters: { title: string; content: string }[]
|
||||
): string {
|
||||
return chapters
|
||||
.map((ch) => `${ch.title}\n${stripHtml(ch.content)}`)
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
export { mergeNamingConflicts }
|
||||
@@ -0,0 +1,31 @@
|
||||
import { net, type BrowserWindow } from 'electron'
|
||||
import { IPC } from '../../shared/ipc-channels'
|
||||
|
||||
export class NetworkMonitorService {
|
||||
private timer: ReturnType<typeof setInterval> | null = null
|
||||
private online = net.isOnline()
|
||||
|
||||
start(getWindow: () => BrowserWindow | null): void {
|
||||
this.stop()
|
||||
this.broadcast(getWindow)
|
||||
this.timer = setInterval(() => {
|
||||
const now = net.isOnline()
|
||||
if (now !== this.online) {
|
||||
this.online = now
|
||||
this.broadcast(getWindow)
|
||||
}
|
||||
}, 30_000)
|
||||
}
|
||||
|
||||
private broadcast(getWindow: () => BrowserWindow | null): void {
|
||||
const win = getWindow()
|
||||
win?.webContents.send(IPC.AI_NETWORK_STATUS, { online: this.online })
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { SearchResult, SearchSourceKind } from '../../shared/types'
|
||||
import type { SqliteDb } from '../db/types'
|
||||
import { ftsSync } from './fts-sync.service'
|
||||
|
||||
export interface SearchQueryParams {
|
||||
text: string
|
||||
@@ -137,8 +138,8 @@ export class SearchService {
|
||||
|
||||
for (const hit of results) {
|
||||
if (hit.kind === 'chapter') {
|
||||
const row = db.prepare('SELECT content FROM chapters WHERE id = ?').get(hit.id) as
|
||||
| { content: string }
|
||||
const row = db.prepare('SELECT title, content FROM chapters WHERE id = ?').get(hit.id) as
|
||||
| { title: string; content: string }
|
||||
| undefined
|
||||
if (!row) continue
|
||||
const next = row.content.replace(re, params.replacement)
|
||||
@@ -147,6 +148,7 @@ export class SearchService {
|
||||
next,
|
||||
hit.id
|
||||
)
|
||||
ftsSync.upsert(db, 'chapter', hit.id, row.title, next)
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
+66
-1
@@ -9,6 +9,14 @@ import type {
|
||||
CreateBookParams,
|
||||
GlobalSettings,
|
||||
InspirationEntry,
|
||||
AiContextBinding,
|
||||
AiContextBuildResult,
|
||||
AiConfig,
|
||||
AiMessage,
|
||||
AiSession,
|
||||
NamingConflict,
|
||||
AiStreamChunkEvent,
|
||||
AiNetworkStatusEvent,
|
||||
IpcResult,
|
||||
LandmarkType,
|
||||
OutlineItem,
|
||||
@@ -67,7 +75,16 @@ const electronAPI = {
|
||||
update: (params: UpdateChapterParams): Promise<IpcResult<Chapter>> =>
|
||||
ipcRenderer.invoke(IPC.CHAPTER_UPDATE, params),
|
||||
delete: (bookId: string, chapterId: string): Promise<IpcResult<void>> =>
|
||||
ipcRenderer.invoke(IPC.CHAPTER_DELETE, { bookId, chapterId })
|
||||
ipcRenderer.invoke(IPC.CHAPTER_DELETE, { bookId, chapterId }),
|
||||
reorder: (bookId: string, volumeId: string, orderedIds: string[]): Promise<IpcResult<Chapter[]>> =>
|
||||
ipcRenderer.invoke(IPC.CHAPTER_REORDER, { bookId, volumeId, orderedIds }),
|
||||
moveToVolume: (
|
||||
bookId: string,
|
||||
chapterId: string,
|
||||
targetVolumeId: string,
|
||||
targetIndex: number
|
||||
): Promise<IpcResult<Chapter>> =>
|
||||
ipcRenderer.invoke(IPC.CHAPTER_MOVE_TO_VOLUME, { bookId, chapterId, targetVolumeId, targetIndex })
|
||||
},
|
||||
outline: {
|
||||
list: (bookId: string): Promise<IpcResult<OutlineItem[]>> =>
|
||||
@@ -213,6 +230,40 @@ const electronAPI = {
|
||||
saveHistory: (query: string): Promise<IpcResult<string[]>> =>
|
||||
ipcRenderer.invoke(IPC.SEARCH_SAVE_HISTORY, { query })
|
||||
},
|
||||
ai: {
|
||||
sessionList: (bookId: string, includeArchived?: boolean): Promise<IpcResult<AiSession[]>> =>
|
||||
ipcRenderer.invoke(IPC.AI_SESSION_LIST, { bookId, includeArchived }),
|
||||
sessionCreate: (bookId: string, title?: string, model?: string): Promise<IpcResult<AiSession>> =>
|
||||
ipcRenderer.invoke(IPC.AI_SESSION_CREATE, { bookId, title, model }),
|
||||
sessionUpdate: (
|
||||
bookId: string,
|
||||
sessionId: string,
|
||||
patch: Partial<{ title: string; model: string; archived: boolean; context: AiContextBinding }>
|
||||
): Promise<IpcResult<AiSession>> =>
|
||||
ipcRenderer.invoke(IPC.AI_SESSION_UPDATE, { bookId, sessionId, patch }),
|
||||
sessionDelete: (bookId: string, sessionId: string): Promise<IpcResult<void>> =>
|
||||
ipcRenderer.invoke(IPC.AI_SESSION_DELETE, { bookId, sessionId }),
|
||||
messageList: (bookId: string, sessionId: string): Promise<IpcResult<AiMessage[]>> =>
|
||||
ipcRenderer.invoke(IPC.AI_MESSAGE_LIST, { bookId, sessionId }),
|
||||
chat: (
|
||||
bookId: string,
|
||||
sessionId: string,
|
||||
content: string,
|
||||
prompt?: string
|
||||
): Promise<IpcResult<{ messageId: string }>> =>
|
||||
ipcRenderer.invoke(IPC.AI_CHAT, { bookId, sessionId, content, prompt }),
|
||||
abort: (messageId: string): Promise<IpcResult<void>> =>
|
||||
ipcRenderer.invoke(IPC.AI_ABORT, { messageId }),
|
||||
buildContext: (
|
||||
bookId: string,
|
||||
binding: AiContextBinding
|
||||
): Promise<IpcResult<AiContextBuildResult>> =>
|
||||
ipcRenderer.invoke(IPC.AI_BUILD_CONTEXT, { bookId, binding }),
|
||||
testConnection: (config?: AiConfig): Promise<IpcResult<{ ok: true }>> =>
|
||||
ipcRenderer.invoke(IPC.AI_TEST_CONNECTION, { config }),
|
||||
namingCheck: (bookId: string): Promise<IpcResult<NamingConflict[]>> =>
|
||||
ipcRenderer.invoke(IPC.AI_NAMING_CHECK, { bookId })
|
||||
},
|
||||
wordfreq: {
|
||||
analyze: (
|
||||
bookId: string,
|
||||
@@ -232,6 +283,20 @@ const electronAPI = {
|
||||
}
|
||||
ipcRenderer.on(IPC.SHORTCUT_TRIGGERED, handler)
|
||||
return () => ipcRenderer.removeListener(IPC.SHORTCUT_TRIGGERED, handler)
|
||||
},
|
||||
onAiStreamChunk: (callback: (payload: AiStreamChunkEvent) => void): (() => void) => {
|
||||
const handler = (_event: IpcRendererEvent, payload: AiStreamChunkEvent) => {
|
||||
callback(payload)
|
||||
}
|
||||
ipcRenderer.on(IPC.AI_STREAM_CHUNK, handler)
|
||||
return () => ipcRenderer.removeListener(IPC.AI_STREAM_CHUNK, handler)
|
||||
},
|
||||
onAiNetworkStatus: (callback: (payload: AiNetworkStatusEvent) => void): (() => void) => {
|
||||
const handler = (_event: IpcRendererEvent, payload: AiNetworkStatusEvent) => {
|
||||
callback(payload)
|
||||
}
|
||||
ipcRenderer.on(IPC.AI_NETWORK_STATUS, handler)
|
||||
return () => ipcRenderer.removeListener(IPC.AI_NETWORK_STATUS, handler)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
-10
@@ -16,10 +16,12 @@ import { useTabStore } from '@renderer/stores/useTabStore'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||
import { SearchModal } from '@renderer/components/search/SearchModal'
|
||||
import { InspirationModal } from '@renderer/components/inspiration/InspirationModal'
|
||||
import { useSearchStore } from '@renderer/stores/useSearchStore'
|
||||
import { useReferenceStore } from '@renderer/stores/useReferenceStore'
|
||||
import { flushEditorSave } from '@renderer/components/editor/TipTapEditor'
|
||||
import { insertLandmarkInEditor } from '@renderer/lib/editor-commands'
|
||||
import { useAiStore } from '@renderer/stores/useAiStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
function AppInner(): React.JSX.Element {
|
||||
@@ -48,6 +50,11 @@ function AppInner(): React.JSX.Element {
|
||||
useEffect(() => {
|
||||
const openSearch = (): void => useSearchStore.getState().setOpen(true)
|
||||
const openLandmarks = (): void => useAppStore.getState().setLandmarksModalOpen(true)
|
||||
const openInspiration = (): void => {
|
||||
if (useAppStore.getState().view !== 'editor') return
|
||||
if (!useBookStore.getState().currentBookId) return
|
||||
useAppStore.getState().setInspirationModalOpen(true)
|
||||
}
|
||||
const onInsertLandmark = (e: Event): void => {
|
||||
const label = (e as CustomEvent<{ label: string }>).detail?.label
|
||||
if (!label) return
|
||||
@@ -59,13 +66,26 @@ function AppInner(): React.JSX.Element {
|
||||
window.electronAPI.bookmark.create(currentBookId, target.id, 0, label, 'todo')
|
||||
)
|
||||
}
|
||||
const onSetAiOnline = (e: Event): void => {
|
||||
const online = (e as CustomEvent<{ online: boolean }>).detail?.online
|
||||
if (typeof online === 'boolean') useAiStore.setState({ online })
|
||||
}
|
||||
const onFlushEditor = (): void => {
|
||||
void flushEditorSave()
|
||||
}
|
||||
window.addEventListener('bilin:e2e-open-search', openSearch)
|
||||
window.addEventListener('bilin:e2e-open-landmarks', openLandmarks)
|
||||
window.addEventListener('bilin:e2e-capture-inspiration', openInspiration)
|
||||
window.addEventListener('bilin:e2e-insert-landmark', onInsertLandmark)
|
||||
window.addEventListener('bilin:e2e-set-ai-online', onSetAiOnline)
|
||||
window.addEventListener('bilin:e2e-flush-editor', onFlushEditor)
|
||||
return () => {
|
||||
window.removeEventListener('bilin:e2e-open-search', openSearch)
|
||||
window.removeEventListener('bilin:e2e-open-landmarks', openLandmarks)
|
||||
window.removeEventListener('bilin:e2e-capture-inspiration', openInspiration)
|
||||
window.removeEventListener('bilin:e2e-insert-landmark', onInsertLandmark)
|
||||
window.removeEventListener('bilin:e2e-set-ai-online', onSetAiOnline)
|
||||
window.removeEventListener('bilin:e2e-flush-editor', onFlushEditor)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -123,16 +143,8 @@ function AppInner(): React.JSX.Element {
|
||||
}
|
||||
if (action === 'captureInspiration') {
|
||||
if (view !== 'editor') return
|
||||
const { currentBookId, addInspirationLocal } = useBookStore.getState()
|
||||
if (!currentBookId) return
|
||||
void (async () => {
|
||||
const item = await ipcCall(() =>
|
||||
window.electronAPI.inspiration.create(currentBookId, t('inspiration.newItem'))
|
||||
)
|
||||
addInspirationLocal(item)
|
||||
useAppStore.getState().setSidebarPanel('inspiration')
|
||||
await useEditStore.getState().switchTarget({ kind: 'inspiration', id: item.id })
|
||||
})()
|
||||
if (!useBookStore.getState().currentBookId) return
|
||||
useAppStore.getState().setInspirationModalOpen(true)
|
||||
return
|
||||
}
|
||||
showToast(t('feature.comingSoon'))
|
||||
@@ -154,6 +166,7 @@ function AppInner(): React.JSX.Element {
|
||||
</div>
|
||||
{showOnboarding && <OnboardingWizard onComplete={handleOnboardingComplete} />}
|
||||
<SearchModal />
|
||||
<InspirationModal />
|
||||
{toast && <div className="toast">{toast}</div>}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { AiWritingMode } from '@shared/types'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import { useAiStore } from '@renderer/stores/useAiStore'
|
||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
import { applySlashCommand } from '@renderer/lib/ai-slash-commands'
|
||||
import { insertToEditor } from '@renderer/lib/editor-commands'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal'
|
||||
|
||||
const MODES: { id: AiWritingMode; labelKey: string }[] = [
|
||||
{ id: 'chat', labelKey: 'ai.mode.chat' },
|
||||
{ id: 'interactive', labelKey: 'ai.mode.interactive' },
|
||||
{ id: 'auto', labelKey: 'ai.mode.auto' },
|
||||
{ id: 'wizard', labelKey: 'ai.mode.wizard' }
|
||||
]
|
||||
|
||||
const QUICK_CMDS = ['/续写', '/扩写', '/润色', '/总结', '/纠错']
|
||||
|
||||
export function AiPanel(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const showToast = useAppStore((s) => s.showToast)
|
||||
const bookId = useBookStore((s) => s.currentBookId)
|
||||
const sessions = useAiStore((s) => s.sessions)
|
||||
const activeSessionId = useAiStore((s) => s.activeSessionId)
|
||||
const messages = useAiStore((s) => s.messages)
|
||||
const streamingBuffer = useAiStore((s) => s.streamingBuffer)
|
||||
const streamingMessageId = useAiStore((s) => s.streamingMessageId)
|
||||
const sending = useAiStore((s) => s.sending)
|
||||
const online = useAiStore((s) => s.online)
|
||||
const backend = useSettingsStore((s) => s.aiConfig.backend)
|
||||
const disabled = backend !== 'lmstudio' && !online
|
||||
const writingMode = useAiStore((s) => s.writingMode)
|
||||
const contextSummary = useAiStore((s) => s.contextSummary)
|
||||
const loadSessions = useAiStore((s) => s.loadSessions)
|
||||
const selectSession = useAiStore((s) => s.selectSession)
|
||||
const createSession = useAiStore((s) => s.createSession)
|
||||
const sendMessage = useAiStore((s) => s.sendMessage)
|
||||
const setWritingMode = useAiStore((s) => s.setWritingMode)
|
||||
const [input, setInput] = useState('')
|
||||
const [contextOpen, setContextOpen] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const prevOnlineRef = useRef(online)
|
||||
|
||||
useEffect(() => {
|
||||
if (!prevOnlineRef.current && online && backend !== 'lmstudio') {
|
||||
showToast(t('ai.networkRestored'))
|
||||
}
|
||||
prevOnlineRef.current = online
|
||||
}, [online, backend, showToast, t])
|
||||
|
||||
useEffect(() => {
|
||||
useAiStore.getState().mount()
|
||||
return () => useAiStore.getState().unmount()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (bookId) void loadSessions(bookId)
|
||||
}, [bookId, loadSessions])
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages, streamingBuffer])
|
||||
|
||||
const handleSend = (): void => {
|
||||
const text = input.trim()
|
||||
if (!text || sending || disabled) return
|
||||
const { display, prompt } = applySlashCommand(text, t)
|
||||
setInput('')
|
||||
void sendMessage(display, prompt !== display ? prompt : undefined)
|
||||
}
|
||||
|
||||
const handleInsert = async (content: string): Promise<void> => {
|
||||
try {
|
||||
await insertToEditor(content, t('snapshot.ai'), async (bookId, chapterId, html, label) => {
|
||||
await ipcCall(() =>
|
||||
window.electronAPI.snapshot.create(bookId, chapterId, 'ai', label, html)
|
||||
)
|
||||
})
|
||||
showToast(t('ai.inserted'))
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}
|
||||
|
||||
const handleModeClick = (mode: AiWritingMode): void => {
|
||||
if (mode !== 'chat') {
|
||||
showToast(t('feature.comingSoon'))
|
||||
return
|
||||
}
|
||||
setWritingMode(mode)
|
||||
}
|
||||
|
||||
if (!bookId) {
|
||||
return (
|
||||
<div className="ai-panel-empty" data-testid="ai-panel">
|
||||
{t('ai.noBook')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id="ai-panel" className="ai-panel" data-testid="ai-panel">
|
||||
<div className="ai-session-bar">
|
||||
<select
|
||||
data-testid="ai-session-select"
|
||||
value={activeSessionId ?? ''}
|
||||
onChange={(e) => void selectSession(e.target.value)}
|
||||
>
|
||||
{sessions.length === 0 && <option value="">{t('ai.noSession')}</option>}
|
||||
{sessions.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.title || t('ai.untitledSession')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-testid="ai-session-new"
|
||||
onClick={() => void createSession(bookId)}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="ai-mode-tabs" role="tablist">
|
||||
{MODES.map((mode) => (
|
||||
<button
|
||||
key={mode.id}
|
||||
type="button"
|
||||
className={`ai-mode-tab ${writingMode === mode.id ? 'active' : ''}`}
|
||||
onClick={() => handleModeClick(mode.id)}
|
||||
>
|
||||
{t(mode.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="context-preview"
|
||||
data-testid="ai-context-preview"
|
||||
onClick={() => setContextOpen(true)}
|
||||
>
|
||||
{contextSummary}
|
||||
</button>
|
||||
|
||||
{disabled && (
|
||||
<div className="offline-banner show" data-testid="ai-offline-banner">
|
||||
{t('ai.offlineBanner')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="chat-messages" data-testid="ai-chat-messages">
|
||||
{messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`chat-msg ${msg.role === 'user' ? 'user' : 'ai'}`}
|
||||
data-testid={msg.role === 'assistant' ? 'chat-message-assistant' : 'chat-message-user'}
|
||||
>
|
||||
<div className="chat-msg-body">{msg.content}</div>
|
||||
{msg.role === 'assistant' && (
|
||||
<button
|
||||
type="button"
|
||||
className="chat-insert-btn"
|
||||
data-testid="ai-insert-to-editor"
|
||||
onClick={() => void handleInsert(msg.content)}
|
||||
>
|
||||
{t('ai.insertToEditor')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{streamingMessageId && streamingBuffer && (
|
||||
<div className="chat-msg ai" data-testid="chat-message-assistant">
|
||||
{streamingBuffer}
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="quick-cmds">
|
||||
{QUICK_CMDS.map((cmd) => (
|
||||
<button
|
||||
key={cmd}
|
||||
type="button"
|
||||
className="quick-cmd"
|
||||
data-testid={`ai-quick-cmd-${cmd.slice(1)}`}
|
||||
disabled={disabled || sending}
|
||||
onClick={() => !disabled && setInput(cmd + ' ')}
|
||||
>
|
||||
{cmd}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="chat-input-area">
|
||||
<input
|
||||
type="text"
|
||||
data-testid="ai-chat-input"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={t('ai.inputPlaceholder')}
|
||||
disabled={sending || disabled}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="ai-send"
|
||||
disabled={sending || disabled || !input.trim()}
|
||||
onClick={handleSend}
|
||||
>
|
||||
{t('ai.send')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ContextEditorModal open={contextOpen} onClose={() => setContextOpen(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { AiContextBinding } from '@shared/types'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useAiStore } from '@renderer/stores/useAiStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
import { formatContextSummary } from '@renderer/lib/ai-context-summary'
|
||||
import i18n from '@renderer/i18n'
|
||||
|
||||
type ContextTab = 'chapter' | 'outline' | 'setting' | 'inspiration'
|
||||
|
||||
interface ContextEditorModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const EMPTY_BINDING: AiContextBinding = {
|
||||
chapterIds: [],
|
||||
outlineIds: [],
|
||||
settingIds: [],
|
||||
inspirationIds: []
|
||||
}
|
||||
|
||||
export function ContextEditorModal({ open, onClose }: ContextEditorModalProps): React.JSX.Element | null {
|
||||
const { t } = useTranslation()
|
||||
const bookId = useBookStore((s) => s.currentBookId)
|
||||
const chapters = useBookStore((s) => s.chapters)
|
||||
const outlines = useBookStore((s) => s.outlines)
|
||||
const settings = useBookStore((s) => s.settings)
|
||||
const inspirations = useBookStore((s) => s.inspirations)
|
||||
const activeSessionId = useAiStore((s) => s.activeSessionId)
|
||||
const sessions = useAiStore((s) => s.sessions)
|
||||
const [tab, setTab] = useState<ContextTab>('setting')
|
||||
const [binding, setBinding] = useState<AiContextBinding>(EMPTY_BINDING)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const session = sessions.find((s) => s.id === activeSessionId)
|
||||
setBinding(session?.context ?? EMPTY_BINDING)
|
||||
}, [open, activeSessionId, sessions])
|
||||
|
||||
const toggle = (kind: ContextTab, id: string): void => {
|
||||
const key =
|
||||
kind === 'chapter'
|
||||
? 'chapterIds'
|
||||
: kind === 'outline'
|
||||
? 'outlineIds'
|
||||
: kind === 'setting'
|
||||
? 'settingIds'
|
||||
: 'inspirationIds'
|
||||
setBinding((prev) => {
|
||||
const list = prev[key]
|
||||
return {
|
||||
...prev,
|
||||
[key]: list.includes(id) ? list.filter((x) => x !== id) : [...list, id]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
if (!bookId || !activeSessionId) return
|
||||
const updated = await ipcCall(() =>
|
||||
window.electronAPI.ai.sessionUpdate(bookId, activeSessionId, { context: binding })
|
||||
)
|
||||
useAiStore.setState((s) => ({
|
||||
sessions: s.sessions.map((x) => (x.id === updated.id ? updated : x))
|
||||
}))
|
||||
await useAiStore.getState().refreshContextPreview()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleRefresh = async (): Promise<void> => {
|
||||
if (!bookId) return
|
||||
const result = await ipcCall(() => window.electronAPI.ai.buildContext(bookId, binding))
|
||||
useAiStore.setState({
|
||||
contextPreview: result.preview,
|
||||
contextSummary: formatContextSummary(result.preview, i18n.t)
|
||||
})
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const items =
|
||||
tab === 'chapter'
|
||||
? chapters.map((c) => ({ id: c.id, title: c.title }))
|
||||
: tab === 'outline'
|
||||
? outlines.map((o) => ({ id: o.id, title: o.title }))
|
||||
: tab === 'setting'
|
||||
? settings.map((s) => ({ id: s.id, title: s.name }))
|
||||
: inspirations.map((i) => ({ id: i.id, title: i.title || t('inspiration.untitled') }))
|
||||
|
||||
const selectedIds =
|
||||
tab === 'chapter'
|
||||
? binding.chapterIds
|
||||
: tab === 'outline'
|
||||
? binding.outlineIds
|
||||
: tab === 'setting'
|
||||
? binding.settingIds
|
||||
: binding.inspirationIds
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" data-testid="context-editor-modal">
|
||||
<div className="modal-content context-editor-modal">
|
||||
<div className="modal-header">
|
||||
<h3>{t('ai.contextEditorTitle')}</h3>
|
||||
<button type="button" className="modal-close" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="context-editor-tabs">
|
||||
{(['chapter', 'outline', 'setting', 'inspiration'] as const).map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={`context-editor-tab ${tab === id ? 'active' : ''}`}
|
||||
data-testid={`context-tab-${id}`}
|
||||
onClick={() => setTab(id)}
|
||||
>
|
||||
{t(`ai.contextTab.${id}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="context-editor-list">
|
||||
{items.length === 0 && <div className="sidebar-empty">{t('ai.contextEmptyList')}</div>}
|
||||
{items.map((item) => (
|
||||
<label key={item.id} className="context-editor-item" data-testid={`context-item-${item.id}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(item.id)}
|
||||
onChange={() => toggle(tab, item.id)}
|
||||
/>
|
||||
<span>{item.title}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn" data-testid="context-refresh" onClick={() => void handleRefresh()}>
|
||||
{t('ai.contextRefresh')}
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={onClose}>
|
||||
{t('dialog.cancel')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" data-testid="context-save" onClick={() => void handleSave()}>
|
||||
{t('ai.contextSave')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { NamingConflict } from '@shared/types'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
interface NamingCheckModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function NamingCheckModal({ open, onClose }: NamingCheckModalProps): React.JSX.Element | null {
|
||||
const { t } = useTranslation()
|
||||
const bookId = useBookStore((s) => s.currentBookId)
|
||||
const openBook = useBookStore((s) => s.openBook)
|
||||
const namingIgnoreWords = useSettingsStore((s) => s.namingIgnoreWords)
|
||||
const updateSettings = useSettingsStore((s) => s.update)
|
||||
const showToast = useAppStore((s) => s.showToast)
|
||||
const reloadTarget = useEditStore((s) => s.reloadTarget)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [conflicts, setConflicts] = useState<NamingConflict[]>([])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const runCheck = async (): Promise<void> => {
|
||||
const activeBookId = useBookStore.getState().currentBookId
|
||||
if (!activeBookId) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const rows = await ipcCall(() => window.electronAPI.ai.namingCheck(activeBookId))
|
||||
const ignore = new Set(namingIgnoreWords)
|
||||
setConflicts(
|
||||
rows.filter((row) => !ignore.has(row.termA) && !ignore.has(row.termB))
|
||||
)
|
||||
if (rows.length === 0) {
|
||||
showToast(t('naming.noConflicts'))
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : String(err))
|
||||
setConflicts([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleIgnore = (row: NamingConflict): void => {
|
||||
const next = Array.from(new Set([...namingIgnoreWords, row.termA, row.termB]))
|
||||
void updateSettings({ namingIgnoreWords: next })
|
||||
setConflicts((list) => list.filter((item) => item !== row))
|
||||
showToast(t('naming.ignored'))
|
||||
}
|
||||
|
||||
const handleReplace = async (row: NamingConflict): Promise<void> => {
|
||||
if (!bookId) return
|
||||
const canonical = row.suggestedCanonical || row.termA
|
||||
const from = row.termB
|
||||
if (!from || from === canonical) return
|
||||
try {
|
||||
const result = await ipcCall(() =>
|
||||
window.electronAPI.search.replace(bookId, from, canonical, false, { caseSensitive: true })
|
||||
)
|
||||
await openBook(bookId)
|
||||
reloadTarget()
|
||||
setConflicts((list) => list.filter((item) => item !== row))
|
||||
showToast(t('naming.replaced', { count: result.count }))
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay naming-modal" data-testid="naming-check-modal">
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h3>{t('naming.title')}</h3>
|
||||
<button type="button" className="modal-close" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="naming-modal-body">
|
||||
{conflicts.length === 0 && !loading && (
|
||||
<p className="naming-empty">{t('naming.emptyHint')}</p>
|
||||
)}
|
||||
{conflicts.map((row, index) => (
|
||||
<div key={`${row.termA}-${row.termB}-${index}`} className="naming-row" data-testid="naming-row">
|
||||
<div className="naming-terms">
|
||||
<strong>{row.termA}</strong> ↔ <strong>{row.termB}</strong>
|
||||
</div>
|
||||
<div className="naming-meta">
|
||||
{t('naming.confidence', { value: Math.round(row.confidence * 100) })}
|
||||
{row.reason ? ` · ${row.reason}` : ''}
|
||||
</div>
|
||||
<div className="naming-actions">
|
||||
<button type="button" className="btn" onClick={() => handleIgnore(row)}>
|
||||
{t('naming.ignore')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={() => void handleReplace(row)}>
|
||||
{t('naming.replace', { term: row.suggestedCanonical || row.termA })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn" onClick={onClose}>
|
||||
{t('dialog.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
data-testid="naming-run-check"
|
||||
disabled={loading || !bookId}
|
||||
onClick={() => void runCheck()}
|
||||
>
|
||||
{loading ? t('naming.running') : t('naming.run')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,11 +8,11 @@ import { useSetAtom } from 'jotai'
|
||||
import type { EditTarget } from '@shared/types'
|
||||
import { editorDirtyAtom, editorSavingAtom, lastSavedAtAtom, wordCountAtom } from '@renderer/atoms/editorAtoms'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { flushEditSession, registerEditorFlush } from '@renderer/stores/useEditStore'
|
||||
import { flushEditSession, registerEditorFlush, useEditStore } from '@renderer/stores/useEditStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
import { LandmarkMark } from '@renderer/extensions/landmark-mark'
|
||||
import { SettingMention } from '@renderer/extensions/mention'
|
||||
import { registerHighlightToken, registerInsertLandmark } from '@renderer/lib/editor-commands'
|
||||
import { registerHighlightToken, registerInsertLandmark, registerEditorInsert } from '@renderer/lib/editor-commands'
|
||||
function countPlain(text: string): number {
|
||||
const stripped = text.replace(/<[^>]+>/g, '').replace(/\s+/g, '')
|
||||
const cjk = stripped.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g)
|
||||
@@ -149,6 +149,13 @@ export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.E
|
||||
}
|
||||
},
|
||||
editorProps: {
|
||||
handleClick: (_view, _pos, event) => {
|
||||
const el = (event.target as HTMLElement).closest('[data-setting-mention]')
|
||||
if (!el) return false
|
||||
const id = el.getAttribute('data-id')
|
||||
if (id) void useEditStore.getState().switchTarget({ kind: 'setting', id })
|
||||
return true
|
||||
},
|
||||
handleDOMEvents: {
|
||||
dragover: (_view, event) => {
|
||||
event.preventDefault()
|
||||
@@ -282,7 +289,29 @@ export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.E
|
||||
}, [editor])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || !target) return
|
||||
if (!editor || !bookId || !target || target.kind !== 'chapter') {
|
||||
registerEditorInsert(null, () => {})
|
||||
return
|
||||
}
|
||||
registerEditorInsert(
|
||||
{
|
||||
bookId,
|
||||
chapterId: target.id,
|
||||
getHtml: () => editor.getHTML()
|
||||
},
|
||||
(text) => {
|
||||
editor.chain().focus().insertContent(text).run()
|
||||
}
|
||||
)
|
||||
return () => registerEditorInsert(null, () => {})
|
||||
}, [editor, bookId, target])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
if (!target) {
|
||||
targetKeyRef.current = null
|
||||
return
|
||||
}
|
||||
const key = `${target.kind}:${target.id}`
|
||||
if (targetKeyRef.current === key) return
|
||||
targetKeyRef.current = key
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
type SpeechRecognitionCtor = new () => SpeechRecognition
|
||||
|
||||
function getSpeechRecognition(): SpeechRecognitionCtor | null {
|
||||
const w = window as Window & {
|
||||
SpeechRecognition?: SpeechRecognitionCtor
|
||||
webkitSpeechRecognition?: SpeechRecognitionCtor
|
||||
}
|
||||
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null
|
||||
}
|
||||
|
||||
export function InspirationModal(): React.JSX.Element | null {
|
||||
const { t } = useTranslation()
|
||||
const open = useAppStore((s) => s.inspirationModalOpen)
|
||||
const setOpen = useAppStore((s) => s.setInspirationModalOpen)
|
||||
const setSidebarPanel = useAppStore((s) => s.setSidebarPanel)
|
||||
const bookId = useBookStore((s) => s.currentBookId)
|
||||
const addInspirationLocal = useBookStore((s) => s.addInspirationLocal)
|
||||
const switchTarget = useEditStore((s) => s.switchTarget)
|
||||
const [title, setTitle] = useState('')
|
||||
const [content, setContent] = useState('')
|
||||
const [tags, setTags] = useState('')
|
||||
const contentRef = useRef<HTMLTextAreaElement>(null)
|
||||
const speechAvailable = getSpeechRecognition() !== null
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setTitle('')
|
||||
setContent('')
|
||||
setTags('')
|
||||
const timer = setTimeout(() => contentRef.current?.focus(), 100)
|
||||
return () => clearTimeout(timer)
|
||||
}, [open])
|
||||
|
||||
const handleVoice = (): void => {
|
||||
const SpeechRecognition = getSpeechRecognition()
|
||||
if (!SpeechRecognition) return
|
||||
const rec = new SpeechRecognition()
|
||||
rec.lang = 'zh-CN'
|
||||
rec.onresult = (e) => {
|
||||
const transcript = e.results[0]?.[0]?.transcript ?? ''
|
||||
if (transcript) setContent((c) => c + transcript)
|
||||
}
|
||||
rec.start()
|
||||
}
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
if (!bookId || !content.trim()) return
|
||||
const tagList = tags
|
||||
.split(/[,,]/)
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
const item = await ipcCall(() =>
|
||||
window.electronAPI.inspiration.create(
|
||||
bookId,
|
||||
title.trim() || t('inspiration.newItem'),
|
||||
content.trim(),
|
||||
tagList
|
||||
)
|
||||
)
|
||||
addInspirationLocal(item)
|
||||
setSidebarPanel('inspiration')
|
||||
await switchTarget({ kind: 'inspiration', id: item.id })
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
id="inspirationModal"
|
||||
data-testid="inspiration-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('inspiration.captureTitle')}
|
||||
>
|
||||
<div className="modal-content inspiration-modal">
|
||||
<div className="modal-header">
|
||||
<h3>{t('inspiration.captureTitle')}</h3>
|
||||
<button type="button" className="modal-close" onClick={() => setOpen(false)}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body inspiration-capture">
|
||||
<input
|
||||
className="form-control form-control--wide"
|
||||
data-testid="inspiration-title-input"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={t('inspiration.titlePlaceholder')}
|
||||
/>
|
||||
<textarea
|
||||
ref={contentRef}
|
||||
className="inspiration-textarea"
|
||||
data-testid="inspiration-content-input"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder={t('inspiration.contentPlaceholder')}
|
||||
rows={6}
|
||||
/>
|
||||
<label className="form-label">{t('inspiration.tagsLabel')}</label>
|
||||
<input
|
||||
className="form-control form-control--wide"
|
||||
data-testid="inspiration-tags-input"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder={t('inspiration.tagsPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-testid="inspiration-voice-btn"
|
||||
disabled={!speechAvailable}
|
||||
title={speechAvailable ? t('inspiration.voice') : t('inspiration.voiceUnavailable')}
|
||||
onClick={handleVoice}
|
||||
>
|
||||
🎤 {t('inspiration.voice')}
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={() => setOpen(false)}>
|
||||
{t('dialog.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
data-testid="inspiration-save-btn"
|
||||
disabled={!content.trim()}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
{t('inspiration.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { NamingCheckModal } from '@renderer/components/ai/NamingCheckModal'
|
||||
|
||||
export function KnowledgePanel(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const [namingOpen, setNamingOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="knowledge-panel" data-testid="knowledge-panel">
|
||||
<div className="knowledge-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-testid="naming-check-open"
|
||||
onClick={() => setNamingOpen(true)}
|
||||
>
|
||||
{t('naming.open')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="placeholder-box">{t('knowledge.placeholder')}</div>
|
||||
</div>
|
||||
<NamingCheckModal open={namingOpen} onClose={() => setNamingOpen(false)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
@@ -122,6 +122,55 @@ export function EditorLayout(): React.JSX.Element {
|
||||
setActiveVolume(vol.id)
|
||||
}
|
||||
|
||||
const [dragOverChapterId, setDragOverChapterId] = useState<string | null>(null)
|
||||
|
||||
const handleChapterDropOnItem = async (
|
||||
draggedId: string,
|
||||
targetVolumeId: string,
|
||||
targetChapterId: string
|
||||
): Promise<void> => {
|
||||
if (!currentBookId || draggedId === targetChapterId) return
|
||||
await flushEditorSave()
|
||||
const dragged = chapters.find((c) => c.id === draggedId)
|
||||
if (!dragged) return
|
||||
const volChapters = chapters
|
||||
.filter((c) => c.volumeId === targetVolumeId)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
const targetIdx = volChapters.findIndex((c) => c.id === targetChapterId)
|
||||
if (dragged.volumeId === targetVolumeId) {
|
||||
const ids = volChapters.map((c) => c.id)
|
||||
const from = ids.indexOf(draggedId)
|
||||
if (from < 0) return
|
||||
ids.splice(from, 1)
|
||||
const insertAt = ids.indexOf(targetChapterId)
|
||||
ids.splice(insertAt, 0, draggedId)
|
||||
await ipcCall(() => window.electronAPI.chapter.reorder(currentBookId, targetVolumeId, ids))
|
||||
} else {
|
||||
await ipcCall(() =>
|
||||
window.electronAPI.chapter.moveToVolume(
|
||||
currentBookId,
|
||||
draggedId,
|
||||
targetVolumeId,
|
||||
Math.max(0, targetIdx)
|
||||
)
|
||||
)
|
||||
}
|
||||
await refreshChapters()
|
||||
}
|
||||
|
||||
const handleChapterDropOnVolume = async (draggedId: string, targetVolumeId: string): Promise<void> => {
|
||||
if (!currentBookId) return
|
||||
await flushEditorSave()
|
||||
const dragged = chapters.find((c) => c.id === draggedId)
|
||||
if (!dragged) return
|
||||
const targetLen = chapters.filter((c) => c.volumeId === targetVolumeId).length
|
||||
if (dragged.volumeId === targetVolumeId) return
|
||||
await ipcCall(() =>
|
||||
window.electronAPI.chapter.moveToVolume(currentBookId, draggedId, targetVolumeId, targetLen)
|
||||
)
|
||||
await refreshChapters()
|
||||
}
|
||||
|
||||
const handleInsertLandmark = async (): Promise<void> => {
|
||||
if (!currentBookId || target?.kind !== 'chapter') {
|
||||
showToast(t('landmark.chapterOnly'))
|
||||
@@ -163,7 +212,14 @@ export function EditorLayout(): React.JSX.Element {
|
||||
<div key={vol.id}>
|
||||
<div
|
||||
className="vol-header"
|
||||
data-volume-id={vol.id}
|
||||
onClick={() => setActiveVolume(vol.id)}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
const draggedId = e.dataTransfer.getData('application/x-bilin-chapter')
|
||||
if (draggedId) void handleChapterDropOnVolume(draggedId, vol.id)
|
||||
}}
|
||||
style={{ color: activeVolumeId === vol.id ? 'var(--accent-light)' : undefined }}
|
||||
>
|
||||
{vol.name}
|
||||
@@ -173,7 +229,24 @@ export function EditorLayout(): React.JSX.Element {
|
||||
.map((ch, idx) => (
|
||||
<div
|
||||
key={ch.id}
|
||||
className={`chapter-item ${target?.kind === 'chapter' && target.id === ch.id ? 'active' : ''}`}
|
||||
className={`chapter-item ${target?.kind === 'chapter' && target.id === ch.id ? 'active' : ''} ${dragOverChapterId === ch.id ? 'drag-over' : ''}`}
|
||||
draggable
|
||||
data-testid={`chapter-item-${ch.id}`}
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('application/x-bilin-chapter', ch.id)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
setDragOverChapterId(ch.id)
|
||||
}}
|
||||
onDragLeave={() => setDragOverChapterId(null)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
setDragOverChapterId(null)
|
||||
const draggedId = e.dataTransfer.getData('application/x-bilin-chapter')
|
||||
if (draggedId) void handleChapterDropOnItem(draggedId, vol.id, ch.id)
|
||||
}}
|
||||
onClick={() => void handleSelectChapter(ch.id)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && void handleSelectChapter(ch.id)}
|
||||
role="button"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import { WordFreqPanel } from '@renderer/components/wordfreq/WordFreqPanel'
|
||||
import { AiPanel } from '@renderer/components/ai/AiPanel'
|
||||
import { KnowledgePanel } from '@renderer/components/knowledge/KnowledgePanel'
|
||||
|
||||
export function RightPanel(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
@@ -28,12 +30,11 @@ export function RightPanel(): React.JSX.Element {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={`panel-content ${rightPanel === 'ai' ? 'active' : ''}`}>
|
||||
<p style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 8 }}>{t('panel.aiPlaceholder')}</p>
|
||||
<input className="ai-input-disabled" disabled placeholder={t('feature.comingSoon')} />
|
||||
<div className={`panel-content ai-panel-wrap ${rightPanel === 'ai' ? 'active' : ''}`}>
|
||||
<AiPanel />
|
||||
</div>
|
||||
<div className={`panel-content ${rightPanel === 'knowledge' ? 'active' : ''}`}>
|
||||
<div className="placeholder-box">{t('feature.comingSoon')}</div>
|
||||
<KnowledgePanel />
|
||||
</div>
|
||||
<div className={`panel-content ${rightPanel === 'wordfreq' ? 'active' : ''}`}>
|
||||
<WordFreqPanel />
|
||||
|
||||
@@ -7,11 +7,13 @@ import { useEditStore } from '@renderer/stores/useEditStore'
|
||||
export function ReferencePanel(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const open = useReferenceStore((s) => s.open)
|
||||
const pinned = useReferenceStore((s) => s.pinned)
|
||||
const tab = useReferenceStore((s) => s.tab)
|
||||
const query = useReferenceStore((s) => s.query)
|
||||
const setTab = useReferenceStore((s) => s.setTab)
|
||||
const setQuery = useReferenceStore((s) => s.setQuery)
|
||||
const setOpen = useReferenceStore((s) => s.setOpen)
|
||||
const setPinned = useReferenceStore((s) => s.setPinned)
|
||||
const switchTarget = useEditStore((s) => s.switchTarget)
|
||||
const outlines = useBookStore((s) => s.outlines)
|
||||
const settings = useBookStore((s) => s.settings)
|
||||
@@ -43,9 +45,28 @@ export function ReferencePanel(): React.JSX.Element {
|
||||
<div id="reference-panel" className="open" data-testid="reference-panel">
|
||||
<div className="ref-header">
|
||||
<span>{t('reference.title')}</span>
|
||||
<button type="button" className="btn" onClick={() => setOpen(false)}>
|
||||
✕
|
||||
</button>
|
||||
<div className="ref-header-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ref-pin-btn ${pinned ? 'active' : ''}`}
|
||||
data-testid="reference-pin"
|
||||
title={pinned ? t('reference.unpin') : t('reference.pin')}
|
||||
onClick={() => setPinned(!pinned)}
|
||||
>
|
||||
📌
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-testid="reference-close"
|
||||
onClick={() => {
|
||||
if (pinned) setPinned(false)
|
||||
else setOpen(false)
|
||||
}}
|
||||
>
|
||||
{pinned ? t('reference.unpin') : '✕'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
className="form-control form-control--wide"
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { SearchResult } from '@shared/types'
|
||||
import { useSearchStore } from '@renderer/stores/useSearchStore'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
export function SearchModal(): React.JSX.Element | null {
|
||||
@@ -17,11 +18,16 @@ export function SearchModal(): React.JSX.Element | null {
|
||||
const setRegex = useSearchStore((s) => s.setRegex)
|
||||
const setCaseSensitive = useSearchStore((s) => s.setCaseSensitive)
|
||||
const bookId = useBookStore((s) => s.currentBookId)
|
||||
const refreshChapters = useBookStore((s) => s.refreshChapters)
|
||||
const setSelectedChapter = useBookStore((s) => s.setSelectedChapter)
|
||||
const switchTarget = useEditStore((s) => s.switchTarget)
|
||||
const reloadTarget = useEditStore((s) => s.reloadTarget)
|
||||
const showToast = useAppStore((s) => s.showToast)
|
||||
const [results, setResults] = useState<SearchResult[]>([])
|
||||
const [history, setHistory] = useState<string[]>([])
|
||||
const [selectedIdx, setSelectedIdx] = useState(0)
|
||||
const [replacement, setReplacement] = useState('')
|
||||
const [replacePreviews, setReplacePreviews] = useState<SearchResult[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
@@ -58,6 +64,26 @@ export function SearchModal(): React.JSX.Element | null {
|
||||
}
|
||||
}
|
||||
|
||||
const previewReplace = async (): Promise<void> => {
|
||||
if (!bookId || !query.trim()) return
|
||||
const result = await ipcCall(() =>
|
||||
window.electronAPI.search.replace(bookId, query, replacement, true, { regex, caseSensitive })
|
||||
)
|
||||
setReplacePreviews(result.previews)
|
||||
}
|
||||
|
||||
const executeReplace = async (): Promise<void> => {
|
||||
if (!bookId || !query.trim()) return
|
||||
if (!window.confirm(t('search.replaceConfirm'))) return
|
||||
const result = await ipcCall(() =>
|
||||
window.electronAPI.search.replace(bookId, query, replacement, false, { regex, caseSensitive })
|
||||
)
|
||||
await refreshChapters()
|
||||
reloadTarget()
|
||||
showToast(t('search.replaceDone', { count: result.count }))
|
||||
setReplacePreviews([])
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
@@ -121,6 +147,46 @@ export function SearchModal(): React.JSX.Element | null {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<details className="search-replace-panel" data-testid="search-replace-panel">
|
||||
<summary>{t('search.replace')}</summary>
|
||||
<input
|
||||
className="form-control form-control--wide"
|
||||
data-testid="search-replace-input"
|
||||
value={replacement}
|
||||
onChange={(e) => setReplacement(e.target.value)}
|
||||
placeholder={t('search.replace')}
|
||||
/>
|
||||
<div className="search-replace-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-testid="search-replace-preview"
|
||||
onClick={() => void previewReplace()}
|
||||
>
|
||||
{t('search.replacePreview')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
data-testid="search-replace-execute"
|
||||
onClick={() => void executeReplace()}
|
||||
>
|
||||
{t('search.replaceExecute')}
|
||||
</button>
|
||||
</div>
|
||||
{replacePreviews.length > 0 && (
|
||||
<div className="search-replace-previews" data-testid="search-replace-previews">
|
||||
{replacePreviews.map((hit) => (
|
||||
<div key={`${hit.kind}-${hit.id}`} className="search-result">
|
||||
<div className="sr-type">
|
||||
{hit.kind} · {hit.title}
|
||||
</div>
|
||||
<div>{hit.snippet}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn" onClick={() => setOpen(false)}>
|
||||
{t('dialog.cancel')}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation, Trans } from 'react-i18next'
|
||||
import type { AiBackend, AiConfig } from '@shared/types'
|
||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
const BACKENDS: { id: AiBackend; labelKey: string }[] = [
|
||||
{ id: 'lmstudio', labelKey: 'ai.settings.backend.lmstudio' },
|
||||
{ id: 'openai', labelKey: 'ai.settings.backend.openai' },
|
||||
{ id: 'anthropic', labelKey: 'ai.settings.backend.anthropic' }
|
||||
]
|
||||
|
||||
export function AiSettingsPage(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const showToast = useAppStore((s) => s.showToast)
|
||||
const settings = useSettingsStore()
|
||||
const aiConfig = settings.aiConfig
|
||||
const [testing, setTesting] = useState(false)
|
||||
|
||||
const patchAiConfig = (patch: Partial<AiConfig>): void => {
|
||||
void settings.update({ aiConfig: { ...aiConfig, ...patch } })
|
||||
}
|
||||
|
||||
const handleTest = async (): Promise<void> => {
|
||||
setTesting(true)
|
||||
try {
|
||||
await ipcCall(() => window.electronAPI.ai.testConnection(aiConfig))
|
||||
showToast(t('ai.settings.testSuccess'))
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const cloudBackend = aiConfig.backend !== 'lmstudio'
|
||||
|
||||
return (
|
||||
<div data-testid="ai-settings-page">
|
||||
<div className="setting-item">
|
||||
<span>{t('ai.settings.backend')}</span>
|
||||
<select
|
||||
data-testid="ai-settings-backend"
|
||||
className="form-control"
|
||||
value={aiConfig.backend}
|
||||
onChange={(e) => patchAiConfig({ backend: e.target.value as AiBackend })}
|
||||
>
|
||||
{BACKENDS.map((b) => (
|
||||
<option key={b.id} value={b.id}>
|
||||
{t(b.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="setting-item">
|
||||
<span>{t('ai.settings.baseUrl')}</span>
|
||||
<input
|
||||
data-testid="ai-settings-base-url"
|
||||
className="form-control"
|
||||
value={aiConfig.baseUrl}
|
||||
onChange={(e) => patchAiConfig({ baseUrl: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="setting-item">
|
||||
<span>{t('ai.settings.model')}</span>
|
||||
<input
|
||||
data-testid="ai-settings-model"
|
||||
className="form-control"
|
||||
value={aiConfig.model}
|
||||
onChange={(e) => patchAiConfig({ model: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{cloudBackend && (
|
||||
<div className="setting-item">
|
||||
<span>{t('ai.settings.apiKey')}</span>
|
||||
<input
|
||||
data-testid="ai-settings-api-key"
|
||||
type="password"
|
||||
className="form-control"
|
||||
value={aiConfig.apiKey ?? ''}
|
||||
onChange={(e) => patchAiConfig({ apiKey: e.target.value })}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="setting-item">
|
||||
<span>{t('ai.settings.timeout')}</span>
|
||||
<input
|
||||
data-testid="ai-settings-timeout"
|
||||
type="number"
|
||||
min={5}
|
||||
max={600}
|
||||
className="form-control form-control--narrow"
|
||||
value={Math.round(aiConfig.timeoutMs / 1000)}
|
||||
onChange={(e) => {
|
||||
const seconds = Number(e.target.value)
|
||||
if (!Number.isFinite(seconds)) return
|
||||
patchAiConfig({ timeoutMs: Math.max(5, seconds) * 1000 })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{cloudBackend && (
|
||||
<p className="ai-license-notice" data-testid="ai-settings-license">
|
||||
<Trans
|
||||
i18nKey="ai.settings.licenseNotice"
|
||||
components={{
|
||||
openai: (
|
||||
<a
|
||||
href="https://openai.com/policies/terms-of-use"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
/>
|
||||
),
|
||||
anthropic: (
|
||||
<a href="https://www.anthropic.com/legal/terms" target="_blank" rel="noreferrer" />
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="setting-item">
|
||||
<span />
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-testid="ai-settings-test"
|
||||
disabled={testing}
|
||||
onClick={() => void handleTest()}
|
||||
>
|
||||
{testing ? t('ai.settings.testing') : t('ai.settings.testConnection')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import type { ThemeId, Language } from '@shared/types'
|
||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
import { ShortcutEditor } from '@renderer/components/settings/ShortcutEditor'
|
||||
import { AiSettingsPage } from '@renderer/components/settings/AiSettingsPage'
|
||||
|
||||
const THEMES: { id: ThemeId; key: string }[] = [
|
||||
{ id: 'default', key: 'theme.default' },
|
||||
@@ -17,7 +18,7 @@ const THEMES: { id: ThemeId; key: string }[] = [
|
||||
export function SettingsPage(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const settings = useSettingsStore()
|
||||
const [section, setSection] = useState<'general' | 'shortcuts' | 'about'>('general')
|
||||
const [section, setSection] = useState<'general' | 'ai' | 'shortcuts' | 'about'>('general')
|
||||
|
||||
return (
|
||||
<div id="settings-page">
|
||||
@@ -30,6 +31,15 @@ export function SettingsPage(): React.JSX.Element {
|
||||
>
|
||||
{t('settings.general')}
|
||||
</div>
|
||||
<div
|
||||
className={`settings-nav-item ${section === 'ai' ? 'active' : ''}`}
|
||||
onClick={() => setSection('ai')}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-testid="settings-nav-ai"
|
||||
>
|
||||
{t('settings.ai')}
|
||||
</div>
|
||||
<div
|
||||
className={`settings-nav-item ${section === 'shortcuts' ? 'active' : ''}`}
|
||||
onClick={() => setSection('shortcuts')}
|
||||
@@ -89,10 +99,11 @@ export function SettingsPage(): React.JSX.Element {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{section === 'ai' && <AiSettingsPage />}
|
||||
{section === 'shortcuts' && <ShortcutEditor />}
|
||||
{section === 'about' && (
|
||||
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
||||
{t('app.name')} v0.1.0
|
||||
{t('app.name')} v0.3.0
|
||||
<br />
|
||||
{t('app.tagline')}
|
||||
</p>
|
||||
|
||||
@@ -10,15 +10,29 @@ export const SettingMention = Mark.create({
|
||||
}
|
||||
},
|
||||
parseHTML() {
|
||||
return [{ tag: 'span[data-setting-mention]' }]
|
||||
return [
|
||||
{
|
||||
tag: 'span[data-setting-mention]',
|
||||
getAttrs: (el) => {
|
||||
if (!(el instanceof HTMLElement)) return false
|
||||
return {
|
||||
id: el.getAttribute('data-id'),
|
||||
label: el.getAttribute('data-label') ?? el.textContent?.replace(/^@/, '') ?? ''
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const label = HTMLAttributes.label ?? ''
|
||||
const id = HTMLAttributes.id ?? ''
|
||||
return [
|
||||
'span',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
'data-setting-mention': 'true',
|
||||
class: 'setting-mention',
|
||||
'data-id': id,
|
||||
'data-label': label,
|
||||
'data-testid': 'setting-mention'
|
||||
}),
|
||||
`@${label}`
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
import type { ContextPreviewItem } from '@shared/types'
|
||||
|
||||
export function formatContextSummary(preview: ContextPreviewItem[], t: TFunction): string {
|
||||
if (preview.length === 0) return t('ai.contextEmpty')
|
||||
|
||||
const counts = { chapter: 0, outline: 0, setting: 0, inspiration: 0 }
|
||||
for (const item of preview) counts[item.kind]++
|
||||
|
||||
return t('ai.contextSummary', {
|
||||
chapters: counts.chapter,
|
||||
outlines: counts.outline,
|
||||
settings: counts.setting,
|
||||
inspirations: counts.inspiration
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { TFunction } from 'i18next'
|
||||
|
||||
const SLASH_KEYS: Record<string, string> = {
|
||||
'/续写': 'ai.slash.continue',
|
||||
'/扩写': 'ai.slash.expand',
|
||||
'/润色': 'ai.slash.polish',
|
||||
'/总结': 'ai.slash.summarize',
|
||||
'/纠错': 'ai.slash.proofread'
|
||||
}
|
||||
|
||||
export function resolveSlashCommand(input: string, t: TFunction): string | null {
|
||||
const token = input.trim().split(/\s+/)[0]
|
||||
const key = SLASH_KEYS[token]
|
||||
if (!key) return null
|
||||
return t(key)
|
||||
}
|
||||
|
||||
export function applySlashCommand(input: string, t: TFunction): { display: string; prompt: string } {
|
||||
const display = input.trim()
|
||||
const resolved = resolveSlashCommand(display, t)
|
||||
return { display, prompt: resolved ?? display }
|
||||
}
|
||||
@@ -3,8 +3,16 @@ export interface LandmarkInsert {
|
||||
landmarkType?: string
|
||||
}
|
||||
|
||||
type EditorInsertContext = {
|
||||
bookId: string
|
||||
chapterId: string
|
||||
getHtml: () => string
|
||||
}
|
||||
|
||||
let insertLandmarkFn: ((payload: LandmarkInsert) => void) | null = null
|
||||
let highlightTokenFn: ((token: string) => void) | null = null
|
||||
let editorInsertContext: EditorInsertContext | null = null
|
||||
let insertTextFn: ((text: string) => void) | null = null
|
||||
|
||||
export function registerInsertLandmark(fn: (payload: LandmarkInsert) => void): void {
|
||||
insertLandmarkFn = fn
|
||||
@@ -14,6 +22,14 @@ export function registerHighlightToken(fn: (token: string) => void): void {
|
||||
highlightTokenFn = fn
|
||||
}
|
||||
|
||||
export function registerEditorInsert(
|
||||
context: EditorInsertContext | null,
|
||||
insert: (text: string) => void
|
||||
): void {
|
||||
editorInsertContext = context
|
||||
insertTextFn = insert
|
||||
}
|
||||
|
||||
export function insertLandmarkInEditor(payload: LandmarkInsert): void {
|
||||
insertLandmarkFn?.(payload)
|
||||
}
|
||||
@@ -21,3 +37,21 @@ export function insertLandmarkInEditor(payload: LandmarkInsert): void {
|
||||
export function highlightTokenInEditor(token: string): void {
|
||||
highlightTokenFn?.(token)
|
||||
}
|
||||
|
||||
export async function insertToEditor(
|
||||
text: string,
|
||||
snapshotLabel: string,
|
||||
createSnapshot: (
|
||||
bookId: string,
|
||||
chapterId: string,
|
||||
html: string,
|
||||
label: string
|
||||
) => Promise<void>
|
||||
): Promise<void> {
|
||||
if (!editorInsertContext || !insertTextFn) {
|
||||
throw new Error('No chapter editor active')
|
||||
}
|
||||
const { bookId, chapterId, getHtml } = editorInsertContext
|
||||
await createSnapshot(bookId, chapterId, getHtml(), snapshotLabel)
|
||||
insertTextFn(text)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { create } from 'zustand'
|
||||
import type { AiContextBinding, AiMessage, AiSession, AiStreamChunkEvent, AiWritingMode, ContextPreviewItem } from '@shared/types'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { formatContextSummary } from '@renderer/lib/ai-context-summary'
|
||||
import i18n from '@renderer/i18n'
|
||||
|
||||
interface AiStore {
|
||||
sessions: AiSession[]
|
||||
activeSessionId: string | null
|
||||
messages: AiMessage[]
|
||||
streamingMessageId: string | null
|
||||
streamingBuffer: string
|
||||
sending: boolean
|
||||
online: boolean
|
||||
writingMode: AiWritingMode
|
||||
contextPreview: ContextPreviewItem[]
|
||||
contextSummary: string
|
||||
listenersAttached: boolean
|
||||
mount: () => void
|
||||
unmount: () => void
|
||||
loadSessions: (bookId: string) => Promise<void>
|
||||
selectSession: (sessionId: string) => Promise<void>
|
||||
createSession: (bookId: string) => Promise<void>
|
||||
sendMessage: (content: string, prompt?: string) => Promise<void>
|
||||
refreshContextPreview: () => Promise<void>
|
||||
appendStreamChunk: (payload: AiStreamChunkEvent) => void
|
||||
setWritingMode: (mode: AiWritingMode) => void
|
||||
}
|
||||
|
||||
let unsubStream: (() => void) | null = null
|
||||
let unsubNetwork: (() => void) | null = null
|
||||
|
||||
export const useAiStore = create<AiStore>((set, get) => ({
|
||||
sessions: [],
|
||||
activeSessionId: null,
|
||||
messages: [],
|
||||
streamingMessageId: null,
|
||||
streamingBuffer: '',
|
||||
sending: false,
|
||||
online: true,
|
||||
writingMode: 'chat',
|
||||
contextPreview: [],
|
||||
contextSummary: i18n.t('ai.contextEmpty'),
|
||||
listenersAttached: false,
|
||||
|
||||
mount: () => {
|
||||
if (get().listenersAttached) return
|
||||
unsubStream = window.electronAPI.onAiStreamChunk((payload) => {
|
||||
get().appendStreamChunk(payload)
|
||||
})
|
||||
unsubNetwork = window.electronAPI.onAiNetworkStatus((payload) => {
|
||||
set({ online: payload.online })
|
||||
})
|
||||
set({ listenersAttached: true })
|
||||
},
|
||||
|
||||
unmount: () => {
|
||||
unsubStream?.()
|
||||
unsubNetwork?.()
|
||||
unsubStream = null
|
||||
unsubNetwork = null
|
||||
set({ listenersAttached: false })
|
||||
},
|
||||
|
||||
loadSessions: async (bookId) => {
|
||||
const sessions = await ipcCall(() => window.electronAPI.ai.sessionList(bookId))
|
||||
const activeSessionId = get().activeSessionId
|
||||
const stillActive = activeSessionId && sessions.some((s) => s.id === activeSessionId)
|
||||
set({ sessions })
|
||||
if (stillActive && activeSessionId) {
|
||||
await get().selectSession(activeSessionId)
|
||||
} else if (sessions.length > 0) {
|
||||
await get().selectSession(sessions[0].id)
|
||||
} else {
|
||||
set({
|
||||
activeSessionId: null,
|
||||
messages: [],
|
||||
contextPreview: [],
|
||||
contextSummary: i18n.t('ai.contextEmpty')
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
selectSession: async (sessionId) => {
|
||||
const bookId = useBookStore.getState().currentBookId
|
||||
if (!bookId) return
|
||||
const messages = await ipcCall(() => window.electronAPI.ai.messageList(bookId, sessionId))
|
||||
set({
|
||||
activeSessionId: sessionId,
|
||||
messages,
|
||||
streamingMessageId: null,
|
||||
streamingBuffer: ''
|
||||
})
|
||||
await get().refreshContextPreview()
|
||||
},
|
||||
|
||||
createSession: async (bookId) => {
|
||||
const session = await ipcCall(() => window.electronAPI.ai.sessionCreate(bookId))
|
||||
set((s) => ({
|
||||
sessions: [session, ...s.sessions],
|
||||
activeSessionId: session.id,
|
||||
messages: [],
|
||||
streamingMessageId: null,
|
||||
streamingBuffer: '',
|
||||
contextPreview: [],
|
||||
contextSummary: i18n.t('ai.contextEmpty')
|
||||
}))
|
||||
},
|
||||
|
||||
refreshContextPreview: async () => {
|
||||
const bookId = useBookStore.getState().currentBookId
|
||||
const sessionId = get().activeSessionId
|
||||
if (!bookId || !sessionId) {
|
||||
set({ contextPreview: [], contextSummary: i18n.t('ai.contextEmpty') })
|
||||
return
|
||||
}
|
||||
const session = get().sessions.find((s) => s.id === sessionId)
|
||||
if (!session) return
|
||||
const result = await ipcCall(() =>
|
||||
window.electronAPI.ai.buildContext(bookId, session.context)
|
||||
)
|
||||
set({
|
||||
contextPreview: result.preview,
|
||||
contextSummary: formatContextSummary(result.preview, i18n.t)
|
||||
})
|
||||
},
|
||||
|
||||
sendMessage: async (content, prompt) => {
|
||||
const trimmed = content.trim()
|
||||
if (!trimmed || get().sending) return
|
||||
|
||||
const bookId = useBookStore.getState().currentBookId
|
||||
if (!bookId) return
|
||||
|
||||
let sessionId = get().activeSessionId
|
||||
if (!sessionId) {
|
||||
await get().createSession(bookId)
|
||||
sessionId = get().activeSessionId
|
||||
}
|
||||
if (!sessionId) return
|
||||
|
||||
const optimisticUser: AiMessage = {
|
||||
id: `optimistic-${Date.now()}`,
|
||||
sessionId,
|
||||
role: 'user',
|
||||
content: trimmed,
|
||||
createdAt: new Date().toISOString()
|
||||
}
|
||||
set({
|
||||
sending: true,
|
||||
streamingMessageId: null,
|
||||
streamingBuffer: '',
|
||||
messages: [...get().messages, optimisticUser]
|
||||
})
|
||||
|
||||
try {
|
||||
await ipcCall(() => window.electronAPI.ai.chat(bookId, sessionId, trimmed, prompt))
|
||||
const messages = await ipcCall(() => window.electronAPI.ai.messageList(bookId, sessionId))
|
||||
const sessions = await ipcCall(() => window.electronAPI.ai.sessionList(bookId))
|
||||
set({ messages, sessions, streamingMessageId: null, streamingBuffer: '' })
|
||||
} finally {
|
||||
set({ sending: false })
|
||||
}
|
||||
},
|
||||
|
||||
appendStreamChunk: (payload) => {
|
||||
const { messageId, delta, done } = payload
|
||||
if (done) return
|
||||
set((s) => ({
|
||||
streamingMessageId: messageId,
|
||||
streamingBuffer:
|
||||
s.streamingMessageId === messageId ? s.streamingBuffer + delta : s.streamingBuffer + delta
|
||||
}))
|
||||
},
|
||||
|
||||
setWritingMode: (mode) => set({ writingMode: mode })
|
||||
}))
|
||||
@@ -8,12 +8,14 @@ interface AppState {
|
||||
rightPanel: 'ai' | 'knowledge' | 'wordfreq' | 'book-settings'
|
||||
versionModalOpen: boolean
|
||||
landmarksModalOpen: boolean
|
||||
inspirationModalOpen: boolean
|
||||
toast: string | null
|
||||
setView: (view: AppView) => void
|
||||
setSidebarPanel: (panel: AppState['sidebarPanel']) => void
|
||||
setRightPanel: (panel: AppState['rightPanel']) => void
|
||||
setVersionModalOpen: (open: boolean) => void
|
||||
setLandmarksModalOpen: (open: boolean) => void
|
||||
setInspirationModalOpen: (open: boolean) => void
|
||||
showToast: (message: string) => void
|
||||
clearToast: () => void
|
||||
}
|
||||
@@ -24,12 +26,14 @@ export const useAppStore = create<AppState>((set) => ({
|
||||
rightPanel: 'ai',
|
||||
versionModalOpen: false,
|
||||
landmarksModalOpen: false,
|
||||
inspirationModalOpen: false,
|
||||
toast: null,
|
||||
setView: (view) => set({ view }),
|
||||
setSidebarPanel: (sidebarPanel) => set({ sidebarPanel }),
|
||||
setRightPanel: (rightPanel) => set({ rightPanel }),
|
||||
setVersionModalOpen: (versionModalOpen) => set({ versionModalOpen }),
|
||||
setLandmarksModalOpen: (landmarksModalOpen) => set({ landmarksModalOpen }),
|
||||
setInspirationModalOpen: (inspirationModalOpen) => set({ inspirationModalOpen }),
|
||||
showToast: (toast) => {
|
||||
set({ toast })
|
||||
setTimeout(() => set({ toast: null }), 2500)
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { EditTarget } from '@shared/types'
|
||||
interface EditState {
|
||||
target: EditTarget | null
|
||||
switchTarget: (next: EditTarget) => Promise<void>
|
||||
reloadTarget: () => void
|
||||
setTarget: (next: EditTarget | null) => void
|
||||
}
|
||||
|
||||
@@ -23,5 +24,11 @@ export const useEditStore = create<EditState>((set) => ({
|
||||
await flushEditSession()
|
||||
set({ target: next })
|
||||
},
|
||||
reloadTarget: () => {
|
||||
const next = useEditStore.getState().target
|
||||
if (!next) return
|
||||
set({ target: null })
|
||||
queueMicrotask(() => set({ target: next }))
|
||||
},
|
||||
setTarget: (next) => set({ target: next })
|
||||
}))
|
||||
|
||||
@@ -4,10 +4,12 @@ type ReferenceTab = 'setting' | 'outline' | 'inspiration'
|
||||
|
||||
interface ReferenceState {
|
||||
open: boolean
|
||||
pinned: boolean
|
||||
tab: ReferenceTab
|
||||
query: string
|
||||
pinnedIds: string[]
|
||||
setOpen: (open: boolean) => void
|
||||
setPinned: (pinned: boolean) => void
|
||||
setTab: (tab: ReferenceTab) => void
|
||||
setQuery: (query: string) => void
|
||||
togglePin: (id: string) => void
|
||||
@@ -15,10 +17,15 @@ interface ReferenceState {
|
||||
|
||||
export const useReferenceStore = create<ReferenceState>((set, get) => ({
|
||||
open: false,
|
||||
pinned: false,
|
||||
tab: 'setting',
|
||||
query: '',
|
||||
pinnedIds: [],
|
||||
setOpen: (open) => set({ open }),
|
||||
setOpen: (open) => {
|
||||
if (!open && get().pinned) return
|
||||
set({ open })
|
||||
},
|
||||
setPinned: (pinned) => set({ pinned }),
|
||||
setTab: (tab) => set({ tab }),
|
||||
setQuery: (query) => set({ query }),
|
||||
togglePin: (id) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from 'zustand'
|
||||
import type { ThemeId, Language, GlobalSettings, ActionId } from '@shared/types'
|
||||
import { DEFAULT_AI_CONFIG } from '@shared/types'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
import { applyTheme } from '@renderer/lib/theme'
|
||||
import i18n from '@renderer/i18n'
|
||||
@@ -17,6 +18,8 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
language: 'zh-CN',
|
||||
dailyWordGoal: 0,
|
||||
shortcuts: {} as Record<ActionId, string>,
|
||||
aiConfig: { ...DEFAULT_AI_CONFIG },
|
||||
namingIgnoreWords: [] as string[],
|
||||
loaded: false,
|
||||
load: async () => {
|
||||
const data = await ipcCall(() => window.electronAPI.settings.get())
|
||||
|
||||
@@ -354,6 +354,10 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.chapter-item.drag-over {
|
||||
box-shadow: inset 0 2px 0 var(--accent);
|
||||
}
|
||||
|
||||
.ch-badge {
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
@@ -500,6 +504,314 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-content.active {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-content.ai-panel-wrap {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ai-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ai-panel-empty {
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ai-session-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ai-session-bar select {
|
||||
flex: 1;
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ai-mode-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ai-mode-tab {
|
||||
flex: 1;
|
||||
padding: 5px 4px;
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.ai-mode-tab.active {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.context-preview {
|
||||
padding: 6px 10px;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-top: none;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.context-preview:hover {
|
||||
color: var(--accent-light);
|
||||
}
|
||||
|
||||
.context-editor-modal {
|
||||
max-width: 520px;
|
||||
width: 90vw;
|
||||
}
|
||||
|
||||
.context-editor-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.context-editor-tab {
|
||||
flex: 1;
|
||||
font-size: 11px;
|
||||
padding: 6px 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.context-editor-tab.active {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.context-editor-list {
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.context-editor-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 4px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.offline-banner {
|
||||
display: none;
|
||||
padding: 6px 12px;
|
||||
background: var(--orange, #f59e0b);
|
||||
color: #000;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.offline-banner.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-msg {
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
max-width: 92%;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chat-msg.user {
|
||||
align-self: flex-end;
|
||||
background: var(--accent-dark, var(--accent));
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.chat-msg.ai {
|
||||
align-self: flex-start;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.quick-cmds {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.quick-cmd {
|
||||
font-size: 10px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.quick-cmd:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.quick-cmd:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.chat-msg {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-insert-btn {
|
||||
margin-top: 6px;
|
||||
font-size: 10px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-insert-btn:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.knowledge-toolbar {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.knowledge-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.naming-modal-body {
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.naming-row {
|
||||
padding: 10px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.naming-terms {
|
||||
font-size: 13px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.naming-meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.naming-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.naming-empty {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
padding: 12px 4px;
|
||||
}
|
||||
|
||||
.chat-input-area {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-input-area input {
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chat-input-area button {
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-input-area button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.placeholder-box {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
@@ -556,6 +868,21 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ai-license-notice {
|
||||
margin: 4px 0 12px;
|
||||
padding: 10px 14px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
background: color-mix(in srgb, var(--accent) 8%, var(--bg-tertiary));
|
||||
border-radius: var(--radius-sm);
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
.ai-license-notice a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.shortcut-key {
|
||||
min-width: 140px;
|
||||
padding: 6px 12px;
|
||||
@@ -734,6 +1061,44 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ref-header-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ref-pin-btn.active {
|
||||
color: var(--accent-light);
|
||||
}
|
||||
|
||||
.inspiration-modal {
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.inspiration-capture {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.inspiration-textarea {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
padding: 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ref-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
@@ -862,6 +1227,31 @@
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.search-replace-panel {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.search-replace-panel summary {
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.search-replace-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.search-replace-previews {
|
||||
margin-top: 8px;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.sr-type {
|
||||
font-size: 10px;
|
||||
color: var(--accent-light);
|
||||
|
||||
Vendored
+38
@@ -7,6 +7,14 @@ import type {
|
||||
CreateBookParams,
|
||||
GlobalSettings,
|
||||
InspirationEntry,
|
||||
AiContextBinding,
|
||||
AiContextBuildResult,
|
||||
AiConfig,
|
||||
AiMessage,
|
||||
AiSession,
|
||||
NamingConflict,
|
||||
AiStreamChunkEvent,
|
||||
AiNetworkStatusEvent,
|
||||
IpcResult,
|
||||
LandmarkType,
|
||||
OutlineItem,
|
||||
@@ -55,6 +63,13 @@ export interface ElectronAPI {
|
||||
get: (bookId: string, chapterId: string) => Promise<IpcResult<Chapter>>
|
||||
update: (params: UpdateChapterParams) => Promise<IpcResult<Chapter>>
|
||||
delete: (bookId: string, chapterId: string) => Promise<IpcResult<void>>
|
||||
reorder: (bookId: string, volumeId: string, orderedIds: string[]) => Promise<IpcResult<Chapter[]>>
|
||||
moveToVolume: (
|
||||
bookId: string,
|
||||
chapterId: string,
|
||||
targetVolumeId: string,
|
||||
targetIndex: number
|
||||
) => Promise<IpcResult<Chapter>>
|
||||
}
|
||||
outline: {
|
||||
list: (bookId: string) => Promise<IpcResult<OutlineItem[]>>
|
||||
@@ -163,6 +178,27 @@ export interface ElectronAPI {
|
||||
getHistory: () => Promise<IpcResult<string[]>>
|
||||
saveHistory: (query: string) => Promise<IpcResult<string[]>>
|
||||
}
|
||||
ai: {
|
||||
sessionList: (bookId: string, includeArchived?: boolean) => Promise<IpcResult<AiSession[]>>
|
||||
sessionCreate: (bookId: string, title?: string, model?: string) => Promise<IpcResult<AiSession>>
|
||||
sessionUpdate: (
|
||||
bookId: string,
|
||||
sessionId: string,
|
||||
patch: Partial<{ title: string; model: string; archived: boolean; context: AiContextBinding }>
|
||||
) => Promise<IpcResult<AiSession>>
|
||||
sessionDelete: (bookId: string, sessionId: string) => Promise<IpcResult<void>>
|
||||
messageList: (bookId: string, sessionId: string) => Promise<IpcResult<AiMessage[]>>
|
||||
chat: (
|
||||
bookId: string,
|
||||
sessionId: string,
|
||||
content: string,
|
||||
prompt?: string
|
||||
) => Promise<IpcResult<{ messageId: string }>>
|
||||
abort: (messageId: string) => Promise<IpcResult<void>>
|
||||
buildContext: (bookId: string, binding: AiContextBinding) => Promise<IpcResult<AiContextBuildResult>>
|
||||
testConnection: (config?: AiConfig) => Promise<IpcResult<{ ok: true }>>
|
||||
namingCheck: (bookId: string) => Promise<IpcResult<NamingConflict[]>>
|
||||
}
|
||||
wordfreq: {
|
||||
analyze: (
|
||||
bookId: string,
|
||||
@@ -174,6 +210,8 @@ export interface ElectronAPI {
|
||||
register: (action: ActionId, accelerator: string) => Promise<IpcResult<void>>
|
||||
}
|
||||
onShortcutTriggered: (callback: (action: ActionId) => void) => () => void
|
||||
onAiStreamChunk: (callback: (payload: AiStreamChunkEvent) => void) => () => void
|
||||
onAiNetworkStatus: (callback: (payload: AiNetworkStatusEvent) => void) => () => void
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -13,6 +13,8 @@ export const IPC = {
|
||||
CHAPTER_GET: 'chapter:get',
|
||||
CHAPTER_UPDATE: 'chapter:update',
|
||||
CHAPTER_DELETE: 'chapter:delete',
|
||||
CHAPTER_REORDER: 'chapter:reorder',
|
||||
CHAPTER_MOVE_TO_VOLUME: 'chapter:moveToVolume',
|
||||
SHORTCUT_GET_ALL: 'shortcut:getAll',
|
||||
SHORTCUT_REGISTER: 'shortcut:register',
|
||||
SHORTCUT_TRIGGERED: 'shortcut:triggered',
|
||||
@@ -49,5 +51,17 @@ export const IPC = {
|
||||
SEARCH_REPLACE: 'search:replace',
|
||||
SEARCH_GET_HISTORY: 'search:getHistory',
|
||||
SEARCH_SAVE_HISTORY: 'search:saveHistory',
|
||||
WORDFREQ_ANALYZE: 'wordfreq:analyze'
|
||||
WORDFREQ_ANALYZE: 'wordfreq:analyze',
|
||||
AI_SESSION_LIST: 'ai:sessionList',
|
||||
AI_SESSION_CREATE: 'ai:sessionCreate',
|
||||
AI_SESSION_UPDATE: 'ai:sessionUpdate',
|
||||
AI_SESSION_DELETE: 'ai:sessionDelete',
|
||||
AI_MESSAGE_LIST: 'ai:messageList',
|
||||
AI_CHAT: 'ai:chat',
|
||||
AI_ABORT: 'ai:abort',
|
||||
AI_BUILD_CONTEXT: 'ai:buildContext',
|
||||
AI_TEST_CONNECTION: 'ai:testConnection',
|
||||
AI_NAMING_CHECK: 'ai:namingCheck',
|
||||
AI_STREAM_CHUNK: 'ai:streamChunk',
|
||||
AI_NETWORK_STATUS: 'ai:networkStatus'
|
||||
} as const
|
||||
|
||||
@@ -60,6 +60,8 @@ export interface GlobalSettings {
|
||||
snapshotMaxAuto: number
|
||||
snapshotMaxPersist: number
|
||||
searchHistory: string[]
|
||||
aiConfig: AiConfig
|
||||
namingIgnoreWords: string[]
|
||||
}
|
||||
|
||||
export interface BookMeta {
|
||||
@@ -192,3 +194,82 @@ export interface UpdateChapterParams {
|
||||
status?: ChapterStatus
|
||||
cursorOffset?: number
|
||||
}
|
||||
|
||||
export type AiBackend = 'lmstudio' | 'openai' | 'anthropic'
|
||||
export type AiMessageRole = 'user' | 'assistant' | 'system'
|
||||
export type AiWritingMode = 'chat' | 'interactive' | 'auto' | 'wizard'
|
||||
|
||||
export interface AiConfig {
|
||||
backend: AiBackend
|
||||
baseUrl: string
|
||||
model: string
|
||||
apiKey?: string
|
||||
timeoutMs: number
|
||||
maxTokens: number
|
||||
}
|
||||
|
||||
export const DEFAULT_AI_CONFIG: AiConfig = {
|
||||
backend: 'lmstudio',
|
||||
baseUrl: 'http://127.0.0.1:1234',
|
||||
model: 'gemma-4-e4b-it',
|
||||
timeoutMs: 60_000,
|
||||
maxTokens: 2048
|
||||
}
|
||||
|
||||
export interface AiContextBinding {
|
||||
chapterIds: string[]
|
||||
outlineIds: string[]
|
||||
settingIds: string[]
|
||||
inspirationIds: string[]
|
||||
}
|
||||
|
||||
export interface AiSession {
|
||||
id: string
|
||||
title: string
|
||||
model: string
|
||||
archived: boolean
|
||||
context: AiContextBinding
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface AiMessage {
|
||||
id: string
|
||||
sessionId: string
|
||||
role: AiMessageRole
|
||||
content: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface NamingConflict {
|
||||
termA: string
|
||||
termB: string
|
||||
confidence: number
|
||||
reason: string
|
||||
suggestedCanonical?: string
|
||||
}
|
||||
|
||||
export interface AiStreamChunkEvent {
|
||||
messageId: string
|
||||
delta: string
|
||||
done: boolean
|
||||
}
|
||||
|
||||
export interface AiNetworkStatusEvent {
|
||||
online: boolean
|
||||
}
|
||||
|
||||
export type ContextPreviewKind = 'chapter' | 'outline' | 'setting' | 'inspiration'
|
||||
|
||||
export interface ContextPreviewItem {
|
||||
kind: ContextPreviewKind
|
||||
id: string
|
||||
title: string
|
||||
excerpt: string
|
||||
charCount: number
|
||||
}
|
||||
|
||||
export interface AiContextBuildResult {
|
||||
systemPrompt: string
|
||||
preview: ContextPreviewItem[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user