feat(studio): 回退/重roll 与 R5 用户确认下一步

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-31 22:27:25 +08:00
parent f3792915a3
commit 63a32bfa7c
8 changed files with 613 additions and 82 deletions

View File

@@ -2,6 +2,79 @@ import { create } from 'zustand';
const DEFAULT_TEMPLATE_ID = 'builtin.studio.example';
async function resolveStudioApiConfig() {
let profileId = null;
let apiConfig = {};
try {
const { default: useApiConfigStore } = await import('../SideBarLeft/ApiConfigSlice');
const apiState = useApiConfigStore.getState();
profileId = apiState.currentProfile?.id || null;
const mainLLM = apiState.currentProfile?.apis?.mainLLM;
if (mainLLM) {
apiConfig = {
api_url: mainLLM.apiUrl || '',
model: mainLLM.model || '',
};
}
} catch {
/* optional store */
}
return { profileId, apiConfig };
}
async function consumeStudioStreamResponse(res, set) {
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let finalRun = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
const event = JSON.parse(line);
if (event.type === 'thinking_delta') {
set({ runStreamingThinking: event.content || '' });
} else if (event.type === 'complete') {
finalRun = event.run;
} else if (event.type === 'error') {
throw new Error(event.detail || '流式处理失败');
}
}
}
if (buffer.trim()) {
const event = JSON.parse(buffer);
if (event.type === 'complete') {
finalRun = event.run;
} else if (event.type === 'error') {
throw new Error(event.detail || '流式处理失败');
} else if (event.type === 'thinking_delta') {
set({ runStreamingThinking: event.content || '' });
}
}
if (!finalRun) {
throw new Error('流式响应未完成');
}
return finalRun;
}
async function parseStudioErrorResponse(res) {
let detail = await res.text();
try {
const parsed = JSON.parse(detail);
detail = parsed.detail || detail;
} catch {
/* keep raw text */
}
return detail;
}
function migrateScoringInNode(node) {
const scoring = node.config?.scoring;
if (!scoring || scoring.dimensions?.length) return node;
@@ -497,24 +570,7 @@ const useStudioStore = create((set, get) => ({
if (!runProjectId || !currentRunId) return null;
set({ runMessaging: true, runStreamingThinking: null, runError: null });
try {
let profileId = null;
let apiConfig = {};
try {
const { default: useApiConfigStore } = await import(
'../SideBarLeft/ApiConfigSlice'
);
const apiState = useApiConfigStore.getState();
profileId = apiState.currentProfile?.id || null;
const mainLLM = apiState.currentProfile?.apis?.mainLLM;
if (mainLLM) {
apiConfig = {
api_url: mainLLM.apiUrl || '',
model: mainLLM.model || '',
};
}
} catch {
/* optional store */
}
const { profileId, apiConfig } = await resolveStudioApiConfig();
const res = await fetch(
`/api/studio/projects/${encodeURIComponent(runProjectId)}/runs/${encodeURIComponent(currentRunId)}/message`,
@@ -531,56 +587,11 @@ const useStudioStore = create((set, get) => ({
);
if (!res.ok) {
let detail = await res.text();
try {
const parsed = JSON.parse(detail);
detail = parsed.detail || detail;
} catch {
/* keep raw text */
}
throw new Error(detail || '发送消息失败');
throw new Error((await parseStudioErrorResponse(res)) || '发送消息失败');
}
if (stream && res.body) {
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let finalRun = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
const event = JSON.parse(line);
if (event.type === 'thinking_delta') {
set({ runStreamingThinking: event.content || '' });
} else if (event.type === 'complete') {
finalRun = event.run;
} else if (event.type === 'error') {
throw new Error(event.detail || '流式消息处理失败');
}
}
}
if (buffer.trim()) {
const event = JSON.parse(buffer);
if (event.type === 'complete') {
finalRun = event.run;
} else if (event.type === 'error') {
throw new Error(event.detail || '流式消息处理失败');
} else if (event.type === 'thinking_delta') {
set({ runStreamingThinking: event.content || '' });
}
}
if (!finalRun) {
throw new Error('流式响应未完成');
}
const finalRun = await consumeStudioStreamResponse(res, set);
set({
currentRun: finalRun,
runMessaging: false,
@@ -606,6 +617,86 @@ const useStudioStore = create((set, get) => ({
}
},
undoRun: async () => {
const { runProjectId, currentRunId } = get();
if (!runProjectId || !currentRunId) return null;
set({ runMessaging: true, runStreamingThinking: null, runError: null });
try {
const res = await fetch(
`/api/studio/projects/${encodeURIComponent(runProjectId)}/runs/${encodeURIComponent(currentRunId)}/undo`,
{ method: 'POST' }
);
if (!res.ok) {
throw new Error((await parseStudioErrorResponse(res)) || '回退失败');
}
const run = await res.json();
set({
currentRun: run,
runMessaging: false,
runStreamingThinking: null,
});
return run;
} catch (e) {
set({
runMessaging: false,
runStreamingThinking: null,
runError: e.message || '回退失败',
});
return null;
}
},
rerollRun: async ({ stream = false } = {}) => {
const { runProjectId, currentRunId } = get();
if (!runProjectId || !currentRunId) return null;
set({ runMessaging: true, runStreamingThinking: null, runError: null });
try {
const { profileId, apiConfig } = await resolveStudioApiConfig();
const res = await fetch(
`/api/studio/projects/${encodeURIComponent(runProjectId)}/runs/${encodeURIComponent(currentRunId)}/reroll`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
stream,
profileId,
apiConfig,
}),
}
);
if (!res.ok) {
throw new Error((await parseStudioErrorResponse(res)) || '重 roll 失败');
}
if (stream && res.body) {
const finalRun = await consumeStudioStreamResponse(res, set);
set({
currentRun: finalRun,
runMessaging: false,
runStreamingThinking: null,
});
return finalRun;
}
const run = await res.json();
set({
currentRun: run,
runMessaging: false,
runStreamingThinking: null,
});
return run;
} catch (e) {
set({
runMessaging: false,
runStreamingThinking: null,
runError: e.message || '重 roll 失败',
});
return null;
}
},
deleteRun: async (runId) => {
const projectId = get().runProjectId;
if (!projectId || !runId) return false;

View File

@@ -49,17 +49,15 @@ function StudioRunChat({
const lastUserIdx = findLastUserMessageIndex(stepMessages);
const historyMessages =
lastUserIdx > 0 ? stepMessages.slice(0, lastUserIdx) : [];
lastUserIdx > 0
? stepMessages.slice(0, lastUserIdx).filter((msg) => msg.role === 'user')
: [];
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 summaryText = evaluation || null;
const hasFocusContent =
thinking || summaryText || pendingUserText || sending || lastUserMessage;
@@ -124,11 +122,9 @@ function StudioRunChat({
{historyMessages.map((msg) => (
<div
key={msg.id}
className={`studio-run-chat-history-item role-${msg.role}`}
className="studio-run-chat-history-item role-user"
>
<span className="studio-run-chat-history-role">
{msg.role === 'user' ? '你' : '助手'}
</span>
<span className="studio-run-chat-history-role"></span>
<span className="studio-run-chat-history-text">{msg.content}</span>
</div>
))}

View File

@@ -752,6 +752,43 @@
cursor: not-allowed;
}
.studio-run-turn-actions__row {
display: flex;
gap: var(--spacing-xs);
}
.studio-run-turn-btn {
flex: 1;
padding: var(--spacing-sm) var(--spacing-md);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
background: var(--color-bg-secondary);
color: var(--color-text-primary);
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.studio-run-turn-btn:hover:not(:disabled) {
background: var(--color-bg-tertiary);
border-color: var(--color-border);
}
.studio-run-turn-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.studio-run-turn-btn--reroll {
border-color: var(--color-accent);
color: var(--color-accent);
}
.studio-run-turn-btn--reroll:hover:not(:disabled) {
background: color-mix(in srgb, var(--color-accent) 12%, var(--color-bg-secondary));
}
.studio-run-stub-note {
margin: 0;
font-size: 0.65rem;

View File

@@ -6,7 +6,7 @@ import {
buildWorkflowVariableLabelMap,
getWorkflowVariableLabel,
} from './edit/workflowVariableLabels';
import { resolveBoundCharacterName } from './studioRunUtils';
import { resolveBoundCharacterName, canUndoRun, canRerollRun, canAdvanceNextStep } from './studioRunUtils';
import useCharacterStore from '../../Store/SideBarLeft/CharacterSlice';
import StudioContextBlockPopup from './StudioContextBlockPopup';
@@ -469,6 +469,8 @@ function StudioRunPage() {
selectRun,
advanceRun,
sendRunMessage,
undoRun,
rerollRun,
deleteRun,
renameRun,
} = useStudioStore();
@@ -629,8 +631,26 @@ function StudioRunPage() {
}
};
const handleNextStep = () => {
window.alert('R5 占位:确认当前产物后将进入下一步)');
const handleUndo = async () => {
if (!isWorldbookActive || runMessaging || !canUndoRun(activeNodeState)) return;
await undoRun();
};
const handleReroll = async () => {
if (!isWorldbookActive || runMessaging || !canRerollRun(activeNodeState)) return;
await rerollRun({ stream: streamOutput });
};
const handleNextStep = async () => {
if (!isWorldbookActive || runAdvancing || runMessaging || !canAdvanceNextStep(activeNodeState)) {
return;
}
const run = await advanceRun({});
if (run) {
setChatInput('');
setToolOptionIndex(0);
setPendingUserText(null);
}
};
const handleDeleteRun = async (runId) => {
@@ -659,6 +679,10 @@ function StudioRunPage() {
const inSession = runSessionEntered && !!currentRun;
const showToolCarousel = inSession && isWorldbookActive && toolQuestions.length > 0;
const showNextStep = inSession && isWorldbookActive;
const canUndo = canUndoRun(activeNodeState);
const canReroll = canRerollRun(activeNodeState);
const canNextStep = canAdvanceNextStep(activeNodeState);
const showTurnActions = inSession && isWorldbookActive;
const renderRunList = () => (
<>
@@ -881,13 +905,40 @@ function StudioRunPage() {
/>
)}
{showTurnActions && (
<div className="studio-run-right-section studio-run-turn-actions">
<h2 className="studio-run-section-title">回合控制</h2>
<div className="studio-run-turn-actions__row">
<button
type="button"
className="studio-run-turn-btn studio-run-turn-btn--undo"
onClick={handleUndo}
disabled={runAdvancing || runMessaging || !canUndo}
title={canUndo ? '回退到上一回合' : '无可回退的回合'}
>
回退
</button>
<button
type="button"
className="studio-run-turn-btn studio-run-turn-btn--reroll"
onClick={handleReroll}
disabled={runAdvancing || runMessaging || !canReroll}
title={canReroll ? '用相同输入重新生成' : '尚无用户消息'}
>
重roll
</button>
</div>
</div>
)}
{showNextStep && (
<div className="studio-run-right-section studio-run-right-actions">
<button
type="button"
className="studio-run-next-btn"
onClick={handleNextStep}
disabled={runAdvancing || runMessaging}
disabled={runAdvancing || runMessaging || !canNextStep}
title={canNextStep ? '确认当前产物并进入下一节点' : '请先生成当前步骤产物'}
>
下一步
</button>

View File

@@ -26,3 +26,18 @@ export function findLastUserMessageIndex(stepMessages = []) {
}
return -1;
}
/** Whether undo is available for the active node state. */
export function canUndoRun(activeNodeState) {
return (activeNodeState?.turnHistory?.length ?? 0) > 0;
}
/** Whether reroll is available (at least one user message in current step). */
export function canRerollRun(activeNodeState) {
return findLastUserMessageIndex(activeNodeState?.stepMessages) >= 0;
}
/** Whether the user may advance to the next pipeline node (R5). */
export function canAdvanceNextStep(activeNodeState) {
return Boolean(activeNodeState?.lastDraft);
}