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,165 @@
.studio-binding-preview {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
min-height: 200px;
}
.studio-binding-preview__tabs {
display: flex;
gap: 4px;
padding-bottom: var(--spacing-xs);
border-bottom: 1px solid var(--color-border-light);
}
.studio-binding-preview__tab {
flex: 1;
padding: 4px 8px;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-muted);
font-size: 0.72rem;
cursor: pointer;
}
.studio-binding-preview__tab.is-active {
border-color: var(--color-border);
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
font-weight: 600;
}
.studio-binding-preview__panel {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
max-height: min(50vh, 420px);
overflow: auto;
}
.studio-binding-preview__status {
margin: 0;
font-size: 0.75rem;
color: var(--color-text-muted);
}
.studio-binding-preview__status--error {
color: #f5576c;
}
.studio-binding-preview__empty {
margin: 0;
font-size: 0.75rem;
color: var(--color-text-muted);
}
.studio-binding-preview__hero {
display: flex;
gap: var(--spacing-sm);
align-items: center;
}
.studio-binding-preview__avatar {
width: 48px;
height: 48px;
border-radius: var(--radius-sm);
object-fit: cover;
flex-shrink: 0;
border: 1px solid var(--color-border-light);
}
.studio-binding-preview__avatar--placeholder {
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-tertiary);
font-size: 1.2rem;
}
.studio-binding-preview__name {
margin: 0;
font-size: 0.9rem;
color: var(--color-text-primary);
}
.studio-binding-preview__meta {
margin: 2px 0 0;
font-size: 0.68rem;
color: var(--color-text-muted);
}
.studio-binding-preview__field-label {
margin: 0 0 4px;
font-size: 0.68rem;
font-weight: 600;
color: var(--color-text-muted);
}
.studio-binding-preview__field-value {
margin: 0;
padding: var(--spacing-xs);
border-radius: var(--radius-sm);
border: 1px solid var(--color-border-light);
background: var(--color-bg-tertiary);
white-space: pre-wrap;
font-size: 0.72rem;
line-height: 1.45;
max-height: 120px;
overflow: auto;
}
.studio-binding-preview__entries {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.studio-binding-preview__entry {
border: 1px solid var(--color-border-light);
border-radius: var(--radius-sm);
overflow: hidden;
}
.studio-binding-preview__entry-head {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: var(--spacing-xs) var(--spacing-sm);
border: none;
background: var(--color-bg-tertiary);
cursor: pointer;
text-align: left;
}
.studio-binding-preview__entry-head:hover {
background: color-mix(in srgb, var(--color-accent) 8%, var(--color-bg-tertiary));
}
.studio-binding-preview__entry-title {
font-size: 0.78rem;
color: var(--color-text-primary);
font-weight: 500;
}
.studio-binding-preview__entry-meta {
font-size: 0.65rem;
color: var(--color-text-muted);
}
.studio-binding-preview__entry-content {
margin: 0;
padding: var(--spacing-sm);
border-top: 1px solid var(--color-border-light);
background: var(--color-bg-secondary);
white-space: pre-wrap;
font-size: 0.72rem;
line-height: 1.45;
max-height: 200px;
overflow: auto;
}

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;

View File

@@ -11,8 +11,8 @@
}
.studio-insertion-popup.is-expanded {
width: min(420px, calc(100vw - 32px));
max-height: min(70vh, 520px);
width: min(480px, calc(100vw - 32px));
max-height: min(75vh, 560px);
display: flex;
flex-direction: column;
}

View File

@@ -286,6 +286,14 @@
color: var(--color-text-primary);
}
.studio-run-chat-option-unavailable {
display: block;
padding: var(--spacing-xs) 0;
font-size: 0.85rem;
color: var(--color-text-muted);
cursor: help;
}
.studio-run-chat-pending {
color: var(--color-text-muted);
font-style: italic;

View File

@@ -37,8 +37,8 @@ function StudioRunChat({
disabled = false,
sending = false,
canInterrupt = false,
streamOutput = false,
onStreamOutputChange,
streamOutput: _streamOutput = false,
onStreamOutputChange: _onStreamOutputChange,
placeholder = '输入消息…',
}) {
const containerRef = useRef(null);
@@ -186,15 +186,12 @@ function StudioRunChat({
{RENDER_MODE_LABELS[renderMode]}
</button>
<div className="studio-run-chat-options-title">功能</div>
<label className="studio-run-chat-option-checkbox">
<input
type="checkbox"
checked={streamOutput}
onChange={(e) => onStreamOutputChange?.(e.target.checked)}
/>
<span className="studio-run-chat-checkmark" />
<span className="studio-run-chat-option-label">流式输出</span>
</label>
<span
className="studio-run-chat-option-unavailable"
title="暂不支持"
>
流式输出 · 暂不支持
</span>
</div>
)}
</div>

View File

@@ -76,7 +76,7 @@ function StudioRunNodeGraph({
if (nodeId === currentNodeId) return false;
const state = stateMap[nodeId];
if (canSelectNode) return canSelectNode(state);
return state?.skillId === 'studio.worldbook_entry';
return ['studio.worldbook_entry', 'studio.init_bind'].includes(state?.skillId);
},
[onNodeSelect, currentNodeId, stateMap, canSelectNode]
);

View File

@@ -227,6 +227,11 @@
overflow: auto;
}
.studio-run-left-session__graph .studio-run-graph {
min-height: 180px;
height: 200px;
}
.studio-run-center {
min-height: 0;
display: flex;
@@ -789,6 +794,32 @@
background: color-mix(in srgb, var(--color-accent) 12%, var(--color-bg-secondary));
}
.studio-run-turn-btn--append {
border-color: rgba(16, 185, 129, 0.4);
color: #10b981;
}
.studio-run-turn-btn--append.is-confirm-pending {
border-color: #10b981;
background: color-mix(in srgb, #10b981 12%, var(--color-bg-secondary));
color: #10b981;
}
.studio-run-turn-btn--overwrite {
border-color: rgba(245, 158, 11, 0.4);
color: #f59e0b;
}
.studio-run-turn-btn--overwrite.is-confirm-pending {
border-color: #f59e0b;
background: color-mix(in srgb, #f59e0b 12%, var(--color-bg-secondary));
color: #f59e0b;
}
.studio-run-save-actions {
margin-top: var(--spacing-xs);
}
.studio-run-turn-btn.is-confirm-pending {
border-color: #f5576c;
background: color-mix(in srgb, #f5576c 12%, var(--color-bg-secondary));