feat: ship v0.7.0 with AI knowledge context integration
Add semi-automatic knowledge suggestions and user-confirmed injection across chat, interactive, auto, and wizard modes; fix writing flows to persist full systemPrompt in contextJson. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,7 +6,8 @@ const EMPTY_CONTEXT: AiContextBinding = {
|
||||
chapterIds: [],
|
||||
outlineIds: [],
|
||||
settingIds: [],
|
||||
inspirationIds: []
|
||||
inspirationIds: [],
|
||||
knowledgeIds: []
|
||||
}
|
||||
|
||||
function parseContext(json: string): AiContextBinding {
|
||||
@@ -16,7 +17,8 @@ function parseContext(json: string): AiContextBinding {
|
||||
chapterIds: parsed.chapterIds ?? [],
|
||||
outlineIds: parsed.outlineIds ?? [],
|
||||
settingIds: parsed.settingIds ?? [],
|
||||
inspirationIds: parsed.inspirationIds ?? []
|
||||
inspirationIds: parsed.inspirationIds ?? [],
|
||||
knowledgeIds: parsed.knowledgeIds ?? []
|
||||
}
|
||||
} catch {
|
||||
return { ...EMPTY_CONTEXT }
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { BrowserWindow, ipcMain } from 'electron'
|
||||
import { IPC } from '../../../shared/ipc-channels'
|
||||
import type { AutoWritingConfig } from '../../../shared/types'
|
||||
import type { AiContextBinding, AutoWritingConfig } from '../../../shared/types'
|
||||
import { BookRegistryService } from '../../services/book-registry'
|
||||
import { GlobalSettingsService } from '../../services/global-settings'
|
||||
import { AiClientService } from '../../services/ai-client.service'
|
||||
import { buildFlowContextPayload, readFlowSystemPrompt } from '../../services/flow-context.util'
|
||||
import { checkInteractiveGate } from '../../services/interactive-gate.service'
|
||||
import { AutoWritingService } from '../../services/auto-writing.service'
|
||||
import { InteractiveRepository } from '../../db/repositories/interactive.repo'
|
||||
@@ -26,12 +27,7 @@ function readContextText(registry: BookRegistryService, bookId: string, flowId:
|
||||
.getDb(bookId)
|
||||
.prepare('SELECT context_json FROM interactive_flows WHERE id = ?')
|
||||
.get(flowId) as { context_json: string } | undefined
|
||||
try {
|
||||
const parsed = JSON.parse(row?.context_json ?? '{}') as { contextSummary?: string }
|
||||
return parsed.contextSummary ?? '(无上下文摘要)'
|
||||
} catch {
|
||||
return '(无上下文摘要)'
|
||||
}
|
||||
return readFlowSystemPrompt(row?.context_json ?? '{}')
|
||||
}
|
||||
|
||||
export function registerAutoHandlers(
|
||||
@@ -76,6 +72,24 @@ export function registerAutoHandlers(
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.AUTO_CONFIRM_CONTEXT,
|
||||
(
|
||||
_e,
|
||||
{ bookId, flowId, binding }: { bookId: string; flowId: string; binding: AiContextBinding }
|
||||
) =>
|
||||
wrap(() => {
|
||||
const svc = serviceFor(registry, bookId, aiClient)
|
||||
const payload = buildFlowContextPayload(
|
||||
binding,
|
||||
registry,
|
||||
bookId,
|
||||
settings.get().penName
|
||||
)
|
||||
return svc.enrichFlow(svc.confirmContext(flowId, payload))
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.AUTO_PLAN_SCENES,
|
||||
(_e, { bookId, flowId, contextSummary }: { bookId: string; flowId: string; contextSummary?: string }) =>
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { AiContextBinding, PlotSelection } 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 { buildFlowContextPayload, readFlowSystemPrompt } from '../../services/flow-context.util'
|
||||
import { checkInteractiveGate } from '../../services/interactive-gate.service'
|
||||
import { InteractiveWritingService } from '../../services/interactive-writing.service'
|
||||
import { wrap } from '../result'
|
||||
@@ -56,13 +56,13 @@ export function registerInteractiveHandlers(
|
||||
}: { bookId: string; flowId: string; binding: AiContextBinding }
|
||||
) =>
|
||||
wrap(() => {
|
||||
const built = aiContextBuilder.build(binding, registry, bookId, settings.get().penName)
|
||||
const summary = built.preview.map((p) => p.title).join('、') || '(空)'
|
||||
const flow = serviceFor(registry, bookId, aiClient).confirmContext(
|
||||
flowId,
|
||||
const payload = buildFlowContextPayload(
|
||||
binding,
|
||||
summary
|
||||
registry,
|
||||
bookId,
|
||||
settings.get().penName
|
||||
)
|
||||
const flow = serviceFor(registry, bookId, aiClient).confirmContext(flowId, payload)
|
||||
return serviceFor(registry, bookId, aiClient).enrichFlow(flow)
|
||||
})
|
||||
)
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { IPC } from '../../../shared/ipc-channels'
|
||||
import type { CreateKnowledgeInput, KnowledgeStatus, KnowledgeType } from '../../../shared/types'
|
||||
import type { CreateKnowledgeInput, KnowledgeStatus, KnowledgeType, KnowledgeSuggestOptions } from '../../../shared/types'
|
||||
import { BookRegistryService } from '../../services/book-registry'
|
||||
import { GlobalSettingsService } from '../../services/global-settings'
|
||||
import { KnowledgeRetrievalService } from '../../services/knowledge-retrieval.service'
|
||||
import { KnowledgeService } from '../../services/knowledge.service'
|
||||
import { wrap } from '../result'
|
||||
|
||||
export function registerKnowledgeHandlers(registry: BookRegistryService): void {
|
||||
export function registerKnowledgeHandlers(
|
||||
registry: BookRegistryService,
|
||||
settings: GlobalSettingsService
|
||||
): void {
|
||||
ipcMain.handle(
|
||||
IPC.KNOWLEDGE_LIST,
|
||||
(
|
||||
@@ -65,4 +70,16 @@ export function registerKnowledgeHandlers(registry: BookRegistryService): void {
|
||||
ipcMain.handle(IPC.KNOWLEDGE_STATS, (_e, { bookId }: { bookId: string }) =>
|
||||
wrap(() => new KnowledgeService(registry.getDb(bookId)).getForeshadowStats())
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.KNOWLEDGE_SUGGEST_CONTEXT,
|
||||
(_e, { bookId, opts }: { bookId: string; opts?: KnowledgeSuggestOptions }) =>
|
||||
wrap(() => {
|
||||
const topN = opts?.topN ?? settings.get().knowledgeSuggestTopN ?? 8
|
||||
return new KnowledgeRetrievalService(registry.getDb(bookId)).suggest({
|
||||
...opts,
|
||||
topN
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { AiContextBinding, AutoRhythm, AutoWritingConfig, WizardWritingConf
|
||||
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 { buildFlowContextPayload, readFlowSystemPrompt } from '../../services/flow-context.util'
|
||||
import { WizardWritingService } from '../../services/wizard-writing.service'
|
||||
import { InteractiveRepository } from '../../db/repositories/interactive.repo'
|
||||
import { wrap } from '../result'
|
||||
@@ -20,12 +20,7 @@ function readContextText(registry: BookRegistryService, bookId: string, flowId:
|
||||
.getDb(bookId)
|
||||
.prepare('SELECT context_json FROM interactive_flows WHERE id = ?')
|
||||
.get(flowId) as { context_json: string } | undefined
|
||||
try {
|
||||
const parsed = JSON.parse(row?.context_json ?? '{}') as { contextSummary?: string }
|
||||
return parsed.contextSummary ?? '(无上下文摘要)'
|
||||
} catch {
|
||||
return '(无上下文摘要)'
|
||||
}
|
||||
return readFlowSystemPrompt(row?.context_json ?? '{}')
|
||||
}
|
||||
|
||||
export function registerWizardHandlers(
|
||||
@@ -98,10 +93,14 @@ export function registerWizardHandlers(
|
||||
{ bookId, flowId, binding }: { bookId: string; flowId: string; binding: AiContextBinding }
|
||||
) =>
|
||||
wrap(() => {
|
||||
const built = aiContextBuilder.build(binding, registry, bookId, settings.get().penName)
|
||||
const summary = built.preview.map((p) => p.title).join('、') || '(空)'
|
||||
const payload = buildFlowContextPayload(
|
||||
binding,
|
||||
registry,
|
||||
bookId,
|
||||
settings.get().penName
|
||||
)
|
||||
const svc = serviceFor(registry, bookId, aiClient)
|
||||
return svc.enrichFlow(svc.confirmContext(flowId, binding, summary))
|
||||
return svc.enrichFlow(svc.confirmContext(flowId, payload))
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
|
||||
registerInteractiveHandlers(books, settings, aiClient)
|
||||
registerAutoHandlers(books, settings, aiClient)
|
||||
registerWizardHandlers(books, settings, aiClient)
|
||||
registerKnowledgeHandlers(books)
|
||||
registerKnowledgeHandlers(books, settings)
|
||||
registerCockpitHandlers(books, settings)
|
||||
registerBridgeHandlers(books, aiClient)
|
||||
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { stripHtml } from './word-count'
|
||||
import type { AiContextBinding, AiContextBuildResult, ContextPreviewItem } from '../../shared/types'
|
||||
import type {
|
||||
AiContextBinding,
|
||||
AiContextBuildResult,
|
||||
ContextPreviewItem,
|
||||
KnowledgeType
|
||||
} from '../../shared/types'
|
||||
import type { BookRegistryService } from './book-registry'
|
||||
import { KnowledgeRepository } from '../db/repositories/knowledge.repo'
|
||||
|
||||
const CHAPTER_CHAR_LIMIT = 2000
|
||||
const OTHER_CHAR_LIMIT = 500
|
||||
const KNOWLEDGE_CHAR_LIMIT = 500
|
||||
const KNOWLEDGE_TOTAL_LIMIT = 4000
|
||||
const TOTAL_CHAR_LIMIT = 16 * 1024
|
||||
|
||||
interface BlockCandidate {
|
||||
sortKey: number
|
||||
text: string
|
||||
preview: ContextPreviewItem
|
||||
isKnowledge?: boolean
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
@@ -17,12 +26,33 @@ function truncate(text: string, max: number): string {
|
||||
return `${text.slice(0, max)}…`
|
||||
}
|
||||
|
||||
function knowledgeTypeLabel(type: KnowledgeType): string {
|
||||
const map: Record<KnowledgeType, string> = {
|
||||
character: '角色',
|
||||
foreshadow: '伏笔',
|
||||
location: '地点',
|
||||
relationship: '关系',
|
||||
fact: '事实'
|
||||
}
|
||||
return map[type]
|
||||
}
|
||||
|
||||
function formatBlock(kind: ContextPreviewItem['kind'], title: string, body: string): string {
|
||||
const label =
|
||||
kind === 'chapter' ? '章节' : kind === 'outline' ? '大纲' : kind === 'setting' ? '设定' : '灵感'
|
||||
kind === 'chapter'
|
||||
? '章节'
|
||||
: kind === 'outline'
|
||||
? '大纲'
|
||||
: kind === 'setting'
|
||||
? '设定'
|
||||
: '灵感'
|
||||
return `【${label}】${title}\n${body}`
|
||||
}
|
||||
|
||||
function formatKnowledgeBlock(type: KnowledgeType, title: string, body: string): string {
|
||||
return `【知识·${knowledgeTypeLabel(type)}】${title}\n${body}`
|
||||
}
|
||||
|
||||
export class AiContextBuilderService {
|
||||
build(
|
||||
binding: AiContextBinding,
|
||||
@@ -34,6 +64,7 @@ export class AiContextBuilderService {
|
||||
if (!meta) throw new Error('Book not found')
|
||||
|
||||
const candidates: BlockCandidate[] = []
|
||||
const knowledgeIds = binding.knowledgeIds ?? []
|
||||
|
||||
for (const id of binding.chapterIds) {
|
||||
const chapter = registry.getChapterRepo(bookId).get(id)
|
||||
@@ -104,6 +135,38 @@ export class AiContextBuilderService {
|
||||
})
|
||||
}
|
||||
|
||||
const knowledgeRepo = new KnowledgeRepository(registry.getDb(bookId))
|
||||
const knowledgeCandidates: BlockCandidate[] = []
|
||||
for (const id of knowledgeIds) {
|
||||
const entry = knowledgeRepo.get(id)
|
||||
if (!entry || entry.status !== 'approved') continue
|
||||
const body = truncate(stripHtml(entry.content || entry.title), KNOWLEDGE_CHAR_LIMIT)
|
||||
knowledgeCandidates.push({
|
||||
sortKey: entry.importance * 1000,
|
||||
isKnowledge: true,
|
||||
text: formatKnowledgeBlock(entry.type, entry.title, body),
|
||||
preview: {
|
||||
kind: 'knowledge',
|
||||
id,
|
||||
title: entry.title,
|
||||
excerpt: body,
|
||||
charCount: body.length,
|
||||
knowledgeType: entry.type
|
||||
}
|
||||
})
|
||||
}
|
||||
knowledgeCandidates.sort((a, b) => b.sortKey - a.sortKey)
|
||||
|
||||
let knowledgeChars = 0
|
||||
const trimmedKnowledge: BlockCandidate[] = []
|
||||
for (const k of knowledgeCandidates) {
|
||||
const next = knowledgeChars + k.text.length + (trimmedKnowledge.length > 0 ? 2 : 0)
|
||||
if (next > KNOWLEDGE_TOTAL_LIMIT) break
|
||||
trimmedKnowledge.push(k)
|
||||
knowledgeChars = next
|
||||
}
|
||||
|
||||
candidates.push(...trimmedKnowledge)
|
||||
candidates.sort((a, b) => b.sortKey - a.sortKey)
|
||||
|
||||
const selected: BlockCandidate[] = []
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
AutoWritingConfig,
|
||||
FlowContextPayload,
|
||||
InteractiveFlow,
|
||||
ScenePlanItem
|
||||
} from '../../shared/types'
|
||||
@@ -190,9 +191,26 @@ export class AutoWritingService {
|
||||
}
|
||||
}
|
||||
|
||||
confirmContext(flowId: string, payload: FlowContextPayload): InteractiveFlow {
|
||||
this.repo.updateStep(flowId, this.repo.get(flowId)!.step, {
|
||||
contextJson: JSON.stringify(payload)
|
||||
})
|
||||
return this.repo.get(flowId)!
|
||||
}
|
||||
|
||||
setContextSummary(flowId: string, contextSummary: string): void {
|
||||
this.repo.updateStep(flowId, this.repo.get(flowId)!.step, {
|
||||
contextJson: JSON.stringify({ contextSummary })
|
||||
contextJson: JSON.stringify({
|
||||
binding: {
|
||||
chapterIds: [],
|
||||
outlineIds: [],
|
||||
settingIds: [],
|
||||
inspirationIds: [],
|
||||
knowledgeIds: []
|
||||
},
|
||||
systemPrompt: contextSummary,
|
||||
contextSummary
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { AiContextBinding, FlowContextPayload } from '../../shared/types'
|
||||
import type { BookRegistryService } from './book-registry'
|
||||
import { aiContextBuilder } from './ai-context-builder.service'
|
||||
|
||||
const EMPTY_BINDING: AiContextBinding = {
|
||||
chapterIds: [],
|
||||
outlineIds: [],
|
||||
settingIds: [],
|
||||
inspirationIds: [],
|
||||
knowledgeIds: []
|
||||
}
|
||||
|
||||
export function buildFlowContextPayload(
|
||||
binding: AiContextBinding,
|
||||
registry: BookRegistryService,
|
||||
bookId: string,
|
||||
penName: string
|
||||
): FlowContextPayload {
|
||||
const built = aiContextBuilder.build(binding, registry, bookId, penName)
|
||||
const counts = { chapter: 0, outline: 0, setting: 0, inspiration: 0, knowledge: 0 }
|
||||
for (const p of built.preview) {
|
||||
if (p.kind in counts) counts[p.kind as keyof typeof counts]++
|
||||
}
|
||||
const contextSummary = `${counts.chapter}章·${counts.outline}纲·${counts.setting}设·${counts.inspiration}感·${counts.knowledge}知`
|
||||
return { binding, systemPrompt: built.systemPrompt, contextSummary }
|
||||
}
|
||||
|
||||
export function parseFlowContextPayload(contextJson: string): FlowContextPayload | null {
|
||||
try {
|
||||
const parsed = JSON.parse(contextJson) as Partial<FlowContextPayload>
|
||||
if (parsed.systemPrompt) {
|
||||
return {
|
||||
binding: { ...EMPTY_BINDING, ...parsed.binding },
|
||||
systemPrompt: parsed.systemPrompt,
|
||||
contextSummary: parsed.contextSummary ?? ''
|
||||
}
|
||||
}
|
||||
if (parsed.contextSummary) {
|
||||
return {
|
||||
binding: { ...EMPTY_BINDING, ...parsed.binding },
|
||||
systemPrompt: parsed.contextSummary,
|
||||
contextSummary: parsed.contextSummary
|
||||
}
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function readFlowSystemPrompt(contextJson: string): string {
|
||||
return parseFlowContextPayload(contextJson)?.systemPrompt ?? '(无上下文)'
|
||||
}
|
||||
@@ -28,7 +28,9 @@ function defaults(): GlobalSettings {
|
||||
snapshotMaxPersist: 3,
|
||||
searchHistory: [],
|
||||
aiConfig: defaultAiConfig(),
|
||||
namingIgnoreWords: []
|
||||
namingIgnoreWords: [],
|
||||
knowledgeAutoSuggest: true,
|
||||
knowledgeSuggestTopN: 8
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +51,8 @@ export class GlobalSettingsService {
|
||||
...raw,
|
||||
updateSchedule: raw.updateSchedule ?? 'none',
|
||||
stockBufferThreshold: raw.stockBufferThreshold ?? 3,
|
||||
knowledgeAutoSuggest: raw.knowledgeAutoSuggest ?? true,
|
||||
knowledgeSuggestTopN: raw.knowledgeSuggestTopN ?? 8,
|
||||
shortcuts: { ...DEFAULT_SHORTCUTS, ...raw.shortcuts },
|
||||
aiConfig: { ...DEFAULT_AI_CONFIG, ...raw.aiConfig }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
AiContextBinding,
|
||||
FlowContextPayload,
|
||||
InteractiveFlow,
|
||||
PlotOption,
|
||||
PlotSelection
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
buildRefineMessages,
|
||||
buildSceneGenerateMessages
|
||||
} from './interactive-prompt-builder.service'
|
||||
import { readFlowSystemPrompt } from './flow-context.util'
|
||||
|
||||
export class InteractiveWritingService {
|
||||
private repo: InteractiveRepository
|
||||
@@ -37,9 +39,9 @@ export class InteractiveWritingService {
|
||||
return this.repo.getBySession(sessionId)
|
||||
}
|
||||
|
||||
confirmContext(flowId: string, binding: AiContextBinding, contextSummary: string): InteractiveFlow {
|
||||
confirmContext(flowId: string, payload: FlowContextPayload): InteractiveFlow {
|
||||
this.repo.updateStep(flowId, 'plot_suggest', {
|
||||
contextJson: JSON.stringify({ binding, contextSummary })
|
||||
contextJson: JSON.stringify(payload)
|
||||
})
|
||||
return this.repo.get(flowId)!
|
||||
}
|
||||
@@ -165,8 +167,8 @@ export class InteractiveWritingService {
|
||||
}
|
||||
|
||||
enrichFlow(flow: InteractiveFlow): InteractiveFlow {
|
||||
const raw = this.repo.getContextJson(flow.id)
|
||||
try {
|
||||
const raw = this.repo.getContextJson(flow.id)
|
||||
const parsed = JSON.parse(raw) as { contextSummary?: string }
|
||||
return { ...flow, contextSummary: parsed.contextSummary }
|
||||
} catch {
|
||||
@@ -175,14 +177,6 @@ export class InteractiveWritingService {
|
||||
}
|
||||
|
||||
private readContextText(flowId: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(this.repo.getContextJson(flowId)) as {
|
||||
contextSummary?: string
|
||||
binding?: AiContextBinding
|
||||
}
|
||||
return parsed.contextSummary ?? '(无上下文摘要)'
|
||||
} catch {
|
||||
return '(无上下文摘要)'
|
||||
}
|
||||
return readFlowSystemPrompt(this.repo.getContextJson(flowId))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { KnowledgeEntry, KnowledgeSuggestOptions, ScoredKnowledge } from '../../shared/types'
|
||||
import type { SqliteDb } from '../db/types'
|
||||
import { ChapterRepository } from '../db/repositories/chapter.repo'
|
||||
import { KnowledgeRepository } from '../db/repositories/knowledge.repo'
|
||||
|
||||
const STOP_WORDS = new Set(['的', '了', '在', '是', '与', '和', 'the', 'a', 'an'])
|
||||
|
||||
function tokenize(text: string): string[] {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.split(/[\s,,。!?、;;::]+/)
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length >= 2 && !STOP_WORDS.has(t))
|
||||
}
|
||||
|
||||
function overlapRatio(a: string, b: string): number {
|
||||
const ta = new Set(tokenize(a))
|
||||
const tb = tokenize(b)
|
||||
if (ta.size === 0 || tb.length === 0) return 0
|
||||
let hit = 0
|
||||
for (const t of tb) if (ta.has(t)) hit++
|
||||
return hit / tb.length
|
||||
}
|
||||
|
||||
function chapterProximityBonus(
|
||||
entry: KnowledgeEntry,
|
||||
currentSort: number | null,
|
||||
chapterSort: Map<string, number>
|
||||
): number {
|
||||
if (currentSort == null) return 0
|
||||
const ids = [entry.sourceChapterId, entry.lastMentionChapterId].filter(Boolean) as string[]
|
||||
if (ids.length === 0) return 0
|
||||
let best = Infinity
|
||||
for (const id of ids) {
|
||||
const order = chapterSort.get(id)
|
||||
if (order != null) best = Math.min(best, Math.abs(currentSort - order))
|
||||
}
|
||||
if (best === Infinity) return 0
|
||||
if (best === 0) return 30
|
||||
if (best <= 3) return 20
|
||||
if (best <= 10) return 10
|
||||
return 0
|
||||
}
|
||||
|
||||
export class KnowledgeRetrievalService {
|
||||
constructor(private db: SqliteDb) {}
|
||||
|
||||
suggest(opts: KnowledgeSuggestOptions = {}): ScoredKnowledge[] {
|
||||
const topN = opts.topN ?? 8
|
||||
const entries = new KnowledgeRepository(this.db).list({ status: 'approved' })
|
||||
if (entries.length === 0) return []
|
||||
|
||||
const chapters = new ChapterRepository(this.db).list()
|
||||
const sortMap = new Map(chapters.map((c) => [c.id, c.sortOrder]))
|
||||
const currentSort = opts.currentChapterId
|
||||
? (sortMap.get(opts.currentChapterId) ?? null)
|
||||
: null
|
||||
const goalText = opts.goalText ?? ''
|
||||
|
||||
const scored: ScoredKnowledge[] = entries.map((entry) => {
|
||||
let score = entry.importance * 20
|
||||
const reasons: string[] = []
|
||||
|
||||
const prox = chapterProximityBonus(entry, currentSort, sortMap)
|
||||
score += prox
|
||||
if (prox > 0) reasons.push('knowledge.reason.chapterRelevant')
|
||||
|
||||
if (entry.type === 'foreshadow') {
|
||||
const fs = entry.foreshadowStatus ?? 'buried'
|
||||
if (fs === 'buried') {
|
||||
score += 25
|
||||
reasons.push('knowledge.reason.foreshadowBuried')
|
||||
} else if (fs === 'partial') score += 15
|
||||
}
|
||||
|
||||
if (goalText) {
|
||||
const text = `${entry.title} ${entry.content}`
|
||||
const ratio = overlapRatio(goalText, text)
|
||||
score += Math.round(ratio * 30)
|
||||
if (ratio > 0.2) reasons.push('knowledge.reason.goalMatch')
|
||||
}
|
||||
|
||||
if (entry.importance >= 4) reasons.push('knowledge.reason.highImportance')
|
||||
|
||||
return { entry, score, reasons: [...new Set(reasons)] }
|
||||
})
|
||||
|
||||
return scored.sort((a, b) => b.score - a.score).slice(0, topN)
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,14 @@ import type {
|
||||
AiContextBinding,
|
||||
AutoRhythm,
|
||||
AutoWritingConfig,
|
||||
FlowContextPayload,
|
||||
InteractiveFlow,
|
||||
WizardWritingConfig
|
||||
} from '../../shared/types'
|
||||
import type { SqliteDb } from '../db/types'
|
||||
import { InteractiveRepository } from '../db/repositories/interactive.repo'
|
||||
import { SettingRepository } from '../db/repositories/setting.repo'
|
||||
import { KnowledgeRepository } from '../db/repositories/knowledge.repo'
|
||||
import type { AiClientService } from './ai-client.service'
|
||||
import { buildWizardPreviewMessages } from './auto-prompt-builder.service'
|
||||
import { AutoWritingService } from './auto-writing.service'
|
||||
@@ -58,15 +60,11 @@ export class WizardWritingService {
|
||||
return this.repo.updateStep(flowId, 'wizard_context')
|
||||
}
|
||||
|
||||
confirmContext(
|
||||
flowId: string,
|
||||
binding: AiContextBinding,
|
||||
contextSummary: string
|
||||
): InteractiveFlow {
|
||||
confirmContext(flowId: string, payload: FlowContextPayload): InteractiveFlow {
|
||||
const config = this.repo.getModeConfig<WizardWritingConfig>(flowId)
|
||||
this.repo.setModeConfig(flowId, { ...config, contextBinding: binding })
|
||||
this.repo.setModeConfig(flowId, { ...config, contextBinding: payload.binding })
|
||||
this.repo.updateStep(flowId, 'wizard_preview', {
|
||||
contextJson: JSON.stringify({ binding, contextSummary })
|
||||
contextJson: JSON.stringify(payload)
|
||||
})
|
||||
return this.repo.get(flowId)!
|
||||
}
|
||||
@@ -75,7 +73,17 @@ export class WizardWritingService {
|
||||
const config = this.repo.getModeConfig<WizardWritingConfig>(flowId)
|
||||
const messages = buildWizardPreviewMessages(config, contextText, povName)
|
||||
const html = await this.aiClient.chat(messages)
|
||||
const preview = html.trim().includes('<p>') ? html.trim() : `<p>${html.trim()}</p>`
|
||||
let preview = html.trim().includes('<p>') ? html.trim() : `<p>${html.trim()}</p>`
|
||||
const binding = config.contextBinding
|
||||
if (binding?.knowledgeIds?.length) {
|
||||
const repo = new KnowledgeRepository(this.db)
|
||||
const titles = binding.knowledgeIds
|
||||
.map((id) => repo.get(id)?.title)
|
||||
.filter(Boolean)
|
||||
if (titles.length) {
|
||||
preview += `<p><strong>将注入知识:</strong>${titles.join('、')}</p>`
|
||||
}
|
||||
}
|
||||
this.repo.setModeConfig(flowId, { ...config, strategyPreview: preview })
|
||||
return preview
|
||||
}
|
||||
|
||||
+14
-1
@@ -1,6 +1,8 @@
|
||||
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron'
|
||||
import { IPC } from '../shared/ipc-channels'
|
||||
import type {
|
||||
KnowledgeSuggestOptions,
|
||||
ScoredKnowledge,
|
||||
ActionId,
|
||||
Bookmark,
|
||||
BookMeta,
|
||||
@@ -346,6 +348,12 @@ const electronAPI = {
|
||||
config: Partial<AutoWritingConfig>
|
||||
): Promise<IpcResult<InteractiveFlow>> =>
|
||||
ipcRenderer.invoke(IPC.AUTO_SET_GOAL, { bookId, flowId, config }),
|
||||
confirmContext: (
|
||||
bookId: string,
|
||||
flowId: string,
|
||||
binding: AiContextBinding
|
||||
): Promise<IpcResult<InteractiveFlow>> =>
|
||||
ipcRenderer.invoke(IPC.AUTO_CONFIRM_CONTEXT, { bookId, flowId, binding }),
|
||||
planScenes: (
|
||||
bookId: string,
|
||||
flowId: string,
|
||||
@@ -448,7 +456,12 @@ const electronAPI = {
|
||||
stats: (
|
||||
bookId: string
|
||||
): Promise<IpcResult<{ buried: number; resolved: number; forgotten: number }>> =>
|
||||
ipcRenderer.invoke(IPC.KNOWLEDGE_STATS, { bookId })
|
||||
ipcRenderer.invoke(IPC.KNOWLEDGE_STATS, { bookId }),
|
||||
suggestContext: (
|
||||
bookId: string,
|
||||
opts?: KnowledgeSuggestOptions
|
||||
): Promise<IpcResult<ScoredKnowledge[]>> =>
|
||||
ipcRenderer.invoke(IPC.KNOWLEDGE_SUGGEST_CONTEXT, { bookId, opts })
|
||||
},
|
||||
cockpit: {
|
||||
getSummary: (bookId: string, volumeId?: string): Promise<IpcResult<CockpitSummary>> =>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { AutoWritingConfig, OutlineItem } from '@shared/types'
|
||||
import type { AutoWritingConfig, OutlineItem, ScoredKnowledge } from '@shared/types'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
import { useAutoStore } from '@renderer/stores/useAutoStore'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useAiStore } from '@renderer/stores/useAiStore'
|
||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
import { KnowledgeSuggestList } from '@renderer/components/ai/KnowledgeSuggestList'
|
||||
|
||||
interface AutoPanelProps {
|
||||
bookId: string
|
||||
@@ -37,6 +41,20 @@ export function AutoPanel({ bookId, sessionId, disabled }: AutoPanelProps): Reac
|
||||
const [outlineIds, setOutlineIds] = useState<string[]>([])
|
||||
const [outlines, setOutlines] = useState<OutlineItem[]>([])
|
||||
const [chapterTitle, setChapterTitle] = useState('')
|
||||
const [suggested, setSuggested] = useState<ScoredKnowledge[]>([])
|
||||
const [knowledgeIds, setKnowledgeIds] = useState<string[]>([])
|
||||
const knowledgeAutoSuggest = useSettingsStore((s) => s.knowledgeAutoSuggest ?? true)
|
||||
const selectedChapterId = useBookStore((s) => s.selectedChapterId)
|
||||
const sessions = useAiStore((s) => s.sessions)
|
||||
const activeSessionId = useAiStore((s) => s.activeSessionId)
|
||||
|
||||
const goalText = useMemo(() => {
|
||||
const outlineText = outlines
|
||||
.filter((o) => outlineIds.includes(o.id))
|
||||
.map((o) => o.description ?? '')
|
||||
.join(' ')
|
||||
return [goal.trim(), outlineText].filter(Boolean).join(' ')
|
||||
}, [goal, outlineIds, outlines])
|
||||
|
||||
useEffect(() => {
|
||||
mount()
|
||||
@@ -52,6 +70,52 @@ export function AutoPanel({ bookId, sessionId, disabled }: AutoPanelProps): Reac
|
||||
void ipcCall(() => window.electronAPI.outline.list(bookId)).then(setOutlines)
|
||||
}, [bookId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!bookId || !sessionId || !knowledgeAutoSuggest) return
|
||||
const session = sessions.find((s) => s.id === sessionId)
|
||||
const existing = session?.context.knowledgeIds ?? []
|
||||
setKnowledgeIds(existing)
|
||||
void ipcCall(() =>
|
||||
window.electronAPI.knowledge.suggestContext(bookId, {
|
||||
currentChapterId: selectedChapterId ?? undefined,
|
||||
goalText: goalText || undefined
|
||||
})
|
||||
).then((items) => {
|
||||
setSuggested(items)
|
||||
const merged = [...new Set([...existing, ...items.map((x) => x.entry.id)])]
|
||||
setKnowledgeIds(merged)
|
||||
if (!session) return
|
||||
void ipcCall(() =>
|
||||
window.electronAPI.ai.sessionUpdate(bookId, sessionId, {
|
||||
context: { ...session.context, knowledgeIds: merged }
|
||||
})
|
||||
).then((updated) => {
|
||||
useAiStore.setState((s) => ({
|
||||
sessions: s.sessions.map((x) => (x.id === updated.id ? updated : x))
|
||||
}))
|
||||
})
|
||||
})
|
||||
}, [bookId, sessionId, knowledgeAutoSuggest, selectedChapterId, goalText, sessions])
|
||||
|
||||
const toggleKnowledge = (id: string): void => {
|
||||
if (!bookId || !activeSessionId) return
|
||||
const session = sessions.find((s) => s.id === activeSessionId)
|
||||
if (!session) return
|
||||
const next = knowledgeIds.includes(id)
|
||||
? knowledgeIds.filter((x) => x !== id)
|
||||
: [...knowledgeIds, id]
|
||||
setKnowledgeIds(next)
|
||||
void ipcCall(() =>
|
||||
window.electronAPI.ai.sessionUpdate(bookId, activeSessionId, {
|
||||
context: { ...session.context, knowledgeIds: next }
|
||||
})
|
||||
).then((updated) => {
|
||||
useAiStore.setState((s) => ({
|
||||
sessions: s.sessions.map((x) => (x.id === updated.id ? updated : x))
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
if (!sessionId || !flow) {
|
||||
return (
|
||||
<div className="auto-panel" data-testid="auto-panel">
|
||||
@@ -121,6 +185,16 @@ export function AutoPanel({ bookId, sessionId, disabled }: AutoPanelProps): Reac
|
||||
onChange={(e) => setWordMax(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
{knowledgeAutoSuggest && (
|
||||
<details className="auto-knowledge-section" data-testid="auto-knowledge-suggest">
|
||||
<summary>{t('interactive.confirmKnowledge')}</summary>
|
||||
<KnowledgeSuggestList
|
||||
items={suggested}
|
||||
selectedIds={knowledgeIds}
|
||||
onToggle={toggleKnowledge}
|
||||
/>
|
||||
</details>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
|
||||
@@ -1,45 +1,80 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { AiContextBinding } from '@shared/types'
|
||||
import type { AiContextBinding, KnowledgeEntry, ScoredKnowledge } from '@shared/types'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useAiStore } from '@renderer/stores/useAiStore'
|
||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
import { formatContextSummary } from '@renderer/lib/ai-context-summary'
|
||||
import { KnowledgeSuggestList } from '@renderer/components/ai/KnowledgeSuggestList'
|
||||
import i18n from '@renderer/i18n'
|
||||
|
||||
type ContextTab = 'chapter' | 'outline' | 'setting' | 'inspiration'
|
||||
type ContextTab = 'chapter' | 'outline' | 'setting' | 'inspiration' | 'knowledge'
|
||||
|
||||
interface ContextEditorModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
goalText?: string
|
||||
}
|
||||
|
||||
const EMPTY_BINDING: AiContextBinding = {
|
||||
chapterIds: [],
|
||||
outlineIds: [],
|
||||
settingIds: [],
|
||||
inspirationIds: []
|
||||
inspirationIds: [],
|
||||
knowledgeIds: []
|
||||
}
|
||||
|
||||
export function ContextEditorModal({ open, onClose }: ContextEditorModalProps): React.JSX.Element | null {
|
||||
export function ContextEditorModal({
|
||||
open,
|
||||
onClose,
|
||||
goalText = ''
|
||||
}: ContextEditorModalProps): React.JSX.Element | null {
|
||||
const { t } = useTranslation()
|
||||
const bookId = useBookStore((s) => s.currentBookId)
|
||||
const selectedChapterId = useBookStore((s) => s.selectedChapterId)
|
||||
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 knowledgeAutoSuggest = useSettingsStore((s) => s.knowledgeAutoSuggest ?? true)
|
||||
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)
|
||||
const [suggested, setSuggested] = useState<ScoredKnowledge[]>([])
|
||||
const [approvedKnowledge, setApprovedKnowledge] = useState<KnowledgeEntry[]>([])
|
||||
|
||||
const loadSuggestions = useCallback(async (): Promise<void> => {
|
||||
if (!bookId || !knowledgeAutoSuggest) {
|
||||
setSuggested([])
|
||||
return
|
||||
}
|
||||
const items = await ipcCall(() =>
|
||||
window.electronAPI.knowledge.suggestContext(bookId, {
|
||||
currentChapterId: selectedChapterId ?? undefined,
|
||||
goalText: goalText || undefined
|
||||
})
|
||||
)
|
||||
setSuggested(items)
|
||||
setBinding((prev) => {
|
||||
const merged = new Set([...prev.knowledgeIds, ...items.map((x) => x.entry.id)])
|
||||
return { ...prev, knowledgeIds: [...merged] }
|
||||
})
|
||||
}, [bookId, goalText, knowledgeAutoSuggest, selectedChapterId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const session = sessions.find((s) => s.id === activeSessionId)
|
||||
setBinding(session?.context ?? EMPTY_BINDING)
|
||||
}, [open, activeSessionId, sessions])
|
||||
if (!bookId) return
|
||||
void ipcCall(() =>
|
||||
window.electronAPI.knowledge.list(bookId, { status: 'approved' })
|
||||
).then(setApprovedKnowledge)
|
||||
void loadSuggestions()
|
||||
}, [open, activeSessionId, sessions, bookId, loadSuggestions])
|
||||
|
||||
const toggle = (kind: ContextTab, id: string): void => {
|
||||
const toggle = (kind: Exclude<ContextTab, 'knowledge'>, id: string): void => {
|
||||
const key =
|
||||
kind === 'chapter'
|
||||
? 'chapterIds'
|
||||
@@ -57,6 +92,15 @@ export function ContextEditorModal({ open, onClose }: ContextEditorModalProps):
|
||||
})
|
||||
}
|
||||
|
||||
const toggleKnowledge = (id: string): void => {
|
||||
setBinding((prev) => ({
|
||||
...prev,
|
||||
knowledgeIds: prev.knowledgeIds.includes(id)
|
||||
? prev.knowledgeIds.filter((x) => x !== id)
|
||||
: [...prev.knowledgeIds, id]
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
if (!bookId || !activeSessionId) return
|
||||
const updated = await ipcCall(() =>
|
||||
@@ -87,7 +131,9 @@ export function ContextEditorModal({ open, onClose }: ContextEditorModalProps):
|
||||
? 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') }))
|
||||
: tab === 'inspiration'
|
||||
? inspirations.map((i) => ({ id: i.id, title: i.title || t('inspiration.untitled') }))
|
||||
: approvedKnowledge.map((k) => ({ id: k.id, title: k.title }))
|
||||
|
||||
const selectedIds =
|
||||
tab === 'chapter'
|
||||
@@ -96,7 +142,9 @@ export function ContextEditorModal({ open, onClose }: ContextEditorModalProps):
|
||||
? binding.outlineIds
|
||||
: tab === 'setting'
|
||||
? binding.settingIds
|
||||
: binding.inspirationIds
|
||||
: tab === 'inspiration'
|
||||
? binding.inspirationIds
|
||||
: binding.knowledgeIds
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" data-testid="context-editor-modal">
|
||||
@@ -108,7 +156,7 @@ export function ContextEditorModal({ open, onClose }: ContextEditorModalProps):
|
||||
</button>
|
||||
</div>
|
||||
<div className="context-editor-tabs">
|
||||
{(['chapter', 'outline', 'setting', 'inspiration'] as const).map((id) => (
|
||||
{(['chapter', 'outline', 'setting', 'inspiration', 'knowledge'] as const).map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
@@ -120,18 +168,65 @@ export function ContextEditorModal({ open, onClose }: ContextEditorModalProps):
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{tab === 'knowledge' && (
|
||||
<div className="context-knowledge-suggest">
|
||||
<div className="context-knowledge-suggest-header">
|
||||
<span>{t('ai.contextKnowledgeSuggestTitle')}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
data-testid="context-refresh-suggest"
|
||||
onClick={() => void loadSuggestions()}
|
||||
>
|
||||
{t('ai.contextRefreshSuggest')}
|
||||
</button>
|
||||
</div>
|
||||
<KnowledgeSuggestList
|
||||
items={suggested}
|
||||
selectedIds={binding.knowledgeIds}
|
||||
onToggle={toggleKnowledge}
|
||||
testId="context-knowledge-suggest"
|
||||
/>
|
||||
</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>
|
||||
))}
|
||||
{tab === 'knowledge' ? (
|
||||
<>
|
||||
<div className="context-knowledge-all-title">{t('ai.contextKnowledgeAllTitle')}</div>
|
||||
{approvedKnowledge.length === 0 && (
|
||||
<div className="sidebar-empty">{t('ai.contextKnowledgeEmpty')}</div>
|
||||
)}
|
||||
{approvedKnowledge.map((entry) => (
|
||||
<label
|
||||
key={entry.id}
|
||||
className="context-editor-item"
|
||||
data-testid={`context-knowledge-item-${entry.id}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={binding.knowledgeIds.includes(entry.id)}
|
||||
onChange={() => toggleKnowledge(entry.id)}
|
||||
/>
|
||||
<span>{entry.title}</span>
|
||||
<span className="knowledge-suggest-type">{t(`knowledge.type.${entry.type}`)}</span>
|
||||
</label>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{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 as Exclude<ContextTab, 'knowledge'>, item.id)}
|
||||
/>
|
||||
<span>{item.title}</span>
|
||||
</label>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn" data-testid="context-refresh" onClick={() => void handleRefresh()}>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { PlotSelection } from '@shared/types'
|
||||
import type { PlotSelection, ScoredKnowledge } from '@shared/types'
|
||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useAiStore } from '@renderer/stores/useAiStore'
|
||||
import { useInteractiveStore } from '@renderer/stores/useInteractiveStore'
|
||||
import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal'
|
||||
import { KnowledgeSuggestList } from '@renderer/components/ai/KnowledgeSuggestList'
|
||||
import { PlotOptionCard } from '@renderer/components/ai/PlotOptionCard'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
interface InteractivePanelProps {
|
||||
bookId: string
|
||||
@@ -19,6 +23,9 @@ export function InteractivePanel({
|
||||
}: InteractivePanelProps): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const backend = useSettingsStore((s) => s.aiConfig.backend)
|
||||
const knowledgeAutoSuggest = useSettingsStore((s) => s.knowledgeAutoSuggest ?? true)
|
||||
const selectedChapterId = useBookStore((s) => s.selectedChapterId)
|
||||
const sessions = useAiStore((s) => s.sessions)
|
||||
const {
|
||||
flow,
|
||||
plots,
|
||||
@@ -37,6 +44,8 @@ export function InteractivePanel({
|
||||
} = useInteractiveStore()
|
||||
|
||||
const [contextOpen, setContextOpen] = useState(false)
|
||||
const [suggested, setSuggested] = useState<ScoredKnowledge[]>([])
|
||||
const [knowledgeIds, setKnowledgeIds] = useState<string[]>([])
|
||||
const [selectedPlots, setSelectedPlots] = useState<Array<'A' | 'B' | 'C'>>([])
|
||||
const [userNote, setUserNote] = useState('')
|
||||
const [refineInput, setRefineInput] = useState('')
|
||||
@@ -52,6 +61,33 @@ export function InteractivePanel({
|
||||
if (bookId && sessionId) void loadFlow(bookId, sessionId)
|
||||
}, [bookId, sessionId, loadFlow])
|
||||
|
||||
const step = flow?.step
|
||||
useEffect(() => {
|
||||
if (!bookId || !sessionId || step !== 'context_confirm' || !knowledgeAutoSuggest) return
|
||||
const session = sessions.find((s) => s.id === sessionId)
|
||||
const existing = session?.context.knowledgeIds ?? []
|
||||
setKnowledgeIds(existing)
|
||||
void ipcCall(() =>
|
||||
window.electronAPI.knowledge.suggestContext(bookId, {
|
||||
currentChapterId: selectedChapterId ?? undefined
|
||||
})
|
||||
).then((items) => {
|
||||
setSuggested(items)
|
||||
const merged = [...new Set([...existing, ...items.map((x) => x.entry.id)])]
|
||||
setKnowledgeIds(merged)
|
||||
if (!session) return
|
||||
void ipcCall(() =>
|
||||
window.electronAPI.ai.sessionUpdate(bookId, sessionId, {
|
||||
context: { ...session.context, knowledgeIds: merged }
|
||||
})
|
||||
).then((updated) => {
|
||||
useAiStore.setState((s) => ({
|
||||
sessions: s.sessions.map((x) => (x.id === updated.id ? updated : x))
|
||||
}))
|
||||
})
|
||||
})
|
||||
}, [bookId, sessionId, step, knowledgeAutoSuggest, selectedChapterId, sessions])
|
||||
|
||||
if (!sessionId || !flow) {
|
||||
return (
|
||||
<div className="interactive-panel" data-testid="interactive-panel">
|
||||
@@ -60,13 +96,31 @@ export function InteractivePanel({
|
||||
)
|
||||
}
|
||||
|
||||
const step = flow.step
|
||||
const previewHtml = streaming ? streamBuffer : sceneDraft
|
||||
const showReview =
|
||||
step === 'scene_review' ||
|
||||
(!streaming && sceneDraft.trim().length > 0 && step === 'scene_generate')
|
||||
const showFinishChapter = flow.scenes.length > 0 && flow.status !== 'completed'
|
||||
|
||||
const toggleKnowledge = (id: string): void => {
|
||||
setKnowledgeIds((prev) => {
|
||||
const next = prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
|
||||
const session = sessions.find((s) => s.id === sessionId)
|
||||
if (bookId && sessionId && session) {
|
||||
void ipcCall(() =>
|
||||
window.electronAPI.ai.sessionUpdate(bookId, sessionId, {
|
||||
context: { ...session.context, knowledgeIds: next }
|
||||
})
|
||||
).then((updated) => {
|
||||
useAiStore.setState((s) => ({
|
||||
sessions: s.sessions.map((x) => (x.id === updated.id ? updated : x))
|
||||
}))
|
||||
})
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleStart = (): void => {
|
||||
void confirmContextAndStart(bookId, flow.id)
|
||||
}
|
||||
@@ -94,6 +148,16 @@ export function InteractivePanel({
|
||||
{step === 'context_confirm' && (
|
||||
<div className="interactive-section">
|
||||
<p>{flow.contextSummary ?? t('ai.contextEmpty')}</p>
|
||||
{knowledgeAutoSuggest && (
|
||||
<div className="interactive-knowledge-section" data-testid="interactive-knowledge-suggest">
|
||||
<h4>{t('interactive.confirmKnowledge')}</h4>
|
||||
<KnowledgeSuggestList
|
||||
items={suggested}
|
||||
selectedIds={knowledgeIds}
|
||||
onToggle={toggleKnowledge}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<button type="button" className="btn" onClick={() => setContextOpen(true)}>
|
||||
{t('ai.contextEditorTitle')}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ScoredKnowledge } from '@shared/types'
|
||||
|
||||
interface KnowledgeSuggestListProps {
|
||||
items: ScoredKnowledge[]
|
||||
selectedIds: string[]
|
||||
onToggle: (id: string) => void
|
||||
testId?: string
|
||||
}
|
||||
|
||||
export function KnowledgeSuggestList({
|
||||
items,
|
||||
selectedIds,
|
||||
onToggle,
|
||||
testId = 'knowledge-suggest-list'
|
||||
}: KnowledgeSuggestListProps): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (items.length === 0) {
|
||||
return <div className="sidebar-empty">{t('ai.contextKnowledgeEmpty')}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="knowledge-suggest-list" data-testid={testId}>
|
||||
{items.map(({ entry, reasons }) => (
|
||||
<label
|
||||
key={entry.id}
|
||||
className="context-editor-item knowledge-suggest-item"
|
||||
data-testid={`knowledge-suggest-item-${entry.id}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(entry.id)}
|
||||
onChange={() => onToggle(entry.id)}
|
||||
/>
|
||||
<span className="knowledge-suggest-title">{entry.title}</span>
|
||||
<span className="knowledge-suggest-type">{t(`knowledge.type.${entry.type}`)}</span>
|
||||
{reasons.length > 0 && (
|
||||
<span className="knowledge-suggest-reasons">
|
||||
{reasons.map((reason) => (
|
||||
<span key={reason} className="knowledge-reason-badge">
|
||||
{t(reason)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { AutoRhythm, AutoWritingConfig, SettingEntry, WizardWritingConfig } from '@shared/types'
|
||||
import type { AutoRhythm, AutoWritingConfig, ScoredKnowledge, SettingEntry, WizardWritingConfig } from '@shared/types'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
import { useWizardStore } from '@renderer/stores/useWizardStore'
|
||||
import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal'
|
||||
import { KnowledgeSuggestList } from '@renderer/components/ai/KnowledgeSuggestList'
|
||||
import { useAiStore } from '@renderer/stores/useAiStore'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
|
||||
interface WizardPanelProps {
|
||||
bookId: string
|
||||
@@ -41,6 +44,44 @@ export function WizardPanel({ bookId, sessionId, disabled }: WizardPanelProps):
|
||||
const [characters, setCharacters] = useState<SettingEntry[]>([])
|
||||
const [povId, setPovId] = useState('')
|
||||
const [contextOpen, setContextOpen] = useState(false)
|
||||
const [suggested, setSuggested] = useState<ScoredKnowledge[]>([])
|
||||
const [knowledgeIds, setKnowledgeIds] = useState<string[]>([])
|
||||
const knowledgeAutoSuggest = useSettingsStore((s) => s.knowledgeAutoSuggest ?? true)
|
||||
const selectedChapterId = useBookStore((s) => s.selectedChapterId)
|
||||
const sessions = useAiStore((s) => s.sessions)
|
||||
|
||||
const goalText = useMemo(
|
||||
() => [goal.trim(), styleNote.trim()].filter(Boolean).join(' '),
|
||||
[goal, styleNote]
|
||||
)
|
||||
|
||||
const step = flow?.step
|
||||
useEffect(() => {
|
||||
if (!bookId || !sessionId || step !== 'wizard_context' || !knowledgeAutoSuggest) return
|
||||
const session = sessions.find((s) => s.id === sessionId)
|
||||
const existing = session?.context.knowledgeIds ?? []
|
||||
setKnowledgeIds(existing)
|
||||
void ipcCall(() =>
|
||||
window.electronAPI.knowledge.suggestContext(bookId, {
|
||||
currentChapterId: selectedChapterId ?? undefined,
|
||||
goalText: goalText || undefined
|
||||
})
|
||||
).then((items) => {
|
||||
setSuggested(items)
|
||||
const merged = [...new Set([...existing, ...items.map((x) => x.entry.id)])]
|
||||
setKnowledgeIds(merged)
|
||||
if (!session) return
|
||||
void ipcCall(() =>
|
||||
window.electronAPI.ai.sessionUpdate(bookId, sessionId, {
|
||||
context: { ...session.context, knowledgeIds: merged }
|
||||
})
|
||||
).then((updated) => {
|
||||
useAiStore.setState((s) => ({
|
||||
sessions: s.sessions.map((x) => (x.id === updated.id ? updated : x))
|
||||
}))
|
||||
})
|
||||
})
|
||||
}, [bookId, sessionId, step, knowledgeAutoSuggest, selectedChapterId, goalText, sessions])
|
||||
|
||||
useEffect(() => {
|
||||
mount()
|
||||
@@ -66,9 +107,32 @@ export function WizardPanel({ bookId, sessionId, disabled }: WizardPanelProps):
|
||||
)
|
||||
}
|
||||
|
||||
const step = flow.step
|
||||
const config = flow.modeConfig as WizardWritingConfig
|
||||
|
||||
const toggleKnowledge = (id: string): void => {
|
||||
if (!bookId || !sessionId) return
|
||||
const session = sessions.find((s) => s.id === sessionId)
|
||||
if (!session) return
|
||||
const next = knowledgeIds.includes(id)
|
||||
? knowledgeIds.filter((x) => x !== id)
|
||||
: [...knowledgeIds, id]
|
||||
setKnowledgeIds(next)
|
||||
void ipcCall(() =>
|
||||
window.electronAPI.ai.sessionUpdate(bookId, sessionId, {
|
||||
context: { ...session.context, knowledgeIds: next }
|
||||
})
|
||||
).then((updated) => {
|
||||
useAiStore.setState((s) => ({
|
||||
sessions: s.sessions.map((x) => (x.id === updated.id ? updated : x))
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
const handleConfirmContext = (): void => {
|
||||
const session = sessions.find((s) => s.id === sessionId)
|
||||
if (session) void confirmContext(bookId, session.context)
|
||||
}
|
||||
|
||||
const handleContextClose = (): void => {
|
||||
setContextOpen(false)
|
||||
const session = useAiStore.getState().sessions.find(
|
||||
@@ -178,15 +242,33 @@ export function WizardPanel({ bookId, sessionId, disabled }: WizardPanelProps):
|
||||
{step === 'wizard_context' && (
|
||||
<div className="interactive-section">
|
||||
<p>{t('wizard.contextHint')}</p>
|
||||
{knowledgeAutoSuggest && (
|
||||
<div className="wizard-knowledge-section" data-testid="wizard-knowledge-suggest">
|
||||
<h4>{t('interactive.confirmKnowledge')}</h4>
|
||||
<KnowledgeSuggestList
|
||||
items={suggested}
|
||||
selectedIds={knowledgeIds}
|
||||
onToggle={toggleKnowledge}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
className="btn"
|
||||
data-testid="wizard-context-edit"
|
||||
disabled={disabled}
|
||||
onClick={() => setContextOpen(true)}
|
||||
>
|
||||
{t('ai.contextEditorTitle')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
disabled={disabled}
|
||||
onClick={handleConfirmContext}
|
||||
>
|
||||
{t('wizard.next')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -230,7 +312,7 @@ export function WizardPanel({ bookId, sessionId, disabled }: WizardPanelProps):
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ContextEditorModal open={contextOpen} onClose={handleContextClose} />
|
||||
<ContextEditorModal open={contextOpen} onClose={handleContextClose} goalText={goalText} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ export function SettingsPage(): React.JSX.Element {
|
||||
{section === 'shortcuts' && <ShortcutEditor />}
|
||||
{section === 'about' && (
|
||||
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
||||
{t('app.name')} v0.6.0
|
||||
{t('app.name')} v0.7.0
|
||||
<br />
|
||||
{t('app.tagline')}
|
||||
</p>
|
||||
|
||||
@@ -4,13 +4,14 @@ 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 }
|
||||
const counts = { chapter: 0, outline: 0, setting: 0, inspiration: 0, knowledge: 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
|
||||
inspirations: counts.inspiration,
|
||||
knowledge: counts.knowledge
|
||||
})
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ interface AutoState {
|
||||
loadFlow: (bookId: string, sessionId: string) => Promise<void>
|
||||
start: (bookId: string, sessionId: string) => Promise<void>
|
||||
setGoal: (bookId: string, config: Partial<AutoWritingConfig>) => Promise<void>
|
||||
confirmContext: (bookId: string) => Promise<void>
|
||||
planScenes: (bookId: string) => Promise<void>
|
||||
generateNext: (bookId: string) => Promise<void>
|
||||
resolveNaming: (bookId: string, chosenName: string) => Promise<void>
|
||||
@@ -64,11 +65,24 @@ export const useAutoStore = create<AutoState>((set, get) => ({
|
||||
set({ flow })
|
||||
},
|
||||
|
||||
confirmContext: async (bookId) => {
|
||||
const flowId = get().flow?.id
|
||||
const session = useAiStore.getState().sessions.find(
|
||||
(s) => s.id === useAiStore.getState().activeSessionId
|
||||
)
|
||||
if (!flowId || !session) return
|
||||
const flow = await ipcCall(() =>
|
||||
window.electronAPI.auto.confirmContext(bookId, flowId, session.context)
|
||||
)
|
||||
set({ flow })
|
||||
},
|
||||
|
||||
planScenes: async (bookId) => {
|
||||
const flowId = get().flow?.id
|
||||
if (!flowId) return
|
||||
set({ streaming: true })
|
||||
try {
|
||||
await get().confirmContext(bookId)
|
||||
const summary = useAiStore.getState().contextSummary
|
||||
const flow = await ipcCall(() =>
|
||||
window.electronAPI.auto.planScenes(bookId, flowId, summary)
|
||||
|
||||
@@ -67,7 +67,8 @@ export const useInteractiveStore = create<InteractiveState>((set, get) => ({
|
||||
chapterIds: [],
|
||||
outlineIds: [],
|
||||
settingIds: [],
|
||||
inspirationIds: []
|
||||
inspirationIds: [],
|
||||
knowledgeIds: []
|
||||
}
|
||||
const flow = await ipcCall(() =>
|
||||
window.electronAPI.interactive.confirmContext(bookId, flowId, binding)
|
||||
|
||||
@@ -22,6 +22,8 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
shortcuts: {} as Record<ActionId, string>,
|
||||
aiConfig: { ...DEFAULT_AI_CONFIG },
|
||||
namingIgnoreWords: [] as string[],
|
||||
knowledgeAutoSuggest: true,
|
||||
knowledgeSuggestTopN: 8,
|
||||
loaded: false,
|
||||
load: async () => {
|
||||
const data = await ipcCall(() => window.electronAPI.settings.get())
|
||||
|
||||
Vendored
+11
@@ -35,6 +35,8 @@ import type {
|
||||
Volume,
|
||||
WordFreqResult,
|
||||
KnowledgeEntry,
|
||||
KnowledgeSuggestOptions,
|
||||
ScoredKnowledge,
|
||||
CreateKnowledgeInput,
|
||||
KnowledgeStatus,
|
||||
KnowledgeType,
|
||||
@@ -263,6 +265,11 @@ export interface ElectronAPI {
|
||||
flowId: string,
|
||||
config: Partial<AutoWritingConfig>
|
||||
) => Promise<IpcResult<InteractiveFlow>>
|
||||
confirmContext: (
|
||||
bookId: string,
|
||||
flowId: string,
|
||||
binding: AiContextBinding
|
||||
) => Promise<IpcResult<InteractiveFlow>>
|
||||
planScenes: (
|
||||
bookId: string,
|
||||
flowId: string,
|
||||
@@ -338,6 +345,10 @@ export interface ElectronAPI {
|
||||
stats: (
|
||||
bookId: string
|
||||
) => Promise<IpcResult<{ buried: number; resolved: number; forgotten: number }>>
|
||||
suggestContext: (
|
||||
bookId: string,
|
||||
opts?: KnowledgeSuggestOptions
|
||||
) => Promise<IpcResult<ScoredKnowledge[]>>
|
||||
}
|
||||
cockpit: {
|
||||
getSummary: (bookId: string, volumeId?: string) => Promise<IpcResult<CockpitSummary>>
|
||||
|
||||
@@ -82,6 +82,7 @@ export const IPC = {
|
||||
AUTO_GET_FLOW: 'auto:getFlow',
|
||||
AUTO_START: 'auto:start',
|
||||
AUTO_SET_GOAL: 'auto:setGoal',
|
||||
AUTO_CONFIRM_CONTEXT: 'auto:confirmContext',
|
||||
AUTO_PLAN_SCENES: 'auto:planScenes',
|
||||
AUTO_GENERATE: 'auto:generate',
|
||||
AUTO_RESOLVE_NAMING: 'auto:resolveNaming',
|
||||
@@ -110,6 +111,7 @@ export const IPC = {
|
||||
KNOWLEDGE_BATCH_APPROVE: 'knowledge:batchApprove',
|
||||
KNOWLEDGE_CREATE_FROM_BOOKMARK: 'knowledge:createFromBookmark',
|
||||
KNOWLEDGE_STATS: 'knowledge:stats',
|
||||
KNOWLEDGE_SUGGEST_CONTEXT: 'knowledge:suggestContext',
|
||||
COCKPIT_SUMMARY: 'cockpit:summary',
|
||||
COCKPIT_MARK_SEEN: 'cockpit:markSeen',
|
||||
COCKPIT_SHOULD_SHOW: 'cockpit:shouldShowOnOpen',
|
||||
|
||||
+23
-1
@@ -99,6 +99,24 @@ export interface ChapterBridgeResult {
|
||||
aiSuggestion?: string
|
||||
}
|
||||
|
||||
export interface KnowledgeSuggestOptions {
|
||||
currentChapterId?: string
|
||||
goalText?: string
|
||||
topN?: number
|
||||
}
|
||||
|
||||
export interface ScoredKnowledge {
|
||||
entry: KnowledgeEntry
|
||||
score: number
|
||||
reasons: string[]
|
||||
}
|
||||
|
||||
export interface FlowContextPayload {
|
||||
binding: AiContextBinding
|
||||
systemPrompt: string
|
||||
contextSummary: string
|
||||
}
|
||||
|
||||
export interface GlobalSettings {
|
||||
penName: string
|
||||
onboardingCompleted: boolean
|
||||
@@ -114,6 +132,8 @@ export interface GlobalSettings {
|
||||
searchHistory: string[]
|
||||
aiConfig: AiConfig
|
||||
namingIgnoreWords: string[]
|
||||
knowledgeAutoSuggest?: boolean
|
||||
knowledgeSuggestTopN?: number
|
||||
}
|
||||
|
||||
export interface BookMeta {
|
||||
@@ -274,6 +294,7 @@ export interface AiContextBinding {
|
||||
outlineIds: string[]
|
||||
settingIds: string[]
|
||||
inspirationIds: string[]
|
||||
knowledgeIds: string[]
|
||||
}
|
||||
|
||||
export interface AiSession {
|
||||
@@ -312,7 +333,7 @@ export interface AiNetworkStatusEvent {
|
||||
online: boolean
|
||||
}
|
||||
|
||||
export type ContextPreviewKind = 'chapter' | 'outline' | 'setting' | 'inspiration'
|
||||
export type ContextPreviewKind = 'chapter' | 'outline' | 'setting' | 'inspiration' | 'knowledge'
|
||||
|
||||
export interface ContextPreviewItem {
|
||||
kind: ContextPreviewKind
|
||||
@@ -320,6 +341,7 @@ export interface ContextPreviewItem {
|
||||
title: string
|
||||
excerpt: string
|
||||
charCount: number
|
||||
knowledgeType?: KnowledgeType
|
||||
}
|
||||
|
||||
export interface AiContextBuildResult {
|
||||
|
||||
Reference in New Issue
Block a user