Fix DashScope env loading for scene image generation

This commit is contained in:
2026-04-06 15:01:15 +08:00
parent fcd8d727b0
commit d678929064
23 changed files with 4943 additions and 138 deletions

View File

@@ -31,6 +31,7 @@ import {
CustomWorldPlayableNpc,
CustomWorldProfile,
CustomWorldSceneConnection,
type ItemRarity,
} from '../types';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { CharacterAnimator } from './CharacterAnimator';
@@ -64,6 +65,14 @@ const [
BACKSTORY_UNLOCK_AFFINITY_CLOSE,
] = AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS;
const ITEM_RARITY_OPTIONS: Array<{ value: ItemRarity; label: string }> = [
{ value: 'common', label: 'common' },
{ value: 'uncommon', label: 'uncommon' },
{ value: 'rare', label: 'rare' },
{ value: 'epic', label: 'epic' },
{ value: 'legendary', label: 'legendary' },
];
function slugify(value: string) {
const normalized = value
.trim()
@@ -101,6 +110,48 @@ function clampInitialAffinity(value: string, fallback: number) {
return Math.max(-40, Math.min(90, Math.round(parsed)));
}
function parseOptionalNumber(value: string) {
const trimmed = value.trim();
if (!trimmed) return undefined;
const parsed = Number.parseInt(trimmed, 10);
return Number.isFinite(parsed) ? parsed : undefined;
}
function createRoleSkillDraft(seedLabel: string, index: number) {
return {
id: createEntryId('skill', seedLabel, Date.now() + index),
name: `新技能${index + 1}`,
summary: '',
style: '起手压制',
};
}
function createRoleInitialItemDraft(seedLabel: string, index: number) {
return {
id: createEntryId('item', seedLabel, Date.now() + index),
name: `新物品${index + 1}`,
category: '材料',
quantity: 1,
rarity: 'rare' as ItemRarity,
description: '',
tags: [],
};
}
function createBackstoryChapterDraft(seedLabel: string, index: number) {
return {
id: createEntryId('backstory-chapter', seedLabel, Date.now() + index),
title: `背景片段${index + 1}`,
affinityRequired:
AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS[
Math.min(index, AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS.length - 1)
] ?? BACKSTORY_UNLOCK_AFFINITY_CLOSE,
teaser: '',
content: '',
contextSnippet: '',
};
}
function syncLandmarksWithStoryNpcs(
landmarks: CustomWorldLandmark[],
storyNpcs: CustomWorldProfile['storyNpcs'],
@@ -700,7 +751,8 @@ function SaveBar({
onSave: () => void;
}) {
return (
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-end">
<div className="sticky bottom-0 z-10 -mx-4 border-t border-white/10 bg-[linear-gradient(180deg,rgba(8,10,17,0.2)_0%,rgba(8,10,17,0.96)_28%)] px-4 pb-[calc(env(safe-area-inset-bottom,0px)+0.35rem)] pt-3 backdrop-blur sm:static sm:mx-0 sm:border-0 sm:bg-transparent sm:px-0 sm:pb-0 sm:pt-2 sm:backdrop-blur-0">
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button
type="button"
onClick={onClose}
@@ -716,16 +768,423 @@ function SaveBar({
paddingX: 16,
paddingY: 10,
})}
>
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-semibold text-white"></span>
<span className="text-white/60"></span>
</div>
</button>
>
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-semibold text-white"></span>
<span className="text-white/60"></span>
</div>
</button>
</div>
</div>
);
}
function SectionPanel({
title,
subtitle,
actions,
children,
}: {
title: string;
subtitle?: string;
actions?: ReactNode;
children: ReactNode;
}) {
return (
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 sm:px-4 sm:py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-[11px] font-bold tracking-[0.16em] text-zinc-300">
{title}
</div>
{subtitle ? (
<div className="mt-2 text-sm leading-6 text-zinc-400">
{subtitle}
</div>
) : null}
</div>
{actions}
</div>
<div className="mt-4 space-y-3">{children}</div>
</div>
);
}
function BackstoryRevealEditor({
value,
onChange,
}: {
value: CustomWorldPlayableNpc['backstoryReveal'];
onChange: (value: CustomWorldPlayableNpc['backstoryReveal']) => void;
}) {
const updateChapter = (
index: number,
updater: (
chapter: CustomWorldPlayableNpc['backstoryReveal']['chapters'][number],
) => CustomWorldPlayableNpc['backstoryReveal']['chapters'][number],
) => {
onChange({
...value,
chapters: value.chapters.map((chapter, chapterIndex) =>
chapterIndex === index ? updater(chapter) : chapter,
),
});
};
const addChapter = () => {
onChange({
...value,
chapters: [
...value.chapters,
createBackstoryChapterDraft('custom-role', value.chapters.length),
],
});
};
const removeChapter = (index: number) => {
if (value.chapters.length <= 1) {
window.alert('至少保留一个背景章节。');
return;
}
onChange({
...value,
chapters: value.chapters.filter(
(_chapter, chapterIndex) => chapterIndex !== index,
),
});
};
return (
<SectionPanel
title="背景公开与章节"
subtitle="这里直接决定结果页、关系推进和后续剧情提示词看到的背景摘要与章节线索。"
actions={
<ActionButton label="新增章节" onClick={addChapter} tone="sky" />
}
>
<Field label="公开背景摘要">
<TextArea
value={value.publicSummary}
onChange={(nextValue) =>
onChange({
...value,
publicSummary: nextValue,
})
}
rows={3}
/>
</Field>
{value.chapters.map((chapter, index) => (
<div
key={`${chapter.id}-${index}`}
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white">
#{index + 1}
</div>
<ActionButton
label="删除章节"
onClick={() => removeChapter(index)}
/>
</div>
<Field label="章节标题">
<TextInput
value={chapter.title}
onChange={(nextValue) =>
updateChapter(index, (current) => ({
...current,
title: nextValue,
}))
}
/>
</Field>
<Field label="解锁好感">
<TextInput
type="number"
value={chapter.affinityRequired}
onChange={(nextValue) =>
updateChapter(index, (current) => ({
...current,
affinityRequired: clampInitialAffinity(
nextValue,
current.affinityRequired,
),
}))
}
/>
</Field>
<Field label="章节提示">
<TextArea
value={chapter.teaser}
onChange={(nextValue) =>
updateChapter(index, (current) => ({
...current,
teaser: nextValue,
}))
}
rows={2}
/>
</Field>
<Field label="章节内容">
<TextArea
value={chapter.content}
onChange={(nextValue) =>
updateChapter(index, (current) => ({
...current,
content: nextValue,
}))
}
rows={3}
/>
</Field>
<Field label="剧情引用摘要">
<TextArea
value={chapter.contextSnippet}
onChange={(nextValue) =>
updateChapter(index, (current) => ({
...current,
contextSnippet: nextValue,
}))
}
rows={2}
/>
</Field>
</div>
))}
</SectionPanel>
);
}
function SkillListEditor({
value,
onChange,
labelSeed,
}: {
value: CustomWorldPlayableNpc['skills'];
onChange: (value: CustomWorldPlayableNpc['skills']) => void;
labelSeed: string;
}) {
const updateSkill = (
index: number,
updater: (
skill: CustomWorldPlayableNpc['skills'][number],
) => CustomWorldPlayableNpc['skills'][number],
) => {
onChange(
value.map((skill, skillIndex) =>
skillIndex === index ? updater(skill) : skill,
),
);
};
return (
<SectionPanel
title="技能"
subtitle="技能名、摘要和风格都会进入结果页与运行时 NPC 档案。"
actions={
<ActionButton
label="新增技能"
onClick={() =>
onChange([...value, createRoleSkillDraft(labelSeed, value.length)])
}
tone="sky"
/>
}
>
{value.map((skill, index) => (
<div
key={`${skill.id}-${index}`}
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white">
#{index + 1}
</div>
<ActionButton
label="删除技能"
onClick={() =>
onChange(value.filter((_skill, skillIndex) => skillIndex !== index))
}
/>
</div>
<Field label="技能名称">
<TextInput
value={skill.name}
onChange={(nextValue) =>
updateSkill(index, (current) => ({
...current,
name: nextValue,
}))
}
/>
</Field>
<Field label="技能风格">
<TextInput
value={skill.style}
onChange={(nextValue) =>
updateSkill(index, (current) => ({
...current,
style: nextValue,
}))
}
/>
</Field>
<Field label="技能摘要">
<TextArea
value={skill.summary}
onChange={(nextValue) =>
updateSkill(index, (current) => ({
...current,
summary: nextValue,
}))
}
rows={3}
/>
</Field>
</div>
))}
</SectionPanel>
);
}
function InitialItemsEditor({
value,
onChange,
labelSeed,
}: {
value: CustomWorldPlayableNpc['initialItems'];
onChange: (value: CustomWorldPlayableNpc['initialItems']) => void;
labelSeed: string;
}) {
const updateItem = (
index: number,
updater: (
item: CustomWorldPlayableNpc['initialItems'][number],
) => CustomWorldPlayableNpc['initialItems'][number],
) => {
onChange(
value.map((item, itemIndex) =>
itemIndex === index ? updater(item) : item,
),
);
};
return (
<SectionPanel
title="初始物品"
subtitle="这里的内容会影响结果页展示,也会作为后续运行时参考档案。"
actions={
<ActionButton
label="新增物品"
onClick={() =>
onChange([
...value,
createRoleInitialItemDraft(labelSeed, value.length),
])
}
tone="sky"
/>
}
>
{value.map((item, index) => (
<div
key={`${item.id}-${index}`}
className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3"
>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white">
#{index + 1}
</div>
<ActionButton
label="删除物品"
onClick={() =>
onChange(value.filter((_item, itemIndex) => itemIndex !== index))
}
/>
</div>
<Field label="名称">
<TextInput
value={item.name}
onChange={(nextValue) =>
updateItem(index, (current) => ({
...current,
name: nextValue,
}))
}
/>
</Field>
<div className="grid gap-3 sm:grid-cols-2">
<Field label="分类">
<TextInput
value={item.category}
onChange={(nextValue) =>
updateItem(index, (current) => ({
...current,
category: nextValue,
}))
}
/>
</Field>
<Field label="稀有度">
<SelectField
value={item.rarity}
onChange={(nextValue) =>
updateItem(index, (current) => ({
...current,
rarity: nextValue as ItemRarity,
}))
}
options={ITEM_RARITY_OPTIONS}
/>
</Field>
</div>
<Field label="数量">
<TextInput
type="number"
value={item.quantity}
onChange={(nextValue) =>
updateItem(index, (current) => ({
...current,
quantity: Math.max(
1,
parseOptionalNumber(nextValue) ?? current.quantity,
),
}))
}
/>
</Field>
<Field label="描述">
<TextArea
value={item.description}
onChange={(nextValue) =>
updateItem(index, (current) => ({
...current,
description: nextValue,
}))
}
rows={3}
/>
</Field>
<Field label="标签">
<TextArea
value={commaText(item.tags)}
onChange={(nextValue) =>
updateItem(index, (current) => ({
...current,
tags: parseCommaText(nextValue),
}))
}
rows={2}
/>
</Field>
</div>
))}
</SectionPanel>
);
}
function StoryNpcVisualEditorModal({
npc,
visual,
@@ -873,7 +1332,7 @@ function PlayableNpcEditor({
return (
<ModalShell
title={mode === 'create' ? '新增可扮演角色' : `编辑角色:${npc.name}`}
subtitle="可为角色指定外观模板,结果页和正式选角都会同步使用。"
subtitle="这里可以直接修改可扮演角色的完整档案字段,结果页和正式选角都会同步使用。"
onClose={onClose}
>
<div className="space-y-4">
@@ -1026,6 +1485,35 @@ function PlayableNpcEditor({
rows={2}
/>
</Field>
<BackstoryRevealEditor
value={draft.backstoryReveal}
onChange={(backstoryReveal) =>
setDraft((current) => ({
...current,
backstoryReveal,
}))
}
/>
<SkillListEditor
value={draft.skills}
onChange={(skills) =>
setDraft((current) => ({
...current,
skills,
}))
}
labelSeed={draft.name || draft.id}
/>
<InitialItemsEditor
value={draft.initialItems}
onChange={(initialItems) =>
setDraft((current) => ({
...current,
initialItems,
}))
}
labelSeed={draft.name || draft.id}
/>
<SaveBar
onClose={onClose}
onSave={() => {
@@ -1059,7 +1547,7 @@ function StoryNpcEditor({
return (
<ModalShell
title={mode === 'create' ? '新增场景角色' : `编辑场景角色:${npc.name}`}
subtitle="场景角色形象编辑已拆分到独立面板,当前页面只保留档案信息与预览。"
subtitle="这里可以直接修改场景角色的完整档案字段,形象编辑仍保留在独立面板。"
onClose={onClose}
>
<div className="space-y-4">
@@ -1200,6 +1688,35 @@ function StoryNpcEditor({
rows={2}
/>
</Field>
<BackstoryRevealEditor
value={draft.backstoryReveal}
onChange={(backstoryReveal) =>
setDraft((current) => ({
...current,
backstoryReveal,
}))
}
/>
<SkillListEditor
value={draft.skills}
onChange={(skills) =>
setDraft((current) => ({
...current,
skills,
}))
}
labelSeed={draft.name || draft.id}
/>
<InitialItemsEditor
value={draft.initialItems}
onChange={(initialItems) =>
setDraft((current) => ({
...current,
initialItems,
}))
}
labelSeed={draft.name || draft.id}
/>
<SaveBar
onClose={onClose}
onSave={() => {