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

@@ -1,13 +1,16 @@
import json
import logging import logging
from typing import Any, Dict, List from typing import Any, Dict, List
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from models.studio_models import ( from models.studio_models import (
AdvanceRunRequest, AdvanceRunRequest,
CreateStudioProjectRequest, CreateStudioProjectRequest,
PipelineDefinition, PipelineDefinition,
RenameRunRequest, RenameRunRequest,
RunMessageRequest,
StudioProject, StudioProject,
StudioProjectSummary, StudioProjectSummary,
StudioRun, StudioRun,
@@ -186,6 +189,66 @@ async def advance_studio_run(
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/projects/{project_id}/runs/{run_id}/message")
async def send_studio_run_message(
project_id: str, run_id: str, req: RunMessageRequest
):
if req.stream:
async def ndjson_stream():
try:
async for event in studio_run_service.send_run_message_stream(
project_id,
run_id,
req.content,
profile_id=req.profileId,
api_config=req.apiConfig,
):
yield json.dumps(event, ensure_ascii=False) + "\n"
except FileNotFoundError as e:
yield json.dumps(
{"type": "error", "detail": str(e)}, ensure_ascii=False
) + "\n"
except ValueError as e:
yield json.dumps(
{"type": "error", "detail": str(e)}, ensure_ascii=False
) + "\n"
except Exception as e:
logger.error(
"Failed to stream studio run message %s/%s: %s",
project_id,
run_id,
e,
)
yield json.dumps(
{"type": "error", "detail": f"消息处理失败:{e}"},
ensure_ascii=False,
) + "\n"
return StreamingResponse(
ndjson_stream(),
media_type="application/x-ndjson",
)
try:
return await studio_run_service.send_run_message(
project_id,
run_id,
req.content,
stream=req.stream,
profile_id=req.profileId,
api_config=req.apiConfig,
)
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(
"Failed to send studio run message %s/%s: %s", project_id, run_id, e
)
raise HTTPException(status_code=500, detail=f"消息处理失败:{e}")
@router.delete("/projects/{project_id}/runs/{run_id}") @router.delete("/projects/{project_id}/runs/{run_id}")
async def delete_studio_run(project_id: str, run_id: str): async def delete_studio_run(project_id: str, run_id: str):
try: try:

View File

@@ -159,8 +159,18 @@ class ToolQuestionOption(BaseModel):
options: List[str] = Field(default_factory=list) options: List[str] = Field(default_factory=list)
class StepMessage(BaseModel):
"""Short step-scoped dialogue (not full chat history)."""
id: str
role: str # user | assistant
content: str
createdAt: Optional[str] = None
class LastToolResponse(BaseModel): class LastToolResponse(BaseModel):
"""LLM tool-call payload surfaced to the run UI (R2+).""" """LLM tool-call payload surfaced to the run UI (R2+)."""
thinking: Optional[str] = None
evaluation: Optional[str] = None
questions: List[ToolQuestionOption] = Field(default_factory=list) questions: List[ToolQuestionOption] = Field(default_factory=list)
generatedAt: Optional[str] = None generatedAt: Optional[str] = None
@@ -181,6 +191,7 @@ class StudioNodeRunState(BaseModel):
loopUntilSatisfied: bool = False loopUntilSatisfied: bool = False
lastDraft: Optional[Dict[str, Any]] = None lastDraft: Optional[Dict[str, Any]] = None
lastToolResponse: Optional[LastToolResponse] = None lastToolResponse: Optional[LastToolResponse] = None
stepMessages: List[StepMessage] = Field(default_factory=list)
class StudioRun(BaseModel): class StudioRun(BaseModel):
@@ -202,6 +213,13 @@ class AdvanceRunRequest(BaseModel):
displayParams: Dict[str, str] = Field(default_factory=dict) displayParams: Dict[str, str] = Field(default_factory=dict)
class RunMessageRequest(BaseModel):
content: str = Field(..., min_length=1, max_length=32000)
stream: bool = False
profileId: Optional[str] = None
apiConfig: Optional[Dict[str, str]] = None
class RenameRunRequest(BaseModel): class RenameRunRequest(BaseModel):
title: str = Field(..., min_length=1, max_length=120) title: str = Field(..., min_length=1, max_length=120)

View File

@@ -10,7 +10,7 @@ import shutil
import uuid import uuid
from datetime import datetime from datetime import datetime
from pathlib import Path 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 core.config import settings
from models.studio_models import ( from models.studio_models import (
@@ -22,8 +22,13 @@ from models.studio_models import (
StudioRunSummary, StudioRunSummary,
) )
from services.character_service import CharacterService 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_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 from services.worldbook_service import worldbook_service
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -302,6 +307,181 @@ class StudioRunService:
self._save_run(project_id, run_id, updated_run) self._save_run(project_id, run_id, updated_run)
return 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: def delete_run(self, project_id: str, run_id: str) -> None:
run_dir = self._run_dir(project_id, run_id) run_dir = self._run_dir(project_id, run_id)
if not run_dir.exists(): if not run_dir.exists():

View File

@@ -0,0 +1,427 @@
"""
Studio worldbook step LLM responder (R3/R4).
Assembles R2 context blocks + short step dialogue, calls LLM for structured JSON,
returns thinking, draft, questions, and evaluation.
"""
from __future__ import annotations
import json
import re
import uuid
from datetime import datetime
from typing import Any, AsyncGenerator, Dict, List, Optional, Tuple
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from models.studio_models import (
LastToolResponse,
PromptBlock,
StudioNode,
StepMessage,
ToolQuestionOption,
)
from services.studio_context_service import assemble_prompt_blocks
from utils.llm_client import LLMClient
_llm_client = LLMClient()
_JSON_FENCE = re.compile(r"```(?:json)?\s*([\s\S]*?)```", re.IGNORECASE)
def resolve_api_config(
profile_id: Optional[str],
api_config: Optional[Dict[str, str]],
) -> Dict[str, str]:
"""Merge frontend apiConfig with stored profile mainLLM key (same as chat WS)."""
resolved = dict(api_config or {})
if profile_id:
try:
try:
from api.routes.apiConfigRoute import load_profile
except ImportError:
from backend.api.routes.apiConfigRoute import load_profile
profile = load_profile(profile_id)
if profile:
main_llm = profile.get("apis", {}).get("mainLLM", {})
if main_llm.get("apiUrl") and not resolved.get("api_url"):
resolved["api_url"] = main_llm.get("apiUrl", "")
if main_llm.get("model") and not resolved.get("model"):
resolved["model"] = main_llm.get("model", "")
api_key = main_llm.get("apiKey", "")
if api_key:
resolved["api_key"] = api_key
except Exception as exc:
print(f"[StudioStepRespond] 加载 API 配置失败: {exc}")
return resolved
def _blocks_to_context_text(blocks: List[PromptBlock]) -> str:
sections: List[str] = []
for block in blocks:
sections.append(f"## {block.label}\n{block.content}")
return "\n\n".join(sections)
def _build_system_prompt(node: StudioNode) -> str:
insertion = (node.config or {}).get("insertion") or {}
key = insertion.get("key") or "(未配置关键词)"
comment = insertion.get("comment") or ""
return f"""你是 Studio 创作助手,负责为当前流水线步骤生成或修订世界书条目草稿。
当前步骤:{node.displayName}
目标关键词:{key}
备注:{comment or "(无)"}
你必须只输出一个 JSON 对象(不要 markdown 代码块外的其他文字),字段如下:
{{
"thinking": "你的内部思考过程(逐步推理,中文)",
"currentProduct": "世界书条目正文(纯文本或 Markdown可直接写入条目 content",
"questions": [
{{
"question": "需要用户澄清的问题",
"options": ["选项A", "选项B", "选项C"]
}}
],
"evaluation": "对照评价维度的自检与优化建议(中文,面向用户)"
}}
规则:
1. currentProduct 必须是完整、可注入世界书的条目正文。
2. questions 为 03 条;每条至少 2 个 options若无需澄清则 questions 为空数组。
3. evaluation 需引用上下文中的评价标准,给出具体、可操作的反馈。
4. 若用户要求修改,在 currentProduct 中输出修订后的完整条目,而非仅说明改了什么。
5. 全部字段使用中文(专有名词除外)。"""
def _dialogue_to_langchain(
step_messages: List[StepMessage],
) -> List[Any]:
messages: List[Any] = []
for msg in step_messages:
if msg.role == "user":
messages.append(HumanMessage(content=msg.content))
elif msg.role == "assistant":
messages.append(AIMessage(content=msg.content))
return messages
def _build_llm_messages(
node: StudioNode,
prompt_blocks: List[PromptBlock],
step_messages: List[StepMessage],
user_message: str,
) -> List[Any]:
context_text = _blocks_to_context_text(prompt_blocks)
system_prompt = _build_system_prompt(node)
messages: List[Any] = [SystemMessage(content=system_prompt)]
messages.append(
HumanMessage(
content=f"以下为当前步骤上下文(不含完整聊天历史):\n\n{context_text}"
)
)
messages.extend(_dialogue_to_langchain(step_messages))
messages.append(HumanMessage(content=user_message))
return messages
def _validate_api_config(api_config: Dict[str, str]) -> None:
if not api_config.get("api_key"):
raise ValueError("API Key 未配置,请先在 API 配置页面保存密钥")
if not api_config.get("api_url"):
raise ValueError("API 地址未配置,请先在 API 配置页面保存 mainLLM")
def _extract_json(raw: str) -> Dict[str, Any]:
text = (raw or "").strip()
if not text:
raise ValueError("模型返回为空")
fence = _JSON_FENCE.search(text)
if fence:
text = fence.group(1).strip()
try:
return json.loads(text)
except json.JSONDecodeError:
start = text.find("{")
end = text.rfind("}")
if start >= 0 and end > start:
return json.loads(text[start : end + 1])
raise ValueError("无法解析模型返回的 JSON")
def _decode_json_string_partial(raw: str) -> str:
"""Decode a possibly incomplete JSON string body (no surrounding quotes)."""
out: List[str] = []
i = 0
while i < len(raw):
if raw[i] == "\\" and i + 1 < len(raw):
nxt = raw[i + 1]
if nxt == "n":
out.append("\n")
elif nxt == "t":
out.append("\t")
elif nxt == "r":
out.append("\r")
elif nxt == '"':
out.append('"')
elif nxt == "\\":
out.append("\\")
elif nxt == "/":
out.append("/")
elif nxt == "u" and i + 5 < len(raw):
try:
out.append(chr(int(raw[i + 2 : i + 6], 16)))
i += 6
continue
except ValueError:
out.append(nxt)
else:
out.append(nxt)
i += 2
else:
out.append(raw[i])
i += 1
return "".join(out)
def _extract_partial_thinking(raw: str) -> Optional[str]:
"""Best-effort extraction of thinking field from incomplete JSON stream."""
marker = '"thinking"'
idx = raw.find(marker)
if idx < 0:
return None
colon = raw.find(":", idx + len(marker))
if colon < 0:
return None
rest = raw[colon + 1 :].lstrip()
if not rest.startswith('"'):
return None
body_start = 1
i = body_start
while i < len(rest):
ch = rest[i]
if ch == '"':
break
if ch == "\\":
i += 2
continue
i += 1
partial = rest[body_start:i]
if not partial:
return None
return _decode_json_string_partial(partial)
def _normalize_draft(
current_product: Any,
node: StudioNode,
existing_draft: Optional[Dict[str, Any]],
) -> Dict[str, Any]:
draft: Dict[str, Any] = dict(existing_draft or {})
insertion = (node.config or {}).get("insertion") or {}
if isinstance(current_product, str):
draft["entryContent"] = current_product.strip()
elif isinstance(current_product, dict):
draft.update(current_product)
if "entryContent" not in draft and "content" in draft:
draft["entryContent"] = draft["content"]
else:
draft["entryContent"] = str(current_product)
if insertion.get("key"):
draft["insertionKey"] = insertion["key"]
if insertion.get("comment"):
draft["insertionComment"] = insertion["comment"]
draft["nodeId"] = node.id
draft["displayName"] = node.displayName
return draft
def _normalize_questions(raw: Any) -> List[ToolQuestionOption]:
if not isinstance(raw, list):
return []
result: List[ToolQuestionOption] = []
for item in raw:
if not isinstance(item, dict):
continue
question = (item.get("question") or "").strip()
if not question:
continue
options = [
str(o).strip()
for o in (item.get("options") or [])
if str(o).strip()
]
if len(options) < 2:
continue
result.append(ToolQuestionOption(question=question, options=options))
return result[:3]
def _assistant_message_text(parsed: Dict[str, Any]) -> str:
evaluation = (parsed.get("evaluation") or "").strip()
if evaluation:
return evaluation
product = parsed.get("currentProduct")
if isinstance(product, str) and product.strip():
preview = product.strip()
if len(preview) > 400:
preview = preview[:400] + ""
return f"已更新条目草稿:\n\n{preview}"
return "已处理您的消息,请查看左侧目前产物。"
def _build_turn_result(
parsed: Dict[str, Any],
*,
node: StudioNode,
user_message: str,
existing_draft: Optional[Dict[str, Any]],
) -> Tuple[Dict[str, Any], LastToolResponse, StepMessage, StepMessage]:
now = datetime.now().isoformat()
last_draft = _normalize_draft(
parsed.get("currentProduct"),
node,
existing_draft,
)
last_tool_response = LastToolResponse(
thinking=(parsed.get("thinking") or "").strip() or None,
evaluation=(parsed.get("evaluation") or "").strip() or None,
questions=_normalize_questions(parsed.get("questions")),
generatedAt=now,
)
user_step_msg = StepMessage(
id=f"msg-{uuid.uuid4().hex[:12]}",
role="user",
content=user_message,
createdAt=now,
)
assistant_step_msg = StepMessage(
id=f"msg-{uuid.uuid4().hex[:12]}",
role="assistant",
content=_assistant_message_text(parsed),
createdAt=now,
)
return last_draft, last_tool_response, user_step_msg, assistant_step_msg
async def studio_step_respond(
*,
node: StudioNode,
prompt_blocks: List[PromptBlock],
step_messages: List[StepMessage],
user_message: str,
existing_draft: Optional[Dict[str, Any]],
api_config: Dict[str, str],
stream: bool = False,
) -> Tuple[Dict[str, Any], LastToolResponse, StepMessage, StepMessage]:
"""
Execute one worldbook step turn (non-streaming).
Returns (last_draft, last_tool_response, user_step_msg, assistant_step_msg).
"""
_validate_api_config(api_config)
messages = _build_llm_messages(node, prompt_blocks, step_messages, user_message)
model = api_config.get("model") or "gpt-4o-mini"
if stream:
print("[StudioStepRespond] stream=True 应使用 studio_step_respond_stream")
response = await _llm_client.chat_completion(
messages=messages,
api_url=api_config.get("api_url", ""),
api_key=api_config.get("api_key", ""),
model=model,
temperature=0.7,
max_tokens=8000,
request_timeout=120,
stream=False,
)
raw_content = ""
if isinstance(response, dict):
raw_content = (
response.get("choices", [{}])[0]
.get("message", {})
.get("content", "")
)
else:
raw_content = str(response)
parsed = _extract_json(raw_content)
return _build_turn_result(
parsed,
node=node,
user_message=user_message,
existing_draft=existing_draft,
)
async def studio_step_respond_stream(
*,
node: StudioNode,
prompt_blocks: List[PromptBlock],
step_messages: List[StepMessage],
user_message: str,
existing_draft: Optional[Dict[str, Any]],
api_config: Dict[str, str],
) -> AsyncGenerator[Dict[str, Any], None]:
"""
Stream thinking field while LLM generates structured JSON (R4).
Yields:
- {"type": "thinking_delta", "content": "..."}
- {"type": "complete", "last_draft", "last_tool_response", "user_msg", "assistant_msg"}
"""
_validate_api_config(api_config)
messages = _build_llm_messages(node, prompt_blocks, step_messages, user_message)
model = api_config.get("model") or "gpt-4o-mini"
accumulated = ""
last_thinking = ""
async for chunk in _llm_client.stream_chat(
messages=messages,
api_url=api_config.get("api_url", ""),
api_key=api_config.get("api_key", ""),
model=model,
temperature=0.7,
max_tokens=8000,
request_timeout=120,
):
if chunk.get("type") != "chunk":
continue
accumulated += chunk.get("content") or ""
partial = _extract_partial_thinking(accumulated)
if partial and partial != last_thinking:
last_thinking = partial
yield {"type": "thinking_delta", "content": partial}
parsed = _extract_json(accumulated)
last_draft, last_tool_response, user_msg, assistant_msg = _build_turn_result(
parsed,
node=node,
user_message=user_message,
existing_draft=existing_draft,
)
yield {
"type": "complete",
"last_draft": last_draft,
"last_tool_response": last_tool_response.model_dump(mode="json"),
"user_msg": user_msg.model_dump(mode="json"),
"assistant_msg": assistant_msg.model_dump(mode="json"),
}

View File

@@ -52,7 +52,22 @@
"supportsLoopUntilSatisfied": true, "supportsLoopUntilSatisfied": true,
"supportsInputs": true, "supportsInputs": true,
"supportsInsertion": true, "supportsInsertion": true,
"supportsScoring": true "supportsScoring": true,
"configDefaults": {
"stepGoal": "",
"thinkingPrompt": "====== 思考流程 ======\nStep1: 简短确认任务性质(新设计/修改)\nStep2: 阅读绑定角色/世界书与上文引用\nStep3: 按步骤目标起草世界书条目\nStep4: 对照评价维度自检并优化表述\n\n核心目的、评价标准与优化建议由系统在运行时自动注入无需在此填写",
"insertion": {
"position": 1,
"activationType": "permanent",
"key": "",
"keysecondary": "",
"comment": ""
},
"scoring": {
"enabled": true,
"dimensions": []
}
}
} }
] ]
} }

