{entries.map((entry) => {
@@ -140,9 +153,16 @@ export function ChapterVersionHistoryPanel({
+
{tab === 'preview' ?
{selectedEntry.body} : null}
{tab === 'diff' ?
: null}
+ {tab === 'canon' ? <>
+
按今天启用的规则核对所选版本,不改写当时的审稿记录。
+ {canon ?
:
本章没有启用可核查的硬设定。
}
+ > : null}
{tab === 'links' ? (
草稿采纳提交
diff --git a/src/renderer/src/views/chapters/chapter-titles.css b/src/renderer/src/views/chapters/chapter-titles.css
new file mode 100644
index 0000000..72e35ac
--- /dev/null
+++ b/src/renderer/src/views/chapters/chapter-titles.css
@@ -0,0 +1,22 @@
+.chapter-title-dialog {
+ width: min(48rem, 92vw); max-height: 88vh; overflow: auto; box-sizing: border-box;
+ padding: 1.25rem; color: var(--ink); background: var(--surface); border: 1px solid var(--line); border-radius: 8px;
+}
+.chapter-title-dialog::backdrop { background: rgb(0 0 0 / 35%); }
+.chapter-title-dialog header, .chapter-title-filters { display: flex; gap: .75rem; align-items: center; flex-wrap: wrap; }
+.chapter-title-dialog header { justify-content: space-between; }
+.chapter-title-dialog h2, .chapter-title-dialog h3 { margin: 0; }
+.chapter-title-dialog fieldset { min-width: 0; padding: 0; border: 0; margin: 0; }
+.chapter-title-filters { margin: 1rem 0; }
+.chapter-title-filters label { display: grid; gap: .25rem; }
+.chapter-title-filters input { width: 6em; }
+.chapter-title-grid { overflow: auto; max-height: 36vh; }
+.chapter-title-grid table { width: 100%; border-collapse: collapse; table-layout: fixed; }
+.chapter-title-grid th, .chapter-title-grid td { padding: .4rem; text-align: left; overflow-wrap: anywhere; border-bottom: 1px solid var(--line); }
+.chapter-title-grid th:first-child { width: 4.5em; }
+.chapter-title-grid td input:not([type=checkbox]) { width: 100%; min-width: 0; box-sizing: border-box; }
+.chapter-title-grid td label { display: flex; gap: .4em; align-items: center; }
+.chapter-title-preview, .chapter-title-dialog footer { margin-top: 1rem; }
+.chapter-title-preview code { overflow-wrap: anywhere; }
+.chapter-title-dialog footer { justify-content: flex-end; }
+.chapter-current-title input { flex: 1; min-width: 0; }
diff --git a/src/renderer/src/views/chapters/chapterAiCandidateActionHandlers.ts b/src/renderer/src/views/chapters/chapterAiCandidateActionHandlers.ts
index 6201753..9100f4d 100644
--- a/src/renderer/src/views/chapters/chapterAiCandidateActionHandlers.ts
+++ b/src/renderer/src/views/chapters/chapterAiCandidateActionHandlers.ts
@@ -24,6 +24,7 @@ export interface ChapterCharacterActionContext {
project: Project
characters: Character[]
characterStateFacts: CharacterStateFact[]
+ reviewSourceBodyHash?: string
saveData: SaveDataHandler
setCharacterSuggestions: Dispatch>
setAiMessage: Dispatch>
@@ -83,9 +84,9 @@ export async function createStateChangeCandidate(
const character = characters.find((item) => item.id === suggestion.characterId)
if (!character) return
const timestamp = now()
- const existingFact = characterStateFacts.find(
- (fact) => fact.characterId === suggestion.characterId && fact.key === suggestion.key && fact.status === 'active'
- )
+ const matches = characterStateFacts.filter(f => f.projectId === project.id && f.characterId === suggestion.characterId &&
+ f.category === suggestion.category && f.key === suggestion.key && f.status === 'active')
+ const existingFact = matches.length === 1 ? matches[0] : undefined
const proposedFact: CharacterStateFact = {
id: existingFact?.id ?? newId(),
projectId: project.id,
@@ -125,6 +126,7 @@ export async function createStateChangeCandidate(
reason: suggestion.evidence,
evidence: suggestion.evidence,
source: 'chapter_review',
+ sourceBodyHash: context.reviewSourceBodyHash,
status: 'pending',
createdAt: timestamp,
updatedAt: timestamp
diff --git a/src/renderer/src/views/chapters/chapterAiContext.ts b/src/renderer/src/views/chapters/chapterAiContext.ts
index a3efbe8..0551279 100644
--- a/src/renderer/src/views/chapters/chapterAiContext.ts
+++ b/src/renderer/src/views/chapters/chapterAiContext.ts
@@ -1,10 +1,13 @@
-import type { Chapter, Character, Foreshadowing, Project, StoryBible } from '../../../../shared/types'
+import type { Chapter, Character, CharacterStateFact, Foreshadowing, Project, StoryBible } from '../../../../shared/types'
+import { getRelevantCharacterStatesForPrompt } from '../../../../services/characterState/stateSelection'
+import { formatCharacterStateLedgerSlice } from '../../../../services/promptFormatters/characterFormatters'
interface ChapterAiContextInput {
project: Project
bible: StoryBible | null
chapters: Chapter[]
characters: Character[]
+ characterStateFacts?: CharacterStateFact[]
foreshadowings: Foreshadowing[]
selectedChapter: Chapter | null
}
@@ -19,6 +22,7 @@ export function buildChapterAiContext({
bible,
chapters,
characters,
+ characterStateFacts = [],
foreshadowings,
selectedChapter
}: ChapterAiContextInput): string {
@@ -30,6 +34,10 @@ export function buildChapterAiContext({
.filter(Boolean)
.join('\n')
const mainCharacters = characters.filter((character) => character.isMain).map((character) => character.name).filter(Boolean).join('、')
+ const cast = characters.filter(c => c.projectId === project.id && (c.isMain || (c.name && selectedChapter?.body.includes(c.name))))
+ const facts = selectedChapter ? getRelevantCharacterStatesForPrompt(cast.map(c => c.id), null, selectedChapter.order,
+ characterStateFacts.filter(f => f.projectId === project.id)) : []
+ const ledger = formatCharacterStateLedgerSlice(cast, facts)
const activeForeshadowings = foreshadowings
.filter((item) => item.status !== 'resolved' && item.status !== 'abandoned')
.map((item) => item.title)
@@ -44,6 +52,7 @@ export function buildChapterAiContext({
bible?.mainConflict ? `主线冲突:${bible.mainConflict}` : '',
bible?.immutableFacts ? `重要不可违背设定:${bible.immutableFacts}` : '',
mainCharacters ? `主要角色:${mainCharacters}` : '',
+ ledger ? `【角色状态账本切片】\n${ledger}` : '',
activeForeshadowings ? `未回收伏笔:${activeForeshadowings}` : '',
recentChapters ? `最近章节摘要:\n${recentChapters}` : '未提供最近章节摘要;请只依据当前正文和已给出的项目资料分析,不要补写历史剧情。'
].filter(Boolean).join('\n')
diff --git a/src/renderer/src/views/chapters/useChapterAiDrafts.ts b/src/renderer/src/views/chapters/useChapterAiDrafts.ts
index 37e5824..05e3bd5 100644
--- a/src/renderer/src/views/chapters/useChapterAiDrafts.ts
+++ b/src/renderer/src/views/chapters/useChapterAiDrafts.ts
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { draftContentHash } from '../../../../services/DraftDiagnosticBindingService'
import type {
AIResult,
AppSettings,
@@ -12,6 +13,7 @@ import type {
} from '../../../../shared/types'
interface UseChapterAiDraftsInput {
+ projectId: string
settings: AppSettings
selected: Chapter | null
characters: Character[]
@@ -23,6 +25,7 @@ interface UseChapterAiDraftsInput {
}
export function useChapterAiDrafts({
+ projectId,
settings,
selected,
characters,
@@ -35,6 +38,7 @@ export function useChapterAiDrafts({
const [loadingAction, setLoadingAction] = useState(null)
const [rawAIText, setRawAIText] = useState('')
const [reviewDraft, setReviewDraft] = useState(null)
+ const [reviewSourceBodyHash, setReviewSourceBodyHash] = useState()
const [characterSuggestions, setCharacterSuggestions] = useState([])
const [foreshadowingDraft, setForeshadowingDraft] = useState(null)
const [nextSuggestions, setNextSuggestions] = useState(null)
@@ -45,8 +49,8 @@ export function useChapterAiDrafts({
const getAiService = useCallback(async () => {
const { AIService } = await import('../../../../services/AIService')
- return new AIService(settings)
- }, [settings])
+ return new AIService(settings, { projectId })
+ }, [settings, projectId])
useEffect(() => {
actionTokenRef.current += 1
@@ -54,6 +58,7 @@ export function useChapterAiDrafts({
setLoadingAction(null)
setRawAIText('')
setReviewDraft(null)
+ setReviewSourceBodyHash(undefined)
setCharacterSuggestions([])
setForeshadowingDraft(null)
setNextSuggestions(null)
@@ -102,11 +107,14 @@ export function useChapterAiDrafts({
}, [flushBody, handleResult, selected, setMessage])
const actions = useMemo(() => ({
- generateReview: () => runAction(
- 'review',
- async () => (await getAiService()).generateChapterReview(chapterText(), chapterContext()),
- setReviewDraft
- ),
+ generateReview: () => {
+ let sourceHash: string
+ return runAction('review', async () => {
+ const body = chapterText()
+ sourceHash = draftContentHash(body)
+ return (await getAiService()).generateChapterReview(body, chapterContext())
+ }, review => { setReviewDraft(review); setReviewSourceBodyHash(sourceHash) })
+ },
extractCharacters: () => runAction(
'characters',
async () => (await getAiService()).updateCharacterStates(chapterText(), characters, chapterContext()),
@@ -130,6 +138,7 @@ export function useChapterAiDrafts({
loadingAction,
rawAIText,
reviewDraft,
+ reviewSourceBodyHash,
characterSuggestions,
foreshadowingDraft,
nextSuggestions,
diff --git a/src/renderer/src/views/chapters/useChapterContinuityActions.ts b/src/renderer/src/views/chapters/useChapterContinuityActions.ts
index df8da30..e8de6c6 100644
--- a/src/renderer/src/views/chapters/useChapterContinuityActions.ts
+++ b/src/renderer/src/views/chapters/useChapterContinuityActions.ts
@@ -65,12 +65,12 @@ export function useChapterContinuityActions({ selected, selectedBridge, project,
? current.chapterContinuityBridges.map((item) => (item.id === existing.id ? bridge : item))
: [bridge, ...current.chapterContinuityBridges]
}))
- if (!saved.ok) return
- setAiMessage('已保存下一章衔接状态。')
+ if (saved.ok) setAiMessage('已保存下一章衔接状态。')
+ return saved
}
async function updateContinuityBridgeField(field: keyof ChapterContinuityBridgeSuggestion, value: string) {
- await saveContinuityBridge({ ...(selectedBridge ?? emptyBridgeSuggestion), [field]: value })
+ return saveContinuityBridge({ ...(selectedBridge ?? emptyBridgeSuggestion), [field]: value })
}
return {
diff --git a/src/renderer/src/views/chapters/useChapterTitleActions.tsx b/src/renderer/src/views/chapters/useChapterTitleActions.tsx
new file mode 100644
index 0000000..367ea83
--- /dev/null
+++ b/src/renderer/src/views/chapters/useChapterTitleActions.tsx
@@ -0,0 +1,41 @@
+import { useRef, useState } from 'react'
+import { ListOrdered } from 'lucide-react'
+import type { Chapter } from '../../../../shared/types'
+import type { RunPersistedStorageOperation, SaveDataOutcome } from '../../utils/saveDataState'
+import { ChapterTitleDialog } from './ChapterTitleDialog'
+
+/** A failed field stays pending until that same field has actually been saved. */
+function useChapterFormSaves() {
+ const outstanding = useRef(new Map())
+ const [unsaved, setUnsaved] = useState(false)
+ async function track(keys: string[], save: () => Promise) {
+ const token = Symbol()
+ for (const key of keys) outstanding.current.set(key, token)
+ setUnsaved(true)
+ const result = await save()
+ if (result?.ok) {
+ for (const key of keys) if (outstanding.current.get(key) === token) outstanding.current.delete(key)
+ setUnsaved(outstanding.current.size !== 0)
+ }
+ return result
+ }
+ return { unsaved, track }
+}
+
+export function useChapterTitleActions({ projectId, chapters, run, storageBlocked, editorBusy, onMessage }: {
+ projectId: string; chapters: Chapter[]; run?: RunPersistedStorageOperation
+ storageBlocked?: boolean; editorBusy: boolean; onMessage: (message: string) => void
+}) {
+ const [target, setTarget] = useState<{ singleId: string | null } | null>(null)
+ const forms = useChapterFormSaves()
+ const blocked = !run ? '当前环境不能提交正式章名。'
+ : storageBlocked ? '请先处理数据冲突或未确认的写回。'
+ : editorBusy || forms.unsaved ? '请先完成当前章节表单的保存,再更改章名。' : ''
+ return {
+ blocked, track: forms.track, openSingle: (id: string) => setTarget({ singleId: id }),
+ batchButton: ,
+ dialog: target && run && { setTarget(null); if (message) onMessage(message) }} />
+ }
+}
diff --git a/src/renderer/src/views/characters/CharacterCardReviewPanel.tsx b/src/renderer/src/views/characters/CharacterCardReviewPanel.tsx
new file mode 100644
index 0000000..776c43b
--- /dev/null
+++ b/src/renderer/src/views/characters/CharacterCardReviewPanel.tsx
@@ -0,0 +1,126 @@
+import { useMemo, useRef, useState, type ReactNode } from 'react'
+import { ArrowUpRight, Check, Pencil } from 'lucide-react'
+import type { AppData, Character, CharacterCardReviewCommand, CharacterProfilePatch, ID } from '../../../../shared/types'
+import { createCharacterCardReviewReader, type CardReviewEvidence } from '../../../../services/characterState/cardReview'
+import { getUserFriendlyError } from '../../../../shared/errorUtils'
+import { getNovelDirectorDataApi } from '../../platform/novelDirectorBridge'
+import type { RunPersistedStorageOperation } from '../../utils/saveDataState'
+import { formatDate } from '../../utils/format'
+import { CharacterProfilePanels } from './CharacterProfilePanels'
+
+interface Props {
+ data: AppData
+ character: Character
+ runOperation?: RunPersistedStorageOperation
+ onOpenChapter?: (id: ID) => void
+ children: ReactNode
+}
+
+function display(value: CardReviewEvidence['after'], unit: string) {
+ return value === null ? '未记录' : (Array.isArray(value) ? value.join('、') : String(value)) + unit
+}
+
+export function CharacterCardReviewPanel({ data, character, runOperation, onOpenChapter, children }: Props) {
+ const reader = useMemo(() => createCharacterCardReviewReader(data, character.projectId), [data, character.projectId])
+ const [choice, setChoice] = useState(() => reader.chapters.at(-1) ?? null)
+ const target = reader.chapters.find(c => c.id === choice?.id && c.order === choice.order)
+ const selectionMissing = !target && (Boolean(choice) || reader.chapters.length > 0)
+ const review = useMemo(() => reader.get(character.id, target?.order), [reader, character.id, target?.order])
+ const [draft, setDraft] = useState<{ original: Character; token: string; patch: CharacterProfilePatch } | null>(null)
+ const [message, setMessage] = useState('')
+ const [saving, setSaving] = useState(false)
+ const pending = useRef(false)
+ const old = review.lastCardReview
+ const oldOrder = review.lastReviewedChapter?.chapterOrder ?? old?.chapterOrder
+ const staleDraft = Boolean(draft && draft.token !== review.previewFingerprint)
+ const evidenceRows = review.evidence.length || !review.latestConfirmedFact ? review.evidence : [review.latestConfirmedFact]
+ const canReview = target && review.canMarkReviewed && runOperation && !saving
+ const hasEdits = draft && Object.entries(draft.patch).some(([key, value]) => draft.original[key as keyof CharacterProfilePatch] !== value)
+
+ async function commit() {
+ if (!target || !runOperation || !canReview || staleDraft || pending.current) return
+ const command: CharacterCardReviewCommand = { projectId: character.projectId, characterId: character.id,
+ chapterId: target.id, chapterOrder: target.order, expectedFingerprint: draft?.token ?? review.previewFingerprint,
+ ...(draft ? { decision: 'update', patch: draft.patch } : { decision: 'keep' }) }
+ pending.current = true
+ setSaving(true)
+ setMessage('')
+ try {
+ await runOperation(async current => {
+ const api = getNovelDirectorDataApi()
+ if (typeof api.reviewCharacterCard !== 'function') throw new Error('当前版本不能提交角色卡核对,请更新应用。')
+ const result = await api.reviewCharacterCard(command)
+ const updated = result.character
+ return { storagePath: result.storagePath, data: { ...current, characters: current.characters.map(c =>
+ c.projectId === updated.projectId && c.id === updated.id ? updated : c) } }
+ })
+ setDraft(null)
+ setMessage(`已${command.decision === 'keep' ? '保留原卡' : '更新角色卡'},核对到第 ${target.order} 章。`)
+ } catch (error) {
+ setMessage(getUserFriendlyError(error))
+ } finally {
+ pending.current = false
+ setSaving(false)
+ }
+ }
+
+ return <>
+