Studio: fix edit/run UI and write worldbook on advance (R6)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-31 22:46:28 +08:00
parent 63a32bfa7c
commit 5bfe7a733f
7 changed files with 185 additions and 154 deletions

View File

@@ -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+"

View File

@@ -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]:
"""

View File

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

View File

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

View File

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

View File

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

View File

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