Studio workflow engine complete (R0-R6, run page, node switch, binding editor); R4 streaming deferred; R7 unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-31 23:59:56 +08:00
parent 2d0f82ee87
commit 54e17c9795
7 changed files with 415 additions and 14 deletions

View File

@@ -0,0 +1,200 @@
import React, { useCallback, useEffect, useState } from 'react';
import StudioInsertionPopup from './StudioInsertionPopup';
import './StudioBindingPreviewPopup.css';
const CHARACTER_FIELDS = [
{ key: 'description', label: '描述' },
{ key: 'personality', label: '性格' },
{ key: 'scenario', label: '场景' },
{ key: 'first_mes', label: '开场白' },
{ key: 'mes_example', label: '对话示例' },
];
function StudioBindingPreviewPopup({
open,
characterName,
worldbookName,
onClose,
}) {
const [tab, setTab] = useState('character');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [character, setCharacter] = useState(null);
const [entries, setEntries] = useState([]);
const [expandedEntryUid, setExpandedEntryUid] = useState(null);
const loadData = useCallback(async () => {
if (!characterName && !worldbookName) return;
setLoading(true);
setError('');
try {
const tasks = [];
if (characterName) {
tasks.push(
fetch(`/api/characters/${encodeURIComponent(characterName)}`)
.then(async (res) => {
if (!res.ok) throw new Error('加载角色卡失败');
return res.json();
})
.then(setCharacter)
);
} else {
setCharacter(null);
}
if (worldbookName) {
tasks.push(
fetch(
`/api/worldbooks/${encodeURIComponent(worldbookName)}/entries?page=1&page_size=50`
)
.then(async (res) => {
if (!res.ok) throw new Error('加载世界书条目失败');
return res.json();
})
.then((data) => setEntries(data.entries || []))
);
} else {
setEntries([]);
}
await Promise.all(tasks);
} catch (e) {
setError(e.message || '加载绑定资源失败');
} finally {
setLoading(false);
}
}, [characterName, worldbookName]);
useEffect(() => {
if (open) {
setTab('character');
setExpandedEntryUid(null);
loadData();
}
}, [open, loadData]);
const title = characterName ? `绑定预览 · ${characterName}` : '绑定预览';
return (
<StudioInsertionPopup
open={open}
title={title}
defaultExpanded
onClose={onClose}
>
{() => (
<div className="studio-binding-preview">
<div className="studio-binding-preview__tabs" role="tablist">
<button
type="button"
role="tab"
aria-selected={tab === 'character'}
className={`studio-binding-preview__tab${tab === 'character' ? ' is-active' : ''}`}
onClick={() => setTab('character')}
>
角色卡
</button>
<button
type="button"
role="tab"
aria-selected={tab === 'worldbook'}
className={`studio-binding-preview__tab${tab === 'worldbook' ? ' is-active' : ''}`}
onClick={() => setTab('worldbook')}
>
世界书
</button>
</div>
{loading ? (
<p className="studio-binding-preview__status">加载中</p>
) : error ? (
<p className="studio-binding-preview__status studio-binding-preview__status--error">
{error}
</p>
) : tab === 'character' ? (
<div className="studio-binding-preview__panel">
{!character ? (
<p className="studio-binding-preview__empty">暂无绑定角色卡</p>
) : (
<>
<div className="studio-binding-preview__hero">
{character.avatar ? (
<img
src={character.avatar}
alt=""
className="studio-binding-preview__avatar"
/>
) : (
<div className="studio-binding-preview__avatar studio-binding-preview__avatar--placeholder">
🎭
</div>
)}
<div>
<h3 className="studio-binding-preview__name">{character.name}</h3>
{worldbookName ? (
<p className="studio-binding-preview__meta">世界书{worldbookName}</p>
) : null}
</div>
</div>
{CHARACTER_FIELDS.map(({ key, label }) => {
const value = (character[key] || '').trim();
if (!value) return null;
return (
<section key={key} className="studio-binding-preview__field">
<h4 className="studio-binding-preview__field-label">{label}</h4>
<pre className="studio-binding-preview__field-value">{value}</pre>
</section>
);
})}
</>
)}
</div>
) : (
<div className="studio-binding-preview__panel">
{!worldbookName ? (
<p className="studio-binding-preview__empty">暂无绑定世界书</p>
) : entries.length === 0 ? (
<p className="studio-binding-preview__empty">世界书{worldbookName}暂无条目</p>
) : (
<ul className="studio-binding-preview__entries">
{entries.map((entry) => {
const expanded = expandedEntryUid === entry.uid;
const comment = entry.comment || entry.key?.join?.(', ') || '未命名条目';
return (
<li key={entry.uid} className="studio-binding-preview__entry">
<button
type="button"
className="studio-binding-preview__entry-head"
onClick={() =>
setExpandedEntryUid(expanded ? null : entry.uid)
}
>
<span className="studio-binding-preview__entry-title">
{comment}
</span>
<span className="studio-binding-preview__entry-meta">
{entry.activationType || '—'} · order {entry.order ?? 0}
</span>
</button>
{expanded ? (
<pre className="studio-binding-preview__entry-content">
{entry.content || '(空内容)'}
</pre>
) : null}
</li>
);
})}
</ul>
)}
</div>
)}
</div>
)}
</StudioInsertionPopup>
);
}
export default StudioBindingPreviewPopup;