feat: ship v0.4.0 with interactive writing mode
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}))
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user