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

@@ -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>
)}