feat(studio): R3 step_respond、运行页 UX 与 R4 思考流式

新增 worldbook 步骤 LLM 回复(thinking/draft/questions/evaluation),
运行页聊天区与产物/选项联动;评价区标签改为「评价与修改建议」;
流式开关开启时 NDJSON 推送思考内容,完成后拆分更新各字段。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-31 22:07:42 +08:00
parent fa6907fb8d
commit f3792915a3
20 changed files with 1639 additions and 304 deletions

View File

@@ -50,7 +50,6 @@ function StudioEditPage() {
meta,
pipeline,
skillTemplates,
niches,
selectedNodeId,
loading,
saving,
@@ -499,36 +498,6 @@ function StudioEditPage() {
value={selectedTpl?.displayName || selectedNode.skillId}
/>
</label>
{isWorldbook && niches.length > 0 && (
<label className="studio-edit-meta-strip__field">
<span className="studio-edit-field-label">
<FieldLabel tip="选择预设领域可自动填充步骤目标建议">
预设领域
</FieldLabel>
</span>
<select
className="studio-edit-select"
value={selectedNode.niche ?? ''}
onChange={(e) => {
const nicheId = e.target.value || null;
const niche = niches.find((n) => n.id === nicheId);
updateNode(selectedNode.id, { niche: nicheId });
if (niche?.suggestedStepGoal && !selectedNode.config?.stepGoal) {
updateNodeConfig(selectedNode.id, {
stepGoal: niche.suggestedStepGoal,
});
}
}}
>
<option value=""></option>
{niches.map((n) => (
<option key={n.id} value={n.id}>
{n.label}
</option>
))}
</select>
</label>
)}
{selectedTpl?.artifacts?.length > 0 && (
<span className="studio-edit-meta-strip__hint">
产物

View File

@@ -44,6 +44,118 @@
background: rgba(255, 255, 255, 0.6);
}
[data-color-theme='dark'] .studio-run-chat-message.role-assistant,
[data-color-theme='dark'] .studio-run-chat-message.role-system {
background-color: rgba(45, 45, 48, 0.5);
color: var(--color-text-primary);
}
.studio-run-chat-history {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: var(--spacing-md);
opacity: 0.35;
max-height: 120px;
overflow: hidden;
pointer-events: none;
user-select: none;
}
.studio-run-chat-history-item {
display: flex;
gap: var(--spacing-xs);
font-size: 0.72rem;
line-height: 1.35;
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.studio-run-chat-history-role {
flex-shrink: 0;
font-weight: 600;
}
.studio-run-chat-history-text {
overflow: hidden;
text-overflow: ellipsis;
}
.studio-run-chat-focus {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
min-height: min(40vh, 320px);
}
.studio-run-chat-last-user {
display: flex;
gap: var(--spacing-sm);
align-items: flex-start;
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md);
background: rgba(102, 126, 234, 0.08);
border-left: 3px solid var(--color-accent);
}
[data-color-theme='dark'] .studio-run-chat-last-user {
background: rgba(212, 167, 106, 0.1);
}
.studio-run-chat-last-user--pending {
opacity: 0.85;
}
.studio-run-chat-last-user-label {
flex-shrink: 0;
font-size: 0.7rem;
font-weight: 600;
color: var(--color-text-muted);
}
.studio-run-chat-last-user-text {
font-size: 0.85rem;
color: var(--color-text-primary);
line-height: 1.5;
white-space: pre-wrap;
}
.studio-run-chat-thinking,
.studio-run-chat-summary {
padding: var(--spacing-md) var(--spacing-lg);
border-radius: var(--radius-md);
border: 1px solid var(--color-border-light);
background: var(--color-bg-primary);
}
[data-color-theme='dark'] .studio-run-chat-thinking,
[data-color-theme='dark'] .studio-run-chat-summary {
background-color: rgba(45, 45, 48, 0.5);
border-color: var(--color-border);
}
.studio-run-chat-thinking--pending {
border-style: dashed;
}
.studio-run-chat-block-label {
display: block;
font-size: 0.72rem;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: var(--spacing-sm);
}
.studio-run-chat-block-body {
font-size: 0.95rem;
color: var(--color-text-primary);
line-height: 1.6;
}
.studio-run-chat-message-role {
display: block;
font-size: 0.7rem;
@@ -89,6 +201,24 @@
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
[data-color-theme='dark'] .studio-run-chat-input-container {
background-color: var(--color-bg-tertiary);
box-shadow: none;
}
[data-color-theme='dark'] .studio-run-chat-input-container:focus-within {
box-shadow: 0 0 0 1px var(--color-accent);
}
[data-color-theme='dark'] .studio-run-chat-options-toggle:hover,
[data-color-theme='dark'] .studio-run-chat-options-toggle.active {
background-color: var(--color-accent-ultra-light);
}
[data-color-theme='dark'] .studio-run-chat-textarea:focus {
background-color: transparent;
}
.studio-run-chat-options-wrapper {
position: relative;
flex-shrink: 0;
@@ -166,6 +296,70 @@
color: var(--color-accent);
}
.studio-run-chat-option-checkbox {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
cursor: pointer;
border-radius: var(--radius-md);
transition: all 0.15s ease;
margin-bottom: var(--spacing-xs);
}
.studio-run-chat-option-checkbox:hover {
background-color: rgba(102, 126, 234, 0.06);
}
.studio-run-chat-option-checkbox input[type="checkbox"] {
display: none;
}
.studio-run-chat-checkmark {
width: 16px;
height: 16px;
border: 2px solid var(--color-border);
border-radius: 3px;
position: relative;
transition: all 0.15s ease;
flex-shrink: 0;
}
.studio-run-chat-option-checkbox:hover .studio-run-chat-checkmark {
border-color: var(--color-accent);
}
.studio-run-chat-option-checkbox input:checked + .studio-run-chat-checkmark {
background-color: var(--color-accent);
border-color: var(--color-accent);
}
.studio-run-chat-option-checkbox input:checked + .studio-run-chat-checkmark::after {
content: '';
position: absolute;
left: 4px;
top: 1px;
width: 4px;
height: 8px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.studio-run-chat-option-label {
font-size: 0.85rem;
color: var(--color-text-primary);
}
.studio-run-chat-pending {
color: var(--color-text-muted);
font-style: italic;
}
.studio-run-chat-message.is-pending {
opacity: 0.85;
}
.studio-run-chat-input-area {
flex: 1;
min-width: 0;

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState } from 'react';
import MarkdownRenderer from '../shared/MarkdownRenderer';
import { findLastUserMessageIndex } from './studioRunUtils';
import './StudioRunChat.css';
@@ -11,7 +12,8 @@ const RENDER_MODE_LABELS = {
markdown: '📝 Markdown',
};
function renderMessageBody(text, renderMode, isUser) {
function renderBody(text, renderMode, isUser) {
if (!text) return null;
if (renderMode === 'html' && !isUser) {
const hasHtmlTags = /<[a-z][\s\S]*>/i.test(text);
if (hasHtmlTags) {
@@ -26,23 +28,48 @@ function renderMessageBody(text, renderMode, isUser) {
}
function StudioRunChat({
messages,
stepMessages = [],
thinking,
evaluation,
pendingUserText,
inputValue,
onInputChange,
onSend,
disabled = false,
placeholder = '输入消息…',
sending = false,
streamOutput = false,
onStreamOutputChange,
placeholder = '输入消息…',
}) {
const messagesEndRef = useRef(null);
const messagesContainerRef = useRef(null);
const containerRef = useRef(null);
const focusAnchorRef = useRef(null);
const optionsRef = useRef(null);
const [showOptions, setShowOptions] = useState(false);
const [renderMode, setRenderMode] = useState('markdown');
const lastUserIdx = findLastUserMessageIndex(stepMessages);
const historyMessages =
lastUserIdx > 0 ? stepMessages.slice(0, lastUserIdx) : [];
const lastUserMessage =
lastUserIdx >= 0 ? stepMessages[lastUserIdx] : pendingUserText
? { role: 'user', content: pendingUserText }
: null;
const summaryText =
evaluation ||
(lastUserIdx >= 0 && stepMessages[lastUserIdx + 1]?.role === 'assistant'
? stepMessages[lastUserIdx + 1].content
: null);
const hasFocusContent =
thinking || summaryText || pendingUserText || sending || lastUserMessage;
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const container = containerRef.current;
const anchor = focusAnchorRef.current;
if (!container || !anchor) return;
container.scrollTop = Math.max(0, anchor.offsetTop - container.offsetTop);
}, [stepMessages, thinking, evaluation, pendingUserText, sending]);
useEffect(() => {
const handleClickOutside = (event) => {
@@ -89,28 +116,77 @@ function StudioRunChat({
setRenderMode(RENDER_MODES[(idx + 1) % RENDER_MODES.length]);
};
const roleLabel = (role) => {
if (role === 'user') return '你';
if (role === 'system') return '系统';
return '助手';
};
return (
<div className="studio-run-chat">
<div className="studio-run-chat-messages" ref={messagesContainerRef}>
{messages.length === 0 ? (
<div className="studio-run-chat-empty">暂无对话在下方输入开始交流</div>
) : (
messages.map((msg) => (
<div key={msg.id} className={`studio-run-chat-message role-${msg.role}`}>
<span className="studio-run-chat-message-role">{roleLabel(msg.role)}</span>
<div className="studio-run-chat-message-body">
{renderMessageBody(msg.text, renderMode, msg.role === 'user')}
<div className="studio-run-chat-messages" ref={containerRef}>
{historyMessages.length > 0 && (
<div className="studio-run-chat-history" aria-hidden="true">
{historyMessages.map((msg) => (
<div
key={msg.id}
className={`studio-run-chat-history-item role-${msg.role}`}
>
<span className="studio-run-chat-history-role">
{msg.role === 'user' ? '你' : '助手'}
</span>
<span className="studio-run-chat-history-text">{msg.content}</span>
</div>
</div>
))
))}
</div>
)}
<div ref={messagesEndRef} />
<div ref={focusAnchorRef} className="studio-run-chat-focus">
{!hasFocusContent ? (
<div className="studio-run-chat-empty">
暂无本轮对话在下方输入开始交流
</div>
) : (
<>
{lastUserMessage && !pendingUserText && (
<div className="studio-run-chat-last-user">
<span className="studio-run-chat-last-user-label"></span>
<span className="studio-run-chat-last-user-text">
{lastUserMessage.content}
</span>
</div>
)}
{pendingUserText && (
<div className="studio-run-chat-last-user studio-run-chat-last-user--pending">
<span className="studio-run-chat-last-user-label"></span>
<span className="studio-run-chat-last-user-text">
{pendingUserText}
</span>
</div>
)}
{sending && !thinking && (
<div className="studio-run-chat-thinking studio-run-chat-thinking--pending">
<span className="studio-run-chat-block-label">思考</span>
<span className="studio-run-chat-pending">正在等待模型回复</span>
</div>
)}
{thinking && (
<div className="studio-run-chat-thinking">
<span className="studio-run-chat-block-label">思考</span>
<div className="studio-run-chat-block-body">
{renderBody(thinking, renderMode, false)}
</div>
</div>
)}
{summaryText && (
<div className="studio-run-chat-summary">
<span className="studio-run-chat-block-label">评价与修改建议</span>
<div className="studio-run-chat-block-body">
{renderBody(summaryText, renderMode, false)}
</div>
</div>
)}
</>
)}
</div>
</div>
<form className="studio-run-chat-input-wrapper" onSubmit={handleSubmit}>
@@ -135,6 +211,16 @@ 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>
</div>
)}
</div>

View File

@@ -6,70 +6,119 @@
overflow: hidden;
}
.studio-run-header {
.studio-run-preview__meta {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-md) var(--spacing-lg);
border-bottom: 1px solid var(--color-border-light);
background: var(--color-bg-secondary);
flex-shrink: 0;
flex-wrap: wrap;
}
.studio-run-header-icon {
font-size: 1.25rem;
}
.studio-run-header-title {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
margin-right: auto;
}
.studio-run-toolbar {
display: flex;
align-items: center;
gap: var(--spacing-md);
flex-wrap: wrap;
}
.studio-run-label {
display: flex;
align-items: center;
gap: var(--spacing-xs);
font-size: 0.8rem;
gap: var(--spacing-sm) var(--spacing-md);
font-size: 0.75rem;
color: var(--color-text-muted);
}
.studio-run-select {
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: var(--radius-md);
.studio-run-preview__character {
color: var(--color-text-secondary);
}
.studio-run-tool-option.is-selected {
border-color: var(--color-accent);
background: var(--color-accent-ultra-light);
}
.studio-run-tool-copy {
margin-top: var(--spacing-sm);
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.studio-run-tool-copy__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-sm);
}
.studio-run-tool-copy__label {
font-size: 0.72rem;
color: var(--color-text-muted);
}
.studio-run-tool-copy__btn {
padding: 2px var(--spacing-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-secondary);
color: var(--color-text-primary);
font-size: 0.72rem;
cursor: pointer;
font-family: inherit;
}
.studio-run-tool-copy__btn:hover {
border-color: var(--color-accent);
color: var(--color-accent);
}
.studio-run-tool-copy__textarea {
width: 100%;
padding: var(--spacing-sm);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-sm);
background: var(--color-bg-primary);
color: var(--color-text-primary);
font-size: 0.78rem;
font-family: inherit;
line-height: 1.45;
resize: vertical;
}
.studio-run-tool-copy__hint {
font-size: 0.68rem;
color: var(--color-accent);
}
[data-color-theme='dark'] .studio-run-message.role-assistant,
[data-color-theme='dark'] .studio-run-message.role-system {
background-color: rgba(45, 45, 48, 0.5);
color: var(--color-text-primary);
}
[data-color-theme='dark'] .studio-run-message.role-user {
background: rgba(212, 167, 106, 0.1);
}
[data-color-theme='dark'] .studio-run-icon-btn,
[data-color-theme='dark'] .studio-run-tool-option {
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
font-size: 0.875rem;
min-width: 160px;
border-color: var(--color-border);
}
.studio-run-new-btn {
padding: var(--spacing-xs) var(--spacing-md);
border-radius: var(--radius-md);
border: none;
background: var(--color-accent);
color: var(--color-text-inverse, #fff);
font-size: 0.875rem;
cursor: pointer;
[data-color-theme='dark'] .studio-run-list-item {
background: var(--color-bg-tertiary);
}
.studio-run-new-btn:hover:not(:disabled) {
opacity: 0.9;
[data-color-theme='dark'] .studio-run-list-action {
background: var(--color-bg-elevated);
color: var(--color-text-secondary);
}
.studio-run-new-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
[data-color-theme='dark'] .studio-run-product-main__body,
[data-color-theme='dark'] .studio-run-variables__row,
[data-color-theme='dark'] .studio-run-guidance-form {
background: var(--color-bg-tertiary);
border-color: var(--color-border);
}
[data-color-theme='dark'] .studio-run-form-input,
[data-color-theme='dark'] .studio-run-insertion-entry,
[data-color-theme='dark'] .studio-run-tool-carousel {
background: var(--color-bg-elevated);
color: var(--color-text-primary);
}
[data-color-theme='dark'] .studio-run-banner.error {
background: rgba(239, 68, 68, 0.15);
color: #f87171;
}
.studio-run-banner {
@@ -290,11 +339,6 @@
white-space: nowrap;
}
.studio-run-preview__meta {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.studio-run-preview__body {
flex: 1;
min-height: 0;

View File

@@ -6,6 +6,8 @@ import {
buildWorkflowVariableLabelMap,
getWorkflowVariableLabel,
} from './edit/workflowVariableLabels';
import { resolveBoundCharacterName } from './studioRunUtils';
import useCharacterStore from '../../Store/SideBarLeft/CharacterSlice';
import StudioContextBlockPopup from './StudioContextBlockPopup';
import StudioInsertionPopup from './StudioInsertionPopup';
@@ -118,10 +120,34 @@ function InitBindGuidanceForm({ node, runAdvancing, onSubmit, compact = false })
);
}
function ToolOptionsCarousel({ options, index, onPrev, onNext, onSelect, runAdvancing }) {
function ToolOptionsCarousel({ options, index, onPrev, onNext, runAdvancing }) {
const current = options[index];
const [selectedOption, setSelectedOption] = useState(null);
const [copyHint, setCopyHint] = useState('');
useEffect(() => {
setSelectedOption(null);
setCopyHint('');
}, [index, current?.question]);
if (!options.length || !current) return null;
const handleSelect = (opt) => {
setSelectedOption(opt);
setCopyHint('');
};
const handleCopy = async () => {
if (!selectedOption) return;
try {
await navigator.clipboard.writeText(selectedOption);
setCopyHint('已复制到剪贴板');
setTimeout(() => setCopyHint(''), 2000);
} catch {
setCopyHint('复制失败,请手动选择文本');
}
};
return (
<div className="studio-run-tool-carousel">
<div className="studio-run-tool-carousel__head">
@@ -146,8 +172,8 @@ function ToolOptionsCarousel({ options, index, onPrev, onNext, onSelect, runAdva
<button
key={opt}
type="button"
className="studio-run-tool-option"
onClick={() => onSelect(opt)}
className={`studio-run-tool-option${selectedOption === opt ? ' is-selected' : ''}`}
onClick={() => handleSelect(opt)}
disabled={runAdvancing}
>
{opt}
@@ -164,6 +190,30 @@ function ToolOptionsCarousel({ options, index, onPrev, onNext, onSelect, runAdva
</button>
</div>
{selectedOption ? (
<div className="studio-run-tool-copy">
<div className="studio-run-tool-copy__head">
<span className="studio-run-tool-copy__label">复制以下内容到输入框</span>
<button
type="button"
className="studio-run-tool-copy__btn"
onClick={handleCopy}
>
复制
</button>
</div>
<textarea
className="studio-run-tool-copy__textarea"
readOnly
value={selectedOption}
rows={3}
aria-label="选项内容"
/>
{copyHint ? (
<span className="studio-run-tool-copy__hint">{copyHint}</span>
) : null}
</div>
) : null}
</div>
);
}
@@ -400,25 +450,25 @@ function InsertionSidebar({ insertionPreview, onOpenEntry }) {
function StudioRunPage() {
const {
projects,
pipeline: projectPipeline,
workflowVariables: workflowVariablesCatalog,
meta,
runProjectId,
runs,
currentRunId,
currentRun,
runLoading,
runCreating,
runAdvancing,
runMessaging,
runStreamingThinking,
runError,
runSessionEntered,
setRunSessionEntered,
initStudioRun,
fetchProject,
fetchWorkflowVariables,
setRunProjectId,
fetchRuns,
createRun,
selectRun,
advanceRun,
sendRunMessage,
deleteRun,
renameRun,
} = useStudioStore();
@@ -457,13 +507,13 @@ function StudioRunPage() {
() => buildWorkflowVariableLabelMap(workflowVariablesCatalog),
[workflowVariablesCatalog]
);
const [sessionEntered, setSessionEntered] = useState(false);
const [insertionPopupOpen, setInsertionPopupOpen] = useState(false);
const [insertionPopupExpanded, setInsertionPopupExpanded] = useState(true);
const [contextBlockPopup, setContextBlockPopup] = useState(null);
const [chatInput, setChatInput] = useState('');
const [streamOutput, setStreamOutput] = useState(false);
const [toolOptionIndex, setToolOptionIndex] = useState(0);
const [chatMessages, setChatMessages] = useState([]);
const [pendingUserText, setPendingUserText] = useState(null);
const [runListExpanded, setRunListExpanded] = useState(false);
const [renamingId, setRenamingId] = useState(null);
const [renameValue, setRenameValue] = useState('');
@@ -479,10 +529,11 @@ function StudioRunPage() {
}, [runProjectId, fetchProject]);
useEffect(() => {
setSessionEntered(false);
setRunSessionEntered(false);
setInsertionPopupOpen(false);
setContextBlockPopup(null);
}, [currentRunId, runProjectId]);
setPendingUserText(null);
}, [currentRunId, runProjectId, setRunSessionEntered]);
const activeNodeState = useMemo(() => {
if (!currentRun?.currentNodeId) return null;
@@ -531,71 +582,19 @@ function StudioRunPage() {
const promptBlocks = currentRun?.lastPromptBlocks || [];
const runWorkflowVariables = currentRun?.workflowVariables || {};
const stepMessages = activeNodeState?.stepMessages || [];
const lastToolResponse = activeNodeState?.lastToolResponse;
useEffect(() => {
if (!currentRun || !sessionEntered) {
setChatMessages([]);
return;
}
const msgs = [
{
id: 'sys-welcome',
role: 'system',
text: `已开始运行 · 项目 ${currentRun.projectId}`,
},
];
if (isInitBindActive) {
msgs.push({
id: 'sys-init',
role: 'assistant',
text: '请先创建角色卡与世界书,完成后将进入创作步骤。',
});
} else if (isWorldbookActive) {
msgs.push({
id: 'sys-step',
role: 'assistant',
text: `当前步骤:${activeNodeState.displayName}。请在下方输入修改意见或与模型对话R3 接入流式输出)。`,
});
} else if (currentRun.status === 'completed') {
msgs.push({
id: 'sys-done',
role: 'assistant',
text: '本次运行已全部完成。',
});
}
setChatMessages(msgs);
}, [currentRun, currentRunId, isInitBindActive, isWorldbookActive, activeNodeState, sessionEntered]);
const handleProjectChange = async (e) => {
const projectId = e.target.value;
setRunProjectId(projectId);
setSessionEntered(false);
await fetchWorkflowVariables(projectId);
const list = await fetchRuns(projectId);
if (list[0]?.id) {
await selectRun(list[0].id);
}
};
const handleNewRun = async () => {
if (!runProjectId) return;
setSessionEntered(false);
await createRun(runProjectId);
};
const handleSelectRun = async (runId) => {
setSessionEntered(false);
await selectRun(runId);
};
const handleEnterSession = () => {
if (currentRun) {
setSessionEntered(true);
}
};
const characters = useCharacterStore((s) => s.characters);
const boundCharacterName = useMemo(
() =>
resolveBoundCharacterName({
boundCharacterVar: runWorkflowVariables['workflow.boundCharacter'],
characterId: meta?.characterId,
characters,
}),
[runWorkflowVariables, meta?.characterId, characters]
);
const handleOpenInsertion = () => {
setInsertionPopupExpanded(true);
@@ -606,28 +605,38 @@ function StudioRunPage() {
await advanceRun(displayParams);
};
const handleChatSend = (text) => {
if (!text || isInitBindActive) return;
setChatMessages((prev) => [
...prev,
{ id: `user-${Date.now()}`, role: 'user', text },
{
id: `stub-${Date.now()}`,
role: 'assistant',
text: 'R3 占位:模型回复与流式输出尚未接入)',
},
]);
const handleSelectRun = async (runId) => {
setRunSessionEntered(false);
await selectRun(runId);
};
const handleEnterSession = () => {
if (currentRun) {
setRunSessionEntered(true);
}
};
const handleChatSend = async (text) => {
if (!text || isInitBindActive || !isWorldbookActive || runMessaging) return;
setPendingUserText(text);
setChatInput('');
const run = await sendRunMessage(text, { stream: streamOutput });
setPendingUserText(null);
if (!run) {
/* error surfaced via runError */
}
};
const handleNextStep = () => {
window.alert('R3 占位:确认当前产物后将进入下一步)');
window.alert('R5 占位:确认当前产物后将进入下一步)');
};
const handleDeleteRun = async (runId) => {
const name = runDisplayName(runs.find((r) => r.id === runId) || {});
if (!window.confirm(`确定删除运行「${name}」?此操作不可撤销。`)) return;
if (currentRunId === runId) setSessionEntered(false);
if (currentRunId === runId) setRunSessionEntered(false);
await deleteRun(runId);
};
@@ -646,19 +655,8 @@ function StudioRunPage() {
}
};
const handleToolSelect = (option) => {
setChatMessages((prev) => [
...prev,
{
id: `tool-opt-${Date.now()}`,
role: 'user',
text: `[选项] ${option}`,
},
]);
};
const runListNeedsCollapse = runs.length > RUN_LIST_COLLAPSE_THRESHOLD;
const inSession = sessionEntered && !!currentRun;
const inSession = runSessionEntered && !!currentRun;
const showToolCarousel = inSession && isWorldbookActive && toolQuestions.length > 0;
const showNextStep = inSession && isWorldbookActive;
@@ -778,6 +776,11 @@ function StudioRunPage() {
</div>
<div className="studio-run-preview__meta">
<span>创建 {formatRunTime(currentRun.createdAt)}</span>
{boundCharacterName ? (
<span className="studio-run-preview__character">
角色卡{boundCharacterName}
</span>
) : null}
</div>
<button
type="button"
@@ -834,12 +837,17 @@ function StudioRunPage() {
return (
<div className="studio-run-chat-panel">
<StudioRunChat
messages={chatMessages}
stepMessages={stepMessages}
thinking={runStreamingThinking ?? lastToolResponse?.thinking}
evaluation={runStreamingThinking ? null : lastToolResponse?.evaluation}
pendingUserText={pendingUserText}
inputValue={chatInput}
onInputChange={setChatInput}
onSend={handleChatSend}
disabled={!isWorldbookActive || runAdvancing || isInitBindActive}
sending={runAdvancing}
disabled={!isWorldbookActive || runAdvancing || runMessaging || isInitBindActive}
sending={runMessaging}
streamOutput={streamOutput}
onStreamOutputChange={setStreamOutput}
placeholder={
isWorldbookActive
? '输入修改意见或与模型对话…'
@@ -869,8 +877,7 @@ function StudioRunPage() {
onNext={() =>
setToolOptionIndex((i) => (i + 1) % toolQuestions.length)
}
onSelect={handleToolSelect}
runAdvancing={runAdvancing}
runAdvancing={runAdvancing || runMessaging}
/>
)}
@@ -880,7 +887,7 @@ function StudioRunPage() {
type="button"
className="studio-run-next-btn"
onClick={handleNextStep}
disabled={runAdvancing}
disabled={runAdvancing || runMessaging}
>
下一步
</button>
@@ -892,47 +899,6 @@ function StudioRunPage() {
return (
<div className={`studio-run-page${inSession ? ' studio-run-page--session' : ''}`}>
<header className="studio-run-header">
<span className="studio-run-header-icon" aria-hidden="true">🚀</span>
<h1 className="studio-run-header-title">创作运行</h1>
<div className="studio-run-toolbar">
<label className="studio-run-label">
项目
<select
className="studio-run-select"
value={runProjectId || ''}
onChange={handleProjectChange}
disabled={runLoading || runCreating || runAdvancing}
>
{projects.length === 0 ? (
<option value="">暂无项目</option>
) : (
projects.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))
)}
</select>
</label>
<button
type="button"
className="studio-run-new-btn"
onClick={handleNewRun}
disabled={!runProjectId || runCreating || runAdvancing}
>
{runCreating ? '创建中…' : '新开会话'}
</button>
{inSession && (
<button
type="button"
className="studio-run-exit-session-btn"
onClick={() => setSessionEntered(false)}
>
退出会话
</button>
)}
</div>
</header>
{runError && (
<div className="studio-run-banner error" role="alert">{runError}</div>
)}

View File

@@ -0,0 +1,28 @@
/**
* Resolve bound character display name from workflow variable and/or project meta.
*/
export function resolveBoundCharacterName({ boundCharacterVar, characterId, characters = [] }) {
if (boundCharacterVar) {
const nameMatch = String(boundCharacterVar).match(/名称[:]\s*(.+?)(?:\n|$)/);
if (nameMatch?.[1]?.trim()) {
return nameMatch[1].trim();
}
}
if (characterId && characters.length > 0) {
const byId = characters.find((c) => c.id === characterId);
if (byId?.name) return byId.name;
const byName = characters.find((c) => c.name === characterId);
if (byName?.name) return byName.name;
}
return null;
}
/** Index of the last user message in stepMessages, or -1. */
export function findLastUserMessageIndex(stepMessages = []) {
for (let i = stepMessages.length - 1; i >= 0; i -= 1) {
if (stepMessages[i]?.role === 'user') return i;
}
return -1;
}