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:
@@ -88,11 +88,14 @@ const useCharacterStore = create(
|
||||
}
|
||||
},
|
||||
|
||||
// 选择角色
|
||||
// 选择角色(传 null 清除选中)
|
||||
selectCharacter: (character) => {
|
||||
// 使用函数式更新避免不必要的重渲染
|
||||
set((state) => {
|
||||
// 如果选中的是同一个角色,不更新状态
|
||||
if (!character) {
|
||||
return state.selectedCharacter === null
|
||||
? state
|
||||
: { selectedCharacter: null };
|
||||
}
|
||||
if (state.selectedCharacter?.id === character.id) {
|
||||
return state;
|
||||
}
|
||||
@@ -100,6 +103,8 @@ const useCharacterStore = create(
|
||||
});
|
||||
},
|
||||
|
||||
clearSelectedCharacter: () => set({ selectedCharacter: null }),
|
||||
|
||||
// 创建角色
|
||||
createCharacter: async (characterData) => {
|
||||
try {
|
||||
@@ -150,7 +155,19 @@ const useCharacterStore = create(
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete character');
|
||||
|
||||
|
||||
set((state) => {
|
||||
const next = {};
|
||||
if (state.selectedCharacter?.name === name) {
|
||||
next.selectedCharacter = null;
|
||||
}
|
||||
if (state.characterChats[name]) {
|
||||
const { [name]: _removed, ...restChats } = state.characterChats;
|
||||
next.characterChats = restChats;
|
||||
}
|
||||
return Object.keys(next).length ? next : state;
|
||||
});
|
||||
|
||||
// 刷新列表
|
||||
await get().fetchCharacters();
|
||||
} catch (error) {
|
||||
|
||||
@@ -41,7 +41,6 @@ const useStudioStore = create((set, get) => ({
|
||||
meta: null,
|
||||
pipeline: null,
|
||||
skillTemplates: [],
|
||||
niches: [],
|
||||
selectedNodeId: null,
|
||||
loading: false,
|
||||
saving: false,
|
||||
@@ -56,7 +55,10 @@ const useStudioStore = create((set, get) => ({
|
||||
runLoading: false,
|
||||
runCreating: false,
|
||||
runAdvancing: false,
|
||||
runMessaging: false,
|
||||
runStreamingThinking: null,
|
||||
runError: null,
|
||||
runSessionEntered: false,
|
||||
|
||||
setSelectedNodeId: (id) => set({ selectedNodeId: id }),
|
||||
clearSaveMessage: () => set({ saveMessage: null, error: null }),
|
||||
@@ -112,26 +114,11 @@ const useStudioStore = create((set, get) => ({
|
||||
displayParams: tpl?.displayParams ? [...tpl.displayParams] : [],
|
||||
inputs: [],
|
||||
};
|
||||
if (tpl?.configDefaults) {
|
||||
base.config = JSON.parse(JSON.stringify(tpl.configDefaults));
|
||||
}
|
||||
if (skillId === 'studio.worldbook_entry') {
|
||||
base.loopUntilSatisfied = false;
|
||||
base.config = {
|
||||
stepGoal: '',
|
||||
thinkingPrompt: `====== 思考流程 ======
|
||||
Step1: 简短确认任务性质(新设计/修改)
|
||||
Step2: 阅读绑定角色/世界书与上文引用
|
||||
Step3: 按步骤目标起草世界书条目
|
||||
Step4: 对照评价维度自检并优化表述
|
||||
|
||||
(核心目的、评价标准与优化建议由系统在运行时自动注入,无需在此填写)`,
|
||||
insertion: {
|
||||
position: 1,
|
||||
activationType: 'permanent',
|
||||
key: '',
|
||||
keysecondary: '',
|
||||
comment: '',
|
||||
},
|
||||
scoring: { enabled: true, dimensions: [] },
|
||||
};
|
||||
}
|
||||
const nodes = [...pipeline.nodes, base];
|
||||
set({
|
||||
@@ -198,16 +185,10 @@ Step4: 对照评价维度自检并优化表述
|
||||
|
||||
fetchSkillTemplates: async () => {
|
||||
try {
|
||||
const [tplRes, nicheRes] = await Promise.all([
|
||||
fetch('/api/studio/skill-templates'),
|
||||
fetch('/api/studio/niches'),
|
||||
]);
|
||||
if (!tplRes.ok) throw new Error(await tplRes.text());
|
||||
const tplData = await tplRes.json();
|
||||
const niches = nicheRes.ok
|
||||
? (await nicheRes.json()).niches || []
|
||||
: [];
|
||||
set({ skillTemplates: tplData.templates || [], niches });
|
||||
const res = await fetch('/api/studio/skill-templates');
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const tplData = await res.json();
|
||||
set({ skillTemplates: tplData.templates || [] });
|
||||
} catch (e) {
|
||||
set({ error: e.message || '加载技能模板失败' });
|
||||
}
|
||||
@@ -388,9 +369,12 @@ Step4: 对照评价维度自检并优化表述
|
||||
currentRunId: null,
|
||||
currentRun: null,
|
||||
runs: [],
|
||||
runSessionEntered: false,
|
||||
});
|
||||
},
|
||||
|
||||
setRunSessionEntered: (entered) => set({ runSessionEntered: !!entered }),
|
||||
|
||||
fetchRuns: async (projectId) => {
|
||||
if (!projectId) return [];
|
||||
set({ runLoading: true, runError: null });
|
||||
@@ -424,6 +408,7 @@ Step4: 对照评价维度自检并优化表述
|
||||
currentRunId: runId,
|
||||
currentRun: run,
|
||||
runLoading: false,
|
||||
runSessionEntered: false,
|
||||
});
|
||||
return run;
|
||||
} catch (e) {
|
||||
@@ -450,6 +435,7 @@ Step4: 对照评价维度自检并优化表述
|
||||
currentRunId: run.id,
|
||||
currentRun: run,
|
||||
runCreating: false,
|
||||
runSessionEntered: false,
|
||||
});
|
||||
return run;
|
||||
} catch (e) {
|
||||
@@ -506,6 +492,120 @@ Step4: 对照评价维度自检并优化表述
|
||||
}
|
||||
},
|
||||
|
||||
sendRunMessage: async (content, { stream = false } = {}) => {
|
||||
const { runProjectId, currentRunId } = 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 res = await fetch(
|
||||
`/api/studio/projects/${encodeURIComponent(runProjectId)}/runs/${encodeURIComponent(currentRunId)}/message`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
content,
|
||||
stream,
|
||||
profileId,
|
||||
apiConfig,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
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 || '发送消息失败');
|
||||
}
|
||||
|
||||
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('流式响应未完成');
|
||||
}
|
||||
|
||||
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 || '发送消息失败',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
deleteRun: async (runId) => {
|
||||
const projectId = get().runProjectId;
|
||||
if (!projectId || !runId) return false;
|
||||
|
||||
@@ -132,6 +132,8 @@
|
||||
|
||||
/* 角色卡片列表 - 网格布局 */
|
||||
.character-list {
|
||||
position: relative;
|
||||
z-index: var(--z-base-content);
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
@@ -318,14 +320,16 @@
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
position: relative; /* 为悬浮工具栏提供定位上下文 */
|
||||
position: relative;
|
||||
z-index: var(--z-base-content);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* 悬浮工具栏 */
|
||||
.floating-toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: var(--z-top-bar); /* ✅ 组件层 - TopBar 同级 */
|
||||
z-index: 2;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
|
||||
@@ -237,12 +237,21 @@ const CharacterCard = () => {
|
||||
|
||||
try {
|
||||
await deleteCharacter(name);
|
||||
|
||||
// ✅ 删除成功后自动切换到角色分页
|
||||
|
||||
cancelEditing();
|
||||
selectCharacter(null);
|
||||
|
||||
const chatBox = useChatBoxStore.getState();
|
||||
if (chatBox.currentRole === name) {
|
||||
chatBox.setCurrentRole(null);
|
||||
chatBox.setCurrentChat(null);
|
||||
chatBox.setCharacterName('');
|
||||
}
|
||||
|
||||
const { setActiveTab } = useSideBarLeftStore.getState();
|
||||
setActiveTab('character');
|
||||
|
||||
console.log('[CharacterCard] 角色已删除,已切换到角色分页');
|
||||
|
||||
console.log('[CharacterCard] 角色已删除,已返回角色选择列表');
|
||||
} catch (err) {
|
||||
console.error('删除失败:', err);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
产物:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
28
frontend/src/components/Studio/studioRunUtils.js
Normal file
28
frontend/src/components/Studio/studioRunUtils.js
Normal 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;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import useUserStore from '../../Store/UserSlice'; // ✅ 新增 - 用户角色
|
||||
import useWorldBookStore from '../../Store/SideBarLeft/WorldBookSlice';
|
||||
import useApiConfigStore from '../../Store/SideBarLeft/ApiConfigSlice';
|
||||
import PageModeToggle from './items/PageModeToggle';
|
||||
import StudioRunControls from './items/StudioRunControls';
|
||||
import ThemeToggle from './items/ThemeToggle';
|
||||
import RegexPanel from '../SideBarLeft/tabs/Regex/RegexPanel'; // ✅ 新增:导入正则管理组件
|
||||
import './TopBar.css';
|
||||
@@ -17,9 +18,12 @@ const Toolbar = () => {
|
||||
const {
|
||||
sidebarMode,
|
||||
colorTheme,
|
||||
activePage,
|
||||
setSidebarMode,
|
||||
setColorTheme,
|
||||
} = useAppLayoutStore();
|
||||
|
||||
const isStudioRunPage = activePage === 'studio_run';
|
||||
|
||||
// ✅ 从 UserStore 获取用户角色和方法
|
||||
const { currentUserRole, userRoles, updateRoleName, updateRoleDescription, selectUserRole, addUserRole } = useUserStore();
|
||||
@@ -149,6 +153,8 @@ const Toolbar = () => {
|
||||
<div className="top-bar-content">
|
||||
{/* 左侧:状态徽章区域 */}
|
||||
<div className="status-section">
|
||||
{isStudioRunPage ? <StudioRunControls /> : null}
|
||||
|
||||
{/* 当前玩家角色 */}
|
||||
<div
|
||||
className="status-badge"
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
.studio-run-topbar-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
flex-shrink: 0;
|
||||
margin-right: var(--spacing-md);
|
||||
}
|
||||
|
||||
.studio-run-topbar-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.studio-run-topbar-label-text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.studio-run-topbar-select {
|
||||
padding: 4px var(--spacing-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.8rem;
|
||||
min-width: 140px;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.studio-run-topbar-btn {
|
||||
padding: 4px var(--spacing-md);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
font-family: inherit;
|
||||
transition: opacity 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.studio-run-topbar-btn--primary {
|
||||
border: none;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-inverse, #fff);
|
||||
}
|
||||
|
||||
.studio-run-topbar-btn--primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.studio-run-topbar-btn--ghost {
|
||||
border: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.studio-run-topbar-btn--ghost:hover:not(:disabled) {
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.studio-run-topbar-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.studio-run-topbar-character {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
padding: 4px var(--spacing-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border-light);
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.studio-run-topbar-character-icon {
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.studio-run-topbar-character-name {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
|
||||
import useStudioStore from '../../../../Store/Studio/StudioSlice';
|
||||
import useCharacterStore from '../../../../Store/SideBarLeft/CharacterSlice';
|
||||
import { resolveBoundCharacterName } from '../../../Studio/studioRunUtils';
|
||||
|
||||
import './StudioRunControls.css';
|
||||
|
||||
function StudioRunControls() {
|
||||
const {
|
||||
projects,
|
||||
meta,
|
||||
runProjectId,
|
||||
currentRun,
|
||||
runLoading,
|
||||
runCreating,
|
||||
runAdvancing,
|
||||
runSessionEntered,
|
||||
setRunProjectId,
|
||||
setRunSessionEntered,
|
||||
fetchWorkflowVariables,
|
||||
fetchRuns,
|
||||
selectRun,
|
||||
createRun,
|
||||
} = useStudioStore();
|
||||
|
||||
const characters = useCharacterStore((s) => s.characters);
|
||||
|
||||
const boundCharacterName = useMemo(() => {
|
||||
return resolveBoundCharacterName({
|
||||
boundCharacterVar: currentRun?.workflowVariables?.['workflow.boundCharacter'],
|
||||
characterId: meta?.characterId,
|
||||
characters,
|
||||
});
|
||||
}, [currentRun?.workflowVariables, meta?.characterId, characters]);
|
||||
|
||||
const handleProjectChange = useCallback(
|
||||
async (e) => {
|
||||
const projectId = e.target.value;
|
||||
setRunProjectId(projectId);
|
||||
setRunSessionEntered(false);
|
||||
await fetchWorkflowVariables(projectId);
|
||||
const list = await fetchRuns(projectId);
|
||||
if (list[0]?.id) {
|
||||
await selectRun(list[0].id);
|
||||
}
|
||||
},
|
||||
[
|
||||
setRunProjectId,
|
||||
setRunSessionEntered,
|
||||
fetchWorkflowVariables,
|
||||
fetchRuns,
|
||||
selectRun,
|
||||
]
|
||||
);
|
||||
|
||||
const handleNewRun = useCallback(async () => {
|
||||
if (!runProjectId) return;
|
||||
setRunSessionEntered(false);
|
||||
await createRun(runProjectId);
|
||||
}, [runProjectId, setRunSessionEntered, createRun]);
|
||||
|
||||
const inSession = runSessionEntered && !!currentRun;
|
||||
|
||||
return (
|
||||
<div className="studio-run-topbar-controls">
|
||||
<label className="studio-run-topbar-label">
|
||||
<span className="studio-run-topbar-label-text">项目</span>
|
||||
<select
|
||||
className="studio-run-topbar-select"
|
||||
value={runProjectId || ''}
|
||||
onChange={handleProjectChange}
|
||||
disabled={runLoading || runCreating || runAdvancing}
|
||||
aria-label="选择 Studio 项目"
|
||||
>
|
||||
{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-topbar-btn studio-run-topbar-btn--primary"
|
||||
onClick={handleNewRun}
|
||||
disabled={!runProjectId || runCreating || runAdvancing}
|
||||
>
|
||||
{runCreating ? '创建中…' : '新开会话'}
|
||||
</button>
|
||||
|
||||
{boundCharacterName ? (
|
||||
<div className="studio-run-topbar-character" title="绑定角色卡">
|
||||
<span className="studio-run-topbar-character-icon" aria-hidden="true">
|
||||
🎭
|
||||
</span>
|
||||
<span className="studio-run-topbar-character-name">{boundCharacterName}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{inSession ? (
|
||||
<button
|
||||
type="button"
|
||||
className="studio-run-topbar-btn studio-run-topbar-btn--ghost"
|
||||
onClick={() => setRunSessionEntered(false)}
|
||||
>
|
||||
退出会话
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StudioRunControls;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './StudioRunControls';
|
||||
Reference in New Issue
Block a user