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:
165
frontend/src/components/Studio/StudioBindingPreviewPopup.css
Normal file
165
frontend/src/components/Studio/StudioBindingPreviewPopup.css
Normal 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;
|
||||
}
|
||||
200
frontend/src/components/Studio/StudioBindingPreviewPopup.jsx
Normal file
200
frontend/src/components/Studio/StudioBindingPreviewPopup.jsx
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user