feat: ship v0.4.0 with interactive writing mode

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 17:46:16 +08:00
parent 0a054606fe
commit a8e0ba9ac9
39 changed files with 2113 additions and 89 deletions
+55 -13
View File
@@ -9,6 +9,8 @@ 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'
import { InteractivePanel } from '@renderer/components/ai/InteractivePanel'
import { useInteractiveStore } from '@renderer/stores/useInteractiveStore'
const MODES: { id: AiWritingMode; labelKey: string }[] = [
{ id: 'chat', labelKey: 'ai.mode.chat' },
@@ -39,6 +41,9 @@ export function AiPanel(): React.JSX.Element {
const createSession = useAiStore((s) => s.createSession)
const sendMessage = useAiStore((s) => s.sendMessage)
const setWritingMode = useAiStore((s) => s.setWritingMode)
const gateEligible = useInteractiveStore((s) => s.gateEligible)
const checkGate = useInteractiveStore((s) => s.checkGate)
const startInteractive = useInteractiveStore((s) => s.start)
const [input, setInput] = useState('')
const [contextOpen, setContextOpen] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
@@ -57,8 +62,11 @@ export function AiPanel(): React.JSX.Element {
}, [])
useEffect(() => {
if (bookId) void loadSessions(bookId)
}, [bookId, loadSessions])
if (bookId) {
void loadSessions(bookId)
void checkGate(bookId)
}
}, [bookId, loadSessions, checkGate])
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
@@ -86,10 +94,32 @@ export function AiPanel(): React.JSX.Element {
}
const handleModeClick = (mode: AiWritingMode): void => {
if (mode !== 'chat') {
if (mode === 'auto' || mode === 'wizard') {
showToast(t('feature.comingSoon'))
return
}
if (mode === 'interactive') {
void (async () => {
if (bookId) await checkGate(bookId)
if (!useInteractiveStore.getState().gateEligible) {
showToast(t('interactive.gateBlocked'))
return
}
setWritingMode(mode)
const sid = activeSessionId ?? useAiStore.getState().activeSessionId
if (!bookId) return
if (!sid) {
await createSession(bookId)
}
const sessionId = useAiStore.getState().activeSessionId
if (!sessionId) return
await useInteractiveStore.getState().loadFlow(bookId, sessionId)
if (!useInteractiveStore.getState().flow) {
await startInteractive(bookId, sessionId)
}
})()
return
}
setWritingMode(mode)
}
@@ -128,18 +158,28 @@ export function AiPanel(): React.JSX.Element {
</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>
))}
{MODES.map((mode) => {
const interactiveDisabled = mode.id === 'interactive' && !gateEligible
return (
<button
key={mode.id}
type="button"
className={`ai-mode-tab ${writingMode === mode.id ? 'active' : ''}`}
data-testid={`ai-mode-tab-${mode.id}`}
disabled={interactiveDisabled}
title={interactiveDisabled ? t('interactive.gateTooltip') : undefined}
onClick={() => handleModeClick(mode.id)}
>
{t(mode.labelKey)}
</button>
)
})}
</div>
{writingMode === 'interactive' ? (
<InteractivePanel bookId={bookId} sessionId={activeSessionId} disabled={disabled} />
) : (
<>
<button
type="button"
className="context-preview"
@@ -222,6 +262,8 @@ export function AiPanel(): React.JSX.Element {
{t('ai.send')}
</button>
</div>
</>
)}
</div>
<ContextEditorModal open={contextOpen} onClose={() => setContextOpen(false)} />
</>
@@ -0,0 +1,267 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { PlotSelection } from '@shared/types'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
import { useInteractiveStore } from '@renderer/stores/useInteractiveStore'
import { ContextEditorModal } from '@renderer/components/ai/ContextEditorModal'
import { PlotOptionCard } from '@renderer/components/ai/PlotOptionCard'
interface InteractivePanelProps {
bookId: string
sessionId: string | null
disabled: boolean
}
export function InteractivePanel({
bookId,
sessionId,
disabled
}: InteractivePanelProps): React.JSX.Element {
const { t } = useTranslation()
const backend = useSettingsStore((s) => s.aiConfig.backend)
const {
flow,
plots,
streamBuffer,
streaming,
sceneDraft,
loadFlow,
confirmContextAndStart,
selectPlot,
resolveNaming,
refineScene,
acceptScene,
finishChapter,
mount,
unmount
} = useInteractiveStore()
const [contextOpen, setContextOpen] = useState(false)
const [selectedPlots, setSelectedPlots] = useState<Array<'A' | 'B' | 'C'>>([])
const [userNote, setUserNote] = useState('')
const [refineInput, setRefineInput] = useState('')
const [customName, setCustomName] = useState('')
const [chapterTitle, setChapterTitle] = useState('')
useEffect(() => {
mount()
return () => unmount()
}, [mount, unmount])
useEffect(() => {
if (bookId && sessionId) void loadFlow(bookId, sessionId)
}, [bookId, sessionId, loadFlow])
if (!sessionId || !flow) {
return (
<div className="interactive-panel" data-testid="interactive-panel">
<p className="placeholder-box">{t('ai.noSession')}</p>
</div>
)
}
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 handleStart = (): void => {
void confirmContextAndStart(bookId, flow.id)
}
const handleConfirmPlot = (): void => {
if (selectedPlots.length === 0) return
const selection: PlotSelection = { optionIds: selectedPlots, userNote: userNote.trim() || undefined }
void selectPlot(bookId, selection)
setSelectedPlots([])
setUserNote('')
}
const togglePlot = (id: 'A' | 'B' | 'C'): void => {
setSelectedPlots((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]))
}
return (
<>
<div className="interactive-panel" data-testid="interactive-panel">
<div className="interactive-step-bar" data-testid="interactive-step-indicator">
{t('interactive.step.context')} {t('interactive.step.plot')} {t('interactive.step.scene')} {' '}
{t('interactive.step.review')}
</div>
{step === 'context_confirm' && (
<div className="interactive-section">
<p>{flow.contextSummary ?? t('ai.contextEmpty')}</p>
<button type="button" className="btn" onClick={() => setContextOpen(true)}>
{t('ai.contextEditorTitle')}
</button>
<button
type="button"
className="btn primary"
data-testid="interactive-start"
disabled={disabled || streaming}
onClick={handleStart}
>
{t('interactive.start')}
</button>
</div>
)}
{(step === 'plot_suggest' || step === 'plot_select') && (
<div className="interactive-section">
{plots.length === 0 ? (
<p className="placeholder-box">{streaming ? t('ai.sending') : t('interactive.loadingPlots')}</p>
) : (
<>
<div className="plot-options">
{plots.map((p) => (
<PlotOptionCard
key={p.id}
option={p}
selected={selectedPlots.includes(p.id)}
onSelect={() => togglePlot(p.id)}
/>
))}
</div>
<textarea
className="form-control"
placeholder={t('interactive.plotNotePlaceholder')}
value={userNote}
onChange={(e) => setUserNote(e.target.value)}
rows={2}
/>
<button
type="button"
className="btn primary"
data-testid="interactive-confirm-plot"
disabled={disabled || selectedPlots.length === 0 || streaming}
onClick={handleConfirmPlot}
>
{t('interactive.generateScene')}
</button>
</>
)}
</div>
)}
{(step === 'scene_generate' || step === 'scene_review') && !showReview && (
<div className="interactive-section">
<div
className="interactive-scene-preview"
data-testid="interactive-scene-preview"
dangerouslySetInnerHTML={{ __html: previewHtml || '<p>…</p>' }}
/>
</div>
)}
{step === 'naming_pause' && flow.namingPending && !showReview && (
<div className="interactive-section naming-options">
<h4>{t('interactive.namingTitle', { type: flow.namingPending.elementType })}</h4>
<p>{flow.namingPending.context}</p>
{flow.namingPending.options.map((name, i) => (
<button
key={name}
type="button"
className="btn"
data-testid={`naming-option-${i}`}
disabled={disabled || streaming}
onClick={() => void resolveNaming(bookId, name)}
>
{name}
</button>
))}
<div className="naming-custom">
<input
className="form-control"
data-testid="naming-custom"
value={customName}
onChange={(e) => setCustomName(e.target.value)}
placeholder={t('interactive.customName')}
/>
<button
type="button"
className="btn primary"
disabled={disabled || streaming || !customName.trim()}
onClick={() => void resolveNaming(bookId, customName.trim())}
>
{t('interactive.useCustomName')}
</button>
</div>
</div>
)}
{showReview && (
<div className="interactive-section">
<div
className="interactive-scene-preview"
data-testid="interactive-scene-preview"
dangerouslySetInnerHTML={{ __html: previewHtml || '<p>…</p>' }}
/>
<textarea
className="form-control"
data-testid="interactive-refine-input"
placeholder={t('interactive.refinePlaceholder')}
value={refineInput}
onChange={(e) => setRefineInput(e.target.value)}
rows={2}
/>
<div className="interactive-actions">
<button
type="button"
className="btn"
disabled={disabled || streaming || !refineInput.trim()}
onClick={() => {
void refineScene(bookId, refineInput)
setRefineInput('')
}}
>
{t('interactive.refine')}
</button>
<button
type="button"
className="btn primary"
data-testid="interactive-accept-scene"
disabled={disabled || streaming || !sceneDraft.trim()}
onClick={() => void acceptScene(bookId)}
>
{t('interactive.acceptScene')}
</button>
</div>
</div>
)}
{showFinishChapter && (
<div className="interactive-section interactive-finish-section">
<div className="interactive-finish">
<input
className="form-control"
value={chapterTitle}
onChange={(e) => setChapterTitle(e.target.value)}
placeholder={t('interactive.chapterTitlePlaceholder')}
/>
<button
type="button"
className="btn primary"
data-testid="interactive-finish-chapter"
disabled={disabled || streaming}
onClick={() => void finishChapter(bookId, chapterTitle.trim() || undefined)}
>
{t('interactive.finishChapter')}
</button>
</div>
<p className="interactive-scene-count">
{t('interactive.sceneCount', { count: flow.scenes.length })}
</p>
</div>
)}
{disabled && backend !== 'lmstudio' && (
<div className="offline-banner show">{t('ai.offlineBanner')}</div>
)}
</div>
<ContextEditorModal open={contextOpen} onClose={() => setContextOpen(false)} />
</>
)
}
@@ -0,0 +1,21 @@
import type { PlotOption } from '@shared/types'
interface PlotOptionCardProps {
option: PlotOption
selected: boolean
onSelect: () => void
}
export function PlotOptionCard({ option, selected, onSelect }: PlotOptionCardProps): React.JSX.Element {
return (
<button
type="button"
className={`plot-option-card ${selected ? 'selected' : ''}`}
data-testid={`plot-option-${option.id}`}
onClick={onSelect}
>
<span className="opt-label">{option.label}</span>
<p className="plot-option-summary">{option.summary}</p>
</button>
)
}
@@ -1,5 +1,7 @@
import { useTranslation } from 'react-i18next'
import { useAppStore } from '@renderer/stores/useAppStore'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useInteractiveStore } from '@renderer/stores/useInteractiveStore'
import { WordFreqPanel } from '@renderer/components/wordfreq/WordFreqPanel'
import { AiPanel } from '@renderer/components/ai/AiPanel'
import { KnowledgePanel } from '@renderer/components/knowledge/KnowledgePanel'
@@ -24,7 +26,13 @@ export function RightPanel(): React.JSX.Element {
type="button"
className={`panel-tab ${rightPanel === tab.id ? 'active' : ''}`}
data-testid={`panel-tab-${tab.id}`}
onClick={() => setRightPanel(tab.id)}
onClick={() => {
if (tab.id === 'ai') {
const bookId = useBookStore.getState().currentBookId
if (bookId) void useInteractiveStore.getState().checkGate(bookId)
}
setRightPanel(tab.id)
}}
>
{tab.label}
</button>
@@ -103,7 +103,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.3.0
{t('app.name')} v0.4.0
<br />
{t('app.tagline')}
</p>
+198
View File
@@ -0,0 +1,198 @@
import { create } from 'zustand'
import type { InteractiveFlow, PlotOption, PlotSelection } from '@shared/types'
import { ipcCall } from '@renderer/lib/ipc-client'
import { useBookStore } from '@renderer/stores/useBookStore'
import { useAppStore } from '@renderer/stores/useAppStore'
import { useEditStore } from '@renderer/stores/useEditStore'
import { useAiStore } from '@renderer/stores/useAiStore'
import i18n from '@renderer/i18n'
interface InteractiveState {
gateEligible: boolean
gateReason?: string
flow: InteractiveFlow | null
plots: PlotOption[]
streamBuffer: string
streaming: boolean
sceneDraft: string
listenersAttached: boolean
checkGate: (bookId: string) => Promise<void>
loadFlow: (bookId: string, sessionId: string) => Promise<void>
start: (bookId: string, sessionId: string) => Promise<void>
confirmContextAndStart: (bookId: string, flowId: string) => Promise<void>
suggestPlots: (bookId: string) => Promise<void>
selectPlot: (bookId: string, selection: PlotSelection) => Promise<void>
generateScene: (bookId: string) => Promise<void>
resolveNaming: (bookId: string, chosenName: string) => Promise<void>
refineScene: (bookId: string, instruction: string) => Promise<void>
acceptScene: (bookId: string) => Promise<void>
finishChapter: (bookId: string, title?: string) => Promise<void>
refreshSceneDraft: (bookId: string) => Promise<void>
mount: () => void
unmount: () => void
}
let unsubStream: (() => void) | null = null
export const useInteractiveStore = create<InteractiveState>((set, get) => ({
gateEligible: false,
flow: null,
plots: [],
streamBuffer: '',
streaming: false,
sceneDraft: '',
listenersAttached: false,
checkGate: async (bookId) => {
const r = await ipcCall(() => window.electronAPI.interactive.checkGate(bookId))
set({ gateEligible: r.eligible, gateReason: r.reason })
},
loadFlow: async (bookId, sessionId) => {
const flow = await ipcCall(() => window.electronAPI.interactive.getFlow(bookId, sessionId))
set({ flow })
if (flow) await get().refreshSceneDraft(bookId)
},
start: async (bookId, sessionId) => {
const flow = await ipcCall(() => window.electronAPI.interactive.start(bookId, sessionId))
set({ flow, plots: [], streamBuffer: '', sceneDraft: '' })
},
confirmContextAndStart: async (bookId, flowId) => {
const session = useAiStore.getState().sessions.find(
(s) => s.id === useAiStore.getState().activeSessionId
)
const binding = session?.context ?? {
chapterIds: [],
outlineIds: [],
settingIds: [],
inspirationIds: []
}
const flow = await ipcCall(() =>
window.electronAPI.interactive.confirmContext(bookId, flowId, binding)
)
set({ flow })
await get().suggestPlots(bookId)
},
suggestPlots: async (bookId) => {
const flowId = get().flow?.id
if (!flowId) return
set({ streaming: true })
try {
const plots = await ipcCall(() =>
window.electronAPI.interactive.suggestPlots(bookId, flowId)
)
const flow = await ipcCall(() => window.electronAPI.interactive.getFlow(bookId, get().flow!.sessionId))
set({ plots, flow })
} finally {
set({ streaming: false })
}
},
selectPlot: async (bookId, selection) => {
const flowId = get().flow?.id
if (!flowId) return
const flow = await ipcCall(() =>
window.electronAPI.interactive.selectPlot(bookId, flowId, selection)
)
set({ flow, streamBuffer: '', sceneDraft: '' })
await get().generateScene(bookId)
},
generateScene: async (bookId) => {
const flowId = get().flow?.id
if (!flowId) return
set({ streaming: true, streamBuffer: '' })
try {
const flow = await ipcCall(() =>
window.electronAPI.interactive.generateScene(bookId, flowId)
)
set({ flow })
await get().refreshSceneDraft(bookId)
} finally {
set({ streaming: false, streamBuffer: '' })
}
},
resolveNaming: async (bookId, chosenName) => {
const flowId = get().flow?.id
if (!flowId) return
set({ streaming: true, streamBuffer: '' })
try {
const flow = await ipcCall(() =>
window.electronAPI.interactive.resolveNaming(bookId, flowId, chosenName)
)
set({ flow })
await get().refreshSceneDraft(bookId)
} finally {
set({ streaming: false, streamBuffer: '' })
}
},
refineScene: async (bookId, instruction) => {
const flowId = get().flow?.id
if (!flowId || !instruction.trim()) return
set({ streaming: true, streamBuffer: '' })
try {
const flow = await ipcCall(() =>
window.electronAPI.interactive.refineScene(bookId, flowId, instruction.trim())
)
set({ flow })
await get().refreshSceneDraft(bookId)
} finally {
set({ streaming: false, streamBuffer: '' })
}
},
acceptScene: async (bookId) => {
const flowId = get().flow?.id
if (!flowId) return
const flow = await ipcCall(() => window.electronAPI.interactive.acceptScene(bookId, flowId))
set({ flow, plots: [], sceneDraft: '', streamBuffer: '' })
await get().suggestPlots(bookId)
},
finishChapter: async (bookId, title) => {
const flowId = get().flow?.id
const volumeId = useBookStore.getState().activeVolumeId
if (!flowId || !volumeId) throw new Error('no volume')
const { chapterId } = await ipcCall(() =>
window.electronAPI.interactive.finishChapter(bookId, flowId, volumeId, title)
)
await useBookStore.getState().refreshChapters()
await useBookStore.getState().openBook(bookId)
useBookStore.getState().setSelectedChapter(chapterId)
useEditStore.getState().setTarget({ kind: 'chapter', id: chapterId })
useAppStore.getState().setView('editor')
useAppStore.getState().showToast(i18n.t('interactive.chapterCreated'))
},
refreshSceneDraft: async (bookId) => {
const flowId = get().flow?.id
if (!flowId) return
const draft = await ipcCall(() =>
window.electronAPI.interactive.getSceneDraft(bookId, flowId)
)
set({ sceneDraft: draft })
},
mount: () => {
if (get().listenersAttached) return
unsubStream = window.electronAPI.onInteractiveStreamChunk((payload) => {
if (payload.done) return
set((s) => ({
streaming: true,
streamBuffer: s.streamBuffer + payload.delta
}))
})
set({ listenersAttached: true })
},
unmount: () => {
unsubStream?.()
unsubStream = null
set({ listenersAttached: false })
}
}))
+97
View File
@@ -1375,3 +1375,100 @@
.mention-item:hover {
background: var(--bg-hover);
}
.interactive-panel {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 8px;
gap: 10px;
}
.interactive-step-bar {
font-size: 11px;
color: var(--text-muted);
padding-bottom: 6px;
border-bottom: 1px solid var(--border);
}
.interactive-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.plot-options {
display: flex;
flex-direction: column;
gap: 8px;
}
.plot-option-card {
text-align: left;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-surface);
color: var(--text-primary);
cursor: pointer;
}
.plot-option-card.selected {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, var(--bg-surface));
}
.plot-option-card .opt-label {
font-weight: 600;
font-size: 13px;
color: var(--accent-light);
}
.plot-option-summary {
font-size: 12px;
color: var(--text-secondary);
margin-top: 6px;
line-height: 1.5;
}
.interactive-scene-preview {
font-size: 13px;
line-height: 1.7;
color: var(--text-primary);
max-height: 240px;
overflow-y: auto;
padding: 8px;
background: var(--bg-tertiary);
border-radius: var(--radius-sm);
border: 1px solid var(--border);
}
.naming-options .btn {
margin-bottom: 4px;
}
.naming-custom {
display: flex;
gap: 6px;
margin-top: 8px;
}
.interactive-actions,
.interactive-finish {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.interactive-scene-count {
font-size: 11px;
color: var(--text-muted);
}
.ai-mode-tab:disabled {
opacity: 0.45;
cursor: not-allowed;
}