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:
2026-05-31 22:07:42 +08:00
parent fa6907fb8d
commit f3792915a3
20 changed files with 1639 additions and 304 deletions

View File

@@ -10,7 +10,7 @@ import shutil
import uuid
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, AsyncGenerator, Dict, List, Optional
from core.config import settings
from models.studio_models import (
@@ -22,8 +22,13 @@ from models.studio_models import (
StudioRunSummary,
)
from services.character_service import CharacterService
from services.studio_context_service import store_context_on_run
from services.studio_context_service import assemble_prompt_blocks, store_context_on_run
from services.studio_project_service import studio_project_service
from services.studio_step_respond import (
resolve_api_config,
studio_step_respond,
studio_step_respond_stream,
)
from services.worldbook_service import worldbook_service
logger = logging.getLogger(__name__)
@@ -302,6 +307,181 @@ class StudioRunService:
self._save_run(project_id, run_id, updated_run)
return updated_run
async def send_run_message(
self,
project_id: str,
run_id: str,
content: str,
*,
stream: bool = False,
profile_id: Optional[str] = None,
api_config: Optional[Dict[str, str]] = None,
) -> StudioRun:
"""Send a user message to the active worldbook step and invoke LLM (R3)."""
trimmed = (content or "").strip()
if not trimmed:
raise ValueError("消息内容不能为空")
run, current_node, current_state, current_node_id = self._prepare_run_message(
project_id, run_id, trimmed
)
resolved_api = resolve_api_config(profile_id, api_config)
prompt_blocks = assemble_prompt_blocks(run, current_node_id)
step_messages = list(current_state.stepMessages or [])
last_draft, last_tool_response, user_msg, assistant_msg = (
await studio_step_respond(
node=current_node,
prompt_blocks=prompt_blocks,
step_messages=step_messages,
user_message=trimmed,
existing_draft=current_state.lastDraft,
api_config=resolved_api,
stream=stream,
)
)
return self._persist_run_message_turn(
project_id,
run_id,
run,
current_node_id,
prompt_blocks,
step_messages,
last_draft,
last_tool_response,
user_msg,
assistant_msg,
)
async def send_run_message_stream(
self,
project_id: str,
run_id: str,
content: str,
*,
profile_id: Optional[str] = None,
api_config: Optional[Dict[str, str]] = None,
) -> AsyncGenerator[Dict[str, Any], None]:
"""Stream thinking deltas, then persist and emit complete run (R4)."""
trimmed = (content or "").strip()
if not trimmed:
raise ValueError("消息内容不能为空")
run, current_node, current_state, current_node_id = self._prepare_run_message(
project_id, run_id, trimmed
)
resolved_api = resolve_api_config(profile_id, api_config)
prompt_blocks = assemble_prompt_blocks(run, current_node_id)
step_messages = list(current_state.stepMessages or [])
async for event in studio_step_respond_stream(
node=current_node,
prompt_blocks=prompt_blocks,
step_messages=step_messages,
user_message=trimmed,
existing_draft=current_state.lastDraft,
api_config=resolved_api,
):
if event.get("type") == "thinking_delta":
yield event
continue
if event.get("type") == "complete":
from models.studio_models import LastToolResponse, StepMessage
last_draft = event["last_draft"]
last_tool_response = LastToolResponse(**event["last_tool_response"])
user_msg = StepMessage(**event["user_msg"])
assistant_msg = StepMessage(**event["assistant_msg"])
updated_run = self._persist_run_message_turn(
project_id,
run_id,
run,
current_node_id,
prompt_blocks,
step_messages,
last_draft,
last_tool_response,
user_msg,
assistant_msg,
)
yield {
"type": "complete",
"run": updated_run.model_dump(mode="json"),
}
def _prepare_run_message(
self,
project_id: str,
run_id: str,
trimmed: str,
) -> tuple[StudioRun, StudioNode, StudioNodeRunState, str]:
run = self.get_run(project_id, run_id)
if run.status != StudioRunStatus.RUNNING:
raise ValueError("运行未处于进行中,无法发送消息")
current_node_id = run.currentNodeId
if not current_node_id:
raise ValueError("当前运行无活动节点")
current_node = _find_node(run.pipelineSnapshot, current_node_id)
if not current_node:
raise ValueError(f"节点不存在:{current_node_id}")
if current_node.skillId != "studio.worldbook_entry":
raise ValueError(
f"当前步骤「{current_node.displayName}」不支持对话消息"
)
current_state = next(
(s for s in run.nodeStates if s.nodeId == current_node_id), None
)
if not current_state or current_state.status != "active":
raise ValueError("当前节点不可执行")
return run, current_node, current_state, current_node_id
def _persist_run_message_turn(
self,
project_id: str,
run_id: str,
run: StudioRun,
current_node_id: str,
prompt_blocks: list,
step_messages: list,
last_draft: Dict[str, Any],
last_tool_response,
user_msg,
assistant_msg,
) -> StudioRun:
step_messages = list(step_messages)
step_messages.extend([user_msg, assistant_msg])
now = datetime.now().isoformat()
new_node_states: list[StudioNodeRunState] = []
for state in run.nodeStates:
updated = state.model_copy()
if state.nodeId == current_node_id:
updated.lastDraft = last_draft
updated.lastToolResponse = last_tool_response
updated.stepMessages = step_messages
new_node_states.append(updated)
updated_run = run.model_copy(
update={
"nodeStates": new_node_states,
"lastPromptBlocks": prompt_blocks,
"updatedAt": now,
}
)
updated_run = store_context_on_run(updated_run, current_node_id)
self._save_run(project_id, run_id, updated_run)
return updated_run
def delete_run(self, project_id: str, run_id: str) -> None:
run_dir = self._run_dir(project_id, run_id)
if not run_dir.exists():