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:
@@ -1,13 +1,16 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from models.studio_models import (
|
||||
AdvanceRunRequest,
|
||||
CreateStudioProjectRequest,
|
||||
PipelineDefinition,
|
||||
RenameRunRequest,
|
||||
RunMessageRequest,
|
||||
StudioProject,
|
||||
StudioProjectSummary,
|
||||
StudioRun,
|
||||
@@ -186,6 +189,66 @@ async def advance_studio_run(
|
||||
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}")
|
||||
async def delete_studio_run(project_id: str, run_id: str):
|
||||
try:
|
||||
|
||||
@@ -159,8 +159,18 @@ class ToolQuestionOption(BaseModel):
|
||||
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):
|
||||
"""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)
|
||||
generatedAt: Optional[str] = None
|
||||
|
||||
@@ -181,6 +191,7 @@ class StudioNodeRunState(BaseModel):
|
||||
loopUntilSatisfied: bool = False
|
||||
lastDraft: Optional[Dict[str, Any]] = None
|
||||
lastToolResponse: Optional[LastToolResponse] = None
|
||||
stepMessages: List[StepMessage] = Field(default_factory=list)
|
||||
|
||||
|
||||
class StudioRun(BaseModel):
|
||||
@@ -202,6 +213,13 @@ class AdvanceRunRequest(BaseModel):
|
||||
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):
|
||||
title: str = Field(..., min_length=1, max_length=120)
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
427
backend/services/studio_step_respond.py
Normal file
427
backend/services/studio_step_respond.py
Normal 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 为 0–3 条;每条至少 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"),
|
||||
}
|
||||
@@ -52,7 +52,22 @@
|
||||
"supportsLoopUntilSatisfied": true,
|
||||
"supportsInputs": 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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
"id": "aesthetic",
|
||||
"skillId": "studio.worldbook_entry",
|
||||
"displayName": "整体美学",
|
||||
"niche": "aesthetic_tone",
|
||||
"enabled": true,
|
||||
"loopUntilSatisfied": true,
|
||||
"config": {
|
||||
@@ -64,7 +63,6 @@
|
||||
"id": "persona",
|
||||
"skillId": "studio.worldbook_entry",
|
||||
"displayName": "具体人设",
|
||||
"niche": "persona_detail",
|
||||
"enabled": true,
|
||||
"loopUntilSatisfied": true,
|
||||
"config": {
|
||||
|
||||
@@ -88,11 +88,14 @@ const useCharacterStore = create(
|
||||
}
|
||||
},
|
||||
|
||||
// 选择角色
|
||||
// 选择角色(传 null 清除选中)
|
||||
selectCharacter: (character) => {
|
||||
// 使用函数式更新避免不必要的重渲染
|
||||
set((state) => {
|
||||
// 如果选中的是同一个角色,不更新状态
|
||||
if (!character) {
|
||||
return state.selectedCharacter === null
|
||||
? state
|
||||
: { selectedCharacter: null };
|
||||
}
|
||||
if (state.selectedCharacter?.id === character.id) {
|
||||
return state;
|
||||
}
|
||||
@@ -100,6 +103,8 @@ const useCharacterStore = create(
|
||||
});
|
||||
},
|
||||
|
||||
clearSelectedCharacter: () => set({ selectedCharacter: null }),
|
||||
|
||||
// 创建角色
|
||||
createCharacter: async (characterData) => {
|
||||
try {
|
||||
@@ -151,6 +156,18 @@ const useCharacterStore = create(
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete character');
|
||||
|
||||
set((state) => {
|
||||
const next = {};
|
||||
if (state.selectedCharacter?.name === name) {
|
||||
next.selectedCharacter = null;
|
||||
}
|
||||
if (state.characterChats[name]) {
|
||||
const { [name]: _removed, ...restChats } = state.characterChats;
|
||||
next.characterChats = restChats;
|
||||
}
|
||||
return Object.keys(next).length ? next : state;
|
||||
});
|
||||
|
||||
// 刷新列表
|
||||
await get().fetchCharacters();
|
||||
} catch (error) {
|
||||
|
||||
@@ -41,7 +41,6 @@ const useStudioStore = create((set, get) => ({
|
||||
meta: null,
|
||||
pipeline: null,
|
||||
skillTemplates: [],
|
||||
niches: [],
|
||||
selectedNodeId: null,
|
||||
loading: false,
|
||||
saving: false,
|
||||
@@ -56,7 +55,10 @@ const useStudioStore = create((set, get) => ({
|
||||
runLoading: false,
|
||||
runCreating: false,
|
||||
runAdvancing: false,
|
||||
runMessaging: false,
|
||||
runStreamingThinking: null,
|
||||
runError: null,
|
||||
runSessionEntered: false,
|
||||
|
||||
setSelectedNodeId: (id) => set({ selectedNodeId: id }),
|
||||
clearSaveMessage: () => set({ saveMessage: null, error: null }),
|
||||
@@ -112,26 +114,11 @@ const useStudioStore = create((set, get) => ({
|
||||
displayParams: tpl?.displayParams ? [...tpl.displayParams] : [],
|
||||
inputs: [],
|
||||
};
|
||||
if (tpl?.configDefaults) {
|
||||
base.config = JSON.parse(JSON.stringify(tpl.configDefaults));
|
||||
}
|
||||
if (skillId === 'studio.worldbook_entry') {
|
||||
base.loopUntilSatisfied = false;
|
||||
base.config = {
|
||||
stepGoal: '',
|
||||
thinkingPrompt: `====== 思考流程 ======
|
||||
Step1: 简短确认任务性质(新设计/修改)
|
||||
Step2: 阅读绑定角色/世界书与上文引用
|
||||
Step3: 按步骤目标起草世界书条目
|
||||
Step4: 对照评价维度自检并优化表述
|
||||
|
||||
(核心目的、评价标准与优化建议由系统在运行时自动注入,无需在此填写)`,
|
||||
insertion: {
|
||||
position: 1,
|
||||
activationType: 'permanent',
|
||||
key: '',
|
||||
keysecondary: '',
|
||||
comment: '',
|
||||
},
|
||||
scoring: { enabled: true, dimensions: [] },
|
||||
};
|
||||
}
|
||||
const nodes = [...pipeline.nodes, base];
|
||||
set({
|
||||
@@ -198,16 +185,10 @@ Step4: 对照评价维度自检并优化表述
|
||||
|
||||
fetchSkillTemplates: async () => {
|
||||
try {
|
||||
const [tplRes, nicheRes] = await Promise.all([
|
||||
fetch('/api/studio/skill-templates'),
|
||||
fetch('/api/studio/niches'),
|
||||
]);
|
||||
if (!tplRes.ok) throw new Error(await tplRes.text());
|
||||
const tplData = await tplRes.json();
|
||||
const niches = nicheRes.ok
|
||||
? (await nicheRes.json()).niches || []
|
||||
: [];
|
||||
set({ skillTemplates: tplData.templates || [], niches });
|
||||
const res = await fetch('/api/studio/skill-templates');
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const tplData = await res.json();
|
||||
set({ skillTemplates: tplData.templates || [] });
|
||||
} catch (e) {
|
||||
set({ error: e.message || '加载技能模板失败' });
|
||||
}
|
||||
@@ -388,9 +369,12 @@ Step4: 对照评价维度自检并优化表述
|
||||
currentRunId: null,
|
||||
currentRun: null,
|
||||
runs: [],
|
||||
runSessionEntered: false,
|
||||
});
|
||||
},
|
||||
|
||||
setRunSessionEntered: (entered) => set({ runSessionEntered: !!entered }),
|
||||
|
||||
fetchRuns: async (projectId) => {
|
||||
if (!projectId) return [];
|
||||
set({ runLoading: true, runError: null });
|
||||
@@ -424,6 +408,7 @@ Step4: 对照评价维度自检并优化表述
|
||||
currentRunId: runId,
|
||||
currentRun: run,
|
||||
runLoading: false,
|
||||
runSessionEntered: false,
|
||||
});
|
||||
return run;
|
||||
} catch (e) {
|
||||
@@ -450,6 +435,7 @@ Step4: 对照评价维度自检并优化表述
|
||||
currentRunId: run.id,
|
||||
currentRun: run,
|
||||
runCreating: false,
|
||||
runSessionEntered: false,
|
||||
});
|
||||
return run;
|
||||
} catch (e) {
|
||||
@@ -506,6 +492,120 @@ Step4: 对照评价维度自检并优化表述
|
||||
}
|
||||
},
|
||||
|
||||
sendRunMessage: async (content, { stream = false } = {}) => {
|
||||
const { runProjectId, currentRunId } = get();
|
||||
if (!runProjectId || !currentRunId) return null;
|
||||
set({ runMessaging: true, runStreamingThinking: null, runError: null });
|
||||
try {
|
||||
let profileId = null;
|
||||
let apiConfig = {};
|
||||
try {
|
||||
const { default: useApiConfigStore } = await import(
|
||||
'../SideBarLeft/ApiConfigSlice'
|
||||
);
|
||||
const apiState = useApiConfigStore.getState();
|
||||
profileId = apiState.currentProfile?.id || null;
|
||||
const mainLLM = apiState.currentProfile?.apis?.mainLLM;
|
||||
if (mainLLM) {
|
||||
apiConfig = {
|
||||
api_url: mainLLM.apiUrl || '',
|
||||
model: mainLLM.model || '',
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* optional store */
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`/api/studio/projects/${encodeURIComponent(runProjectId)}/runs/${encodeURIComponent(currentRunId)}/message`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
content,
|
||||
stream,
|
||||
profileId,
|
||||
apiConfig,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
let detail = await res.text();
|
||||
try {
|
||||
const parsed = JSON.parse(detail);
|
||||
detail = parsed.detail || detail;
|
||||
} catch {
|
||||
/* keep raw text */
|
||||
}
|
||||
throw new Error(detail || '发送消息失败');
|
||||
}
|
||||
|
||||
if (stream && res.body) {
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let finalRun = null;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
const event = JSON.parse(line);
|
||||
if (event.type === 'thinking_delta') {
|
||||
set({ runStreamingThinking: event.content || '' });
|
||||
} else if (event.type === 'complete') {
|
||||
finalRun = event.run;
|
||||
} else if (event.type === 'error') {
|
||||
throw new Error(event.detail || '流式消息处理失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
const event = JSON.parse(buffer);
|
||||
if (event.type === 'complete') {
|
||||
finalRun = event.run;
|
||||
} else if (event.type === 'error') {
|
||||
throw new Error(event.detail || '流式消息处理失败');
|
||||
} else if (event.type === 'thinking_delta') {
|
||||
set({ runStreamingThinking: event.content || '' });
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalRun) {
|
||||
throw new Error('流式响应未完成');
|
||||
}
|
||||
|
||||
set({
|
||||
currentRun: finalRun,
|
||||
runMessaging: false,
|
||||
runStreamingThinking: null,
|
||||
});
|
||||
return finalRun;
|
||||
}
|
||||
|
||||
const run = await res.json();
|
||||
set({
|
||||
currentRun: run,
|
||||
runMessaging: false,
|
||||
runStreamingThinking: null,
|
||||
});
|
||||
return run;
|
||||
} catch (e) {
|
||||
set({
|
||||
runMessaging: false,
|
||||
runStreamingThinking: null,
|
||||
runError: e.message || '发送消息失败',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
deleteRun: async (runId) => {
|
||||
const projectId = get().runProjectId;
|
||||
if (!projectId || !runId) return false;
|
||||
|
||||
@@ -132,6 +132,8 @@
|
||||
|
||||
/* 角色卡片列表 - 网格布局 */
|
||||
.character-list {
|
||||
position: relative;
|
||||
z-index: var(--z-base-content);
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
@@ -318,14 +320,16 @@
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
position: relative; /* 为悬浮工具栏提供定位上下文 */
|
||||
position: relative;
|
||||
z-index: var(--z-base-content);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* 悬浮工具栏 */
|
||||
.floating-toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: var(--z-top-bar); /* ✅ 组件层 - TopBar 同级 */
|
||||
z-index: 2;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
|
||||
@@ -238,11 +238,20 @@ const CharacterCard = () => {
|
||||
try {
|
||||
await deleteCharacter(name);
|
||||
|
||||
// ✅ 删除成功后自动切换到角色分页
|
||||
cancelEditing();
|
||||
selectCharacter(null);
|
||||
|
||||
const chatBox = useChatBoxStore.getState();
|
||||
if (chatBox.currentRole === name) {
|
||||
chatBox.setCurrentRole(null);
|
||||
chatBox.setCurrentChat(null);
|
||||
chatBox.setCharacterName('');
|
||||
}
|
||||
|
||||
const { setActiveTab } = useSideBarLeftStore.getState();
|
||||
setActiveTab('character');
|
||||
|
||||
console.log('[CharacterCard] 角色已删除,已切换到角色分页');
|
||||
console.log('[CharacterCard] 角色已删除,已返回角色选择列表');
|
||||
} catch (err) {
|
||||
console.error('删除失败:', err);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,6 @@ function StudioEditPage() {
|
||||
meta,
|
||||
pipeline,
|
||||
skillTemplates,
|
||||
niches,
|
||||
selectedNodeId,
|
||||
loading,
|
||||
saving,
|
||||
@@ -499,36 +498,6 @@ function StudioEditPage() {
|
||||
value={selectedTpl?.displayName || selectedNode.skillId}
|
||||
/>
|
||||
</label>
|
||||
{isWorldbook && niches.length > 0 && (
|
||||
<label className="studio-edit-meta-strip__field">
|
||||
<span className="studio-edit-field-label">
|
||||
<FieldLabel tip="选择预设领域可自动填充步骤目标建议">
|
||||
预设领域
|
||||
</FieldLabel>
|
||||
</span>
|
||||
<select
|
||||
className="studio-edit-select"
|
||||
value={selectedNode.niche ?? ''}
|
||||
onChange={(e) => {
|
||||
const nicheId = e.target.value || null;
|
||||
const niche = niches.find((n) => n.id === nicheId);
|
||||
updateNode(selectedNode.id, { niche: nicheId });
|
||||
if (niche?.suggestedStepGoal && !selectedNode.config?.stepGoal) {
|
||||
updateNodeConfig(selectedNode.id, {
|
||||
stepGoal: niche.suggestedStepGoal,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">无</option>
|
||||
{niches.map((n) => (
|
||||
<option key={n.id} value={n.id}>
|
||||
{n.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{selectedTpl?.artifacts?.length > 0 && (
|
||||
<span className="studio-edit-meta-strip__hint">
|
||||
产物:
|
||||
|
||||
@@ -44,6 +44,118 @@
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
[data-color-theme='dark'] .studio-run-chat-message.role-assistant,
|
||||
[data-color-theme='dark'] .studio-run-chat-message.role-system {
|
||||
background-color: rgba(45, 45, 48, 0.5);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.studio-run-chat-history {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-bottom: var(--spacing-md);
|
||||
opacity: 0.35;
|
||||
max-height: 120px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.studio-run-chat-history-item {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.35;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.studio-run-chat-history-role {
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.studio-run-chat-history-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.studio-run-chat-focus {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
min-height: min(40vh, 320px);
|
||||
}
|
||||
|
||||
.studio-run-chat-last-user {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
align-items: flex-start;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(102, 126, 234, 0.08);
|
||||
border-left: 3px solid var(--color-accent);
|
||||
}
|
||||
|
||||
[data-color-theme='dark'] .studio-run-chat-last-user {
|
||||
background: rgba(212, 167, 106, 0.1);
|
||||
}
|
||||
|
||||
.studio-run-chat-last-user--pending {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.studio-run-chat-last-user-label {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.studio-run-chat-last-user-text {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.studio-run-chat-thinking,
|
||||
.studio-run-chat-summary {
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border-light);
|
||||
background: var(--color-bg-primary);
|
||||
}
|
||||
|
||||
[data-color-theme='dark'] .studio-run-chat-thinking,
|
||||
[data-color-theme='dark'] .studio-run-chat-summary {
|
||||
background-color: rgba(45, 45, 48, 0.5);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.studio-run-chat-thinking--pending {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.studio-run-chat-block-label {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.studio-run-chat-block-body {
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.studio-run-chat-message-role {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
@@ -89,6 +201,24 @@
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
[data-color-theme='dark'] .studio-run-chat-input-container {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
[data-color-theme='dark'] .studio-run-chat-input-container:focus-within {
|
||||
box-shadow: 0 0 0 1px var(--color-accent);
|
||||
}
|
||||
|
||||
[data-color-theme='dark'] .studio-run-chat-options-toggle:hover,
|
||||
[data-color-theme='dark'] .studio-run-chat-options-toggle.active {
|
||||
background-color: var(--color-accent-ultra-light);
|
||||
}
|
||||
|
||||
[data-color-theme='dark'] .studio-run-chat-textarea:focus {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.studio-run-chat-options-wrapper {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
@@ -166,6 +296,70 @@
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.studio-run-chat-option-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-md);
|
||||
transition: all 0.15s ease;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.studio-run-chat-option-checkbox:hover {
|
||||
background-color: rgba(102, 126, 234, 0.06);
|
||||
}
|
||||
|
||||
.studio-run-chat-option-checkbox input[type="checkbox"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.studio-run-chat-checkmark {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
position: relative;
|
||||
transition: all 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.studio-run-chat-option-checkbox:hover .studio-run-chat-checkmark {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.studio-run-chat-option-checkbox input:checked + .studio-run-chat-checkmark {
|
||||
background-color: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.studio-run-chat-option-checkbox input:checked + .studio-run-chat-checkmark::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 1px;
|
||||
width: 4px;
|
||||
height: 8px;
|
||||
border: solid white;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.studio-run-chat-option-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.studio-run-chat-pending {
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.studio-run-chat-message.is-pending {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.studio-run-chat-input-area {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import MarkdownRenderer from '../shared/MarkdownRenderer';
|
||||
import { findLastUserMessageIndex } from './studioRunUtils';
|
||||
|
||||
import './StudioRunChat.css';
|
||||
|
||||
@@ -11,7 +12,8 @@ const RENDER_MODE_LABELS = {
|
||||
markdown: '📝 Markdown',
|
||||
};
|
||||
|
||||
function renderMessageBody(text, renderMode, isUser) {
|
||||
function renderBody(text, renderMode, isUser) {
|
||||
if (!text) return null;
|
||||
if (renderMode === 'html' && !isUser) {
|
||||
const hasHtmlTags = /<[a-z][\s\S]*>/i.test(text);
|
||||
if (hasHtmlTags) {
|
||||
@@ -26,23 +28,48 @@ function renderMessageBody(text, renderMode, isUser) {
|
||||
}
|
||||
|
||||
function StudioRunChat({
|
||||
messages,
|
||||
stepMessages = [],
|
||||
thinking,
|
||||
evaluation,
|
||||
pendingUserText,
|
||||
inputValue,
|
||||
onInputChange,
|
||||
onSend,
|
||||
disabled = false,
|
||||
placeholder = '输入消息…',
|
||||
sending = false,
|
||||
streamOutput = false,
|
||||
onStreamOutputChange,
|
||||
placeholder = '输入消息…',
|
||||
}) {
|
||||
const messagesEndRef = useRef(null);
|
||||
const messagesContainerRef = useRef(null);
|
||||
const containerRef = useRef(null);
|
||||
const focusAnchorRef = useRef(null);
|
||||
const optionsRef = useRef(null);
|
||||
const [showOptions, setShowOptions] = useState(false);
|
||||
const [renderMode, setRenderMode] = useState('markdown');
|
||||
|
||||
const lastUserIdx = findLastUserMessageIndex(stepMessages);
|
||||
const historyMessages =
|
||||
lastUserIdx > 0 ? stepMessages.slice(0, lastUserIdx) : [];
|
||||
const lastUserMessage =
|
||||
lastUserIdx >= 0 ? stepMessages[lastUserIdx] : pendingUserText
|
||||
? { role: 'user', content: pendingUserText }
|
||||
: null;
|
||||
|
||||
const summaryText =
|
||||
evaluation ||
|
||||
(lastUserIdx >= 0 && stepMessages[lastUserIdx + 1]?.role === 'assistant'
|
||||
? stepMessages[lastUserIdx + 1].content
|
||||
: null);
|
||||
|
||||
const hasFocusContent =
|
||||
thinking || summaryText || pendingUserText || sending || lastUserMessage;
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
const container = containerRef.current;
|
||||
const anchor = focusAnchorRef.current;
|
||||
if (!container || !anchor) return;
|
||||
container.scrollTop = Math.max(0, anchor.offsetTop - container.offsetTop);
|
||||
}, [stepMessages, thinking, evaluation, pendingUserText, sending]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
@@ -89,28 +116,77 @@ function StudioRunChat({
|
||||
setRenderMode(RENDER_MODES[(idx + 1) % RENDER_MODES.length]);
|
||||
};
|
||||
|
||||
const roleLabel = (role) => {
|
||||
if (role === 'user') return '你';
|
||||
if (role === 'system') return '系统';
|
||||
return '助手';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="studio-run-chat">
|
||||
<div className="studio-run-chat-messages" ref={messagesContainerRef}>
|
||||
{messages.length === 0 ? (
|
||||
<div className="studio-run-chat-empty">暂无对话,在下方输入开始交流</div>
|
||||
) : (
|
||||
messages.map((msg) => (
|
||||
<div key={msg.id} className={`studio-run-chat-message role-${msg.role}`}>
|
||||
<span className="studio-run-chat-message-role">{roleLabel(msg.role)}</span>
|
||||
<div className="studio-run-chat-message-body">
|
||||
{renderMessageBody(msg.text, renderMode, msg.role === 'user')}
|
||||
<div className="studio-run-chat-messages" ref={containerRef}>
|
||||
{historyMessages.length > 0 && (
|
||||
<div className="studio-run-chat-history" aria-hidden="true">
|
||||
{historyMessages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`studio-run-chat-history-item role-${msg.role}`}
|
||||
>
|
||||
<span className="studio-run-chat-history-role">
|
||||
{msg.role === 'user' ? '你' : '助手'}
|
||||
</span>
|
||||
<span className="studio-run-chat-history-text">{msg.content}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
|
||||
<div ref={focusAnchorRef} className="studio-run-chat-focus">
|
||||
{!hasFocusContent ? (
|
||||
<div className="studio-run-chat-empty">
|
||||
暂无本轮对话,在下方输入开始交流
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{lastUserMessage && !pendingUserText && (
|
||||
<div className="studio-run-chat-last-user">
|
||||
<span className="studio-run-chat-last-user-label">你</span>
|
||||
<span className="studio-run-chat-last-user-text">
|
||||
{lastUserMessage.content}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingUserText && (
|
||||
<div className="studio-run-chat-last-user studio-run-chat-last-user--pending">
|
||||
<span className="studio-run-chat-last-user-label">你</span>
|
||||
<span className="studio-run-chat-last-user-text">
|
||||
{pendingUserText}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sending && !thinking && (
|
||||
<div className="studio-run-chat-thinking studio-run-chat-thinking--pending">
|
||||
<span className="studio-run-chat-block-label">思考</span>
|
||||
<span className="studio-run-chat-pending">正在等待模型回复…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{thinking && (
|
||||
<div className="studio-run-chat-thinking">
|
||||
<span className="studio-run-chat-block-label">思考</span>
|
||||
<div className="studio-run-chat-block-body">
|
||||
{renderBody(thinking, renderMode, false)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{summaryText && (
|
||||
<div className="studio-run-chat-summary">
|
||||
<span className="studio-run-chat-block-label">评价与修改建议</span>
|
||||
<div className="studio-run-chat-block-body">
|
||||
{renderBody(summaryText, renderMode, false)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="studio-run-chat-input-wrapper" onSubmit={handleSubmit}>
|
||||
@@ -135,6 +211,16 @@ function StudioRunChat({
|
||||
>
|
||||
{RENDER_MODE_LABELS[renderMode]}
|
||||
</button>
|
||||
<div className="studio-run-chat-options-title">功能</div>
|
||||
<label className="studio-run-chat-option-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={streamOutput}
|
||||
onChange={(e) => onStreamOutputChange?.(e.target.checked)}
|
||||
/>
|
||||
<span className="studio-run-chat-checkmark" />
|
||||
<span className="studio-run-chat-option-label">流式输出</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,70 +6,119 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.studio-run-header {
|
||||
.studio-run-preview__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
background: var(--color-bg-secondary);
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.studio-run-header-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.studio-run-header-title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.studio-run-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.studio-run-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
font-size: 0.8rem;
|
||||
gap: var(--spacing-sm) var(--spacing-md);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.studio-run-select {
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: var(--radius-md);
|
||||
.studio-run-preview__character {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.studio-run-tool-option.is-selected {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-accent-ultra-light);
|
||||
}
|
||||
|
||||
.studio-run-tool-copy {
|
||||
margin-top: var(--spacing-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.studio-run-tool-copy__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.studio-run-tool-copy__label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.studio-run-tool-copy__btn {
|
||||
padding: 2px var(--spacing-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.studio-run-tool-copy__btn:hover {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.studio-run-tool-copy__textarea {
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.78rem;
|
||||
font-family: inherit;
|
||||
line-height: 1.45;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.studio-run-tool-copy__hint {
|
||||
font-size: 0.68rem;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
[data-color-theme='dark'] .studio-run-message.role-assistant,
|
||||
[data-color-theme='dark'] .studio-run-message.role-system {
|
||||
background-color: rgba(45, 45, 48, 0.5);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
[data-color-theme='dark'] .studio-run-message.role-user {
|
||||
background: rgba(212, 167, 106, 0.1);
|
||||
}
|
||||
|
||||
[data-color-theme='dark'] .studio-run-icon-btn,
|
||||
[data-color-theme='dark'] .studio-run-tool-option {
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.875rem;
|
||||
min-width: 160px;
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.studio-run-new-btn {
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-inverse, #fff);
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
[data-color-theme='dark'] .studio-run-list-item {
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.studio-run-new-btn:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
[data-color-theme='dark'] .studio-run-list-action {
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.studio-run-new-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
[data-color-theme='dark'] .studio-run-product-main__body,
|
||||
[data-color-theme='dark'] .studio-run-variables__row,
|
||||
[data-color-theme='dark'] .studio-run-guidance-form {
|
||||
background: var(--color-bg-tertiary);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
[data-color-theme='dark'] .studio-run-form-input,
|
||||
[data-color-theme='dark'] .studio-run-insertion-entry,
|
||||
[data-color-theme='dark'] .studio-run-tool-carousel {
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
[data-color-theme='dark'] .studio-run-banner.error {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.studio-run-banner {
|
||||
@@ -290,11 +339,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.studio-run-preview__meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.studio-run-preview__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
buildWorkflowVariableLabelMap,
|
||||
getWorkflowVariableLabel,
|
||||
} from './edit/workflowVariableLabels';
|
||||
import { resolveBoundCharacterName } from './studioRunUtils';
|
||||
import useCharacterStore from '../../Store/SideBarLeft/CharacterSlice';
|
||||
|
||||
import StudioContextBlockPopup from './StudioContextBlockPopup';
|
||||
import StudioInsertionPopup from './StudioInsertionPopup';
|
||||
@@ -118,10 +120,34 @@ function InitBindGuidanceForm({ node, runAdvancing, onSubmit, compact = false })
|
||||
);
|
||||
}
|
||||
|
||||
function ToolOptionsCarousel({ options, index, onPrev, onNext, onSelect, runAdvancing }) {
|
||||
function ToolOptionsCarousel({ options, index, onPrev, onNext, runAdvancing }) {
|
||||
const current = options[index];
|
||||
const [selectedOption, setSelectedOption] = useState(null);
|
||||
const [copyHint, setCopyHint] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedOption(null);
|
||||
setCopyHint('');
|
||||
}, [index, current?.question]);
|
||||
|
||||
if (!options.length || !current) return null;
|
||||
|
||||
const handleSelect = (opt) => {
|
||||
setSelectedOption(opt);
|
||||
setCopyHint('');
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!selectedOption) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(selectedOption);
|
||||
setCopyHint('已复制到剪贴板');
|
||||
setTimeout(() => setCopyHint(''), 2000);
|
||||
} catch {
|
||||
setCopyHint('复制失败,请手动选择文本');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="studio-run-tool-carousel">
|
||||
<div className="studio-run-tool-carousel__head">
|
||||
@@ -146,8 +172,8 @@ function ToolOptionsCarousel({ options, index, onPrev, onNext, onSelect, runAdva
|
||||
<button
|
||||
key={opt}
|
||||
type="button"
|
||||
className="studio-run-tool-option"
|
||||
onClick={() => onSelect(opt)}
|
||||
className={`studio-run-tool-option${selectedOption === opt ? ' is-selected' : ''}`}
|
||||
onClick={() => handleSelect(opt)}
|
||||
disabled={runAdvancing}
|
||||
>
|
||||
{opt}
|
||||
@@ -164,6 +190,30 @@ function ToolOptionsCarousel({ options, index, onPrev, onNext, onSelect, runAdva
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
{selectedOption ? (
|
||||
<div className="studio-run-tool-copy">
|
||||
<div className="studio-run-tool-copy__head">
|
||||
<span className="studio-run-tool-copy__label">复制以下内容到输入框</span>
|
||||
<button
|
||||
type="button"
|
||||
className="studio-run-tool-copy__btn"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
className="studio-run-tool-copy__textarea"
|
||||
readOnly
|
||||
value={selectedOption}
|
||||
rows={3}
|
||||
aria-label="选项内容"
|
||||
/>
|
||||
{copyHint ? (
|
||||
<span className="studio-run-tool-copy__hint">{copyHint}</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -400,25 +450,25 @@ function InsertionSidebar({ insertionPreview, onOpenEntry }) {
|
||||
|
||||
function StudioRunPage() {
|
||||
const {
|
||||
projects,
|
||||
pipeline: projectPipeline,
|
||||
workflowVariables: workflowVariablesCatalog,
|
||||
meta,
|
||||
runProjectId,
|
||||
runs,
|
||||
currentRunId,
|
||||
currentRun,
|
||||
runLoading,
|
||||
runCreating,
|
||||
runAdvancing,
|
||||
runMessaging,
|
||||
runStreamingThinking,
|
||||
runError,
|
||||
runSessionEntered,
|
||||
setRunSessionEntered,
|
||||
initStudioRun,
|
||||
fetchProject,
|
||||
fetchWorkflowVariables,
|
||||
setRunProjectId,
|
||||
fetchRuns,
|
||||
createRun,
|
||||
selectRun,
|
||||
advanceRun,
|
||||
sendRunMessage,
|
||||
deleteRun,
|
||||
renameRun,
|
||||
} = useStudioStore();
|
||||
@@ -457,13 +507,13 @@ function StudioRunPage() {
|
||||
() => buildWorkflowVariableLabelMap(workflowVariablesCatalog),
|
||||
[workflowVariablesCatalog]
|
||||
);
|
||||
const [sessionEntered, setSessionEntered] = useState(false);
|
||||
const [insertionPopupOpen, setInsertionPopupOpen] = useState(false);
|
||||
const [insertionPopupExpanded, setInsertionPopupExpanded] = useState(true);
|
||||
const [contextBlockPopup, setContextBlockPopup] = useState(null);
|
||||
const [chatInput, setChatInput] = useState('');
|
||||
const [streamOutput, setStreamOutput] = useState(false);
|
||||
const [toolOptionIndex, setToolOptionIndex] = useState(0);
|
||||
const [chatMessages, setChatMessages] = useState([]);
|
||||
const [pendingUserText, setPendingUserText] = useState(null);
|
||||
const [runListExpanded, setRunListExpanded] = useState(false);
|
||||
const [renamingId, setRenamingId] = useState(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
@@ -479,10 +529,11 @@ function StudioRunPage() {
|
||||
}, [runProjectId, fetchProject]);
|
||||
|
||||
useEffect(() => {
|
||||
setSessionEntered(false);
|
||||
setRunSessionEntered(false);
|
||||
setInsertionPopupOpen(false);
|
||||
setContextBlockPopup(null);
|
||||
}, [currentRunId, runProjectId]);
|
||||
setPendingUserText(null);
|
||||
}, [currentRunId, runProjectId, setRunSessionEntered]);
|
||||
|
||||
const activeNodeState = useMemo(() => {
|
||||
if (!currentRun?.currentNodeId) return null;
|
||||
@@ -531,71 +582,19 @@ function StudioRunPage() {
|
||||
|
||||
const promptBlocks = currentRun?.lastPromptBlocks || [];
|
||||
const runWorkflowVariables = currentRun?.workflowVariables || {};
|
||||
const stepMessages = activeNodeState?.stepMessages || [];
|
||||
const lastToolResponse = activeNodeState?.lastToolResponse;
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentRun || !sessionEntered) {
|
||||
setChatMessages([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const msgs = [
|
||||
{
|
||||
id: 'sys-welcome',
|
||||
role: 'system',
|
||||
text: `已开始运行 · 项目 ${currentRun.projectId}`,
|
||||
},
|
||||
];
|
||||
|
||||
if (isInitBindActive) {
|
||||
msgs.push({
|
||||
id: 'sys-init',
|
||||
role: 'assistant',
|
||||
text: '请先创建角色卡与世界书,完成后将进入创作步骤。',
|
||||
});
|
||||
} else if (isWorldbookActive) {
|
||||
msgs.push({
|
||||
id: 'sys-step',
|
||||
role: 'assistant',
|
||||
text: `当前步骤:${activeNodeState.displayName}。请在下方输入修改意见或与模型对话(R3 接入流式输出)。`,
|
||||
});
|
||||
} else if (currentRun.status === 'completed') {
|
||||
msgs.push({
|
||||
id: 'sys-done',
|
||||
role: 'assistant',
|
||||
text: '本次运行已全部完成。',
|
||||
});
|
||||
}
|
||||
|
||||
setChatMessages(msgs);
|
||||
}, [currentRun, currentRunId, isInitBindActive, isWorldbookActive, activeNodeState, sessionEntered]);
|
||||
|
||||
const handleProjectChange = async (e) => {
|
||||
const projectId = e.target.value;
|
||||
setRunProjectId(projectId);
|
||||
setSessionEntered(false);
|
||||
await fetchWorkflowVariables(projectId);
|
||||
const list = await fetchRuns(projectId);
|
||||
if (list[0]?.id) {
|
||||
await selectRun(list[0].id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewRun = async () => {
|
||||
if (!runProjectId) return;
|
||||
setSessionEntered(false);
|
||||
await createRun(runProjectId);
|
||||
};
|
||||
|
||||
const handleSelectRun = async (runId) => {
|
||||
setSessionEntered(false);
|
||||
await selectRun(runId);
|
||||
};
|
||||
|
||||
const handleEnterSession = () => {
|
||||
if (currentRun) {
|
||||
setSessionEntered(true);
|
||||
}
|
||||
};
|
||||
const characters = useCharacterStore((s) => s.characters);
|
||||
const boundCharacterName = useMemo(
|
||||
() =>
|
||||
resolveBoundCharacterName({
|
||||
boundCharacterVar: runWorkflowVariables['workflow.boundCharacter'],
|
||||
characterId: meta?.characterId,
|
||||
characters,
|
||||
}),
|
||||
[runWorkflowVariables, meta?.characterId, characters]
|
||||
);
|
||||
|
||||
const handleOpenInsertion = () => {
|
||||
setInsertionPopupExpanded(true);
|
||||
@@ -606,28 +605,38 @@ function StudioRunPage() {
|
||||
await advanceRun(displayParams);
|
||||
};
|
||||
|
||||
const handleChatSend = (text) => {
|
||||
if (!text || isInitBindActive) return;
|
||||
setChatMessages((prev) => [
|
||||
...prev,
|
||||
{ id: `user-${Date.now()}`, role: 'user', text },
|
||||
{
|
||||
id: `stub-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
text: '(R3 占位:模型回复与流式输出尚未接入)',
|
||||
},
|
||||
]);
|
||||
const handleSelectRun = async (runId) => {
|
||||
setRunSessionEntered(false);
|
||||
await selectRun(runId);
|
||||
};
|
||||
|
||||
const handleEnterSession = () => {
|
||||
if (currentRun) {
|
||||
setRunSessionEntered(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChatSend = async (text) => {
|
||||
if (!text || isInitBindActive || !isWorldbookActive || runMessaging) return;
|
||||
|
||||
setPendingUserText(text);
|
||||
setChatInput('');
|
||||
|
||||
const run = await sendRunMessage(text, { stream: streamOutput });
|
||||
setPendingUserText(null);
|
||||
if (!run) {
|
||||
/* error surfaced via runError */
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextStep = () => {
|
||||
window.alert('(R3 占位:确认当前产物后将进入下一步)');
|
||||
window.alert('(R5 占位:确认当前产物后将进入下一步)');
|
||||
};
|
||||
|
||||
const handleDeleteRun = async (runId) => {
|
||||
const name = runDisplayName(runs.find((r) => r.id === runId) || {});
|
||||
if (!window.confirm(`确定删除运行「${name}」?此操作不可撤销。`)) return;
|
||||
if (currentRunId === runId) setSessionEntered(false);
|
||||
if (currentRunId === runId) setRunSessionEntered(false);
|
||||
await deleteRun(runId);
|
||||
};
|
||||
|
||||
@@ -646,19 +655,8 @@ function StudioRunPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToolSelect = (option) => {
|
||||
setChatMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `tool-opt-${Date.now()}`,
|
||||
role: 'user',
|
||||
text: `[选项] ${option}`,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const runListNeedsCollapse = runs.length > RUN_LIST_COLLAPSE_THRESHOLD;
|
||||
const inSession = sessionEntered && !!currentRun;
|
||||
const inSession = runSessionEntered && !!currentRun;
|
||||
const showToolCarousel = inSession && isWorldbookActive && toolQuestions.length > 0;
|
||||
const showNextStep = inSession && isWorldbookActive;
|
||||
|
||||
@@ -778,6 +776,11 @@ function StudioRunPage() {
|
||||
</div>
|
||||
<div className="studio-run-preview__meta">
|
||||
<span>创建 {formatRunTime(currentRun.createdAt)}</span>
|
||||
{boundCharacterName ? (
|
||||
<span className="studio-run-preview__character">
|
||||
角色卡:{boundCharacterName}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -834,12 +837,17 @@ function StudioRunPage() {
|
||||
return (
|
||||
<div className="studio-run-chat-panel">
|
||||
<StudioRunChat
|
||||
messages={chatMessages}
|
||||
stepMessages={stepMessages}
|
||||
thinking={runStreamingThinking ?? lastToolResponse?.thinking}
|
||||
evaluation={runStreamingThinking ? null : lastToolResponse?.evaluation}
|
||||
pendingUserText={pendingUserText}
|
||||
inputValue={chatInput}
|
||||
onInputChange={setChatInput}
|
||||
onSend={handleChatSend}
|
||||
disabled={!isWorldbookActive || runAdvancing || isInitBindActive}
|
||||
sending={runAdvancing}
|
||||
disabled={!isWorldbookActive || runAdvancing || runMessaging || isInitBindActive}
|
||||
sending={runMessaging}
|
||||
streamOutput={streamOutput}
|
||||
onStreamOutputChange={setStreamOutput}
|
||||
placeholder={
|
||||
isWorldbookActive
|
||||
? '输入修改意见或与模型对话…'
|
||||
@@ -869,8 +877,7 @@ function StudioRunPage() {
|
||||
onNext={() =>
|
||||
setToolOptionIndex((i) => (i + 1) % toolQuestions.length)
|
||||
}
|
||||
onSelect={handleToolSelect}
|
||||
runAdvancing={runAdvancing}
|
||||
runAdvancing={runAdvancing || runMessaging}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -880,7 +887,7 @@ function StudioRunPage() {
|
||||
type="button"
|
||||
className="studio-run-next-btn"
|
||||
onClick={handleNextStep}
|
||||
disabled={runAdvancing}
|
||||
disabled={runAdvancing || runMessaging}
|
||||
>
|
||||
下一步
|
||||
</button>
|
||||
@@ -892,47 +899,6 @@ function StudioRunPage() {
|
||||
|
||||
return (
|
||||
<div className={`studio-run-page${inSession ? ' studio-run-page--session' : ''}`}>
|
||||
<header className="studio-run-header">
|
||||
<span className="studio-run-header-icon" aria-hidden="true">🚀</span>
|
||||
<h1 className="studio-run-header-title">创作运行</h1>
|
||||
<div className="studio-run-toolbar">
|
||||
<label className="studio-run-label">
|
||||
项目
|
||||
<select
|
||||
className="studio-run-select"
|
||||
value={runProjectId || ''}
|
||||
onChange={handleProjectChange}
|
||||
disabled={runLoading || runCreating || runAdvancing}
|
||||
>
|
||||
{projects.length === 0 ? (
|
||||
<option value="">暂无项目</option>
|
||||
) : (
|
||||
projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="studio-run-new-btn"
|
||||
onClick={handleNewRun}
|
||||
disabled={!runProjectId || runCreating || runAdvancing}
|
||||
>
|
||||
{runCreating ? '创建中…' : '新开会话'}
|
||||
</button>
|
||||
{inSession && (
|
||||
<button
|
||||
type="button"
|
||||
className="studio-run-exit-session-btn"
|
||||
onClick={() => setSessionEntered(false)}
|
||||
>
|
||||
退出会话
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{runError && (
|
||||
<div className="studio-run-banner error" role="alert">{runError}</div>
|
||||
)}
|
||||
|
||||
28
frontend/src/components/Studio/studioRunUtils.js
Normal file
28
frontend/src/components/Studio/studioRunUtils.js
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Resolve bound character display name from workflow variable and/or project meta.
|
||||
*/
|
||||
export function resolveBoundCharacterName({ boundCharacterVar, characterId, characters = [] }) {
|
||||
if (boundCharacterVar) {
|
||||
const nameMatch = String(boundCharacterVar).match(/名称[::]\s*(.+?)(?:\n|$)/);
|
||||
if (nameMatch?.[1]?.trim()) {
|
||||
return nameMatch[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (characterId && characters.length > 0) {
|
||||
const byId = characters.find((c) => c.id === characterId);
|
||||
if (byId?.name) return byId.name;
|
||||
const byName = characters.find((c) => c.name === characterId);
|
||||
if (byName?.name) return byName.name;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Index of the last user message in stepMessages, or -1. */
|
||||
export function findLastUserMessageIndex(stepMessages = []) {
|
||||
for (let i = stepMessages.length - 1; i >= 0; i -= 1) {
|
||||
if (stepMessages[i]?.role === 'user') return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import useUserStore from '../../Store/UserSlice'; // ✅ 新增 - 用户角色
|
||||
import useWorldBookStore from '../../Store/SideBarLeft/WorldBookSlice';
|
||||
import useApiConfigStore from '../../Store/SideBarLeft/ApiConfigSlice';
|
||||
import PageModeToggle from './items/PageModeToggle';
|
||||
import StudioRunControls from './items/StudioRunControls';
|
||||
import ThemeToggle from './items/ThemeToggle';
|
||||
import RegexPanel from '../SideBarLeft/tabs/Regex/RegexPanel'; // ✅ 新增:导入正则管理组件
|
||||
import './TopBar.css';
|
||||
@@ -17,10 +18,13 @@ const Toolbar = () => {
|
||||
const {
|
||||
sidebarMode,
|
||||
colorTheme,
|
||||
activePage,
|
||||
setSidebarMode,
|
||||
setColorTheme,
|
||||
} = useAppLayoutStore();
|
||||
|
||||
const isStudioRunPage = activePage === 'studio_run';
|
||||
|
||||
// ✅ 从 UserStore 获取用户角色和方法
|
||||
const { currentUserRole, userRoles, updateRoleName, updateRoleDescription, selectUserRole, addUserRole } = useUserStore();
|
||||
|
||||
@@ -149,6 +153,8 @@ const Toolbar = () => {
|
||||
<div className="top-bar-content">
|
||||
{/* 左侧:状态徽章区域 */}
|
||||
<div className="status-section">
|
||||
{isStudioRunPage ? <StudioRunControls /> : null}
|
||||
|
||||
{/* 当前玩家角色 */}
|
||||
<div
|
||||
className="status-badge"
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
.studio-run-topbar-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
flex-shrink: 0;
|
||||
margin-right: var(--spacing-md);
|
||||
}
|
||||
|
||||
.studio-run-topbar-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.studio-run-topbar-label-text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.studio-run-topbar-select {
|
||||
padding: 4px var(--spacing-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.8rem;
|
||||
min-width: 140px;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.studio-run-topbar-btn {
|
||||
padding: 4px var(--spacing-md);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
font-family: inherit;
|
||||
transition: opacity 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.studio-run-topbar-btn--primary {
|
||||
border: none;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-inverse, #fff);
|
||||
}
|
||||
|
||||
.studio-run-topbar-btn--primary:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.studio-run-topbar-btn--ghost {
|
||||
border: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.studio-run-topbar-btn--ghost:hover:not(:disabled) {
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.studio-run-topbar-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.studio-run-topbar-character {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
padding: 4px var(--spacing-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-tertiary);
|
||||
border: 1px solid var(--color-border-light);
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.studio-run-topbar-character-icon {
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.studio-run-topbar-character-name {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
|
||||
import useStudioStore from '../../../../Store/Studio/StudioSlice';
|
||||
import useCharacterStore from '../../../../Store/SideBarLeft/CharacterSlice';
|
||||
import { resolveBoundCharacterName } from '../../../Studio/studioRunUtils';
|
||||
|
||||
import './StudioRunControls.css';
|
||||
|
||||
function StudioRunControls() {
|
||||
const {
|
||||
projects,
|
||||
meta,
|
||||
runProjectId,
|
||||
currentRun,
|
||||
runLoading,
|
||||
runCreating,
|
||||
runAdvancing,
|
||||
runSessionEntered,
|
||||
setRunProjectId,
|
||||
setRunSessionEntered,
|
||||
fetchWorkflowVariables,
|
||||
fetchRuns,
|
||||
selectRun,
|
||||
createRun,
|
||||
} = useStudioStore();
|
||||
|
||||
const characters = useCharacterStore((s) => s.characters);
|
||||
|
||||
const boundCharacterName = useMemo(() => {
|
||||
return resolveBoundCharacterName({
|
||||
boundCharacterVar: currentRun?.workflowVariables?.['workflow.boundCharacter'],
|
||||
characterId: meta?.characterId,
|
||||
characters,
|
||||
});
|
||||
}, [currentRun?.workflowVariables, meta?.characterId, characters]);
|
||||
|
||||
const handleProjectChange = useCallback(
|
||||
async (e) => {
|
||||
const projectId = e.target.value;
|
||||
setRunProjectId(projectId);
|
||||
setRunSessionEntered(false);
|
||||
await fetchWorkflowVariables(projectId);
|
||||
const list = await fetchRuns(projectId);
|
||||
if (list[0]?.id) {
|
||||
await selectRun(list[0].id);
|
||||
}
|
||||
},
|
||||
[
|
||||
setRunProjectId,
|
||||
setRunSessionEntered,
|
||||
fetchWorkflowVariables,
|
||||
fetchRuns,
|
||||
selectRun,
|
||||
]
|
||||
);
|
||||
|
||||
const handleNewRun = useCallback(async () => {
|
||||
if (!runProjectId) return;
|
||||
setRunSessionEntered(false);
|
||||
await createRun(runProjectId);
|
||||
}, [runProjectId, setRunSessionEntered, createRun]);
|
||||
|
||||
const inSession = runSessionEntered && !!currentRun;
|
||||
|
||||
return (
|
||||
<div className="studio-run-topbar-controls">
|
||||
<label className="studio-run-topbar-label">
|
||||
<span className="studio-run-topbar-label-text">项目</span>
|
||||
<select
|
||||
className="studio-run-topbar-select"
|
||||
value={runProjectId || ''}
|
||||
onChange={handleProjectChange}
|
||||
disabled={runLoading || runCreating || runAdvancing}
|
||||
aria-label="选择 Studio 项目"
|
||||
>
|
||||
{projects.length === 0 ? (
|
||||
<option value="">暂无项目</option>
|
||||
) : (
|
||||
projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="studio-run-topbar-btn studio-run-topbar-btn--primary"
|
||||
onClick={handleNewRun}
|
||||
disabled={!runProjectId || runCreating || runAdvancing}
|
||||
>
|
||||
{runCreating ? '创建中…' : '新开会话'}
|
||||
</button>
|
||||
|
||||
{boundCharacterName ? (
|
||||
<div className="studio-run-topbar-character" title="绑定角色卡">
|
||||
<span className="studio-run-topbar-character-icon" aria-hidden="true">
|
||||
🎭
|
||||
</span>
|
||||
<span className="studio-run-topbar-character-name">{boundCharacterName}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{inSession ? (
|
||||
<button
|
||||
type="button"
|
||||
className="studio-run-topbar-btn studio-run-topbar-btn--ghost"
|
||||
onClick={() => setRunSessionEntered(false)}
|
||||
>
|
||||
退出会话
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StudioRunControls;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './StudioRunControls';
|
||||
Reference in New Issue
Block a user