diff --git a/backend/api/routes/studioRoute.py b/backend/api/routes/studioRoute.py index 7a5b2de..2e56ed7 100644 --- a/backend/api/routes/studioRoute.py +++ b/backend/api/routes/studioRoute.py @@ -1,13 +1,16 @@ +import json import logging from typing import Any, Dict, List from fastapi import APIRouter, HTTPException +from fastapi.responses import StreamingResponse from models.studio_models import ( AdvanceRunRequest, CreateStudioProjectRequest, PipelineDefinition, RenameRunRequest, + RunMessageRequest, StudioProject, StudioProjectSummary, StudioRun, @@ -186,6 +189,66 @@ async def advance_studio_run( raise HTTPException(status_code=500, detail=str(e)) +@router.post("/projects/{project_id}/runs/{run_id}/message") +async def send_studio_run_message( + project_id: str, run_id: str, req: RunMessageRequest +): + if req.stream: + async def ndjson_stream(): + try: + async for event in studio_run_service.send_run_message_stream( + project_id, + run_id, + req.content, + profile_id=req.profileId, + api_config=req.apiConfig, + ): + yield json.dumps(event, ensure_ascii=False) + "\n" + except FileNotFoundError as e: + yield json.dumps( + {"type": "error", "detail": str(e)}, ensure_ascii=False + ) + "\n" + except ValueError as e: + yield json.dumps( + {"type": "error", "detail": str(e)}, ensure_ascii=False + ) + "\n" + except Exception as e: + logger.error( + "Failed to stream studio run message %s/%s: %s", + project_id, + run_id, + e, + ) + yield json.dumps( + {"type": "error", "detail": f"消息处理失败:{e}"}, + ensure_ascii=False, + ) + "\n" + + return StreamingResponse( + ndjson_stream(), + media_type="application/x-ndjson", + ) + + try: + return await studio_run_service.send_run_message( + project_id, + run_id, + req.content, + stream=req.stream, + profile_id=req.profileId, + api_config=req.apiConfig, + ) + except FileNotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error( + "Failed to send studio run message %s/%s: %s", project_id, run_id, e + ) + raise HTTPException(status_code=500, detail=f"消息处理失败:{e}") + + @router.delete("/projects/{project_id}/runs/{run_id}") async def delete_studio_run(project_id: str, run_id: str): try: diff --git a/backend/models/studio_models.py b/backend/models/studio_models.py index 4e5bb07..caa5986 100644 --- a/backend/models/studio_models.py +++ b/backend/models/studio_models.py @@ -159,8 +159,18 @@ class ToolQuestionOption(BaseModel): options: List[str] = Field(default_factory=list) +class StepMessage(BaseModel): + """Short step-scoped dialogue (not full chat history).""" + id: str + role: str # user | assistant + content: str + createdAt: Optional[str] = None + + class LastToolResponse(BaseModel): """LLM tool-call payload surfaced to the run UI (R2+).""" + thinking: Optional[str] = None + evaluation: Optional[str] = None questions: List[ToolQuestionOption] = Field(default_factory=list) generatedAt: Optional[str] = None @@ -181,6 +191,7 @@ class StudioNodeRunState(BaseModel): loopUntilSatisfied: bool = False lastDraft: Optional[Dict[str, Any]] = None lastToolResponse: Optional[LastToolResponse] = None + stepMessages: List[StepMessage] = Field(default_factory=list) class StudioRun(BaseModel): @@ -202,6 +213,13 @@ class AdvanceRunRequest(BaseModel): displayParams: Dict[str, str] = Field(default_factory=dict) +class RunMessageRequest(BaseModel): + content: str = Field(..., min_length=1, max_length=32000) + stream: bool = False + profileId: Optional[str] = None + apiConfig: Optional[Dict[str, str]] = None + + class RenameRunRequest(BaseModel): title: str = Field(..., min_length=1, max_length=120) diff --git a/backend/services/studio_run_service.py b/backend/services/studio_run_service.py index b13c277..4a4ef0c 100644 --- a/backend/services/studio_run_service.py +++ b/backend/services/studio_run_service.py @@ -10,7 +10,7 @@ import shutil import uuid from datetime import datetime from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, AsyncGenerator, Dict, List, Optional from core.config import settings from models.studio_models import ( @@ -22,8 +22,13 @@ from models.studio_models import ( StudioRunSummary, ) from services.character_service import CharacterService -from services.studio_context_service import store_context_on_run +from services.studio_context_service import assemble_prompt_blocks, store_context_on_run from services.studio_project_service import studio_project_service +from services.studio_step_respond import ( + resolve_api_config, + studio_step_respond, + studio_step_respond_stream, +) from services.worldbook_service import worldbook_service logger = logging.getLogger(__name__) @@ -302,6 +307,181 @@ class StudioRunService: self._save_run(project_id, run_id, updated_run) return updated_run + async def send_run_message( + self, + project_id: str, + run_id: str, + content: str, + *, + stream: bool = False, + profile_id: Optional[str] = None, + api_config: Optional[Dict[str, str]] = None, + ) -> StudioRun: + """Send a user message to the active worldbook step and invoke LLM (R3).""" + trimmed = (content or "").strip() + if not trimmed: + raise ValueError("消息内容不能为空") + + run, current_node, current_state, current_node_id = self._prepare_run_message( + project_id, run_id, trimmed + ) + + resolved_api = resolve_api_config(profile_id, api_config) + prompt_blocks = assemble_prompt_blocks(run, current_node_id) + step_messages = list(current_state.stepMessages or []) + + last_draft, last_tool_response, user_msg, assistant_msg = ( + await studio_step_respond( + node=current_node, + prompt_blocks=prompt_blocks, + step_messages=step_messages, + user_message=trimmed, + existing_draft=current_state.lastDraft, + api_config=resolved_api, + stream=stream, + ) + ) + + return self._persist_run_message_turn( + project_id, + run_id, + run, + current_node_id, + prompt_blocks, + step_messages, + last_draft, + last_tool_response, + user_msg, + assistant_msg, + ) + + async def send_run_message_stream( + self, + project_id: str, + run_id: str, + content: str, + *, + profile_id: Optional[str] = None, + api_config: Optional[Dict[str, str]] = None, + ) -> AsyncGenerator[Dict[str, Any], None]: + """Stream thinking deltas, then persist and emit complete run (R4).""" + trimmed = (content or "").strip() + if not trimmed: + raise ValueError("消息内容不能为空") + + run, current_node, current_state, current_node_id = self._prepare_run_message( + project_id, run_id, trimmed + ) + + resolved_api = resolve_api_config(profile_id, api_config) + prompt_blocks = assemble_prompt_blocks(run, current_node_id) + step_messages = list(current_state.stepMessages or []) + + async for event in studio_step_respond_stream( + node=current_node, + prompt_blocks=prompt_blocks, + step_messages=step_messages, + user_message=trimmed, + existing_draft=current_state.lastDraft, + api_config=resolved_api, + ): + if event.get("type") == "thinking_delta": + yield event + continue + + if event.get("type") == "complete": + from models.studio_models import LastToolResponse, StepMessage + + last_draft = event["last_draft"] + last_tool_response = LastToolResponse(**event["last_tool_response"]) + user_msg = StepMessage(**event["user_msg"]) + assistant_msg = StepMessage(**event["assistant_msg"]) + + updated_run = self._persist_run_message_turn( + project_id, + run_id, + run, + current_node_id, + prompt_blocks, + step_messages, + last_draft, + last_tool_response, + user_msg, + assistant_msg, + ) + yield { + "type": "complete", + "run": updated_run.model_dump(mode="json"), + } + + def _prepare_run_message( + self, + project_id: str, + run_id: str, + trimmed: str, + ) -> tuple[StudioRun, StudioNode, StudioNodeRunState, str]: + run = self.get_run(project_id, run_id) + if run.status != StudioRunStatus.RUNNING: + raise ValueError("运行未处于进行中,无法发送消息") + + current_node_id = run.currentNodeId + if not current_node_id: + raise ValueError("当前运行无活动节点") + + current_node = _find_node(run.pipelineSnapshot, current_node_id) + if not current_node: + raise ValueError(f"节点不存在:{current_node_id}") + + if current_node.skillId != "studio.worldbook_entry": + raise ValueError( + f"当前步骤「{current_node.displayName}」不支持对话消息" + ) + + current_state = next( + (s for s in run.nodeStates if s.nodeId == current_node_id), None + ) + if not current_state or current_state.status != "active": + raise ValueError("当前节点不可执行") + + return run, current_node, current_state, current_node_id + + def _persist_run_message_turn( + self, + project_id: str, + run_id: str, + run: StudioRun, + current_node_id: str, + prompt_blocks: list, + step_messages: list, + last_draft: Dict[str, Any], + last_tool_response, + user_msg, + assistant_msg, + ) -> StudioRun: + step_messages = list(step_messages) + step_messages.extend([user_msg, assistant_msg]) + now = datetime.now().isoformat() + + new_node_states: list[StudioNodeRunState] = [] + for state in run.nodeStates: + updated = state.model_copy() + if state.nodeId == current_node_id: + updated.lastDraft = last_draft + updated.lastToolResponse = last_tool_response + updated.stepMessages = step_messages + new_node_states.append(updated) + + updated_run = run.model_copy( + update={ + "nodeStates": new_node_states, + "lastPromptBlocks": prompt_blocks, + "updatedAt": now, + } + ) + updated_run = store_context_on_run(updated_run, current_node_id) + self._save_run(project_id, run_id, updated_run) + return updated_run + def delete_run(self, project_id: str, run_id: str) -> None: run_dir = self._run_dir(project_id, run_id) if not run_dir.exists(): diff --git a/backend/services/studio_step_respond.py b/backend/services/studio_step_respond.py new file mode 100644 index 0000000..d23b591 --- /dev/null +++ b/backend/services/studio_step_respond.py @@ -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"), + } diff --git a/data/agent/skill_templates.json b/data/agent/skill_templates.json index 745daec..ffa2ef8 100644 --- a/data/agent/skill_templates.json +++ b/data/agent/skill_templates.json @@ -52,7 +52,22 @@ "supportsLoopUntilSatisfied": true, "supportsInputs": true, "supportsInsertion": true, - "supportsScoring": true + "supportsScoring": true, + "configDefaults": { + "stepGoal": "", + "thinkingPrompt": "====== 思考流程 ======\nStep1: 简短确认任务性质(新设计/修改)\nStep2: 阅读绑定角色/世界书与上文引用\nStep3: 按步骤目标起草世界书条目\nStep4: 对照评价维度自检并优化表述\n\n(核心目的、评价标准与优化建议由系统在运行时自动注入,无需在此填写)", + "insertion": { + "position": 1, + "activationType": "permanent", + "key": "", + "keysecondary": "", + "comment": "" + }, + "scoring": { + "enabled": true, + "dimensions": [] + } + } } ] } diff --git a/data/agent/templates/builtin.studio.example/pipeline.json b/data/agent/templates/builtin.studio.example/pipeline.json index e3cc281..549abc9 100644 --- a/data/agent/templates/builtin.studio.example/pipeline.json +++ b/data/agent/templates/builtin.studio.example/pipeline.json @@ -29,7 +29,6 @@ "id": "aesthetic", "skillId": "studio.worldbook_entry", "displayName": "整体美学", - "niche": "aesthetic_tone", "enabled": true, "loopUntilSatisfied": true, "config": { @@ -64,7 +63,6 @@ "id": "persona", "skillId": "studio.worldbook_entry", "displayName": "具体人设", - "niche": "persona_detail", "enabled": true, "loopUntilSatisfied": true, "config": { diff --git a/frontend/src/Store/SideBarLeft/CharacterSlice.jsx b/frontend/src/Store/SideBarLeft/CharacterSlice.jsx index 0b175cb..7928c16 100644 --- a/frontend/src/Store/SideBarLeft/CharacterSlice.jsx +++ b/frontend/src/Store/SideBarLeft/CharacterSlice.jsx @@ -88,11 +88,14 @@ const useCharacterStore = create( } }, - // 选择角色 + // 选择角色(传 null 清除选中) selectCharacter: (character) => { - // 使用函数式更新避免不必要的重渲染 set((state) => { - // 如果选中的是同一个角色,不更新状态 + if (!character) { + return state.selectedCharacter === null + ? state + : { selectedCharacter: null }; + } if (state.selectedCharacter?.id === character.id) { return state; } @@ -100,6 +103,8 @@ const useCharacterStore = create( }); }, + clearSelectedCharacter: () => set({ selectedCharacter: null }), + // 创建角色 createCharacter: async (characterData) => { try { @@ -150,7 +155,19 @@ const useCharacterStore = create( }); if (!response.ok) throw new Error('Failed to delete character'); - + + set((state) => { + const next = {}; + if (state.selectedCharacter?.name === name) { + next.selectedCharacter = null; + } + if (state.characterChats[name]) { + const { [name]: _removed, ...restChats } = state.characterChats; + next.characterChats = restChats; + } + return Object.keys(next).length ? next : state; + }); + // 刷新列表 await get().fetchCharacters(); } catch (error) { diff --git a/frontend/src/Store/Studio/StudioSlice.jsx b/frontend/src/Store/Studio/StudioSlice.jsx index 79617cd..beee5f9 100644 --- a/frontend/src/Store/Studio/StudioSlice.jsx +++ b/frontend/src/Store/Studio/StudioSlice.jsx @@ -41,7 +41,6 @@ const useStudioStore = create((set, get) => ({ meta: null, pipeline: null, skillTemplates: [], - niches: [], selectedNodeId: null, loading: false, saving: false, @@ -56,7 +55,10 @@ const useStudioStore = create((set, get) => ({ runLoading: false, runCreating: false, runAdvancing: false, + runMessaging: false, + runStreamingThinking: null, runError: null, + runSessionEntered: false, setSelectedNodeId: (id) => set({ selectedNodeId: id }), clearSaveMessage: () => set({ saveMessage: null, error: null }), @@ -112,26 +114,11 @@ const useStudioStore = create((set, get) => ({ displayParams: tpl?.displayParams ? [...tpl.displayParams] : [], inputs: [], }; + if (tpl?.configDefaults) { + base.config = JSON.parse(JSON.stringify(tpl.configDefaults)); + } if (skillId === 'studio.worldbook_entry') { base.loopUntilSatisfied = false; - base.config = { - stepGoal: '', - thinkingPrompt: `====== 思考流程 ====== -Step1: 简短确认任务性质(新设计/修改) -Step2: 阅读绑定角色/世界书与上文引用 -Step3: 按步骤目标起草世界书条目 -Step4: 对照评价维度自检并优化表述 - -(核心目的、评价标准与优化建议由系统在运行时自动注入,无需在此填写)`, - insertion: { - position: 1, - activationType: 'permanent', - key: '', - keysecondary: '', - comment: '', - }, - scoring: { enabled: true, dimensions: [] }, - }; } const nodes = [...pipeline.nodes, base]; set({ @@ -198,16 +185,10 @@ Step4: 对照评价维度自检并优化表述 fetchSkillTemplates: async () => { try { - const [tplRes, nicheRes] = await Promise.all([ - fetch('/api/studio/skill-templates'), - fetch('/api/studio/niches'), - ]); - if (!tplRes.ok) throw new Error(await tplRes.text()); - const tplData = await tplRes.json(); - const niches = nicheRes.ok - ? (await nicheRes.json()).niches || [] - : []; - set({ skillTemplates: tplData.templates || [], niches }); + const res = await fetch('/api/studio/skill-templates'); + if (!res.ok) throw new Error(await res.text()); + const tplData = await res.json(); + set({ skillTemplates: tplData.templates || [] }); } catch (e) { set({ error: e.message || '加载技能模板失败' }); } @@ -388,9 +369,12 @@ Step4: 对照评价维度自检并优化表述 currentRunId: null, currentRun: null, runs: [], + runSessionEntered: false, }); }, + setRunSessionEntered: (entered) => set({ runSessionEntered: !!entered }), + fetchRuns: async (projectId) => { if (!projectId) return []; set({ runLoading: true, runError: null }); @@ -424,6 +408,7 @@ Step4: 对照评价维度自检并优化表述 currentRunId: runId, currentRun: run, runLoading: false, + runSessionEntered: false, }); return run; } catch (e) { @@ -450,6 +435,7 @@ Step4: 对照评价维度自检并优化表述 currentRunId: run.id, currentRun: run, runCreating: false, + runSessionEntered: false, }); return run; } catch (e) { @@ -506,6 +492,120 @@ Step4: 对照评价维度自检并优化表述 } }, + sendRunMessage: async (content, { stream = false } = {}) => { + const { runProjectId, currentRunId } = get(); + if (!runProjectId || !currentRunId) return null; + set({ runMessaging: true, runStreamingThinking: null, runError: null }); + try { + let profileId = null; + let apiConfig = {}; + try { + const { default: useApiConfigStore } = await import( + '../SideBarLeft/ApiConfigSlice' + ); + const apiState = useApiConfigStore.getState(); + profileId = apiState.currentProfile?.id || null; + const mainLLM = apiState.currentProfile?.apis?.mainLLM; + if (mainLLM) { + apiConfig = { + api_url: mainLLM.apiUrl || '', + model: mainLLM.model || '', + }; + } + } catch { + /* optional store */ + } + + const res = await fetch( + `/api/studio/projects/${encodeURIComponent(runProjectId)}/runs/${encodeURIComponent(currentRunId)}/message`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + content, + stream, + profileId, + apiConfig, + }), + } + ); + + if (!res.ok) { + let detail = await res.text(); + try { + const parsed = JSON.parse(detail); + detail = parsed.detail || detail; + } catch { + /* keep raw text */ + } + throw new Error(detail || '发送消息失败'); + } + + if (stream && res.body) { + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let finalRun = null; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + for (const line of lines) { + if (!line.trim()) continue; + const event = JSON.parse(line); + if (event.type === 'thinking_delta') { + set({ runStreamingThinking: event.content || '' }); + } else if (event.type === 'complete') { + finalRun = event.run; + } else if (event.type === 'error') { + throw new Error(event.detail || '流式消息处理失败'); + } + } + } + + if (buffer.trim()) { + const event = JSON.parse(buffer); + if (event.type === 'complete') { + finalRun = event.run; + } else if (event.type === 'error') { + throw new Error(event.detail || '流式消息处理失败'); + } else if (event.type === 'thinking_delta') { + set({ runStreamingThinking: event.content || '' }); + } + } + + if (!finalRun) { + throw new Error('流式响应未完成'); + } + + set({ + currentRun: finalRun, + runMessaging: false, + runStreamingThinking: null, + }); + return finalRun; + } + + const run = await res.json(); + set({ + currentRun: run, + runMessaging: false, + runStreamingThinking: null, + }); + return run; + } catch (e) { + set({ + runMessaging: false, + runStreamingThinking: null, + runError: e.message || '发送消息失败', + }); + return null; + } + }, + deleteRun: async (runId) => { const projectId = get().runProjectId; if (!projectId || !runId) return false; diff --git a/frontend/src/components/SideBarLeft/tabs/CharacterCard/CharacterCard.css b/frontend/src/components/SideBarLeft/tabs/CharacterCard/CharacterCard.css index 3fd1343..6fc0cd4 100644 --- a/frontend/src/components/SideBarLeft/tabs/CharacterCard/CharacterCard.css +++ b/frontend/src/components/SideBarLeft/tabs/CharacterCard/CharacterCard.css @@ -132,6 +132,8 @@ /* 角色卡片列表 - 网格布局 */ .character-list { + position: relative; + z-index: var(--z-base-content); flex: 1; overflow-y: auto; display: grid; @@ -318,14 +320,16 @@ flex-direction: column; gap: 8px; padding: 8px; - position: relative; /* 为悬浮工具栏提供定位上下文 */ + position: relative; + z-index: var(--z-base-content); + isolation: isolate; } /* 悬浮工具栏 */ .floating-toolbar { position: sticky; top: 0; - z-index: var(--z-top-bar); /* ✅ 组件层 - TopBar 同级 */ + z-index: 2; margin-bottom: 8px; } diff --git a/frontend/src/components/SideBarLeft/tabs/CharacterCard/CharacterCard.jsx b/frontend/src/components/SideBarLeft/tabs/CharacterCard/CharacterCard.jsx index 897dff1..facec5a 100644 --- a/frontend/src/components/SideBarLeft/tabs/CharacterCard/CharacterCard.jsx +++ b/frontend/src/components/SideBarLeft/tabs/CharacterCard/CharacterCard.jsx @@ -237,12 +237,21 @@ const CharacterCard = () => { try { await deleteCharacter(name); - - // ✅ 删除成功后自动切换到角色分页 + + cancelEditing(); + selectCharacter(null); + + const chatBox = useChatBoxStore.getState(); + if (chatBox.currentRole === name) { + chatBox.setCurrentRole(null); + chatBox.setCurrentChat(null); + chatBox.setCharacterName(''); + } + const { setActiveTab } = useSideBarLeftStore.getState(); setActiveTab('character'); - - console.log('[CharacterCard] 角色已删除,已切换到角色分页'); + + console.log('[CharacterCard] 角色已删除,已返回角色选择列表'); } catch (err) { console.error('删除失败:', err); } diff --git a/frontend/src/components/Studio/StudioEditPage.jsx b/frontend/src/components/Studio/StudioEditPage.jsx index 898643f..b4008e8 100644 --- a/frontend/src/components/Studio/StudioEditPage.jsx +++ b/frontend/src/components/Studio/StudioEditPage.jsx @@ -50,7 +50,6 @@ function StudioEditPage() { meta, pipeline, skillTemplates, - niches, selectedNodeId, loading, saving, @@ -499,36 +498,6 @@ function StudioEditPage() { value={selectedTpl?.displayName || selectedNode.skillId} /> - {isWorldbook && niches.length > 0 && ( - - )} {selectedTpl?.artifacts?.length > 0 && ( 产物: diff --git a/frontend/src/components/Studio/StudioRunChat.css b/frontend/src/components/Studio/StudioRunChat.css index 3493dae..e7ab1f5 100644 --- a/frontend/src/components/Studio/StudioRunChat.css +++ b/frontend/src/components/Studio/StudioRunChat.css @@ -44,6 +44,118 @@ background: rgba(255, 255, 255, 0.6); } +[data-color-theme='dark'] .studio-run-chat-message.role-assistant, +[data-color-theme='dark'] .studio-run-chat-message.role-system { + background-color: rgba(45, 45, 48, 0.5); + color: var(--color-text-primary); +} + +.studio-run-chat-history { + display: flex; + flex-direction: column; + gap: 2px; + margin-bottom: var(--spacing-md); + opacity: 0.35; + max-height: 120px; + overflow: hidden; + pointer-events: none; + user-select: none; +} + +.studio-run-chat-history-item { + display: flex; + gap: var(--spacing-xs); + font-size: 0.72rem; + line-height: 1.35; + color: var(--color-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.studio-run-chat-history-role { + flex-shrink: 0; + font-weight: 600; +} + +.studio-run-chat-history-text { + overflow: hidden; + text-overflow: ellipsis; +} + +.studio-run-chat-focus { + display: flex; + flex-direction: column; + gap: var(--spacing-md); + min-height: min(40vh, 320px); +} + +.studio-run-chat-last-user { + display: flex; + gap: var(--spacing-sm); + align-items: flex-start; + padding: var(--spacing-sm) var(--spacing-md); + border-radius: var(--radius-md); + background: rgba(102, 126, 234, 0.08); + border-left: 3px solid var(--color-accent); +} + +[data-color-theme='dark'] .studio-run-chat-last-user { + background: rgba(212, 167, 106, 0.1); +} + +.studio-run-chat-last-user--pending { + opacity: 0.85; +} + +.studio-run-chat-last-user-label { + flex-shrink: 0; + font-size: 0.7rem; + font-weight: 600; + color: var(--color-text-muted); +} + +.studio-run-chat-last-user-text { + font-size: 0.85rem; + color: var(--color-text-primary); + line-height: 1.5; + white-space: pre-wrap; +} + +.studio-run-chat-thinking, +.studio-run-chat-summary { + padding: var(--spacing-md) var(--spacing-lg); + border-radius: var(--radius-md); + border: 1px solid var(--color-border-light); + background: var(--color-bg-primary); +} + +[data-color-theme='dark'] .studio-run-chat-thinking, +[data-color-theme='dark'] .studio-run-chat-summary { + background-color: rgba(45, 45, 48, 0.5); + border-color: var(--color-border); +} + +.studio-run-chat-thinking--pending { + border-style: dashed; +} + +.studio-run-chat-block-label { + display: block; + font-size: 0.72rem; + font-weight: 600; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: var(--spacing-sm); +} + +.studio-run-chat-block-body { + font-size: 0.95rem; + color: var(--color-text-primary); + line-height: 1.6; +} + .studio-run-chat-message-role { display: block; font-size: 0.7rem; @@ -89,6 +201,24 @@ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); } +[data-color-theme='dark'] .studio-run-chat-input-container { + background-color: var(--color-bg-tertiary); + box-shadow: none; +} + +[data-color-theme='dark'] .studio-run-chat-input-container:focus-within { + box-shadow: 0 0 0 1px var(--color-accent); +} + +[data-color-theme='dark'] .studio-run-chat-options-toggle:hover, +[data-color-theme='dark'] .studio-run-chat-options-toggle.active { + background-color: var(--color-accent-ultra-light); +} + +[data-color-theme='dark'] .studio-run-chat-textarea:focus { + background-color: transparent; +} + .studio-run-chat-options-wrapper { position: relative; flex-shrink: 0; @@ -166,6 +296,70 @@ color: var(--color-accent); } +.studio-run-chat-option-checkbox { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); + cursor: pointer; + border-radius: var(--radius-md); + transition: all 0.15s ease; + margin-bottom: var(--spacing-xs); +} + +.studio-run-chat-option-checkbox:hover { + background-color: rgba(102, 126, 234, 0.06); +} + +.studio-run-chat-option-checkbox input[type="checkbox"] { + display: none; +} + +.studio-run-chat-checkmark { + width: 16px; + height: 16px; + border: 2px solid var(--color-border); + border-radius: 3px; + position: relative; + transition: all 0.15s ease; + flex-shrink: 0; +} + +.studio-run-chat-option-checkbox:hover .studio-run-chat-checkmark { + border-color: var(--color-accent); +} + +.studio-run-chat-option-checkbox input:checked + .studio-run-chat-checkmark { + background-color: var(--color-accent); + border-color: var(--color-accent); +} + +.studio-run-chat-option-checkbox input:checked + .studio-run-chat-checkmark::after { + content: ''; + position: absolute; + left: 4px; + top: 1px; + width: 4px; + height: 8px; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} + +.studio-run-chat-option-label { + font-size: 0.85rem; + color: var(--color-text-primary); +} + +.studio-run-chat-pending { + color: var(--color-text-muted); + font-style: italic; +} + +.studio-run-chat-message.is-pending { + opacity: 0.85; +} + .studio-run-chat-input-area { flex: 1; min-width: 0; diff --git a/frontend/src/components/Studio/StudioRunChat.jsx b/frontend/src/components/Studio/StudioRunChat.jsx index 421bd09..2935759 100644 --- a/frontend/src/components/Studio/StudioRunChat.jsx +++ b/frontend/src/components/Studio/StudioRunChat.jsx @@ -1,6 +1,7 @@ import React, { useEffect, useRef, useState } from 'react'; import MarkdownRenderer from '../shared/MarkdownRenderer'; +import { findLastUserMessageIndex } from './studioRunUtils'; import './StudioRunChat.css'; @@ -11,7 +12,8 @@ const RENDER_MODE_LABELS = { markdown: '📝 Markdown', }; -function renderMessageBody(text, renderMode, isUser) { +function renderBody(text, renderMode, isUser) { + if (!text) return null; if (renderMode === 'html' && !isUser) { const hasHtmlTags = /<[a-z][\s\S]*>/i.test(text); if (hasHtmlTags) { @@ -26,23 +28,48 @@ function renderMessageBody(text, renderMode, isUser) { } function StudioRunChat({ - messages, + stepMessages = [], + thinking, + evaluation, + pendingUserText, inputValue, onInputChange, onSend, disabled = false, - placeholder = '输入消息…', sending = false, + streamOutput = false, + onStreamOutputChange, + placeholder = '输入消息…', }) { - const messagesEndRef = useRef(null); - const messagesContainerRef = useRef(null); + const containerRef = useRef(null); + const focusAnchorRef = useRef(null); const optionsRef = useRef(null); const [showOptions, setShowOptions] = useState(false); const [renderMode, setRenderMode] = useState('markdown'); + const lastUserIdx = findLastUserMessageIndex(stepMessages); + const historyMessages = + lastUserIdx > 0 ? stepMessages.slice(0, lastUserIdx) : []; + const lastUserMessage = + lastUserIdx >= 0 ? stepMessages[lastUserIdx] : pendingUserText + ? { role: 'user', content: pendingUserText } + : null; + + const summaryText = + evaluation || + (lastUserIdx >= 0 && stepMessages[lastUserIdx + 1]?.role === 'assistant' + ? stepMessages[lastUserIdx + 1].content + : null); + + const hasFocusContent = + thinking || summaryText || pendingUserText || sending || lastUserMessage; + useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [messages]); + const container = containerRef.current; + const anchor = focusAnchorRef.current; + if (!container || !anchor) return; + container.scrollTop = Math.max(0, anchor.offsetTop - container.offsetTop); + }, [stepMessages, thinking, evaluation, pendingUserText, sending]); useEffect(() => { const handleClickOutside = (event) => { @@ -89,28 +116,77 @@ function StudioRunChat({ setRenderMode(RENDER_MODES[(idx + 1) % RENDER_MODES.length]); }; - const roleLabel = (role) => { - if (role === 'user') return '你'; - if (role === 'system') return '系统'; - return '助手'; - }; - return (
-
- {messages.length === 0 ? ( -
暂无对话,在下方输入开始交流
- ) : ( - messages.map((msg) => ( -
- {roleLabel(msg.role)} -
- {renderMessageBody(msg.text, renderMode, msg.role === 'user')} +
+ {historyMessages.length > 0 && ( + - )) + ))} +
)} -
+ +
+ {!hasFocusContent ? ( +
+ 暂无本轮对话,在下方输入开始交流 +
+ ) : ( + <> + {lastUserMessage && !pendingUserText && ( +
+ + + {lastUserMessage.content} + +
+ )} + + {pendingUserText && ( +
+ + + {pendingUserText} + +
+ )} + + {sending && !thinking && ( +
+ 思考 + 正在等待模型回复… +
+ )} + + {thinking && ( +
+ 思考 +
+ {renderBody(thinking, renderMode, false)} +
+
+ )} + + {summaryText && ( +
+ 评价与修改建议 +
+ {renderBody(summaryText, renderMode, false)} +
+
+ )} + + )} +
@@ -135,6 +211,16 @@ function StudioRunChat({ > {RENDER_MODE_LABELS[renderMode]} +
功能
+
)}
diff --git a/frontend/src/components/Studio/StudioRunPage.css b/frontend/src/components/Studio/StudioRunPage.css index d12b966..7fcd161 100644 --- a/frontend/src/components/Studio/StudioRunPage.css +++ b/frontend/src/components/Studio/StudioRunPage.css @@ -6,70 +6,119 @@ overflow: hidden; } -.studio-run-header { +.studio-run-preview__meta { display: flex; - align-items: center; - gap: var(--spacing-sm); - padding: var(--spacing-md) var(--spacing-lg); - border-bottom: 1px solid var(--color-border-light); - background: var(--color-bg-secondary); - flex-shrink: 0; flex-wrap: wrap; -} - -.studio-run-header-icon { - font-size: 1.25rem; -} - -.studio-run-header-title { - margin: 0; - font-size: 1.1rem; - font-weight: 600; - margin-right: auto; -} - -.studio-run-toolbar { - display: flex; - align-items: center; - gap: var(--spacing-md); - flex-wrap: wrap; -} - -.studio-run-label { - display: flex; - align-items: center; - gap: var(--spacing-xs); - font-size: 0.8rem; + gap: var(--spacing-sm) var(--spacing-md); + font-size: 0.75rem; color: var(--color-text-muted); } -.studio-run-select { - padding: var(--spacing-xs) var(--spacing-sm); - border-radius: var(--radius-md); +.studio-run-preview__character { + color: var(--color-text-secondary); +} + +.studio-run-tool-option.is-selected { + border-color: var(--color-accent); + background: var(--color-accent-ultra-light); +} + +.studio-run-tool-copy { + margin-top: var(--spacing-sm); + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.studio-run-tool-copy__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-sm); +} + +.studio-run-tool-copy__label { + font-size: 0.72rem; + color: var(--color-text-muted); +} + +.studio-run-tool-copy__btn { + padding: 2px var(--spacing-sm); border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-bg-secondary); + color: var(--color-text-primary); + font-size: 0.72rem; + cursor: pointer; + font-family: inherit; +} + +.studio-run-tool-copy__btn:hover { + border-color: var(--color-accent); + color: var(--color-accent); +} + +.studio-run-tool-copy__textarea { + width: 100%; + padding: var(--spacing-sm); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-sm); + background: var(--color-bg-primary); + color: var(--color-text-primary); + font-size: 0.78rem; + font-family: inherit; + line-height: 1.45; + resize: vertical; +} + +.studio-run-tool-copy__hint { + font-size: 0.68rem; + color: var(--color-accent); +} + +[data-color-theme='dark'] .studio-run-message.role-assistant, +[data-color-theme='dark'] .studio-run-message.role-system { + background-color: rgba(45, 45, 48, 0.5); + color: var(--color-text-primary); +} + +[data-color-theme='dark'] .studio-run-message.role-user { + background: rgba(212, 167, 106, 0.1); +} + +[data-color-theme='dark'] .studio-run-icon-btn, +[data-color-theme='dark'] .studio-run-tool-option { background: var(--color-bg-tertiary); color: var(--color-text-primary); - font-size: 0.875rem; - min-width: 160px; + border-color: var(--color-border); } -.studio-run-new-btn { - padding: var(--spacing-xs) var(--spacing-md); - border-radius: var(--radius-md); - border: none; - background: var(--color-accent); - color: var(--color-text-inverse, #fff); - font-size: 0.875rem; - cursor: pointer; +[data-color-theme='dark'] .studio-run-list-item { + background: var(--color-bg-tertiary); } -.studio-run-new-btn:hover:not(:disabled) { - opacity: 0.9; +[data-color-theme='dark'] .studio-run-list-action { + background: var(--color-bg-elevated); + color: var(--color-text-secondary); } -.studio-run-new-btn:disabled { - opacity: 0.5; - cursor: not-allowed; +[data-color-theme='dark'] .studio-run-product-main__body, +[data-color-theme='dark'] .studio-run-variables__row, +[data-color-theme='dark'] .studio-run-guidance-form { + background: var(--color-bg-tertiary); + border-color: var(--color-border); +} + +[data-color-theme='dark'] .studio-run-form-input, +[data-color-theme='dark'] .studio-run-insertion-entry, +[data-color-theme='dark'] .studio-run-tool-carousel { + background: var(--color-bg-elevated); + color: var(--color-text-primary); +} + +[data-color-theme='dark'] .studio-run-banner.error { + background: rgba(239, 68, 68, 0.15); + color: #f87171; } .studio-run-banner { @@ -290,11 +339,6 @@ white-space: nowrap; } -.studio-run-preview__meta { - font-size: 0.75rem; - color: var(--color-text-muted); -} - .studio-run-preview__body { flex: 1; min-height: 0; diff --git a/frontend/src/components/Studio/StudioRunPage.jsx b/frontend/src/components/Studio/StudioRunPage.jsx index 804bc9e..f328255 100644 --- a/frontend/src/components/Studio/StudioRunPage.jsx +++ b/frontend/src/components/Studio/StudioRunPage.jsx @@ -6,6 +6,8 @@ import { buildWorkflowVariableLabelMap, getWorkflowVariableLabel, } from './edit/workflowVariableLabels'; +import { resolveBoundCharacterName } from './studioRunUtils'; +import useCharacterStore from '../../Store/SideBarLeft/CharacterSlice'; import StudioContextBlockPopup from './StudioContextBlockPopup'; import StudioInsertionPopup from './StudioInsertionPopup'; @@ -118,10 +120,34 @@ function InitBindGuidanceForm({ node, runAdvancing, onSubmit, compact = false }) ); } -function ToolOptionsCarousel({ options, index, onPrev, onNext, onSelect, runAdvancing }) { +function ToolOptionsCarousel({ options, index, onPrev, onNext, runAdvancing }) { const current = options[index]; + const [selectedOption, setSelectedOption] = useState(null); + const [copyHint, setCopyHint] = useState(''); + + useEffect(() => { + setSelectedOption(null); + setCopyHint(''); + }, [index, current?.question]); + if (!options.length || !current) return null; + const handleSelect = (opt) => { + setSelectedOption(opt); + setCopyHint(''); + }; + + const handleCopy = async () => { + if (!selectedOption) return; + try { + await navigator.clipboard.writeText(selectedOption); + setCopyHint('已复制到剪贴板'); + setTimeout(() => setCopyHint(''), 2000); + } catch { + setCopyHint('复制失败,请手动选择文本'); + } + }; + return (
@@ -146,8 +172,8 @@ function ToolOptionsCarousel({ options, index, onPrev, onNext, onSelect, runAdva
+ {selectedOption ? ( +
+
+ 复制以下内容到输入框 + +
+