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
|
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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
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,
|
"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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
产物:
|
产物:
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
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 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"
|
||||||
|
|||||||
@@ -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