Studio: fix edit/run UI and write worldbook on advance (R6)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -30,6 +30,7 @@ from services.studio_step_respond import (
|
||||
studio_step_respond,
|
||||
studio_step_respond_stream,
|
||||
)
|
||||
from models.converters import WorldBookConverter
|
||||
from services.worldbook_service import worldbook_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -278,6 +279,103 @@ class StudioRunService:
|
||||
}
|
||||
return workflow_variables, last_draft
|
||||
|
||||
@staticmethod
|
||||
def _parse_keyword_field(raw: Any) -> List[str]:
|
||||
if raw is None:
|
||||
return []
|
||||
if isinstance(raw, list):
|
||||
return [str(x).strip() for x in raw if str(x).strip()]
|
||||
text = str(raw).strip()
|
||||
if not text:
|
||||
return []
|
||||
return [part.strip() for part in text.replace(",", ",").split(",") if part.strip()]
|
||||
|
||||
def _resolve_bound_worldbook_name(
|
||||
self, project_id: str, run: StudioRun
|
||||
) -> str:
|
||||
for state in run.nodeStates:
|
||||
if state.skillId != "studio.init_bind" or state.status != "completed":
|
||||
continue
|
||||
draft = state.lastDraft or {}
|
||||
name = (draft.get("worldbookName") or "").strip()
|
||||
if name:
|
||||
return name
|
||||
|
||||
try:
|
||||
project = studio_project_service.get_project(project_id)
|
||||
worldbook_id = project.meta.worldbookId
|
||||
except FileNotFoundError:
|
||||
worldbook_id = None
|
||||
|
||||
if worldbook_id:
|
||||
for summary in worldbook_service.list_worldbooks():
|
||||
wb_name = summary.get("name")
|
||||
if not wb_name:
|
||||
continue
|
||||
try:
|
||||
data = worldbook_service.get_worldbook(wb_name)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
if data.get("id") == worldbook_id:
|
||||
return wb_name
|
||||
|
||||
raise ValueError("未找到绑定的世界书,请先完成「创建并绑定」步骤")
|
||||
|
||||
def _build_worldbook_entry_payload(
|
||||
self, node: StudioNode, draft: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
insertion = (node.config or {}).get("insertion") or {}
|
||||
content = (draft.get("entryContent") or "").strip()
|
||||
if not content:
|
||||
raise ValueError("当前步骤产物为空,请先生成世界书条目内容后再进入下一步")
|
||||
|
||||
activation = (insertion.get("activationType") or "permanent").strip()
|
||||
position = insertion.get("position", 0)
|
||||
if isinstance(position, str):
|
||||
position = WorldBookConverter.POSITION_MAP_ST_TO_INTERNAL.get(position, 0)
|
||||
|
||||
key = self._parse_keyword_field(
|
||||
draft.get("insertionKey") or insertion.get("key")
|
||||
)
|
||||
keysecondary = self._parse_keyword_field(insertion.get("keysecondary"))
|
||||
comment = (
|
||||
(draft.get("insertionComment") or insertion.get("comment") or "").strip()
|
||||
or f"Studio · {node.displayName}"
|
||||
)
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"content": content,
|
||||
"comment": comment,
|
||||
"activationType": activation,
|
||||
"position": position,
|
||||
"key": key,
|
||||
"keysecondary": keysecondary,
|
||||
"order": insertion.get("order", 100),
|
||||
"depth": insertion.get("depth", 4),
|
||||
"probability": insertion.get("probability", 100),
|
||||
"group": insertion.get("group") or [],
|
||||
"disable": bool(insertion.get("disable", False)),
|
||||
}
|
||||
if activation == "rag" and insertion.get("ragConfig"):
|
||||
payload["ragConfig"] = insertion["ragConfig"]
|
||||
return payload
|
||||
|
||||
def _write_worldbook_entry_on_advance(
|
||||
self,
|
||||
worldbook_name: str,
|
||||
node: StudioNode,
|
||||
draft: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
entry_payload = self._build_worldbook_entry_payload(node, draft)
|
||||
try:
|
||||
return worldbook_service.append_entry(worldbook_name, entry_payload)
|
||||
except FileNotFoundError:
|
||||
raise ValueError(f"世界书「{worldbook_name}」不存在,无法写入条目")
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"写入世界书失败:{exc}") from exc
|
||||
except OSError as exc:
|
||||
raise ValueError(f"写入世界书失败:{exc}") from exc
|
||||
|
||||
def advance_run(
|
||||
self,
|
||||
project_id: str,
|
||||
@@ -314,6 +412,15 @@ class StudioRunService:
|
||||
elif current_node.skillId == "studio.worldbook_entry":
|
||||
if not current_state.lastDraft:
|
||||
raise ValueError("请先生成并确认当前产物后再进入下一步")
|
||||
worldbook_name = self._resolve_bound_worldbook_name(project_id, run)
|
||||
written_entry = self._write_worldbook_entry_on_advance(
|
||||
worldbook_name,
|
||||
current_node,
|
||||
current_state.lastDraft,
|
||||
)
|
||||
last_draft = copy.deepcopy(current_state.lastDraft)
|
||||
last_draft["writtenEntryUid"] = written_entry.get("uid")
|
||||
last_draft["writtenWorldbookName"] = worldbook_name
|
||||
else:
|
||||
raise NotImplementedError(
|
||||
f"技能「{current_node.skillId}」的执行尚未实现(R2+)"
|
||||
|
||||
@@ -232,6 +232,32 @@ class WorldBookService:
|
||||
|
||||
raise FileNotFoundError(f"Entry '{uid}' not found in worldbook '{name}'")
|
||||
|
||||
@staticmethod
|
||||
def append_entry(name: str, entry_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
在世界书中追加条目(规范化后写入,与 Chat 侧条目格式一致)。
|
||||
|
||||
Args:
|
||||
name: 世界书名称(文件名,不含 .json)
|
||||
entry_data: 条目字段(content、comment、activationType、position 等)
|
||||
|
||||
Returns:
|
||||
写入后的规范化条目
|
||||
"""
|
||||
data = WorldBookService._load_worldbook(name)
|
||||
if not data:
|
||||
raise FileNotFoundError(f"Worldbook '{name}' not found")
|
||||
|
||||
if not isinstance(data.get("entries"), list):
|
||||
data["entries"] = []
|
||||
|
||||
normalized = WorldBookConverter.normalize_entry(entry_data)
|
||||
data["entries"].append(normalized)
|
||||
now = int(datetime.now().timestamp())
|
||||
data["updatedAt"] = now
|
||||
WorldBookService._save_worldbook(name, data)
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def create_entry(name: str, entry_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
|
||||
@@ -19,12 +19,11 @@
|
||||
--studio-top-control-h: 2.75rem;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: minmax(200px, 240px) minmax(280px, 1fr) minmax(160px, 220px);
|
||||
grid-template-rows: var(--studio-top-label-h) var(--studio-top-control-h) auto;
|
||||
grid-template-columns: minmax(200px, 1fr) minmax(280px, 1.4fr) minmax(160px, 220px);
|
||||
grid-template-rows: var(--studio-top-label-h) var(--studio-top-control-h);
|
||||
grid-template-areas:
|
||||
'label-project label-goal label-desc'
|
||||
'ctrl-project ctrl-goal ctrl-desc'
|
||||
'actions . .';
|
||||
'ctrl-project ctrl-goal ctrl-desc';
|
||||
align-items: stretch;
|
||||
gap: 6px var(--spacing-md);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
@@ -40,7 +39,6 @@
|
||||
grid-template-areas:
|
||||
'label-project label-project'
|
||||
'ctrl-project ctrl-project'
|
||||
'actions actions'
|
||||
'label-goal label-goal'
|
||||
'ctrl-goal ctrl-goal'
|
||||
'label-desc label-desc'
|
||||
@@ -89,6 +87,10 @@
|
||||
|
||||
.studio-edit-top__control--project {
|
||||
grid-area: ctrl-project;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: var(--spacing-xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.studio-edit-top__control--goal {
|
||||
@@ -100,20 +102,32 @@
|
||||
}
|
||||
|
||||
.studio-edit-top__actions {
|
||||
grid-area: actions;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
padding-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.studio-edit-select--project {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: var(--studio-top-control-h);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.studio-edit-top__control--project {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.studio-edit-top__actions {
|
||||
flex: 1 1 100%;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.studio-edit-textarea--goal {
|
||||
width: 100%;
|
||||
min-height: var(--studio-top-control-h);
|
||||
|
||||
@@ -286,6 +286,33 @@ function StudioEditPage() {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="studio-edit-top__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="studio-edit-btn studio-edit-btn-danger studio-edit-btn-sm"
|
||||
onClick={handleDeleteProject}
|
||||
disabled={!currentProjectId || loading || saving}
|
||||
title="删除当前项目及其运行记录"
|
||||
>
|
||||
删除项目
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="studio-edit-btn studio-edit-btn-sm"
|
||||
onClick={openRenameProjectModal}
|
||||
disabled={!currentProjectId || loading || saving}
|
||||
>
|
||||
改名
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="studio-edit-btn studio-edit-btn-sm"
|
||||
onClick={openNewProjectModal}
|
||||
disabled={loading}
|
||||
>
|
||||
新建项目
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="studio-edit-top__control studio-edit-top__control--goal">
|
||||
<textarea
|
||||
@@ -313,33 +340,6 @@ function StudioEditPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="studio-edit-top__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="studio-edit-btn studio-edit-btn-danger studio-edit-btn-sm"
|
||||
onClick={handleDeleteProject}
|
||||
disabled={!currentProjectId || loading || saving}
|
||||
title="删除当前项目及其运行记录"
|
||||
>
|
||||
删除项目
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="studio-edit-btn studio-edit-btn-sm"
|
||||
onClick={openRenameProjectModal}
|
||||
disabled={!currentProjectId || loading || saving}
|
||||
>
|
||||
改名
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="studio-edit-btn studio-edit-btn-sm"
|
||||
onClick={openNewProjectModal}
|
||||
disabled={loading}
|
||||
>
|
||||
新建项目
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
{(error || saveMessage) && (
|
||||
<div
|
||||
|
||||
@@ -50,39 +50,6 @@
|
||||
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;
|
||||
@@ -90,38 +57,6 @@
|
||||
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);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import MarkdownRenderer from '../shared/MarkdownRenderer';
|
||||
import { findLastUserMessageIndex } from './studioRunUtils';
|
||||
|
||||
import './StudioRunChat.css';
|
||||
|
||||
@@ -31,7 +30,6 @@ function StudioRunChat({
|
||||
stepMessages = [],
|
||||
thinking,
|
||||
evaluation,
|
||||
pendingUserText,
|
||||
inputValue,
|
||||
onInputChange,
|
||||
onSend,
|
||||
@@ -47,27 +45,16 @@ function StudioRunChat({
|
||||
const [showOptions, setShowOptions] = useState(false);
|
||||
const [renderMode, setRenderMode] = useState('markdown');
|
||||
|
||||
const lastUserIdx = findLastUserMessageIndex(stepMessages);
|
||||
const historyMessages =
|
||||
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 || null;
|
||||
|
||||
const hasFocusContent =
|
||||
thinking || summaryText || pendingUserText || sending || lastUserMessage;
|
||||
const hasFocusContent = thinking || summaryText || sending;
|
||||
|
||||
useEffect(() => {
|
||||
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]);
|
||||
}, [stepMessages, thinking, evaluation, sending]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
@@ -117,20 +104,6 @@ function StudioRunChat({
|
||||
return (
|
||||
<div className="studio-run-chat">
|
||||
<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-user"
|
||||
>
|
||||
<span className="studio-run-chat-history-role">你</span>
|
||||
<span className="studio-run-chat-history-text">{msg.content}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={focusAnchorRef} className="studio-run-chat-focus">
|
||||
{!hasFocusContent ? (
|
||||
<div className="studio-run-chat-empty">
|
||||
@@ -138,24 +111,6 @@ function StudioRunChat({
|
||||
</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>
|
||||
|
||||
@@ -515,7 +515,6 @@ function StudioRunPage() {
|
||||
const [chatInput, setChatInput] = useState('');
|
||||
const [streamOutput, setStreamOutput] = useState(false);
|
||||
const [toolOptionIndex, setToolOptionIndex] = useState(0);
|
||||
const [pendingUserText, setPendingUserText] = useState(null);
|
||||
const [runListExpanded, setRunListExpanded] = useState(false);
|
||||
const [renamingId, setRenamingId] = useState(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
@@ -534,7 +533,6 @@ function StudioRunPage() {
|
||||
setRunSessionEntered(false);
|
||||
setInsertionPopupOpen(false);
|
||||
setContextBlockPopup(null);
|
||||
setPendingUserText(null);
|
||||
}, [currentRunId, runProjectId, setRunSessionEntered]);
|
||||
|
||||
const activeNodeState = useMemo(() => {
|
||||
@@ -621,11 +619,9 @@ function StudioRunPage() {
|
||||
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 */
|
||||
}
|
||||
@@ -649,7 +645,6 @@ function StudioRunPage() {
|
||||
if (run) {
|
||||
setChatInput('');
|
||||
setToolOptionIndex(0);
|
||||
setPendingUserText(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -864,7 +859,6 @@ function StudioRunPage() {
|
||||
stepMessages={stepMessages}
|
||||
thinking={runStreamingThinking ?? lastToolResponse?.thinking}
|
||||
evaluation={runStreamingThinking ? null : lastToolResponse?.evaluation}
|
||||
pendingUserText={pendingUserText}
|
||||
inputValue={chatInput}
|
||||
onInputChange={setChatInput}
|
||||
onSend={handleChatSend}
|
||||
|
||||
Reference in New Issue
Block a user