1
This commit is contained in:
@@ -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;
|
||||
112
src/components/visual-novel-runtime/VisualNovelHistoryPanel.tsx
Normal file
112
src/components/visual-novel-runtime/VisualNovelHistoryPanel.tsx
Normal file
@@ -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;
|
||||
142
src/components/visual-novel-runtime/VisualNovelRuntimePanels.tsx
Normal file
142
src/components/visual-novel-runtime/VisualNovelRuntimePanels.tsx
Normal file
@@ -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);
|
||||
});
|
||||
603
src/components/visual-novel-runtime/VisualNovelRuntimeShell.tsx
Normal file
603
src/components/visual-novel-runtime/VisualNovelRuntimeShell.tsx
Normal file
@@ -0,0 +1,603 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
Bookmark,
|
||||
ChevronRight,
|
||||
History,
|
||||
MessageSquareText,
|
||||
Send,
|
||||
Settings,
|
||||
SlidersHorizontal,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { ProfileSaveArchiveSummary } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type {
|
||||
VisualNovelCharacterDraft,
|
||||
VisualNovelChoiceDraft,
|
||||
VisualNovelDialogueStep,
|
||||
VisualNovelNarrationStep,
|
||||
VisualNovelResultDraft,
|
||||
VisualNovelRunSnapshot,
|
||||
VisualNovelRuntimeActionRequest,
|
||||
VisualNovelRuntimeStep,
|
||||
VisualNovelSceneChangeStep,
|
||||
VisualNovelTransitionStep,
|
||||
} from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import { useVisualNovelRuntimeController } from './useVisualNovelRuntimeController';
|
||||
import { mockVisualNovelDraft, mockVisualNovelRun } from './visualNovelMockData';
|
||||
import {
|
||||
VisualNovelRuntimePanel,
|
||||
type VisualNovelRuntimePanelKind,
|
||||
} from './VisualNovelRuntimePanels';
|
||||
|
||||
type VisualNovelRuntimeShellProps = {
|
||||
draft?: VisualNovelResultDraft | null;
|
||||
run?: VisualNovelRunSnapshot | null;
|
||||
isBusy?: boolean;
|
||||
isSaving?: boolean;
|
||||
isLoadingArchives?: boolean;
|
||||
resumingWorldKey?: string | null;
|
||||
error?: string | null;
|
||||
streamedSteps?: VisualNovelRuntimeStep[];
|
||||
streamingText?: string;
|
||||
saveArchives?: ProfileSaveArchiveSummary[];
|
||||
onBack: () => void;
|
||||
onSubmitAction?: (payload: VisualNovelRuntimeActionRequest) => void;
|
||||
onContinue?: () => void;
|
||||
onRegenerateHistoryEntry?: (entryId: string) => void;
|
||||
onSaveRun?: () => void;
|
||||
onResumeSaveArchive?: (worldKey: string) => void;
|
||||
onTextModeChange?: (enabled: boolean) => void;
|
||||
};
|
||||
|
||||
type VisualNovelDisplayState = {
|
||||
sceneStep: VisualNovelSceneChangeStep | null;
|
||||
narrationStep: VisualNovelNarrationStep | null;
|
||||
dialogueStep: VisualNovelDialogueStep | null;
|
||||
transitionStep: VisualNovelTransitionStep | null;
|
||||
choiceStep: VisualNovelRuntimeStep | null;
|
||||
};
|
||||
|
||||
function buildClientEventId(kind: string) {
|
||||
return `vn-${kind}-${Date.now()}-${Math.round(Math.random() * 1_000_000)}`;
|
||||
}
|
||||
|
||||
function collectRuntimeSteps(
|
||||
run: VisualNovelRunSnapshot,
|
||||
streamedSteps: VisualNovelRuntimeStep[],
|
||||
) {
|
||||
return [
|
||||
...run.history.flatMap((entry) => entry.steps),
|
||||
...streamedSteps,
|
||||
];
|
||||
}
|
||||
|
||||
function resolveLatestStep<T extends VisualNovelRuntimeStep['type']>(
|
||||
steps: VisualNovelRuntimeStep[],
|
||||
type: T,
|
||||
) {
|
||||
return [...steps].reverse().find((step) => step.type === type) as
|
||||
| Extract<VisualNovelRuntimeStep, { type: T }>
|
||||
| undefined;
|
||||
}
|
||||
|
||||
function resolveDisplayState(
|
||||
run: VisualNovelRunSnapshot,
|
||||
streamedSteps: VisualNovelRuntimeStep[],
|
||||
): VisualNovelDisplayState {
|
||||
const steps = collectRuntimeSteps(run, streamedSteps);
|
||||
|
||||
return {
|
||||
sceneStep: resolveLatestStep(steps, 'scene_change') ?? null,
|
||||
narrationStep: resolveLatestStep(steps, 'narration') ?? null,
|
||||
dialogueStep: resolveLatestStep(steps, 'dialogue') ?? null,
|
||||
transitionStep: resolveLatestStep(steps, 'transition') ?? null,
|
||||
choiceStep: resolveLatestStep(steps, 'choice') ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveSceneId(
|
||||
run: VisualNovelRunSnapshot,
|
||||
sceneStep: VisualNovelSceneChangeStep | null,
|
||||
) {
|
||||
return sceneStep?.sceneId ?? run.currentSceneId;
|
||||
}
|
||||
|
||||
function resolveSceneName(draft: VisualNovelResultDraft, sceneId: string | null) {
|
||||
return draft.scenes.find((scene) => scene.sceneId === sceneId)?.name ?? '';
|
||||
}
|
||||
|
||||
function resolveSceneBackground(
|
||||
draft: VisualNovelResultDraft,
|
||||
sceneId: string | null,
|
||||
sceneStep: VisualNovelSceneChangeStep | null,
|
||||
) {
|
||||
return (
|
||||
sceneStep?.backgroundImageSrc ??
|
||||
draft.scenes.find((scene) => scene.sceneId === sceneId)?.backgroundImageSrc ??
|
||||
draft.coverImageSrc
|
||||
);
|
||||
}
|
||||
|
||||
function resolveVisibleCharacters(
|
||||
draft: VisualNovelResultDraft,
|
||||
run: VisualNovelRunSnapshot,
|
||||
latestDialogue: VisualNovelDialogueStep | null,
|
||||
) {
|
||||
const visibleIds = new Set(run.visibleCharacterIds);
|
||||
if (latestDialogue?.characterId) {
|
||||
visibleIds.add(latestDialogue.characterId);
|
||||
}
|
||||
|
||||
return Array.from(visibleIds)
|
||||
.map((characterId) =>
|
||||
draft.characters.find((character) => character.characterId === characterId),
|
||||
)
|
||||
.filter((character): character is VisualNovelCharacterDraft =>
|
||||
Boolean(character),
|
||||
)
|
||||
.slice(0, 3);
|
||||
}
|
||||
|
||||
function resolveCharacterImage(character: VisualNovelCharacterDraft) {
|
||||
if (character.imageAssets.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
character.imageAssets.find(
|
||||
(asset) => asset.expression === character.defaultExpression,
|
||||
)?.imageSrc ?? character.imageAssets[0]?.imageSrc ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function VisualNovelCharacterStandee({
|
||||
character,
|
||||
index,
|
||||
active,
|
||||
}: {
|
||||
character: VisualNovelCharacterDraft;
|
||||
index: number;
|
||||
active: boolean;
|
||||
}) {
|
||||
const imageSrc = resolveCharacterImage(character);
|
||||
const palette =
|
||||
index % 2 === 0
|
||||
? 'from-sky-100/90 via-slate-100/78 to-slate-300/72'
|
||||
: 'from-rose-100/90 via-zinc-100/78 to-stone-300/72';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex min-w-0 flex-col items-center transition ${
|
||||
active ? 'scale-100 opacity-100' : 'scale-[0.96] opacity-78'
|
||||
}`}
|
||||
>
|
||||
{imageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={imageSrc}
|
||||
alt={character.name}
|
||||
className="h-[min(48dvh,21rem)] w-[9rem] object-contain drop-shadow-[0_26px_54px_rgba(0,0,0,0.42)] sm:w-[12rem]"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`h-[min(44dvh,18rem)] w-[7.5rem] rounded-t-full border border-white/18 bg-gradient-to-b ${palette} shadow-[0_26px_58px_rgba(15,23,42,0.32)] sm:w-[10rem]`}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-2 max-w-[8rem] truncate rounded-full border border-white/16 bg-black/28 px-3 py-1 text-xs font-black text-white backdrop-blur">
|
||||
{character.name}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildTextModeLines(
|
||||
run: VisualNovelRunSnapshot,
|
||||
streamedSteps: VisualNovelRuntimeStep[],
|
||||
) {
|
||||
return [...run.history.flatMap((entry) => entry.steps), ...streamedSteps]
|
||||
.filter((step) => step.type === 'narration' || step.type === 'dialogue')
|
||||
.map((step) =>
|
||||
step.type === 'dialogue'
|
||||
? `${step.characterName}: ${step.text}`
|
||||
: step.text,
|
||||
)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function resolveChoices(
|
||||
run: VisualNovelRunSnapshot,
|
||||
choiceStep: VisualNovelRuntimeStep | null,
|
||||
) {
|
||||
if (choiceStep?.type === 'choice' && choiceStep.choices.length > 0) {
|
||||
return choiceStep.choices;
|
||||
}
|
||||
|
||||
return run.availableChoices;
|
||||
}
|
||||
|
||||
export function VisualNovelRuntimeShell({
|
||||
draft = mockVisualNovelDraft,
|
||||
run = mockVisualNovelRun,
|
||||
isBusy = false,
|
||||
isSaving = false,
|
||||
isLoadingArchives = false,
|
||||
resumingWorldKey = null,
|
||||
error,
|
||||
streamedSteps = [],
|
||||
streamingText = '',
|
||||
saveArchives,
|
||||
onBack,
|
||||
onSubmitAction,
|
||||
onContinue,
|
||||
onRegenerateHistoryEntry,
|
||||
onSaveRun,
|
||||
onResumeSaveArchive,
|
||||
onTextModeChange,
|
||||
}: VisualNovelRuntimeShellProps) {
|
||||
const [activePanel, setActivePanel] =
|
||||
useState<VisualNovelRuntimePanelKind | null>(null);
|
||||
const [freeText, setFreeText] = useState('');
|
||||
const [localTextModeEnabled, setLocalTextModeEnabled] = useState(
|
||||
run?.textModeEnabled ?? draft?.runtimeConfig.defaultTextMode ?? false,
|
||||
);
|
||||
const displayDraft = draft ?? mockVisualNovelDraft;
|
||||
const baseRun = run ?? mockVisualNovelRun;
|
||||
const runtimeController = useVisualNovelRuntimeController({
|
||||
draft: displayDraft,
|
||||
initialRun: baseRun,
|
||||
profileId: displayDraft.profileId,
|
||||
autoStart: false,
|
||||
});
|
||||
const displayRun = runtimeController.run ?? baseRun;
|
||||
const displayBusy = isBusy || runtimeController.isBusy;
|
||||
const displaySaving = isSaving || runtimeController.isSaving;
|
||||
const displayLoadingArchives =
|
||||
isLoadingArchives || runtimeController.isLoadingArchives;
|
||||
const displayResumingWorldKey =
|
||||
resumingWorldKey ?? runtimeController.resumingWorldKey;
|
||||
const displayError = error ?? runtimeController.error;
|
||||
const displaySaveArchives = saveArchives ?? runtimeController.saveArchives;
|
||||
const displayStreamedSteps =
|
||||
streamedSteps.length > 0 ? streamedSteps : runtimeController.streamedSteps;
|
||||
const displayStreamingText =
|
||||
streamingText || runtimeController.streamingText;
|
||||
const displayState = useMemo(
|
||||
() => resolveDisplayState(displayRun, displayStreamedSteps),
|
||||
[displayRun, displayStreamedSteps],
|
||||
);
|
||||
const textModeEnabled = localTextModeEnabled;
|
||||
const sceneId = resolveSceneId(displayRun, displayState.sceneStep);
|
||||
const sceneName = resolveSceneName(displayDraft, sceneId);
|
||||
const backgroundImageSrc = resolveSceneBackground(
|
||||
displayDraft,
|
||||
sceneId,
|
||||
displayState.sceneStep,
|
||||
);
|
||||
const visibleCharacters = useMemo(
|
||||
() =>
|
||||
resolveVisibleCharacters(
|
||||
displayDraft,
|
||||
displayRun,
|
||||
displayState.dialogueStep,
|
||||
),
|
||||
[displayDraft, displayRun, displayState.dialogueStep],
|
||||
);
|
||||
const choices = resolveChoices(displayRun, displayState.choiceStep);
|
||||
const canSubmitFreeText =
|
||||
displayDraft.runtimeConfig.allowFreeTextAction &&
|
||||
freeText.trim() &&
|
||||
!displayBusy;
|
||||
const canShowAttributes =
|
||||
displayDraft.runtimeConfig.attributePanelMode !== 'off';
|
||||
const primarySpeaker =
|
||||
displayState.dialogueStep?.characterName ||
|
||||
(displayState.narrationStep ? '旁白' : displayDraft.workTitle);
|
||||
const primaryText =
|
||||
displayState.dialogueStep?.text ||
|
||||
displayState.narrationStep?.text ||
|
||||
displayDraft.opening.narration ||
|
||||
displayDraft.workDescription;
|
||||
const textModeLines = buildTextModeLines(displayRun, displayStreamedSteps);
|
||||
|
||||
const loadRuntimeSaveArchives = runtimeController.loadSaveArchives;
|
||||
|
||||
useEffect(() => {
|
||||
if (activePanel === 'save' && !saveArchives) {
|
||||
void loadRuntimeSaveArchives();
|
||||
}
|
||||
}, [activePanel, loadRuntimeSaveArchives, saveArchives]);
|
||||
|
||||
const updateTextMode = (enabled: boolean) => {
|
||||
setLocalTextModeEnabled(enabled);
|
||||
onTextModeChange?.(enabled);
|
||||
};
|
||||
|
||||
const submitChoice = (choice: VisualNovelChoiceDraft) => {
|
||||
if (displayBusy) {
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
actionKind: 'choice',
|
||||
choiceId: choice.choiceId,
|
||||
clientEventId: buildClientEventId('choice'),
|
||||
} satisfies VisualNovelRuntimeActionRequest;
|
||||
if (onSubmitAction) {
|
||||
onSubmitAction(payload);
|
||||
return;
|
||||
}
|
||||
void runtimeController.submitAction(payload);
|
||||
};
|
||||
|
||||
const submitFreeText = () => {
|
||||
const text = freeText.trim();
|
||||
if (!text || displayBusy) {
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
actionKind: 'free_text',
|
||||
text,
|
||||
clientEventId: buildClientEventId('free-text'),
|
||||
} satisfies VisualNovelRuntimeActionRequest;
|
||||
if (onSubmitAction) {
|
||||
onSubmitAction(payload);
|
||||
} else {
|
||||
void runtimeController.submitAction(payload);
|
||||
}
|
||||
setFreeText('');
|
||||
};
|
||||
|
||||
const continueRuntime = () => {
|
||||
if (displayBusy) {
|
||||
return;
|
||||
}
|
||||
if (onContinue) {
|
||||
onContinue();
|
||||
return;
|
||||
}
|
||||
void runtimeController.continueRun();
|
||||
};
|
||||
|
||||
const regenerateHistoryEntry = (entryId: string) => {
|
||||
if (onRegenerateHistoryEntry) {
|
||||
onRegenerateHistoryEntry(entryId);
|
||||
return;
|
||||
}
|
||||
void runtimeController.regenerateFromHistory(entryId);
|
||||
};
|
||||
|
||||
const saveRuntime = () => {
|
||||
if (onSaveRun) {
|
||||
onSaveRun();
|
||||
return;
|
||||
}
|
||||
void runtimeController.saveCurrentRun();
|
||||
};
|
||||
|
||||
const resumeArchive = (worldKey: string) => {
|
||||
if (onResumeSaveArchive) {
|
||||
onResumeSaveArchive(worldKey);
|
||||
return;
|
||||
}
|
||||
void runtimeController.resumeSaveArchive(worldKey);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="relative flex min-h-dvh w-full justify-center overflow-hidden bg-[#111827] text-white">
|
||||
{backgroundImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={backgroundImageSrc}
|
||||
alt=""
|
||||
className="absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(15,23,42,0.10),rgba(15,23,42,0.92)),linear-gradient(135deg,#162235_0%,#334155_46%,#111827_100%)]" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(15,23,42,0.18),rgba(15,23,42,0.88)),linear-gradient(90deg,rgba(0,0,0,0.32),transparent_36%,rgba(0,0,0,0.38))]" />
|
||||
<div
|
||||
className="relative flex min-h-dvh min-w-0 flex-col overflow-hidden px-3 pb-[calc(env(safe-area-inset-bottom,0px)+0.8rem)] pt-[calc(env(safe-area-inset-top,0px)+0.65rem)]"
|
||||
style={{
|
||||
boxSizing: 'border-box',
|
||||
maxWidth: '100vw',
|
||||
width: 'min(100vw, 64rem)',
|
||||
}}
|
||||
>
|
||||
<header className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-black/24 text-white backdrop-blur"
|
||||
onClick={onBack}
|
||||
aria-label="返回"
|
||||
title="返回"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<div className="min-w-0 rounded-full border border-white/16 bg-black/24 px-3 py-2 text-center text-sm font-black backdrop-blur">
|
||||
<span className="block max-w-[12rem] truncate sm:max-w-[22rem]">
|
||||
{sceneName || displayDraft.workTitle}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-black/24 text-white backdrop-blur ${
|
||||
textModeEnabled ? 'ring-2 ring-white/40' : ''
|
||||
}`}
|
||||
onClick={() => updateTextMode(!textModeEnabled)}
|
||||
aria-label="文本模式"
|
||||
title="文本模式"
|
||||
>
|
||||
<MessageSquareText size={18} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section className="relative mt-3 flex min-h-0 flex-1 items-end justify-center overflow-hidden rounded-[1.35rem] border border-white/12 bg-black/18 px-3 pb-3 pt-6 shadow-[0_20px_54px_rgba(0,0,0,0.28)] backdrop-blur-sm">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.08),transparent_34%),linear-gradient(90deg,rgba(15,23,42,0.22),transparent_36%,rgba(15,23,42,0.3))]" />
|
||||
<div className="relative flex w-full max-w-4xl items-end justify-center gap-3 sm:gap-9">
|
||||
{visibleCharacters.length > 0 ? (
|
||||
visibleCharacters.map((character, index) => (
|
||||
<VisualNovelCharacterStandee
|
||||
key={character.characterId}
|
||||
character={character}
|
||||
index={index}
|
||||
active={
|
||||
!displayState.dialogueStep ||
|
||||
displayState.dialogueStep.characterId ===
|
||||
character.characterId
|
||||
}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="h-[min(40dvh,16rem)]" />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="relative mt-3 rounded-[1.25rem] border border-white/16 bg-black/46 p-3 shadow-[0_18px_44px_rgba(0,0,0,0.28)] backdrop-blur">
|
||||
<div className="mb-2 flex min-w-0 items-center justify-between gap-3">
|
||||
<div className="min-w-0 truncate text-sm font-black text-white">
|
||||
{primarySpeaker}
|
||||
</div>
|
||||
<div className="shrink-0 text-[11px] font-bold text-white/58">
|
||||
{displayRun.mode === 'test' ? 'TEST' : 'PLAY'}
|
||||
</div>
|
||||
</div>
|
||||
<p className="m-0 min-h-[4.75rem] break-words text-base leading-7 text-white/92">
|
||||
{primaryText}
|
||||
</p>
|
||||
{displayState.transitionStep?.text ? (
|
||||
<div className="mt-3 rounded-[0.9rem] border border-white/12 bg-white/10 px-3 py-2 text-sm font-semibold text-white/76">
|
||||
{displayState.transitionStep.text}
|
||||
</div>
|
||||
) : null}
|
||||
{displayStreamingText ? (
|
||||
<div className="mt-3 rounded-[0.9rem] border border-white/12 bg-white/10 px-3 py-2 text-sm leading-6 text-white/72">
|
||||
{displayStreamingText}
|
||||
</div>
|
||||
) : null}
|
||||
{textModeEnabled ? (
|
||||
<div className="mt-3 max-h-32 overflow-y-auto rounded-[0.9rem] border border-white/12 bg-white/10 px-3 py-2 whitespace-pre-line text-sm leading-6 text-white/78">
|
||||
{textModeLines || primaryText}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{choices.length > 0 ? (
|
||||
<section className="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
{choices.map((choice) => (
|
||||
<button
|
||||
key={choice.choiceId}
|
||||
type="button"
|
||||
disabled={displayBusy}
|
||||
onClick={() => submitChoice(choice)}
|
||||
className="min-h-12 rounded-[0.95rem] border border-white/16 bg-white/12 px-3 py-2 text-left text-sm font-black leading-5 text-white shadow-[0_10px_24px_rgba(0,0,0,0.18)] backdrop-blur transition hover:bg-white/18 disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
<span className="block break-words">{choice.text}</span>
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
) : (
|
||||
<section className="mt-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={displayBusy}
|
||||
onClick={continueRuntime}
|
||||
className="flex min-h-12 w-full items-center justify-center gap-2 rounded-[0.95rem] border border-white/16 bg-white/12 px-3 text-sm font-black text-white backdrop-blur transition hover:bg-white/18 disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
<span>继续</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="mt-3 flex gap-2">
|
||||
<input
|
||||
value={freeText}
|
||||
disabled={
|
||||
displayBusy || !displayDraft.runtimeConfig.allowFreeTextAction
|
||||
}
|
||||
onChange={(event) => setFreeText(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
submitFreeText();
|
||||
}
|
||||
}}
|
||||
className="min-h-11 min-w-0 flex-1 rounded-full border border-white/16 bg-black/26 px-4 text-sm font-semibold text-white outline-none placeholder:text-white/42"
|
||||
placeholder="输入行动"
|
||||
aria-label="输入行动"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSubmitFreeText}
|
||||
onClick={submitFreeText}
|
||||
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-white/16 bg-white/14 text-white backdrop-blur transition hover:bg-white/20 disabled:cursor-not-allowed disabled:opacity-55"
|
||||
aria-label="发送行动"
|
||||
title="发送行动"
|
||||
>
|
||||
<Send size={18} />
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{displayError ? (
|
||||
<div className="mt-3 rounded-[0.95rem] border border-rose-200/35 bg-rose-400/18 px-3 py-2 text-center text-sm font-black text-rose-50">
|
||||
{displayError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<footer
|
||||
className={`mt-3 grid gap-2 ${canShowAttributes ? 'grid-cols-4' : 'grid-cols-3'}`}
|
||||
>
|
||||
{[
|
||||
{ kind: 'history' as const, label: '历史', icon: History },
|
||||
{ kind: 'save' as const, label: '存档', icon: Bookmark },
|
||||
{ kind: 'settings' as const, label: '设置', icon: Settings },
|
||||
...(canShowAttributes
|
||||
? [
|
||||
{
|
||||
kind: 'attributes' as const,
|
||||
label: '属性',
|
||||
icon: SlidersHorizontal,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
].map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.kind}
|
||||
type="button"
|
||||
onClick={() => setActivePanel(item.kind)}
|
||||
className="flex min-h-12 min-w-0 flex-col items-center justify-center gap-1 rounded-[0.95rem] border border-white/14 bg-black/24 px-2 text-xs font-black text-white backdrop-blur transition hover:bg-black/34"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="max-w-full truncate">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
{activePanel ? (
|
||||
<VisualNovelRuntimePanel
|
||||
kind={activePanel}
|
||||
draft={displayDraft}
|
||||
run={displayRun}
|
||||
isBusy={displayBusy}
|
||||
isSaving={displaySaving}
|
||||
isLoadingArchives={displayLoadingArchives}
|
||||
resumingWorldKey={displayResumingWorldKey}
|
||||
saveArchives={displaySaveArchives}
|
||||
allowRegeneration={displayDraft.runtimeConfig.allowHistoryRegeneration}
|
||||
onClose={() => setActivePanel(null)}
|
||||
onRegenerateHistoryEntry={regenerateHistoryEntry}
|
||||
onSaveRun={saveRuntime}
|
||||
onResumeSaveArchive={resumeArchive}
|
||||
textModeEnabled={textModeEnabled}
|
||||
onTextModeChange={updateTextMode}
|
||||
/>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default VisualNovelRuntimeShell;
|
||||
102
src/components/visual-novel-runtime/VisualNovelSavePanel.tsx
Normal file
102
src/components/visual-novel-runtime/VisualNovelSavePanel.tsx
Normal file
@@ -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',
|
||||
);
|
||||
}
|
||||
455
src/components/visual-novel-runtime/visualNovelMockData.ts
Normal file
455
src/components/visual-novel-runtime/visualNovelMockData.ts
Normal file
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user