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:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user