Fix DashScope env loading for scene image generation
This commit is contained in:
@@ -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={() => {
|
||||
|
||||
Reference in New Issue
Block a user