This commit is contained in:
2026-05-08 11:44:42 +08:00
parent b08127031c
commit abf1f1ebea
249 changed files with 39411 additions and 887 deletions
@@ -0,0 +1,38 @@
import type { VisualNovelRunSnapshot } from '../../../packages/shared/src/contracts/visualNovel';
type VisualNovelAttributePanelProps = {
run: VisualNovelRunSnapshot;
};
export function VisualNovelAttributePanel({ run }: VisualNovelAttributePanelProps) {
const metrics = Object.entries(run.metrics);
if (metrics.length === 0) {
return (
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/74 px-4 py-5 text-center text-sm font-semibold text-[var(--platform-text-soft)]">
暂无属性
</div>
);
}
return (
<div className="space-y-3">
{metrics.map(([key, value]) => (
<div key={key}>
<div className="mb-1 flex items-center justify-between text-xs font-bold text-[var(--platform-text-soft)]">
<span>{key}</span>
<span>{value}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-[var(--platform-track-fill)]">
<div
className="h-full rounded-full bg-[var(--platform-button-primary-fill)]"
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
/>
</div>
</div>
))}
</div>
);
}
export default VisualNovelAttributePanel;
@@ -0,0 +1,112 @@
import { RotateCcw } from 'lucide-react';
import type {
VisualNovelHistoryEntry,
VisualNovelRunSnapshot,
VisualNovelRuntimeStep,
} from '../../../packages/shared/src/contracts/visualNovel';
type VisualNovelHistoryPanelProps = {
run: VisualNovelRunSnapshot;
allowRegeneration?: boolean;
isBusy?: boolean;
onRegenerateHistoryEntry?: (entryId: string) => void;
};
function renderStepText(step: VisualNovelRuntimeStep) {
if (step.type === 'narration') {
return step.text;
}
if (step.type === 'dialogue') {
return `${step.characterName}: ${step.text}`;
}
if (step.type === 'transition') {
return step.text ?? '';
}
if (step.type === 'choice') {
return step.choices.map((choice) => choice.text).join(' / ');
}
return '';
}
function VisualNovelHistoryEntryCard({
entry,
allowRegeneration,
isBusy,
onRegenerateHistoryEntry,
}: {
entry: VisualNovelHistoryEntry;
allowRegeneration: boolean;
isBusy: boolean;
onRegenerateHistoryEntry?: (entryId: string) => void;
}) {
const visibleStepTexts = entry.steps.map(renderStepText).filter(Boolean);
const canRegenerate =
allowRegeneration && entry.source === 'assistant' && onRegenerateHistoryEntry;
return (
<article className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/78 p-3">
<div className="mb-2 flex items-center justify-between gap-3 text-xs font-bold text-[var(--platform-text-soft)]">
<span>#{entry.turnIndex}</span>
<span>{entry.source === 'player' ? '玩家' : '故事'}</span>
</div>
{entry.actionText ? (
<p className="m-0 break-words text-sm leading-6 text-[var(--platform-text-strong)]">
{entry.actionText}
</p>
) : null}
{visibleStepTexts.map((text, index) => (
<p
key={`${entry.entryId}-${index}`}
className="m-0 mt-2 break-words text-sm leading-6 text-[var(--platform-text-base)]"
>
{text}
</p>
))}
{canRegenerate ? (
<button
type="button"
disabled={isBusy}
onClick={() => onRegenerateHistoryEntry(entry.entryId)}
className="mt-3 inline-flex min-h-9 items-center gap-2 rounded-full border border-[var(--platform-subpanel-border)] bg-white/86 px-3 text-xs font-black text-[var(--platform-text-strong)] transition hover:bg-white disabled:cursor-not-allowed disabled:opacity-55"
>
<RotateCcw className="h-3.5 w-3.5" />
<span>重生成</span>
</button>
) : null}
</article>
);
}
export function VisualNovelHistoryPanel({
run,
allowRegeneration = false,
isBusy = false,
onRegenerateHistoryEntry,
}: VisualNovelHistoryPanelProps) {
return (
<div className="space-y-3">
{run.history.length > 0 ? (
run.history.map((entry) => (
<VisualNovelHistoryEntryCard
key={entry.entryId}
entry={entry}
allowRegeneration={allowRegeneration}
isBusy={isBusy}
onRegenerateHistoryEntry={onRegenerateHistoryEntry}
/>
))
) : (
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/74 px-4 py-5 text-center text-sm font-semibold text-[var(--platform-text-soft)]">
暂无历史
</div>
)}
</div>
);
}
export default VisualNovelHistoryPanel;
@@ -0,0 +1,142 @@
import { BarChart3, Bookmark, History, Settings, X } from 'lucide-react';
import { createPortal } from 'react-dom';
import type { ProfileSaveArchiveSummary } from '../../../packages/shared/src/contracts/runtime';
import type {
VisualNovelResultDraft,
VisualNovelRunSnapshot,
} from '../../../packages/shared/src/contracts/visualNovel';
import { VisualNovelAttributePanel } from './VisualNovelAttributePanel';
import { VisualNovelHistoryPanel } from './VisualNovelHistoryPanel';
import { VisualNovelSavePanel } from './VisualNovelSavePanel';
import { VisualNovelSettingsPanel } from './VisualNovelSettingsPanel';
export type VisualNovelRuntimePanelKind =
| 'history'
| 'save'
| 'settings'
| 'attributes';
type VisualNovelRuntimePanelProps = {
kind: VisualNovelRuntimePanelKind;
draft: VisualNovelResultDraft;
run: VisualNovelRunSnapshot;
isBusy?: boolean;
isSaving?: boolean;
isLoadingArchives?: boolean;
resumingWorldKey?: string | null;
saveArchives?: ProfileSaveArchiveSummary[];
onClose: () => void;
onRegenerateHistoryEntry?: (entryId: string) => void;
onSaveRun?: () => void;
onResumeSaveArchive?: (worldKey: string) => void;
textModeEnabled?: boolean;
onTextModeChange?: (enabled: boolean) => void;
allowRegeneration?: boolean;
};
const PANEL_META: Record<
VisualNovelRuntimePanelKind,
{ title: string; icon: typeof History }
> = {
history: { title: '历史', icon: History },
save: { title: '存档', icon: Bookmark },
settings: { title: '设置', icon: Settings },
attributes: { title: '属性', icon: BarChart3 },
};
export function VisualNovelRuntimePanel({
kind,
draft,
run,
isBusy = false,
isSaving = false,
isLoadingArchives = false,
resumingWorldKey = null,
saveArchives = [],
onClose,
onRegenerateHistoryEntry,
onSaveRun,
onResumeSaveArchive,
textModeEnabled = false,
onTextModeChange,
allowRegeneration = false,
}: VisualNovelRuntimePanelProps) {
if (typeof document === 'undefined') {
return null;
}
const meta = PANEL_META[kind];
const Icon = meta.icon;
return createPortal(
<div
className="platform-overlay fixed inset-0 z-[150] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4"
onClick={(event) => {
if (event.target === event.currentTarget) {
onClose();
}
}}
>
<section
role="dialog"
aria-modal="true"
aria-label={meta.title}
className="platform-modal-shell platform-remap-surface flex max-h-[min(84vh,40rem)] w-full max-w-lg flex-col overflow-hidden rounded-t-[1.45rem] sm:rounded-[1.45rem]"
onClick={(event) => event.stopPropagation()}
>
<header className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-[var(--platform-neutral-bg)] text-[var(--platform-neutral-text)]">
<Icon className="h-4 w-4" />
</span>
<h2 className="truncate text-base font-black text-[var(--platform-text-strong)]">
{meta.title}
</h2>
</div>
<button
type="button"
className="platform-icon-button h-9 w-9"
onClick={onClose}
aria-label="关闭"
title="关闭"
>
<X className="h-4 w-4" />
</button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
{kind === 'history' ? (
<VisualNovelHistoryPanel
run={run}
allowRegeneration={allowRegeneration}
isBusy={isBusy}
onRegenerateHistoryEntry={onRegenerateHistoryEntry}
/>
) : null}
{kind === 'save' ? (
<VisualNovelSavePanel
run={run}
saveArchives={saveArchives}
isSaving={isSaving}
isLoadingArchives={isLoadingArchives}
resumingWorldKey={resumingWorldKey}
onSaveRun={onSaveRun}
onResumeSaveArchive={onResumeSaveArchive}
/>
) : null}
{kind === 'settings' ? (
<VisualNovelSettingsPanel
draft={draft}
textModeEnabled={textModeEnabled}
onTextModeChange={onTextModeChange}
/>
) : null}
{kind === 'attributes' ? <VisualNovelAttributePanel run={run} /> : null}
</div>
</section>
</div>,
document.body,
);
}
export default VisualNovelRuntimePanel;
@@ -0,0 +1,165 @@
/* @vitest-environment jsdom */
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import { buildVisualNovelForbiddenCopyPattern } from './visualNovelForbiddenCopy';
import { mockVisualNovelDraft, mockVisualNovelRun } from './visualNovelMockData';
import { VisualNovelRuntimeShell } from './VisualNovelRuntimeShell';
test('visual novel runtime renders mock play surface and opens panels as dialogs', async () => {
const user = userEvent.setup();
render(
<VisualNovelRuntimeShell
draft={mockVisualNovelDraft}
run={mockVisualNovelRun}
onBack={() => {}}
/>,
);
expect(screen.getByText('风雪站台')).toBeTruthy();
expect(screen.getAllByText('林遥').length).toBeGreaterThan(0);
expect(screen.queryByText(buildVisualNovelForbiddenCopyPattern())).toBeNull();
await user.click(screen.getByRole('button', { name: '历史' }));
const dialog = screen.getByRole('dialog', { name: '历史' });
expect(within(dialog).getByText('#1')).toBeTruthy();
});
test('visual novel runtime submits free text action with client event id', async () => {
const user = userEvent.setup();
const onSubmitAction = vi.fn();
render(
<VisualNovelRuntimeShell
draft={mockVisualNovelDraft}
run={mockVisualNovelRun}
onBack={() => {}}
onSubmitAction={onSubmitAction}
/>,
);
await user.type(screen.getByLabelText('输入行动'), '检查广播柜');
await user.click(screen.getByRole('button', { name: '发送行动' }));
expect(onSubmitAction).toHaveBeenCalledWith(
expect.objectContaining({
actionKind: 'free_text',
text: '检查广播柜',
}),
);
expect(onSubmitAction.mock.calls[0]?.[0].clientEventId).toMatch(
/^vn-free-text-/u,
);
});
test('visual novel runtime submits choice and continue actions', async () => {
const user = userEvent.setup();
const onSubmitAction = vi.fn();
const onContinue = vi.fn();
const runWithoutChoices = {
...mockVisualNovelRun,
availableChoices: [],
};
const { rerender } = render(
<VisualNovelRuntimeShell
draft={mockVisualNovelDraft}
run={mockVisualNovelRun}
onBack={() => {}}
onSubmitAction={onSubmitAction}
onContinue={onContinue}
/>,
);
await user.click(screen.getByRole('button', { name: '靠近广播柜,确认频段来源。' }));
expect(onSubmitAction).toHaveBeenCalledWith(
expect.objectContaining({
actionKind: 'choice',
choiceId: 'vn-choice-radio',
}),
);
expect(onSubmitAction.mock.calls[0]?.[0].clientEventId).toMatch(
/^vn-choice-/u,
);
rerender(
<VisualNovelRuntimeShell
draft={mockVisualNovelDraft}
run={runWithoutChoices}
onBack={() => {}}
onSubmitAction={onSubmitAction}
onContinue={onContinue}
/>,
);
await user.click(screen.getByRole('button', { name: '继续' }));
expect(onContinue).toHaveBeenCalledTimes(1);
});
test('visual novel runtime panels call regeneration and platform archive actions', async () => {
const user = userEvent.setup();
const onRegenerateHistoryEntry = vi.fn();
const onSaveRun = vi.fn();
const onResumeSaveArchive = vi.fn();
render(
<VisualNovelRuntimeShell
draft={mockVisualNovelDraft}
run={mockVisualNovelRun}
onBack={() => {}}
onRegenerateHistoryEntry={onRegenerateHistoryEntry}
onSaveRun={onSaveRun}
onResumeSaveArchive={onResumeSaveArchive}
saveArchives={[
{
worldKey: 'visual-novel:archive-1',
ownerUserId: 'mock-user',
profileId: 'vn-profile-mock-1',
worldType: 'visual-novel',
worldName: '雪线电台',
subtitle: '风雪站台',
summaryText: '第 2 回合',
coverImageSrc: null,
lastPlayedAt: '2026-05-05T12:00:00.000Z',
},
]}
/>,
);
await user.click(screen.getByRole('button', { name: '历史' }));
await user.click(screen.getByRole('button', { name: '重生成' }));
expect(onRegenerateHistoryEntry).toHaveBeenCalledWith('vn-history-1');
await user.click(screen.getByRole('button', { name: '关闭' }));
await user.click(screen.getByRole('button', { name: '存档' }));
await user.click(screen.getByRole('button', { name: '保存' }));
expect(onSaveRun).toHaveBeenCalledTimes(1);
await user.click(screen.getByText('雪线电台'));
expect(onResumeSaveArchive).toHaveBeenCalledWith('visual-novel:archive-1');
});
test('visual novel runtime shows raw text only as transient stream text', () => {
const transientText = '这是临时流式文本';
render(
<VisualNovelRuntimeShell
draft={mockVisualNovelDraft}
run={mockVisualNovelRun}
streamingText={transientText}
onBack={() => {}}
/>,
);
expect(screen.getByText(transientText)).toBeTruthy();
const textModeBlocks = screen.queryAllByText((content, element) => {
return Boolean(
element?.className.toString().includes('whitespace-pre-line') &&
content.includes(transientText),
);
});
expect(textModeBlocks).toHaveLength(0);
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,102 @@
import { Bookmark, Loader2, Play } from 'lucide-react';
import type { ProfileSaveArchiveSummary } from '../../../packages/shared/src/contracts/runtime';
import type { VisualNovelRunSnapshot } from '../../../packages/shared/src/contracts/visualNovel';
type VisualNovelSavePanelProps = {
run: VisualNovelRunSnapshot;
saveArchives?: ProfileSaveArchiveSummary[];
isSaving?: boolean;
isLoadingArchives?: boolean;
resumingWorldKey?: string | null;
onSaveRun?: () => void;
onResumeSaveArchive?: (worldKey: string) => void;
};
function formatArchiveTime(value: string) {
const timestamp = new Date(value).getTime();
if (Number.isNaN(timestamp)) {
return value;
}
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(timestamp);
}
export function VisualNovelSavePanel({
run,
saveArchives = [],
isSaving = false,
isLoadingArchives = false,
resumingWorldKey = null,
onSaveRun,
onResumeSaveArchive,
}: VisualNovelSavePanelProps) {
return (
<div className="space-y-3">
<button
type="button"
disabled={!onSaveRun || isSaving}
onClick={onSaveRun}
className="flex min-h-12 w-full items-center justify-center gap-2 rounded-full border border-[var(--platform-subpanel-border)] bg-[var(--platform-button-primary-fill)] px-4 text-sm font-black text-white transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-55"
>
{isSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Bookmark className="h-4 w-4" />
)}
<span>{isSaving ? '保存中' : '保存'}</span>
</button>
{isLoadingArchives ? (
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/74 px-4 py-5 text-center text-sm font-semibold text-[var(--platform-text-soft)]">
读取中
</div>
) : saveArchives.length > 0 ? (
<div className="grid gap-3">
{saveArchives.map((entry) => {
const isResuming = resumingWorldKey === entry.worldKey;
return (
<button
key={entry.worldKey}
type="button"
disabled={isResuming || !onResumeSaveArchive}
onClick={() => onResumeSaveArchive?.(entry.worldKey)}
className="flex min-h-20 items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/78 p-3 text-left transition hover:bg-white disabled:cursor-not-allowed disabled:opacity-55"
>
<span className="grid h-11 w-11 shrink-0 place-items-center rounded-[0.85rem] bg-[var(--platform-neutral-bg)] text-[var(--platform-neutral-text)]">
{isResuming ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-black text-[var(--platform-text-strong)]">
{entry.worldName || run.profileId}
</span>
<span className="mt-1 block truncate text-xs font-semibold text-[var(--platform-text-soft)]">
{entry.subtitle || entry.summaryText || run.runId}
</span>
<span className="mt-1 block text-[11px] font-bold text-[var(--platform-text-soft)]">
{formatArchiveTime(entry.lastPlayedAt)}
</span>
</span>
</button>
);
})}
</div>
) : (
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/74 px-4 py-5 text-center text-sm font-semibold text-[var(--platform-text-soft)]">
暂无存档
</div>
)}
</div>
);
}
export default VisualNovelSavePanel;
@@ -0,0 +1,51 @@
import { MessageSquareText } from 'lucide-react';
import type { VisualNovelResultDraft } from '../../../packages/shared/src/contracts/visualNovel';
type VisualNovelSettingsPanelProps = {
draft: VisualNovelResultDraft;
textModeEnabled: boolean;
onTextModeChange?: (enabled: boolean) => void;
};
export function VisualNovelSettingsPanel({
draft,
textModeEnabled,
onTextModeChange,
}: VisualNovelSettingsPanelProps) {
return (
<div className="space-y-2">
<button
type="button"
onClick={() => onTextModeChange?.(!textModeEnabled)}
className="flex min-h-12 w-full items-center justify-between gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/78 px-3 text-left transition hover:bg-white"
>
<span className="flex min-w-0 items-center gap-2 text-sm font-semibold text-[var(--platform-text-strong)]">
<MessageSquareText className="h-4 w-4 shrink-0" />
<span>文本模式</span>
</span>
<span className="rounded-full bg-[var(--platform-neutral-bg)] px-3 py-1 text-xs font-black text-[var(--platform-neutral-text)]">
{textModeEnabled ? '开启' : '关闭'}
</span>
</button>
<div className="flex min-h-12 items-center justify-between gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/78 px-3">
<span className="text-sm font-semibold text-[var(--platform-text-strong)]">
自由输入
</span>
<span className="rounded-full bg-[var(--platform-neutral-bg)] px-3 py-1 text-xs font-black text-[var(--platform-neutral-text)]">
{draft.runtimeConfig.allowFreeTextAction ? '开启' : '关闭'}
</span>
</div>
<div className="flex min-h-12 items-center justify-between gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/78 px-3">
<span className="text-sm font-semibold text-[var(--platform-text-strong)]">
历史重生成
</span>
<span className="rounded-full bg-[var(--platform-neutral-bg)] px-3 py-1 text-xs font-black text-[var(--platform-neutral-text)]">
{draft.runtimeConfig.allowHistoryRegeneration ? '开启' : '关闭'}
</span>
</div>
</div>
);
}
export default VisualNovelSettingsPanel;
@@ -0,0 +1,385 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ProfileSaveArchiveSummary } from '../../../packages/shared/src/contracts/runtime';
import type {
VisualNovelResultDraft,
VisualNovelRunMode,
VisualNovelRunSnapshot,
VisualNovelRuntimeActionRequest,
VisualNovelRuntimeStep,
} from '../../../packages/shared/src/contracts/visualNovel';
import {
buildVisualNovelRuntimeCheckpoint,
buildVisualNovelSaveArchiveState,
visualNovelRuntimeClient,
} from '../../services/visual-novel-runtime';
export type UseVisualNovelRuntimeControllerParams = {
draft?: VisualNovelResultDraft | null;
initialRun?: VisualNovelRunSnapshot | null;
profileId?: string | null;
runId?: string | null;
mode?: VisualNovelRunMode;
autoStart?: boolean;
};
function createVisualNovelClientEventId(kind: string) {
return `vn-${kind}-${Date.now()}-${Math.round(Math.random() * 1_000_000)}`;
}
function resolveVisualNovelErrorMessage(error: unknown, fallback: string) {
return error instanceof Error && error.message.trim()
? error.message.trim()
: fallback;
}
function resolveProfileId(params: UseVisualNovelRuntimeControllerParams) {
return params.profileId?.trim() || params.draft?.profileId?.trim() || null;
}
function resolveRunFromResumeSnapshot(value: unknown) {
if (!value || typeof value !== 'object') {
return null;
}
const snapshot = value as {
currentStory?: { run?: VisualNovelRunSnapshot | null } | null;
gameState?: { runId?: unknown; archiveState?: unknown } | null;
};
return snapshot.currentStory?.run ?? null;
}
function resolveRunIdFromResumeSnapshot(value: unknown) {
if (!value || typeof value !== 'object') {
return null;
}
const snapshot = value as {
gameState?: {
runId?: unknown;
archiveState?: { runId?: unknown } | null;
} | null;
};
const directRunId = snapshot.gameState?.runId;
const archiveRunId = snapshot.gameState?.archiveState?.runId;
if (typeof directRunId === 'string' && directRunId.trim()) {
return directRunId.trim();
}
if (typeof archiveRunId === 'string' && archiveRunId.trim()) {
return archiveRunId.trim();
}
return null;
}
export function useVisualNovelRuntimeController(
params: UseVisualNovelRuntimeControllerParams,
) {
const [run, setRun] = useState<VisualNovelRunSnapshot | null>(
params.initialRun ?? null,
);
const [saveArchives, setSaveArchives] = useState<
ProfileSaveArchiveSummary[]
>([]);
const [streamedSteps, setStreamedSteps] = useState<VisualNovelRuntimeStep[]>(
[],
);
const [streamingText, setStreamingText] = useState('');
const [error, setError] = useState<string | null>(null);
const [isBusy, setIsBusy] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isLoadingArchives, setIsLoadingArchives] = useState(false);
const [resumingWorldKey, setResumingWorldKey] = useState<string | null>(null);
const activeActionAbortRef = useRef<AbortController | null>(null);
const profileId = resolveProfileId(params);
useEffect(() => {
setRun(params.initialRun ?? null);
}, [params.initialRun]);
useEffect(
() => () => {
activeActionAbortRef.current?.abort();
},
[],
);
const loadSaveArchives = useCallback(async () => {
if (!profileId) {
setSaveArchives([]);
return [];
}
setIsLoadingArchives(true);
setError(null);
try {
const entries = await visualNovelRuntimeClient.listSaveArchives(profileId);
setSaveArchives(entries);
return entries;
} catch (loadError) {
setError(resolveVisualNovelErrorMessage(loadError, '读取视觉小说存档失败'));
return [];
} finally {
setIsLoadingArchives(false);
}
}, [profileId]);
const startRun = useCallback(async () => {
if (!profileId || isBusy) {
return null;
}
setIsBusy(true);
setError(null);
setStreamedSteps([]);
setStreamingText('');
try {
const response = await visualNovelRuntimeClient.startRun(profileId, {
profileId,
mode: params.mode ?? 'play',
});
setRun(response.run);
return response.run;
} catch (startError) {
setError(resolveVisualNovelErrorMessage(startError, '启动视觉小说失败'));
return null;
} finally {
setIsBusy(false);
}
}, [isBusy, params.mode, profileId]);
useEffect(() => {
if (run || !params.runId) {
return;
}
let cancelled = false;
setIsBusy(true);
setError(null);
void visualNovelRuntimeClient
.getRun(params.runId)
.then((response) => {
if (!cancelled) {
setRun(response.run);
}
})
.catch((loadError) => {
if (!cancelled) {
setError(resolveVisualNovelErrorMessage(loadError, '读取视觉小说失败'));
}
})
.finally(() => {
if (!cancelled) {
setIsBusy(false);
}
});
return () => {
cancelled = true;
};
}, [params.runId, run]);
useEffect(() => {
if (!params.autoStart || run || params.runId) {
return;
}
void startRun();
}, [params.autoStart, params.runId, run, startRun]);
const submitAction = useCallback(
async (payload: VisualNovelRuntimeActionRequest) => {
if (!run || isBusy) {
return null;
}
activeActionAbortRef.current?.abort();
const abortController = new AbortController();
activeActionAbortRef.current = abortController;
setIsBusy(true);
setError(null);
setStreamedSteps([]);
setStreamingText('');
try {
const nextRun = await visualNovelRuntimeClient.streamAction(
run.runId,
payload,
{
signal: abortController.signal,
onEvent: (event) => {
if (event.type === 'raw_text') {
// 中文注释:raw_text 只用于流式临场反馈,不进入 history 或最终文本模式。
setStreamingText((currentText) => `${currentText}${event.text}`);
}
if (event.type === 'step') {
setStreamedSteps((currentSteps) => [
...currentSteps,
event.step,
]);
}
if (event.type === 'snapshot' || event.type === 'complete') {
setRun(event.run);
}
},
},
);
setRun(nextRun);
setStreamedSteps([]);
setStreamingText('');
return nextRun;
} catch (submitError) {
setError(resolveVisualNovelErrorMessage(submitError, '推进视觉小说失败'));
return null;
} finally {
if (activeActionAbortRef.current === abortController) {
activeActionAbortRef.current = null;
}
setIsBusy(false);
}
},
[isBusy, run],
);
const continueRun = useCallback(
() =>
submitAction({
actionKind: 'continue',
clientEventId: createVisualNovelClientEventId('continue'),
}),
[submitAction],
);
const regenerateFromHistory = useCallback(
async (historyEntryId: string) => {
if (!run || isBusy) {
return null;
}
setIsBusy(true);
setError(null);
setStreamedSteps([]);
setStreamingText('');
try {
const response = await visualNovelRuntimeClient.regenerateRun(run.runId, {
historyEntryId,
clientEventId: createVisualNovelClientEventId('regenerate'),
});
setRun(response.run);
return response.run;
} catch (regenError) {
setError(
resolveVisualNovelErrorMessage(regenError, '重生成视觉小说历史失败'),
);
return null;
} finally {
setIsBusy(false);
}
},
[isBusy, run],
);
const saveCurrentRun = useCallback(async () => {
if (!run || isSaving) {
return null;
}
setIsSaving(true);
setError(null);
try {
const checkpoint = buildVisualNovelRuntimeCheckpoint({ run });
await visualNovelRuntimeClient.putSnapshot(checkpoint);
await loadSaveArchives();
return buildVisualNovelSaveArchiveState(run);
} catch (saveError) {
setError(resolveVisualNovelErrorMessage(saveError, '保存视觉小说失败'));
return null;
} finally {
setIsSaving(false);
}
}, [isSaving, loadSaveArchives, run]);
const resumeSaveArchive = useCallback(
async (worldKey: string) => {
if (!worldKey.trim() || resumingWorldKey) {
return null;
}
setResumingWorldKey(worldKey);
setError(null);
try {
const response =
await visualNovelRuntimeClient.resumeSaveArchive(worldKey);
setSaveArchives((currentEntries) =>
currentEntries.map((entry) =>
entry.worldKey === response.entry.worldKey ? response.entry : entry,
),
);
const restoredRun = resolveRunFromResumeSnapshot(response.snapshot);
if (restoredRun) {
setRun(restoredRun);
return restoredRun;
}
const restoredRunId = resolveRunIdFromResumeSnapshot(response.snapshot);
if (restoredRunId) {
const runResponse =
await visualNovelRuntimeClient.getRun(restoredRunId);
setRun(runResponse.run);
return runResponse.run;
}
return null;
} catch (resumeError) {
setError(resolveVisualNovelErrorMessage(resumeError, '恢复视觉小说失败'));
return null;
} finally {
setResumingWorldKey(null);
}
},
[resumingWorldKey],
);
const currentArchiveState = useMemo(
() => (run ? buildVisualNovelSaveArchiveState(run) : null),
[run],
);
return {
draft: params.draft ?? null,
run,
setRun,
currentArchiveState,
saveArchives,
streamedSteps,
streamingText,
error,
isBusy,
isSaving,
isLoadingArchives,
resumingWorldKey,
startRun,
submitAction,
continueRun,
regenerateFromHistory,
saveCurrentRun,
loadSaveArchives,
resumeSaveArchive,
};
}
export type VisualNovelRuntimeControllerResult = ReturnType<
typeof useVisualNovelRuntimeController
>;
@@ -0,0 +1,13 @@
const VISUAL_NOVEL_FORBIDDEN_COPY_PARTS = [
['回', '放'],
['分享', '回', '放'],
['录', '制'],
['复', '盘'],
];
export function buildVisualNovelForbiddenCopyPattern() {
return new RegExp(
VISUAL_NOVEL_FORBIDDEN_COPY_PARTS.map((parts) => parts.join('')).join('|'),
'u',
);
}
@@ -0,0 +1,455 @@
import type {
VisualNovelAgentSessionSnapshot,
VisualNovelResultDraft,
VisualNovelRunSnapshot,
VisualNovelSourceMode,
} from '../../../packages/shared/src/contracts/visualNovel';
const MOCK_NOW = '2026-05-05T12:00:00.000Z';
// 中文注释:VN-04 只提供 UI 骨架,mock 数据必须保持在前端组件域内,避免被误用成业务真相源。
export const mockVisualNovelDraft: VisualNovelResultDraft = {
profileId: 'vn-profile-mock-1',
workTitle: '雪线电台',
workDescription: '一列停在雪夜边境的列车,牵出旧电台、失踪乘客和未寄出的告白。',
workTags: ['悬疑', '冬夜', '双人叙事'],
coverImageSrc: null,
sourceMode: 'idea',
sourceAssetIds: [],
world: {
title: '北境终点线',
summary: '终年落雪的边境小城依靠一座旧电台维系列车、灯塔和远方来信。',
background:
'十二年前的雪崩掩埋了山间支线,也留下无法解释的夜间广播。现在,最后一班列车重新驶入废弃站台。',
premise: '玩家需要在日出前找出列车停摆的原因,并决定是否公开电台里的旧录音。',
literaryStyle: '克制、冷光感、对白带有细微试探。',
playerRole: '临时接任的列车广播员',
defaultTone: '安静、紧张、带一点温柔',
},
characters: [
{
characterId: 'vn-char-lin-yao',
name: '林遥',
gender: '女',
role: 'main',
appearance: '深灰长外套,围巾边缘有细小电台徽章。',
personality: '谨慎、敏锐,习惯先观察再回答。',
tone: '短句多,情绪压得很低。',
background: '曾在旧电台做夜班实习生,熟悉失踪列车的第一份报案。',
relationshipToPlayer: '玩家的临时搭档',
imageAssets: [],
defaultExpression: 'calm',
isPlayerVisible: false,
},
{
characterId: 'vn-char-he-shen',
name: '赫慎',
gender: '男',
role: 'supporting',
appearance: '车长制服整洁,袖口有被火星燎过的旧痕。',
personality: '礼貌、固执,对列车时刻表近乎偏执。',
tone: '正式、慢速,偶尔忽然沉默。',
background: '事故后仍守着废弃站台的人。',
relationshipToPlayer: '提供线索,也隐瞒关键事故记录',
imageAssets: [],
defaultExpression: 'reserved',
isPlayerVisible: false,
},
],
scenes: [
{
sceneId: 'vn-scene-platform',
name: '风雪站台',
description: '站灯忽明忽暗,远处铁轨被雪压出发亮的线。',
backgroundImageSrc: null,
musicSrc: null,
ambientSoundSrc: null,
availability: 'opening',
phaseIds: ['vn-phase-1'],
},
{
sceneId: 'vn-scene-radio-room',
name: '旧电台室',
description: '木桌上堆着线圈、胶带和没来得及寄出的明信片。',
backgroundImageSrc: null,
musicSrc: null,
ambientSoundSrc: null,
availability: 'phase_locked',
phaseIds: ['vn-phase-2'],
},
],
storyPhases: [
{
phaseId: 'vn-phase-1',
title: '重启站台',
goal: '确认列车为何停在废弃站台。',
summary: '玩家抵达风雪站台,第一次听见旧电台播出自己的名字。',
entryCondition: '开场进入',
exitCondition: '找到车长日志或解开广播频段',
sceneIds: ['vn-scene-platform'],
characterIds: ['vn-char-lin-yao', 'vn-char-he-shen'],
suggestedChoices: ['询问林遥旧电台的来历', '检查站台广播柜', '追问车长日志'],
},
{
phaseId: 'vn-phase-2',
title: '未寄出的录音',
goal: '判断旧录音是否可信。',
summary: '旧电台室中出现十二年前的夜班录音,内容和现实开始互相抵触。',
entryCondition: '完成站台调查',
exitCondition: '选择公开或隐藏录音',
sceneIds: ['vn-scene-radio-room'],
characterIds: ['vn-char-lin-yao'],
suggestedChoices: ['倒回录音最后十秒', '让林遥辨认声音', '检查明信片落款'],
},
],
opening: {
sceneId: 'vn-scene-platform',
narration: '雪落得很慢,像有人把整座车站的时间调低了一格。',
speakerCharacterId: 'vn-char-lin-yao',
firstDialogue: '你听见了吗?电台刚才念出了你的名字。',
initialChoices: [
{
choiceId: 'vn-choice-radio',
text: '靠近广播柜,确认频段来源。',
actionHint: 'investigate_radio',
},
{
choiceId: 'vn-choice-lin',
text: '先问林遥为什么认识这段广播。',
actionHint: 'ask_lin_yao',
},
],
},
runtimeConfig: {
textModeEnabled: true,
defaultTextMode: false,
maxHistoryEntries: 80,
maxAssistantStepCountPerTurn: 8,
allowFreeTextAction: true,
allowHistoryRegeneration: true,
attributePanelMode: 'template_config',
saveArchiveEnabled: true,
},
publishReady: false,
validationIssues: [
{
issueId: 'vn-issue-cover',
code: 'MISSING_COVER_IMAGE',
severity: 'warning',
path: 'coverImageSrc',
message: '封面待补齐。',
},
],
updatedAt: MOCK_NOW,
};
export const mockVisualNovelSession: VisualNovelAgentSessionSnapshot = {
sessionId: 'vn-session-mock-1',
ownerUserId: 'mock-user',
sourceMode: 'idea',
status: 'ready',
messages: [
{
id: 'vn-message-1',
role: 'user',
kind: 'chat',
text: '想做一个雪夜列车和旧电台有关的悬疑视觉小说。',
createdAt: MOCK_NOW,
},
{
id: 'vn-message-2',
role: 'assistant',
kind: 'summary',
text: '底稿已整理到世界观、角色、场景和前两段剧情阶段。',
createdAt: MOCK_NOW,
},
],
draft: mockVisualNovelDraft,
pendingAction: {
actionId: 'vn-action-compile-mock',
kind: 'compile_work_profile',
label: '生成结果页',
},
createdAt: MOCK_NOW,
updatedAt: MOCK_NOW,
};
function createVisualNovelDraftId(prefix: string) {
return `${prefix}-${Date.now()}-${Math.round(Math.random() * 1_000_000)}`;
}
export function createBlankVisualNovelDraft(
sourceMode: VisualNovelSourceMode = 'blank',
): VisualNovelResultDraft {
const draftId = createVisualNovelDraftId('vn-profile-local');
const sceneId = `${draftId}-scene-opening`;
const characterId = `${draftId}-char-main`;
const phaseId = `${draftId}-phase-opening`;
return {
profileId: draftId,
workTitle: '未命名视觉小说',
workDescription: '',
workTags: [],
coverImageSrc: null,
sourceMode,
sourceAssetIds: [],
world: {
title: '',
summary: '',
background: '',
premise: '',
literaryStyle: '',
playerRole: '',
defaultTone: '',
},
characters: [
{
characterId,
name: '',
gender: null,
role: 'main',
appearance: '',
personality: '',
tone: '',
background: '',
relationshipToPlayer: '',
imageAssets: [],
defaultExpression: null,
isPlayerVisible: false,
},
],
scenes: [
{
sceneId,
name: '',
description: '',
backgroundImageSrc: null,
musicSrc: null,
ambientSoundSrc: null,
availability: 'opening',
phaseIds: [phaseId],
},
],
storyPhases: [
{
phaseId,
title: '',
goal: '',
summary: '',
entryCondition: '开场进入',
exitCondition: '',
sceneIds: [sceneId],
characterIds: [characterId],
suggestedChoices: ['', ''],
},
],
opening: {
sceneId,
narration: '',
speakerCharacterId: characterId,
firstDialogue: '',
initialChoices: [
{
choiceId: `${draftId}-choice-1`,
text: '',
actionHint: null,
},
{
choiceId: `${draftId}-choice-2`,
text: '',
actionHint: null,
},
],
},
runtimeConfig: {
textModeEnabled: true,
defaultTextMode: false,
maxHistoryEntries: 80,
maxAssistantStepCountPerTurn: 8,
allowFreeTextAction: true,
allowHistoryRegeneration: true,
attributePanelMode: 'off',
saveArchiveEnabled: true,
},
publishReady: false,
validationIssues: [],
updatedAt: new Date().toISOString(),
};
}
export function createMockVisualNovelSessionFromDraft(
draft: VisualNovelResultDraft,
): VisualNovelAgentSessionSnapshot {
const now = new Date().toISOString();
return {
sessionId: createVisualNovelDraftId('vn-session-local'),
ownerUserId: 'local-user',
sourceMode: draft.sourceMode,
status: 'ready',
messages: [
{
id: createVisualNovelDraftId('vn-message-local'),
role: 'assistant',
kind: 'summary',
text: '已创建可编辑底稿。',
createdAt: now,
},
],
draft,
pendingAction: {
actionId: createVisualNovelDraftId('vn-action-local'),
kind: 'compile_work_profile',
label: '保存草稿',
},
createdAt: now,
updatedAt: now,
};
}
export function createMockVisualNovelRunFromDraft(
draft: VisualNovelResultDraft,
): VisualNovelRunSnapshot {
const now = new Date().toISOString();
const openingSceneId = draft.opening.sceneId ?? draft.scenes[0]?.sceneId ?? null;
const firstPhase = draft.storyPhases.find((phase) =>
openingSceneId ? phase.sceneIds.includes(openingSceneId) : false,
);
const speaker = draft.opening.speakerCharacterId
? draft.characters.find(
(character) =>
character.characterId === draft.opening.speakerCharacterId,
)
: null;
return {
runId: createVisualNovelDraftId('vn-run-local'),
ownerUserId: 'local-user',
profileId: draft.profileId ?? createVisualNovelDraftId('vn-profile-local'),
mode: 'test',
status: 'active',
currentSceneId: openingSceneId,
currentPhaseId: firstPhase?.phaseId ?? draft.storyPhases[0]?.phaseId ?? null,
visibleCharacterIds: draft.characters
.filter((character) => character.role !== 'protagonist')
.map((character) => character.characterId)
.slice(0, 2),
flags: {},
metrics: {},
history: [
{
entryId: createVisualNovelDraftId('vn-history-local'),
runId: 'local-test-run',
turnIndex: 1,
source: 'assistant',
actionText: null,
steps: [
...(openingSceneId
? [
{
type: 'scene_change' as const,
sceneId: openingSceneId,
backgroundImageSrc:
draft.scenes.find((scene) => scene.sceneId === openingSceneId)
?.backgroundImageSrc ?? null,
musicSrc:
draft.scenes.find((scene) => scene.sceneId === openingSceneId)
?.musicSrc ?? null,
},
]
: []),
{
type: 'narration' as const,
text: draft.opening.narration || draft.workDescription || draft.world.summary,
},
...(speaker && draft.opening.firstDialogue
? [
{
type: 'dialogue' as const,
characterId: speaker.characterId,
characterName: speaker.name,
expression: speaker.defaultExpression,
text: draft.opening.firstDialogue,
},
]
: []),
],
snapshotBeforeHash: null,
snapshotAfterHash: 'local-test-snapshot',
createdAt: now,
},
],
availableChoices: draft.opening.initialChoices.filter((choice) =>
choice.text.trim(),
),
textModeEnabled: draft.runtimeConfig.defaultTextMode,
createdAt: now,
updatedAt: now,
};
}
export const mockVisualNovelRun: VisualNovelRunSnapshot = {
runId: 'vn-run-mock-1',
ownerUserId: 'mock-user',
profileId: 'vn-profile-mock-1',
mode: 'test',
status: 'active',
currentSceneId: 'vn-scene-platform',
currentPhaseId: 'vn-phase-1',
visibleCharacterIds: ['vn-char-lin-yao', 'vn-char-he-shen'],
flags: {
radioUnlocked: false,
conductorTrust: 1,
},
metrics: {
tension: 42,
trust: 18,
},
history: [
{
entryId: 'vn-history-1',
runId: 'vn-run-mock-1',
turnIndex: 1,
source: 'assistant',
actionText: null,
steps: [
{
type: 'scene_change',
sceneId: 'vn-scene-platform',
backgroundImageSrc: null,
musicSrc: null,
},
{
type: 'narration',
text: '雪落得很慢,站台灯把铁轨照成两道苍白的线。',
},
{
type: 'dialogue',
characterId: 'vn-char-lin-yao',
characterName: '林遥',
expression: 'calm',
text: '你听见了吗?电台刚才念出了你的名字。',
},
],
snapshotBeforeHash: null,
snapshotAfterHash: 'mock-hash-1',
createdAt: MOCK_NOW,
},
{
entryId: 'vn-history-2',
runId: 'vn-run-mock-1',
turnIndex: 2,
source: 'player',
actionText:
'靠近广播柜,确认频段来源,同时留意林遥是否在隐瞒什么。',
steps: [],
snapshotBeforeHash: 'mock-hash-1',
snapshotAfterHash: 'mock-hash-2',
createdAt: MOCK_NOW,
},
],
availableChoices: mockVisualNovelDraft.opening.initialChoices,
textModeEnabled: true,
createdAt: MOCK_NOW,
updatedAt: MOCK_NOW,
};