View File

@@ -29,7 +29,6 @@
"id": "aesthetic", "id": "aesthetic",
"skillId": "studio.worldbook_entry", "skillId": "studio.worldbook_entry",
"displayName": "整体美学", "displayName": "整体美学",
"niche": "aesthetic_tone",
"enabled": true, "enabled": true,
"loopUntilSatisfied": true, "loopUntilSatisfied": true,
"config": { "config": {
@@ -64,7 +63,6 @@
"id": "persona", "id": "persona",
"skillId": "studio.worldbook_entry", "skillId": "studio.worldbook_entry",
"displayName": "具体人设", "displayName": "具体人设",
"niche": "persona_detail",
"enabled": true, "enabled": true,
"loopUntilSatisfied": true, "loopUntilSatisfied": true,
"config": { "config": {

View File

@@ -88,11 +88,14 @@ const useCharacterStore = create(
} }
}, },
// 选择角色 // 选择角色(传 null 清除选中)
selectCharacter: (character) => { selectCharacter: (character) => {
// 使用函数式更新避免不必要的重渲染
set((state) => { set((state) => {
// 如果选中的是同一个角色,不更新状态 if (!character) {
return state.selectedCharacter === null
? state
: { selectedCharacter: null };
}
if (state.selectedCharacter?.id === character.id) { if (state.selectedCharacter?.id === character.id) {
return state; return state;
} }
@@ -100,6 +103,8 @@ const useCharacterStore = create(
}); });
}, },
clearSelectedCharacter: () => set({ selectedCharacter: null }),
// 创建角色 // 创建角色
createCharacter: async (characterData) => { createCharacter: async (characterData) => {
try { try {
@@ -151,6 +156,18 @@ const useCharacterStore = create(
if (!response.ok) throw new Error('Failed to delete character'); 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(); await get().fetchCharacters();
} catch (error) { } catch (error) {

View File

@@ -41,7 +41,6 @@ const useStudioStore = create((set, get) => ({
meta: null, meta: null,
pipeline: null, pipeline: null,
skillTemplates: [], skillTemplates: [],
niches: [],
selectedNodeId: null, selectedNodeId: null,
loading: false, loading: false,
saving: false, saving: false,
@@ -56,7 +55,10 @@ const useStudioStore = create((set, get) => ({
runLoading: false, runLoading: false,
runCreating: false, runCreating: false,
runAdvancing: false, runAdvancing: false,
runMessaging: false,
runStreamingThinking: null,
runError: null, runError: null,
runSessionEntered: false,
setSelectedNodeId: (id) => set({ selectedNodeId: id }), setSelectedNodeId: (id) => set({ selectedNodeId: id }),
clearSaveMessage: () => set({ saveMessage: null, error: null }), clearSaveMessage: () => set({ saveMessage: null, error: null }),
@@ -112,26 +114,11 @@ const useStudioStore = create((set, get) => ({
displayParams: tpl?.displayParams ? [...tpl.displayParams] : [], displayParams: tpl?.displayParams ? [...tpl.displayParams] : [],
inputs: [], inputs: [],
}; };
if (tpl?.configDefaults) {
base.config = JSON.parse(JSON.stringify(tpl.configDefaults));
}
if (skillId === 'studio.worldbook_entry') { if (skillId === 'studio.worldbook_entry') {
base.loopUntilSatisfied = false; 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]; const nodes = [...pipeline.nodes, base];
set({ set({
@@ -198,16 +185,10 @@ Step4: 对照评价维度自检并优化表述
fetchSkillTemplates: async () => { fetchSkillTemplates: async () => {
try { try {
const [tplRes, nicheRes] = await Promise.all([ const res = await fetch('/api/studio/skill-templates');
fetch('/api/studio/skill-templates'), if (!res.ok) throw new Error(await res.text());
fetch('/api/studio/niches'), const tplData = await res.json();
]); set({ skillTemplates: tplData.templates || [] });
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 });
} catch (e) { } catch (e) {
set({ error: e.message || '加载技能模板失败' }); set({ error: e.message || '加载技能模板失败' });
} }
@@ -388,9 +369,12 @@ Step4: 对照评价维度自检并优化表述
currentRunId: null, currentRunId: null,
currentRun: null, currentRun: null,
runs: [], runs: [],
runSessionEntered: false,
}); });
}, },
setRunSessionEntered: (entered) => set({ runSessionEntered: !!entered }),
fetchRuns: async (projectId) => { fetchRuns: async (projectId) => {
if (!projectId) return []; if (!projectId) return [];
set({ runLoading: true, runError: null }); set({ runLoading: true, runError: null });
@@ -424,6 +408,7 @@ Step4: 对照评价维度自检并优化表述
currentRunId: runId, currentRunId: runId,
currentRun: run, currentRun: run,
runLoading: false, runLoading: false,
runSessionEntered: false,
}); });
return run; return run;
} catch (e) { } catch (e) {
@@ -450,6 +435,7 @@ Step4: 对照评价维度自检并优化表述
currentRunId: run.id, currentRunId: run.id,
currentRun: run, currentRun: run,
runCreating: false, runCreating: false,
runSessionEntered: false,
}); });
return run; return run;
} catch (e) { } 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) => { deleteRun: async (runId) => {
const projectId = get().runProjectId; const projectId = get().runProjectId;
if (!projectId || !runId) return false; if (!projectId || !runId) return false;

View File

@@ -132,6 +132,8 @@
/* 角色卡片列表 - 网格布局 */ /* 角色卡片列表 - 网格布局 */
.character-list { .character-list {
position: relative;
z-index: var(--z-base-content);
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
display: grid; display: grid;
@@ -318,14 +320,16 @@
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
padding: 8px; padding: 8px;
position: relative; /* 为悬浮工具栏提供定位上下文 */ position: relative;
z-index: var(--z-base-content);
isolation: isolate;
} }
/* 悬浮工具栏 */ /* 悬浮工具栏 */
.floating-toolbar { .floating-toolbar {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: var(--z-top-bar); /* ✅ 组件层 - TopBar 同级 */ z-index: 2;
margin-bottom: 8px; margin-bottom: 8px;
} }

View File

@@ -238,11 +238,20 @@ const CharacterCard = () => {
try { try {
await deleteCharacter(name); 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(); const { setActiveTab } = useSideBarLeftStore.getState();
setActiveTab('character'); setActiveTab('character');
console.log('[CharacterCard] 角色已删除,已切换到角色分页'); console.log('[CharacterCard] 角色已删除,已返回角色选择列表');
} catch (err) { } catch (err) {
console.error('删除失败:', err); console.error('删除失败:', err);
} }

View File

@@ -50,7 +50,6 @@ function StudioEditPage() {
meta, meta,
pipeline, pipeline,
skillTemplates, skillTemplates,
niches,
selectedNodeId, selectedNodeId,
loading, loading,
saving, saving,
@@ -499,36 +498,6 @@ function StudioEditPage() {
value={selectedTpl?.displayName || selectedNode.skillId} value={selectedTpl?.displayName || selectedNode.skillId}
/> />
</label> </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 && ( {selectedTpl?.artifacts?.length > 0 && (
<span className="studio-edit-meta-strip__hint"> <span className="studio-edit-meta-strip__hint">
产物 产物

View File

@@ -44,6 +44,118 @@
background: rgba(255, 255, 255, 0.6); 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 { .studio-run-chat-message-role {
display: block; display: block;
font-size: 0.7rem; font-size: 0.7rem;
@@ -89,6 +201,24 @@
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); 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 { .studio-run-chat-options-wrapper {
position: relative; position: relative;
flex-shrink: 0; flex-shrink: 0;
@@ -166,6 +296,70 @@
color: var(--color-accent); 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 { .studio-run-chat-input-area {
flex: 1; flex: 1;
min-width: 0; min-width: 0;

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import MarkdownRenderer from '../shared/MarkdownRenderer'; import MarkdownRenderer from '../shared/MarkdownRenderer';
import { findLastUserMessageIndex } from './studioRunUtils';
import './StudioRunChat.css'; import './StudioRunChat.css';
@@ -11,7 +12,8 @@ const RENDER_MODE_LABELS = {
markdown: '📝 Markdown', markdown: '📝 Markdown',
}; };
function renderMessageBody(text, renderMode, isUser) { function renderBody(text, renderMode, isUser) {
if (!text) return null;
if (renderMode === 'html' && !isUser) { if (renderMode === 'html' && !isUser) {
const hasHtmlTags = /<[a-z][\s\S]*>/i.test(text); const hasHtmlTags = /<[a-z][\s\S]*>/i.test(text);
if (hasHtmlTags) { if (hasHtmlTags) {
@@ -26,23 +28,48 @@ function renderMessageBody(text, renderMode, isUser) {
} }
function StudioRunChat({ function StudioRunChat({
messages, stepMessages = [],
thinking,
evaluation,
pendingUserText,
inputValue, inputValue,
onInputChange, onInputChange,
onSend, onSend,
disabled = false, disabled = false,
placeholder = '输入消息…',
sending = false, sending = false,
streamOutput = false,
onStreamOutputChange,
placeholder = '输入消息…',
}) { }) {
const messagesEndRef = useRef(null); const containerRef = useRef(null);
const messagesContainerRef = useRef(null); const focusAnchorRef = useRef(null);
const optionsRef = useRef(null); const optionsRef = useRef(null);
const [showOptions, setShowOptions] = useState(false); const [showOptions, setShowOptions] = useState(false);
const [renderMode, setRenderMode] = useState('markdown'); 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(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); const container = containerRef.current;
}, [messages]); const anchor = focusAnchorRef.current;
if (!container || !anchor) return;
container.scrollTop = Math.max(0, anchor.offsetTop - container.offsetTop);
}, [stepMessages, thinking, evaluation, pendingUserText, sending]);
useEffect(() => { useEffect(() => {
const handleClickOutside = (event) => { const handleClickOutside = (event) => {
@@ -89,28 +116,77 @@ function StudioRunChat({
setRenderMode(RENDER_MODES[(idx + 1) % RENDER_MODES.length]); setRenderMode(RENDER_MODES[(idx + 1) % RENDER_MODES.length]);
}; };
const roleLabel = (role) => {
if (role === 'user') return '你';
if (role === 'system') return '系统';
return '助手';
};
return ( return (
<div className="studio-run-chat"> <div className="studio-run-chat">
<div className="studio-run-chat-messages" ref={messagesContainerRef}> <div className="studio-run-chat-messages" ref={containerRef}>
{messages.length === 0 ? ( {historyMessages.length > 0 && (
<div className="studio-run-chat-empty">暂无对话在下方输入开始交流</div> <div className="studio-run-chat-history" aria-hidden="true">
) : ( {historyMessages.map((msg) => (
messages.map((msg) => ( <div
<div key={msg.id} className={`studio-run-chat-message role-${msg.role}`}> key={msg.id}
<span className="studio-run-chat-message-role">{roleLabel(msg.role)}</span> className={`studio-run-chat-history-item role-${msg.role}`}
<div className="studio-run-chat-message-body"> >
{renderMessageBody(msg.text, renderMode, msg.role === 'user')} <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>
)} )}
<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> </div>
<form className="studio-run-chat-input-wrapper" onSubmit={handleSubmit}> <form className="studio-run-chat-input-wrapper" onSubmit={handleSubmit}>
@@ -135,6 +211,16 @@ function StudioRunChat({
> >
{RENDER_MODE_LABELS[renderMode]} {RENDER_MODE_LABELS[renderMode]}
</button> </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>
)} )}
</div> </div>

View File

@@ -6,70 +6,119 @@
overflow: hidden; overflow: hidden;
} }
.studio-run-header { .studio-run-preview__meta {
display: flex; 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; flex-wrap: wrap;
} gap: var(--spacing-sm) var(--spacing-md);
font-size: 0.75rem;
.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;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.studio-run-select { .studio-run-preview__character {
padding: var(--spacing-xs) var(--spacing-sm); color: var(--color-text-secondary);
border-radius: var(--radius-md); }
.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: 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); background: var(--color-bg-tertiary);
color: var(--color-text-primary); color: var(--color-text-primary);
font-size: 0.875rem; border-color: var(--color-border);
min-width: 160px;
} }
.studio-run-new-btn { [data-color-theme='dark'] .studio-run-list-item {
padding: var(--spacing-xs) var(--spacing-md); background: var(--color-bg-tertiary);
border-radius: var(--radius-md);
border: none;
background: var(--color-accent);
color: var(--color-text-inverse, #fff);
font-size: 0.875rem;
cursor: pointer;
} }
.studio-run-new-btn:hover:not(:disabled) { [data-color-theme='dark'] .studio-run-list-action {
opacity: 0.9; background: var(--color-bg-elevated);
color: var(--color-text-secondary);
} }
.studio-run-new-btn:disabled { [data-color-theme='dark'] .studio-run-product-main__body,
opacity: 0.5; [data-color-theme='dark'] .studio-run-variables__row,
cursor: not-allowed; [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 { .studio-run-banner {
@@ -290,11 +339,6 @@
white-space: nowrap; white-space: nowrap;
} }
.studio-run-preview__meta {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.studio-run-preview__body { .studio-run-preview__body {
flex: 1; flex: 1;
min-height: 0; min-height: 0;

View File

@@ -6,6 +6,8 @@ import {
buildWorkflowVariableLabelMap, buildWorkflowVariableLabelMap,
getWorkflowVariableLabel, getWorkflowVariableLabel,
} from './edit/workflowVariableLabels'; } from './edit/workflowVariableLabels';
import { resolveBoundCharacterName } from './studioRunUtils';
import useCharacterStore from '../../Store/SideBarLeft/CharacterSlice';
import StudioContextBlockPopup from './StudioContextBlockPopup'; import StudioContextBlockPopup from './StudioContextBlockPopup';
import StudioInsertionPopup from './StudioInsertionPopup'; 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 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; 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 ( return (
<div className="studio-run-tool-carousel"> <div className="studio-run-tool-carousel">
<div className="studio-run-tool-carousel__head"> <div className="studio-run-tool-carousel__head">
@@ -146,8 +172,8 @@ function ToolOptionsCarousel({ options, index, onPrev, onNext, onSelect, runAdva
<button <button
key={opt} key={opt}
type="button" type="button"
className="studio-run-tool-option" className={`studio-run-tool-option${selectedOption === opt ? ' is-selected' : ''}`}
onClick={() => onSelect(opt)} onClick={() => handleSelect(opt)}
disabled={runAdvancing} disabled={runAdvancing}
> >
{opt} {opt}
@@ -164,6 +190,30 @@ function ToolOptionsCarousel({ options, index, onPrev, onNext, onSelect, runAdva
</button> </button>
</div> </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> </div>
); );
} }
@@ -400,25 +450,25 @@ function InsertionSidebar({ insertionPreview, onOpenEntry }) {
function StudioRunPage() { function StudioRunPage() {
const { const {
projects,
pipeline: projectPipeline, pipeline: projectPipeline,
workflowVariables: workflowVariablesCatalog, workflowVariables: workflowVariablesCatalog,
meta,
runProjectId, runProjectId,
runs, runs,
currentRunId, currentRunId,
currentRun, currentRun,
runLoading, runLoading,
runCreating,
runAdvancing, runAdvancing,
runMessaging,
runStreamingThinking,
runError, runError,
runSessionEntered,
setRunSessionEntered,
initStudioRun, initStudioRun,
fetchProject, fetchProject,
fetchWorkflowVariables,
setRunProjectId,
fetchRuns,
createRun,
selectRun, selectRun,
advanceRun, advanceRun,
sendRunMessage,
deleteRun, deleteRun,
renameRun, renameRun,
} = useStudioStore(); } = useStudioStore();
@@ -457,13 +507,13 @@ function StudioRunPage() {
() => buildWorkflowVariableLabelMap(workflowVariablesCatalog), () => buildWorkflowVariableLabelMap(workflowVariablesCatalog),
[workflowVariablesCatalog] [workflowVariablesCatalog]
); );
const [sessionEntered, setSessionEntered] = useState(false);
const [insertionPopupOpen, setInsertionPopupOpen] = useState(false); const [insertionPopupOpen, setInsertionPopupOpen] = useState(false);
const [insertionPopupExpanded, setInsertionPopupExpanded] = useState(true); const [insertionPopupExpanded, setInsertionPopupExpanded] = useState(true);
const [contextBlockPopup, setContextBlockPopup] = useState(null); const [contextBlockPopup, setContextBlockPopup] = useState(null);
const [chatInput, setChatInput] = useState(''); const [chatInput, setChatInput] = useState('');
const [streamOutput, setStreamOutput] = useState(false);
const [toolOptionIndex, setToolOptionIndex] = useState(0); const [toolOptionIndex, setToolOptionIndex] = useState(0);
const [chatMessages, setChatMessages] = useState([]); const [pendingUserText, setPendingUserText] = useState(null);
const [runListExpanded, setRunListExpanded] = useState(false); const [runListExpanded, setRunListExpanded] = useState(false);
const [renamingId, setRenamingId] = useState(null); const [renamingId, setRenamingId] = useState(null);
const [renameValue, setRenameValue] = useState(''); const [renameValue, setRenameValue] = useState('');
@@ -479,10 +529,11 @@ function StudioRunPage() {
}, [runProjectId, fetchProject]); }, [runProjectId, fetchProject]);
useEffect(() => { useEffect(() => {
setSessionEntered(false); setRunSessionEntered(false);
setInsertionPopupOpen(false); setInsertionPopupOpen(false);
setContextBlockPopup(null); setContextBlockPopup(null);
}, [currentRunId, runProjectId]); setPendingUserText(null);
}, [currentRunId, runProjectId, setRunSessionEntered]);
const activeNodeState = useMemo(() => { const activeNodeState = useMemo(() => {
if (!currentRun?.currentNodeId) return null; if (!currentRun?.currentNodeId) return null;
@@ -531,71 +582,19 @@ function StudioRunPage() {
const promptBlocks = currentRun?.lastPromptBlocks || []; const promptBlocks = currentRun?.lastPromptBlocks || [];
const runWorkflowVariables = currentRun?.workflowVariables || {}; const runWorkflowVariables = currentRun?.workflowVariables || {};
const stepMessages = activeNodeState?.stepMessages || [];
const lastToolResponse = activeNodeState?.lastToolResponse;
useEffect(() => { const characters = useCharacterStore((s) => s.characters);
if (!currentRun || !sessionEntered) { const boundCharacterName = useMemo(
setChatMessages([]); () =>
return; resolveBoundCharacterName({
} boundCharacterVar: runWorkflowVariables['workflow.boundCharacter'],
characterId: meta?.characterId,
const msgs = [ characters,
{ }),
id: 'sys-welcome', [runWorkflowVariables, meta?.characterId, characters]
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 handleOpenInsertion = () => { const handleOpenInsertion = () => {
setInsertionPopupExpanded(true); setInsertionPopupExpanded(true);
@@ -606,28 +605,38 @@ function StudioRunPage() {
await advanceRun(displayParams); await advanceRun(displayParams);
}; };
const handleChatSend = (text) => { const handleSelectRun = async (runId) => {
if (!text || isInitBindActive) return; setRunSessionEntered(false);
setChatMessages((prev) => [ await selectRun(runId);
...prev, };
{ id: `user-${Date.now()}`, role: 'user', text },
{ const handleEnterSession = () => {
id: `stub-${Date.now()}`, if (currentRun) {
role: 'assistant', setRunSessionEntered(true);
text: 'R3 占位:模型回复与流式输出尚未接入)', }
}, };
]);
const handleChatSend = async (text) => {
if (!text || isInitBindActive || !isWorldbookActive || runMessaging) return;
setPendingUserText(text);
setChatInput(''); setChatInput('');
const run = await sendRunMessage(text, { stream: streamOutput });
setPendingUserText(null);
if (!run) {
/* error surfaced via runError */
}
}; };
const handleNextStep = () => { const handleNextStep = () => {
window.alert('R3 占位:确认当前产物后将进入下一步)'); window.alert('R5 占位:确认当前产物后将进入下一步)');
}; };
const handleDeleteRun = async (runId) => { const handleDeleteRun = async (runId) => {
const name = runDisplayName(runs.find((r) => r.id === runId) || {}); const name = runDisplayName(runs.find((r) => r.id === runId) || {});
if (!window.confirm(`确定删除运行「${name}」?此操作不可撤销。`)) return; if (!window.confirm(`确定删除运行「${name}」?此操作不可撤销。`)) return;
if (currentRunId === runId) setSessionEntered(false); if (currentRunId === runId) setRunSessionEntered(false);
await deleteRun(runId); 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 runListNeedsCollapse = runs.length > RUN_LIST_COLLAPSE_THRESHOLD;
const inSession = sessionEntered && !!currentRun; const inSession = runSessionEntered && !!currentRun;
const showToolCarousel = inSession && isWorldbookActive && toolQuestions.length > 0; const showToolCarousel = inSession && isWorldbookActive && toolQuestions.length > 0;
const showNextStep = inSession && isWorldbookActive; const showNextStep = inSession && isWorldbookActive;
@@ -778,6 +776,11 @@ function StudioRunPage() {
</div> </div>
<div className="studio-run-preview__meta"> <div className="studio-run-preview__meta">
<span>创建 {formatRunTime(currentRun.createdAt)}</span> <span>创建 {formatRunTime(currentRun.createdAt)}</span>
{boundCharacterName ? (
<span className="studio-run-preview__character">
角色卡{boundCharacterName}
</span>
) : null}
</div> </div>
<button <button
type="button" type="button"
@@ -834,12 +837,17 @@ function StudioRunPage() {
return ( return (
<div className="studio-run-chat-panel"> <div className="studio-run-chat-panel">
<StudioRunChat <StudioRunChat
messages={chatMessages} stepMessages={stepMessages}
thinking={runStreamingThinking ?? lastToolResponse?.thinking}
evaluation={runStreamingThinking ? null : lastToolResponse?.evaluation}
pendingUserText={pendingUserText}
inputValue={chatInput} inputValue={chatInput}
onInputChange={setChatInput} onInputChange={setChatInput}
onSend={handleChatSend} onSend={handleChatSend}
disabled={!isWorldbookActive || runAdvancing || isInitBindActive} disabled={!isWorldbookActive || runAdvancing || runMessaging || isInitBindActive}
sending={runAdvancing} sending={runMessaging}
streamOutput={streamOutput}
onStreamOutputChange={setStreamOutput}
placeholder={ placeholder={
isWorldbookActive isWorldbookActive
? '输入修改意见或与模型对话…' ? '输入修改意见或与模型对话…'
@@ -869,8 +877,7 @@ function StudioRunPage() {
onNext={() => onNext={() =>
setToolOptionIndex((i) => (i + 1) % toolQuestions.length) setToolOptionIndex((i) => (i + 1) % toolQuestions.length)
} }
onSelect={handleToolSelect} runAdvancing={runAdvancing || runMessaging}
runAdvancing={runAdvancing}
/> />
)} )}
@@ -880,7 +887,7 @@ function StudioRunPage() {
type="button" type="button"
className="studio-run-next-btn" className="studio-run-next-btn"
onClick={handleNextStep} onClick={handleNextStep}
disabled={runAdvancing} disabled={runAdvancing || runMessaging}
> >
下一步 下一步
</button> </button>
@@ -892,47 +899,6 @@ function StudioRunPage() {
return ( return (
<div className={`studio-run-page${inSession ? ' studio-run-page--session' : ''}`}> <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 && ( {runError && (
<div className="studio-run-banner error" role="alert">{runError}</div> <div className="studio-run-banner error" role="alert">{runError}</div>
)} )}

View 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;
}

View File

@@ -5,6 +5,7 @@ import useUserStore from '../../Store/UserSlice'; // ✅ 新增 - 用户角色
import useWorldBookStore from '../../Store/SideBarLeft/WorldBookSlice'; import useWorldBookStore from '../../Store/SideBarLeft/WorldBookSlice';
import useApiConfigStore from '../../Store/SideBarLeft/ApiConfigSlice'; import useApiConfigStore from '../../Store/SideBarLeft/ApiConfigSlice';
import PageModeToggle from './items/PageModeToggle'; import PageModeToggle from './items/PageModeToggle';
import StudioRunControls from './items/StudioRunControls';
import ThemeToggle from './items/ThemeToggle'; import ThemeToggle from './items/ThemeToggle';
import RegexPanel from '../SideBarLeft/tabs/Regex/RegexPanel'; // ✅ 新增:导入正则管理组件 import RegexPanel from '../SideBarLeft/tabs/Regex/RegexPanel'; // ✅ 新增:导入正则管理组件
import './TopBar.css'; import './TopBar.css';
@@ -17,10 +18,13 @@ const Toolbar = () => {
const { const {
sidebarMode, sidebarMode,
colorTheme, colorTheme,
activePage,
setSidebarMode, setSidebarMode,
setColorTheme, setColorTheme,
} = useAppLayoutStore(); } = useAppLayoutStore();
const isStudioRunPage = activePage === 'studio_run';
// ✅ 从 UserStore 获取用户角色和方法 // ✅ 从 UserStore 获取用户角色和方法
const { currentUserRole, userRoles, updateRoleName, updateRoleDescription, selectUserRole, addUserRole } = useUserStore(); const { currentUserRole, userRoles, updateRoleName, updateRoleDescription, selectUserRole, addUserRole } = useUserStore();
@@ -149,6 +153,8 @@ const Toolbar = () => {
<div className="top-bar-content"> <div className="top-bar-content">
{/* 左侧:状态徽章区域 */} {/* 左侧:状态徽章区域 */}
<div className="status-section"> <div className="status-section">
{isStudioRunPage ? <StudioRunControls /> : null}
{/* 当前玩家角色 */} {/* 当前玩家角色 */}
<div <div
className="status-badge" className="status-badge"

View File

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

View File

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

View File

@@ -0,0 +1 @@
export { default } from './StudioRunControls';