From bc130d98f4afb952763a52d216322d0ff3d78bd2 Mon Sep 17 00:00:00 2001 From: moranzhi Date: Sun, 31 May 2026 02:30:43 +0800 Subject: [PATCH] Add agent workflow engine foundation and theme-style page switcher. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce WorkflowEngine with state machine, tool registry, and builtin chat template; migrate stream chat to engine callbacks. Move page mode switching to TopBar actions cluster as a ThemeToggle-style dropdown (聊天/工作室/爽文/房间). Co-authored-by: Cursor --- backend/api/routes/chatWsRoute.py | 156 ++---- backend/core/config.py | 6 + backend/models/agent.py | 128 +++++ backend/models/internal.py | 4 + backend/services/chat_workflow_service.py | 504 ++---------------- backend/services/state_machine_runner.py | 105 ++++ backend/services/tool_registry.py | 49 ++ backend/services/tools/__init__.py | 1 + backend/services/tools/chat_tools.py | 261 +++++++++ backend/services/workflow_engine.py | 170 ++++++ .../builtin.chat/skill/chat_reply/SKILL.md | 5 + .../skill/chat_reply/skill.meta.yaml | 3 + .../templates/builtin.chat/state_machine.json | 41 ++ .../templates/builtin.chat/template.json | 16 + frontend/src/App.jsx | 44 +- frontend/src/Store/AppLayoutSlice.jsx | 17 +- .../PlaceholderPage/PlaceholderPage.css | 46 ++ .../PlaceholderPage/PlaceholderPage.jsx | 43 ++ .../src/components/PlaceholderPage/index.js | 1 + frontend/src/components/TopBar/TopBar.jsx | 6 +- .../items/PageModeToggle/PageModeToggle.css | 88 +++ .../items/PageModeToggle/PageModeToggle.jsx | 79 +++ .../TopBar/items/PageModeToggle/index.js | 1 + frontend/src/index.css | 4 + 24 files changed, 1164 insertions(+), 614 deletions(-) create mode 100644 backend/models/agent.py create mode 100644 backend/services/state_machine_runner.py create mode 100644 backend/services/tool_registry.py create mode 100644 backend/services/tools/__init__.py create mode 100644 backend/services/tools/chat_tools.py create mode 100644 backend/services/workflow_engine.py create mode 100644 data/agent/templates/builtin.chat/skill/chat_reply/SKILL.md create mode 100644 data/agent/templates/builtin.chat/skill/chat_reply/skill.meta.yaml create mode 100644 data/agent/templates/builtin.chat/state_machine.json create mode 100644 data/agent/templates/builtin.chat/template.json create mode 100644 frontend/src/components/PlaceholderPage/PlaceholderPage.css create mode 100644 frontend/src/components/PlaceholderPage/PlaceholderPage.jsx create mode 100644 frontend/src/components/PlaceholderPage/index.js create mode 100644 frontend/src/components/TopBar/items/PageModeToggle/PageModeToggle.css create mode 100644 frontend/src/components/TopBar/items/PageModeToggle/PageModeToggle.jsx create mode 100644 frontend/src/components/TopBar/items/PageModeToggle/index.js diff --git a/backend/api/routes/chatWsRoute.py b/backend/api/routes/chatWsRoute.py index 8b9a84f..9542eb7 100644 --- a/backend/api/routes/chatWsRoute.py +++ b/backend/api/routes/chatWsRoute.py @@ -221,119 +221,47 @@ async def _handle_stream_chat( workflow_service ): """ - 处理流式聊天请求 - - Args: - websocket: WebSocket 连接 - role_name: 角色名 - chat_name: 聊天名 - request_data: 请求数据 - workflow_service: 工作流服务实例 + 处理流式聊天请求 – engine callbacks emit worldbook_active / tasks_created / chunk. """ try: print(f"[StreamChat] 🚀 开始流式处理") - - # ✅ 第1步:加载角色卡 - current_role = request_data.get("currentRole") - character_data = request_data.get("characterData") - - if character_data: - try: - from backend.models.internal import CharacterCard - except ImportError: - from models.internal import CharacterCard - character = CharacterCard(**character_data) - else: - from backend.services.character_service import CharacterService - character_service = CharacterService() - character = character_service.get_character_by_name(current_role) - - if not character: - print(f"[StreamChat] ❌ 错误: 无法加载角色 '{current_role}'") - await websocket.send_json({ - "type": "error", - "message": f"角色 '{current_role}' 不存在" - }) - return - - print(f"[StreamChat] ✅ 已加载角色卡: {character.name}") - - # ✅ 第2步:激活世界书条目(在LLM调用之前) - print(f"[StreamChat] 📚 正在激活世界书条目...") - active_entries = await workflow_service._collect_and_activate_worldbooks( - request_data, - character - ) - - # ✅ 发送激活的世界书条目信息(在LLM调用前) - if active_entries: - print(f"[StreamChat] 📤 发送世界书激活信息: {len(active_entries)} 个条目") - # 将 Pydantic 模型转换为字典 - entries_dict = [entry.model_dump() for entry in active_entries] - await websocket.send_json({ - "type": "worldbook_active", - "entries": entries_dict - }) - - # ✅ TODO: RAG检索(暂时为空,待实现) - rag_results = [] - if rag_results: - print(f"[StreamChat] 🔍 发送 RAG 检索结果: {len(rag_results)} 条") - await websocket.send_json({ - "type": "rag_results", - "results": rag_results - }) - - # ✅ 第2步:启动并行任务(在LLM调用前创建任务ID) - options = request_data.get("options", {}) - task_ids = { - "imageWorkflow": None, - "dynamicTable": None - } - - if options.get("imageWorkflow", False): - import uuid - chat_id = f"{role_name}/{chat_name}" - task_ids["imageWorkflow"] = f"img_{uuid.uuid4().hex[:8]}" - from backend.services.task_queue_manager import task_queue_manager, TaskType - await task_queue_manager.add_task(task_ids["imageWorkflow"], TaskType.IMAGE_WORKFLOW, chat_id) - - if options.get("dynamicTable", False): - import uuid - chat_id = f"{role_name}/{chat_name}" - task_ids["dynamicTable"] = f"tbl_{uuid.uuid4().hex[:8]}" - from backend.services.task_queue_manager import task_queue_manager, TaskType - await task_queue_manager.add_task(task_ids["dynamicTable"], TaskType.DYNAMIC_TABLE, chat_id) - - # ✅ 发送任务ID信息(在LLM调用前) - if task_ids.get("imageWorkflow") or task_ids.get("dynamicTable"): - print(f"[StreamChat] 📤 发送任务ID信息: {task_ids}") - await websocket.send_json({ - "type": "tasks_created", - "tasks": task_ids - }) - - # ✅ 第3步:调用LLM流式生成 - chunk_count = [0] # 使用列表以便在闭包中修改 + + chunk_count = [0] + + async def on_worldbook_active(entries): + if entries: + print(f"[StreamChat] 📤 发送世界书激活信息: {len(entries)} 个条目") + await websocket.send_json({ + "type": "worldbook_active", + "entries": entries, + }) + + async def on_tasks_created(task_ids): + if task_ids.get("imageWorkflow") or task_ids.get("dynamicTable"): + print(f"[StreamChat] 📤 发送任务ID信息: {task_ids}") + await websocket.send_json({ + "type": "tasks_created", + "tasks": task_ids, + }) + + async def on_chunk(chunk): + chunk_count[0] += 1 + if chunk_count[0] % 10 == 0: + print(f"[StreamChat] 📤 已发送 {chunk_count[0]} 个 chunks") + await websocket.send_json({"type": "chunk", "content": chunk}) + result = await workflow_service.process_chat_request_stream( request_data, - on_chunk=lambda chunk: asyncio.create_task( - _send_chunk_with_log(websocket, chunk, chunk_count) - ) + on_chunk=on_chunk, + on_worldbook_active=on_worldbook_active, + on_tasks_created=on_tasks_created, ) - + if result["success"]: content = result["content"] - print(f"\n[StreamChat] ✨ 流式生成成功,总长度: {len(content)}") - - # 发送完成信号 print(f"[StreamChat] ✅ 发送完成信号") - await websocket.send_json({ - "type": "complete" - }) - - # 保存消息 + await websocket.send_json({"type": "complete"}) print(f"[StreamChat] 💾 保存消息到文件...") await _save_messages(role_name, chat_name, request_data, content) print(f"[StreamChat] ✅ 消息保存完成\n") @@ -342,35 +270,19 @@ async def _handle_stream_chat( print(f"[StreamChat] ❌ 流式处理失败: {error_msg}") await websocket.send_json({ "type": "error", - "message": error_msg + "message": error_msg, }) - + except Exception as e: print(f"\n[StreamChat] ⚠️ 错误: {str(e)}") import traceback traceback.print_exc() await websocket.send_json({ "type": "error", - "message": f"流式处理失败: {str(e)}" + "message": f"流式处理失败: {str(e)}", }) -async def _send_chunk_with_log(websocket: WebSocket, chunk: str, chunk_count: list): - """ - 发送 chunk 并记录日志 - - Args: - websocket: WebSocket 连接 - chunk: 文本片段 - chunk_count: 计数器(使用列表以便在闭包中修改) - """ - chunk_count[0] += 1 - if chunk_count[0] % 10 == 0: # 每10个chunk记录一次 - print(f"[StreamChat] 📤 已发送 {chunk_count[0]} 个 chunks") - - await websocket.send_json({"type": "chunk", "content": chunk}) - - async def _save_messages( role_name: str, chat_name: str, diff --git a/backend/core/config.py b/backend/core/config.py index d864a38..639b887 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -61,6 +61,10 @@ class Settings: # 图片资源目录 IMAGES_PATH = DATA_PATH / "images" + # Agent 工作流模板与运行记录 + AGENT_TEMPLATES_PATH = DATA_PATH / "agent" / "templates" + AGENT_RUNS_PATH = DATA_PATH / "agent" / "runs" + def ensure_directories(self): """确保所有配置的目录存在,如果不存在则创建""" directories = [ @@ -72,6 +76,8 @@ class Settings: self.COMFYUI_WORKFLOWS_PATH, self.CHARACTERS_PATH, self.IMAGES_PATH, + self.AGENT_TEMPLATES_PATH, + self.AGENT_RUNS_PATH, ] for directory in directories: directory.mkdir(parents=True, exist_ok=True) diff --git a/backend/models/agent.py b/backend/models/agent.py new file mode 100644 index 0000000..8f01bd3 --- /dev/null +++ b/backend/models/agent.py @@ -0,0 +1,128 @@ +""" +Agent workflow engine data models. +""" +from __future__ import annotations + +from datetime import datetime +from enum import Enum +from typing import Any, Awaitable, Callable, Dict, List, Optional + +from pydantic import BaseModel, Field + + +class WorkflowTemplateKind(str, Enum): + BUILTIN_CHAT = "builtin.chat" + + +class RunStatus(str, Enum): + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + + +class RunEventType(str, Enum): + STATE_ENTER = "state_enter" + TOOL_START = "tool_start" + TOOL_END = "tool_end" + WORLD_BOOK_ACTIVE = "worldbook_active" + TASKS_CREATED = "tasks_created" + CHUNK = "chunk" + ERROR = "error" + COMPLETE = "complete" + + +class ToolSpec(BaseModel): + name: str + description: str = "" + parameters: Dict[str, Any] = Field(default_factory=dict) + + +class SkillManifest(BaseModel): + id: str + name: str = "" + description: str = "" + path: str = "" + + +class WorkflowTemplate(BaseModel): + id: str + kind: WorkflowTemplateKind + name: str = "" + description: str = "" + version: str = "1.0.0" + state_machine_path: str = "state_machine.json" + skills: List[SkillManifest] = Field(default_factory=list) + + +class ChatRunBinding(BaseModel): + role_name: str + chat_name: str + template_id: str = WorkflowTemplateKind.BUILTIN_CHAT.value + + +class TurnCallbacks(BaseModel): + """Optional async callbacks for streaming / WS events.""" + + model_config = {"arbitrary_types_allowed": True} + + on_worldbook_active: Optional[Callable[[List[Any]], Awaitable[None]]] = None + on_tasks_created: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None + on_chunk: Optional[Callable[[str], Awaitable[None]]] = None + + +class TurnContext(BaseModel): + """Mutable per-turn execution context passed between tools.""" + + model_config = {"arbitrary_types_allowed": True} + + request_data: Dict[str, Any] = Field(default_factory=dict) + template_id: str = WorkflowTemplateKind.BUILTIN_CHAT.value + run_id: str = "" + stream: bool = False + callbacks: Optional[TurnCallbacks] = None + + current_role: str = "" + current_chat: str = "" + user_message: str = "" + preset_name: Optional[str] = None + character: Any = None + active_entries: List[Any] = Field(default_factory=list) + chat_history: List[Any] = Field(default_factory=list) + prompt_messages: List[Any] = Field(default_factory=list) + generated_content: str = "" + token_usage: Dict[str, Any] = Field(default_factory=dict) + duration: float = 0.0 + task_ids: Dict[str, Optional[str]] = Field(default_factory=dict) + error: Optional[str] = None + + +class WorkflowRun(BaseModel): + id: str + template_id: str + binding: ChatRunBinding + status: RunStatus = RunStatus.PENDING + started_at: str = Field(default_factory=lambda: datetime.now().isoformat()) + finished_at: Optional[str] = None + current_state: Optional[str] = None + result_content: str = "" + error: Optional[str] = None + + +class RunEvent(BaseModel): + run_id: str + type: RunEventType + timestamp: str = Field(default_factory=lambda: datetime.now().isoformat()) + state: Optional[str] = None + tool: Optional[str] = None + payload: Dict[str, Any] = Field(default_factory=dict) + + +class ChatTurnResult(BaseModel): + success: bool + content: str = "" + error: Optional[str] = None + active_entries: List[Any] = Field(default_factory=list) + task_ids: Dict[str, Optional[str]] = Field(default_factory=dict) + run_id: str = "" + workflow_template_id: str = WorkflowTemplateKind.BUILTIN_CHAT.value diff --git a/backend/models/internal.py b/backend/models/internal.py index 902f22b..49ab747 100644 --- a/backend/models/internal.py +++ b/backend/models/internal.py @@ -215,6 +215,10 @@ class ChatHeader(BaseModel): messageCount: int = Field(0, description="消息数量") ragLibraryId: Optional[str] = Field(None, description="关联的 RAG 历史消息库 ID") + # Agent workflow engine (optional, backward compatible) + workflowTemplateId: Optional[str] = Field(None, description="工作流模板 ID") + engineRunId: Optional[str] = Field(None, description="最近一次引擎运行 ID") + class ChatMessage(BaseModel): """ diff --git a/backend/services/chat_workflow_service.py b/backend/services/chat_workflow_service.py index e491630..2d050b3 100644 --- a/backend/services/chat_workflow_service.py +++ b/backend/services/chat_workflow_service.py @@ -17,6 +17,7 @@ try: from backend.utils.llm_client import LLMClient from backend.services.task_queue_manager import task_queue_manager, TaskType from backend.core.config import settings + from backend.services.workflow_engine import workflow_engine except ImportError: from services.character_service import CharacterService from services.worldbook_service import WorldBookService @@ -24,6 +25,7 @@ except ImportError: from utils.llm_client import LLMClient from services.task_queue_manager import task_queue_manager, TaskType from core.config import settings + from services.workflow_engine import workflow_engine class ChatWorkflowService: @@ -128,201 +130,17 @@ class ChatWorkflowService: self, request_data: Dict[str, Any] ) -> Dict[str, Any]: - """ - 处理聊天请求的核心工作流 - - Args: - request_data: 前端发送的完整数据 - - Returns: - { - "success": bool, - "content": str, # 生成的回复内容 - "error": str | None - } - """ - try: - # === 第1步:解析请求数据 === - current_role = request_data.get("currentRole") - current_chat = request_data.get("currentChat") - user_message = request_data.get("mes", "") - - if not current_role or not user_message: - return { - "success": False, - "content": "", - "error": "缺少必要的参数:currentRole 或 mes" - } - - print(f"[ChatWorkflow] 开始处理请求: role={current_role}, chat={current_chat}") - - # ✅ 获取预设名称(用于加载预设绑定的正则规则) - preset_config = request_data.get("presetConfig", {}) - preset_name = preset_config.get("selectedPreset") - - # ✅ 第1.5步:应用用户输入的正则规则 - from services.regex_service import regex_service - from models.regex_rules import RegexPlacement - - processed_user_message = regex_service.apply_rules_by_placement( - text=user_message, - placement=RegexPlacement.USER_INPUT.value, - character_name=current_role, - preset_name=preset_name, - message_depth=0, - is_for_llm=True, # ✅ 用户输入会发送给LLM - is_markdown_rendered=False - ) - - if processed_user_message != user_message: - print(f"[Regex] ✅ 已应用用户输入正则规则") - - user_message = processed_user_message - - # === 第2步:加载角色卡 === - character_data = request_data.get("characterData") - if not character_data: - # 如果没有提供角色卡数据,从后端加载 - character = self.character_service.get_character_by_name(current_role) - if not character: - return { - "success": False, - "content": "", - "error": f"角色 '{current_role}' 不存在" - } - else: - # 使用前端提供的角色卡数据(可能包含用户修改) - try: - from backend.models.internal import CharacterCard - except ImportError: - from models.internal import CharacterCard - character = CharacterCard(**character_data) - - print(f"[ChatWorkflow] 已加载角色卡: {character.name}") - - # === 第3步:收集并激活世界书条目 === - active_entries = await self._collect_and_activate_worldbooks( - request_data, - character - ) - print(f"[ChatWorkflow] 激活了 {len(active_entries)} 个世界书条目") - - # === 第4步:加载聊天历史 === - chat_history = await self._load_chat_history(current_role, current_chat) - print(f"[ChatWorkflow] 加载了 {len(chat_history)} 条历史消息") - - # === 第5步:组装提示词 === - prompt_messages = self._assemble_prompt( - character, - chat_history, - user_message, - active_entries, - request_data - ) - print(f"[ChatWorkflow] 组装了 {len(prompt_messages)} 条提示消息") - - # === 第6步:调用LLM生成回复 === - api_config = request_data.get("apiConfig", {}) - preset_config = request_data.get("presetConfig", {}) - stream_output = request_data.get("stream", False) - - result = await self._generate_response( - prompt_messages, - api_config, - preset_config, - stream_output - ) - - generated_content = result["content"] - token_usage = result.get("usage", {}) - duration = result.get("duration") - - print(f"[ChatWorkflow] 生成完成,内容长度: {len(generated_content)}") - print(f"[ChatWorkflow] Token 使用: {token_usage}") - - # ✅ 第6.5步:应用 AI 输出的正则规则 - processed_ai_output = regex_service.apply_rules_by_placement( - text=generated_content, - placement=RegexPlacement.AI_OUTPUT.value, - character_name=current_role, - preset_name=preset_name, - message_depth=0, - is_for_llm=False, # ✅ AI输出是显示给用户的 - is_markdown_rendered=False - ) - - if processed_ai_output != generated_content: - print(f"[Regex] ✅ 已应用 AI 输出正则规则") - generated_content = processed_ai_output - - # === 第7步:记录 Token 使用 === - chat_id = f"{current_role}/{request_data.get('currentChat', '')}" - floor = request_data.get("floor", 0) - - try: - try: - from backend.services.token_usage_service import token_usage_service - from backend.models.internal import TokenUsageStatus - except ImportError: - from services.token_usage_service import token_usage_service - from models.internal import TokenUsageStatus - - await token_usage_service.record_usage( - chat_id=chat_id, - role_name=current_role, - chat_name=request_data.get('currentChat', ''), - prompt_tokens=token_usage.get("prompt_tokens", 0), - completion_tokens=token_usage.get("completion_tokens", 0), - total_tokens=token_usage.get("total_tokens", 0), - status=TokenUsageStatus.COMPLETED, - floor=floor + 1, # AI 回复的楼层 - duration=duration, - model=api_config.get("model"), - api_provider="openai", # TODO: 从 API URL 检测提供商 - api_url=api_config.get("api_url") # ✅ 记录 API URL - ) - except Exception as e: - print(f"[ChatWorkflow] 记录 Token 使用失败: {e}") - - # === 第8步:启动异步并行任务 === - - # 创建任务ID - image_task_id = None - table_task_id = None - - options = request_data.get("options", {}) - if options.get("imageWorkflow", False): - import uuid - image_task_id = f"img_{uuid.uuid4().hex[:8]}" - await task_queue_manager.add_task(image_task_id, TaskType.IMAGE_WORKFLOW, chat_id) - - if options.get("dynamicTable", False): - import uuid - table_task_id = f"tbl_{uuid.uuid4().hex[:8]}" - await task_queue_manager.add_task(table_task_id, TaskType.DYNAMIC_TABLE, chat_id) - - await self._start_parallel_tasks(request_data, generated_content, image_task_id, table_task_id) - - return { - "success": True, - "content": generated_content, - "error": None, - "activeEntries": active_entries, - "taskIds": { - "imageWorkflow": image_task_id, - "dynamicTable": table_task_id - } - } - - except Exception as e: - print(f"[ChatWorkflow] 错误: {str(e)}") - import traceback - traceback.print_exc() - return { - "success": False, - "content": "", - "error": f"工作流执行失败: {str(e)}" - } + """处理聊天请求 – delegates to WorkflowEngine.run_turn.""" + result = await workflow_engine.run_turn(request_data, stream=False) + return { + "success": result.success, + "content": result.content, + "error": result.error, + "activeEntries": result.active_entries, + "taskIds": result.task_ids, + "engineRunId": result.run_id, + "workflowTemplateId": result.workflow_template_id, + } def _normalize_worldbook_entry(self, entry_data: Dict[str, Any]) -> Dict[str, Any]: """ @@ -1211,280 +1029,24 @@ class ChatWorkflowService: async def process_chat_request_stream( self, request_data: Dict[str, Any], - on_chunk + on_chunk, + on_worldbook_active=None, + on_tasks_created=None, ) -> Dict[str, Any]: - """ - 处理流式聊天请求 - - Args: - request_data: 前端发送的完整数据 - on_chunk: 回调函数,每次收到 chunk 时调用 - - Returns: - { - "success": bool, - "content": str, # 完整的生成内容 - "error": str | None, - "activeEntries": List, - "taskIds": Dict - } - """ - try: - # === 第1步:解析请求数据 === - current_role = request_data.get("currentRole") - current_chat = request_data.get("currentChat") - user_message = request_data.get("mes", "") - - if not current_role or not user_message: - return { - "success": False, - "content": "", - "error": "缺少必要的参数:currentRole 或 mes" - } - - print(f"\n{'#'*80}") - print(f"[ChatWorkflow-Stream] 🚀 开始处理请求") - print(f" - Role: {current_role}") - print(f" - Chat: {current_chat}") - print(f" - Message Length: {len(user_message)}") - print(f"{'#'*80}\n") - - # ✅ 获取预设名称(用于加载预设绑定的正则规则) - preset_config = request_data.get("presetConfig", {}) - preset_name = preset_config.get("selectedPreset") - - # ✅ 第1.5步:应用用户输入的正则规则 - from services.regex_service import regex_service - from models.regex_rules import RegexPlacement - - processed_user_message = regex_service.apply_rules_by_placement( - text=user_message, - placement=RegexPlacement.USER_INPUT.value, - character_name=current_role, - preset_name=preset_name, - message_depth=0, - is_for_llm=True, # ✅ 用户输入会发送给LLM - is_markdown_rendered=False - ) - - if processed_user_message != user_message: - print(f"[Regex] ✅ 已应用用户输入正则规则") - - user_message = processed_user_message - - # === 第2步:加载角色卡 === - character_data = request_data.get("characterData") - if not character_data: - character = self.character_service.get_character_by_name(current_role) - if not character: - return { - "success": False, - "content": "", - "error": f"角色 '{current_role}' 不存在" - } - else: - try: - from backend.models.internal import CharacterCard - except ImportError: - from models.internal import CharacterCard - character = CharacterCard(**character_data) - - print(f"[ChatWorkflow-Stream] ✅ 已加载角色卡: {character.name}") - - # === 第3步:收集并激活世界书条目 === - active_entries = await self._collect_and_activate_worldbooks( - request_data, - character - ) - print(f"\n[ChatWorkflow-Stream] 📚 世界书激活结果: {len(active_entries)} 个条目") - for i, entry in enumerate(active_entries, 1): - print(f" {i}. {getattr(entry, 'name', 'N/A')} (UID: {getattr(entry, 'uid', 'N/A')})") - - # === 第4步:加载聊天历史 === - chat_history = await self._load_chat_history(current_role, current_chat) - print(f"[ChatWorkflow-Stream] 💬 聊天历史加载结果: {len(chat_history)} 条消息") - - # === 第5步:组装提示词 === - prompt_messages = self._assemble_prompt( - character, - chat_history, - user_message, - active_entries, - request_data - ) - print(f"[ChatWorkflow-Stream] 📝 提示词组装结果: {len(prompt_messages)} 条消息") - for i, msg in enumerate(prompt_messages, 1): - role = getattr(msg, 'role', 'unknown') - content_preview = str(getattr(msg, 'content', ''))[:50] - print(f" {i}. [{role}] {content_preview}...") - - # === 第6步:流式调用LLM生成回复 === - api_config = request_data.get("apiConfig", {}) - preset_config = request_data.get("presetConfig", {}) - - print(f"\n[ChatWorkflow-Stream] 🤖 开始流式调用 LLM") - print(f" - Model: {api_config.get('model', 'N/A')}") - print(f" - API URL: {api_config.get('api_url', 'N/A')[:50]}...") - print(f" - API Key: {'已设置' if api_config.get('api_key') else '⚠️ 未设置'}") - print(f" - Temperature: {preset_config.get('parameters', {}).get('temperature', 1.0)}") - print(f" - Max Tokens: {preset_config.get('parameters', {}).get('max_tokens', 30000)}") - print(f" - Request Timeout: {preset_config.get('parameters', {}).get('request_timeout', 60)}s") - print(f"{'~'*80}") - - # ✅ 验证 API Key - if not api_config.get('api_key'): - print(f"[ChatWorkflow-Stream] ❌ 错误: API Key 为空!") - print(f" - 请检查配置文件中是否保存了 API Key") - print(f" - Profile ID: {request_data.get('currentProfile', {}).get('id', 'N/A')}") - raise Exception("API Key 未配置,请先在 API 配置页面保存密钥") - - generated_content = "" - token_usage = {} - duration = 0 - chunk_count = 0 - start_time = time.time() - - print(f"[ChatWorkflow-Stream] 📡 正在调用 llm_client.stream_chat()...") - - try: - async for chunk_dict in self.llm_client.stream_chat( - messages=prompt_messages, - api_url=api_config.get("api_url", ""), - api_key=api_config.get("api_key", ""), - model=api_config.get("model", ""), - temperature=preset_config.get("parameters", {}).get("temperature", 1.0), - max_tokens=preset_config.get("parameters", {}).get("max_tokens", 30000), - request_timeout=preset_config.get("parameters", {}).get("request_timeout", 60) # ✅ 传递超时时间 - ): - # ✅ 处理 LLM 返回的字典格式数据 - if isinstance(chunk_dict, dict): - if chunk_dict.get("type") == "chunk": - chunk_content = chunk_dict.get("content", "") - elif chunk_dict.get("type") == "usage": - # 跳过 usage 信息,不拼接到内容中 - continue - else: - # 兼容旧格式或未知类型,尝试直接获取 content - chunk_content = chunk_dict.get("content", str(chunk_dict)) - else: - # 如果直接返回字符串(兼容情况) - chunk_content = str(chunk_dict) - - # ✅ 拼接纯文本内容 - generated_content += chunk_content - chunk_count += 1 - - # 第一个 chunk 到达时记录 - if chunk_count == 1: - first_chunk_time = time.time() - print(f"[ChatWorkflow-Stream] ✨ 收到第一个 chunk (耗时: {first_chunk_time - start_time:.2f}s)") - - # 每20个chunk记录一次进度 - if chunk_count % 20 == 0: - elapsed = time.time() - start_time - print(f"[ChatWorkflow-Stream] 📊 已接收 {chunk_count} 个 chunks, 当前长度: {len(generated_content)}, 耗时: {elapsed:.2f}s") - - # ✅ 调用回调函数发送纯文本 chunk - await on_chunk(chunk_content) - - except Exception as stream_error: - elapsed = time.time() - start_time - print(f"[ChatWorkflow-Stream] ❌ 流式调用失败 (耗时: {elapsed:.2f}s)") - print(f" - 错误类型: {type(stream_error).__name__}") - print(f" - 错误信息: {str(stream_error)}") - raise - - elapsed = time.time() - start_time - print(f"{'~'*80}") - print(f"[ChatWorkflow-Stream] ✅ LLM 流式调用完成") - print(f" - 总 Chunks: {chunk_count}") - print(f" - 内容长度: {len(generated_content)}") - print(f" - 总耗时: {elapsed:.2f}s") - print(f" - 平均速度: {len(generated_content)/elapsed if elapsed > 0 else 0:.0f} chars/s") - print(f"{'#'*80}\n") - - # ✅ 第6.5步:应用 AI 输出的正则规则 - processed_ai_output = regex_service.apply_rules_by_placement( - text=generated_content, - placement=RegexPlacement.AI_OUTPUT.value, - character_name=current_role, - preset_name=preset_name, - message_depth=0, - is_for_llm=False, # ✅ AI输出是显示给用户的 - is_markdown_rendered=False - ) - - if processed_ai_output != generated_content: - print(f"[Regex] ✅ 已应用 AI 输出正则规则") - generated_content = processed_ai_output - - # === 第7步:记录 Token 使用(估算) === - chat_id = f"{current_role}/{request_data.get('currentChat', '')}" - floor = request_data.get("floor", 0) - - try: - try: - from backend.services.token_usage_service import token_usage_service - from backend.models.internal import TokenUsageStatus - except ImportError: - from services.token_usage_service import token_usage_service - from models.internal import TokenUsageStatus - - # 估算 token 数量 - prompt_tokens = len(str(prompt_messages)) // 4 - completion_tokens = len(generated_content) // 4 - - await token_usage_service.record_usage( - chat_id=chat_id, - role_name=current_role, - chat_name=request_data.get('currentChat', ''), - prompt_tokens=prompt_tokens, - completion_tokens=completion_tokens, - total_tokens=prompt_tokens + completion_tokens, - status=TokenUsageStatus.COMPLETED, - floor=floor + 1, - duration=duration, - model=api_config.get("model"), - api_provider="openai", - api_url=api_config.get("api_url") # ✅ 记录 API URL - ) - except Exception as e: - print(f"[ChatWorkflow-Stream] 记录 Token 使用失败: {e}") - - # === 第8步:启动异步并行任务 === - image_task_id = None - table_task_id = None - - options = request_data.get("options", {}) - if options.get("imageWorkflow", False): - import uuid - image_task_id = f"img_{uuid.uuid4().hex[:8]}" - await task_queue_manager.add_task(image_task_id, TaskType.IMAGE_WORKFLOW, chat_id) - - if options.get("dynamicTable", False): - import uuid - table_task_id = f"tbl_{uuid.uuid4().hex[:8]}" - await task_queue_manager.add_task(table_task_id, TaskType.DYNAMIC_TABLE, chat_id) - - await self._start_parallel_tasks(request_data, generated_content, image_task_id, table_task_id) - - return { - "success": True, - "content": generated_content, - "error": None, - "activeEntries": active_entries, - "taskIds": { - "imageWorkflow": image_task_id, - "dynamicTable": table_task_id - } - } - - except Exception as e: - print(f"[ChatWorkflow-Stream] 错误: {str(e)}") - import traceback - traceback.print_exc() - return { - "success": False, - "content": "", - "error": f"工作流执行失败: {str(e)}" - } + """处理流式聊天请求 – delegates to WorkflowEngine.run_turn with callbacks.""" + result = await workflow_engine.run_turn( + request_data, + stream=True, + on_chunk=on_chunk, + on_worldbook_active=on_worldbook_active, + on_tasks_created=on_tasks_created, + ) + return { + "success": result.success, + "content": result.content, + "error": result.error, + "activeEntries": result.active_entries, + "taskIds": result.task_ids, + "engineRunId": result.run_id, + "workflowTemplateId": result.workflow_template_id, + } diff --git a/backend/services/state_machine_runner.py b/backend/services/state_machine_runner.py new file mode 100644 index 0000000..16b6177 --- /dev/null +++ b/backend/services/state_machine_runner.py @@ -0,0 +1,105 @@ +""" +JSON state machine runner for workflow templates. +""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional + +try: + from backend.models.agent import RunEvent, RunEventType, TurnContext, WorkflowRun, RunStatus + from backend.services.tool_registry import ToolRegistry +except ImportError: + from models.agent import RunEvent, RunEventType, TurnContext, WorkflowRun, RunStatus + from services.tool_registry import ToolRegistry + + +class StateMachineRunner: + def __init__( + self, + definition: Dict[str, Any], + registry: ToolRegistry, + *, + on_event: Optional[Callable[[RunEvent], None]] = None, + ) -> None: + self.definition = definition + self.registry = registry + self.on_event = on_event + self.states: Dict[str, Dict[str, Any]] = definition.get("states", {}) + + @classmethod + def from_file(cls, path: Path, registry: ToolRegistry, **kwargs) -> "StateMachineRunner": + with open(path, "r", encoding="utf-8") as f: + definition = json.load(f) + return cls(definition, registry, **kwargs) + + def _emit(self, run: WorkflowRun, event_type: RunEventType, **payload: Any) -> RunEvent: + event = RunEvent( + run_id=run.id, + type=event_type, + state=run.current_state, + tool=payload.pop("tool", None), + payload=payload, + ) + if self.on_event: + self.on_event(event) + return event + + async def run(self, run: WorkflowRun, ctx: TurnContext) -> List[RunEvent]: + events: List[RunEvent] = [] + original_on_event = self.on_event + + def collect(event: RunEvent) -> None: + events.append(event) + if original_on_event: + original_on_event(event) + + self.on_event = collect + + initial = self.definition.get("initial") + if not initial: + raise ValueError("State machine missing 'initial' state") + + current = initial + run.status = RunStatus.RUNNING + + try: + while current: + state_def = self.states.get(current) + if not state_def: + raise ValueError(f"Unknown state: {current}") + + run.current_state = current + events.append(self._emit(run, RunEventType.STATE_ENTER, state=current)) + + tool_name = state_def.get("tool") + if tool_name: + events.append(self._emit(run, RunEventType.TOOL_START, tool=tool_name)) + await self.registry.execute(tool_name, ctx) + events.append( + self._emit( + run, + RunEventType.TOOL_END, + tool=tool_name, + success=True, + ) + ) + + current = state_def.get("next") + if current == "end" or current is None: + break + + run.status = RunStatus.COMPLETED + run.result_content = ctx.generated_content + events.append(self._emit(run, RunEventType.COMPLETE)) + except Exception as exc: + run.status = RunStatus.FAILED + run.error = str(exc) + ctx.error = str(exc) + events.append(self._emit(run, RunEventType.ERROR, message=str(exc))) + raise + finally: + self.on_event = original_on_event + + return events diff --git a/backend/services/tool_registry.py b/backend/services/tool_registry.py new file mode 100644 index 0000000..0b40f83 --- /dev/null +++ b/backend/services/tool_registry.py @@ -0,0 +1,49 @@ +""" +Tool registry for workflow engine steps. +""" +from __future__ import annotations + +from typing import Any, Awaitable, Callable, Dict, Optional + +try: + from backend.models.agent import TurnContext, ToolSpec +except ImportError: + from models.agent import TurnContext, ToolSpec + +ToolHandler = Callable[[TurnContext], Awaitable[None]] + + +class ToolRegistry: + def __init__(self) -> None: + self._tools: Dict[str, ToolHandler] = {} + self._specs: Dict[str, ToolSpec] = {} + + def register( + self, + name: str, + handler: ToolHandler, + *, + description: str = "", + parameters: Optional[Dict[str, Any]] = None, + ) -> None: + self._tools[name] = handler + self._specs[name] = ToolSpec( + name=name, + description=description, + parameters=parameters or {}, + ) + + def get(self, name: str) -> ToolHandler: + if name not in self._tools: + raise KeyError(f"Unknown tool: {name}") + return self._tools[name] + + def list_specs(self) -> list[ToolSpec]: + return list(self._specs.values()) + + async def execute(self, name: str, ctx: TurnContext) -> None: + handler = self.get(name) + await handler(ctx) + + +default_tool_registry = ToolRegistry() diff --git a/backend/services/tools/__init__.py b/backend/services/tools/__init__.py new file mode 100644 index 0000000..7a35e5d --- /dev/null +++ b/backend/services/tools/__init__.py @@ -0,0 +1 @@ +"""Workflow chat tools package.""" diff --git a/backend/services/tools/chat_tools.py b/backend/services/tools/chat_tools.py new file mode 100644 index 0000000..d49dd32 --- /dev/null +++ b/backend/services/tools/chat_tools.py @@ -0,0 +1,261 @@ +""" +Chat workflow tools extracted from ChatWorkflowService. +""" +from __future__ import annotations + +import asyncio +import time +import uuid +from typing import Any, Dict, List + +try: + from backend.models.agent import TurnContext + from backend.models.internal import CharacterCard, TokenUsageStatus + from backend.models.regex_rules import RegexPlacement + from backend.services.character_service import CharacterService + from backend.services.regex_service import regex_service + from backend.services.task_queue_manager import TaskType, task_queue_manager + from backend.services.token_usage_service import token_usage_service + from backend.core.config import settings +except ImportError: + from models.agent import TurnContext + from models.internal import CharacterCard, TokenUsageStatus + from models.regex_rules import RegexPlacement + from services.character_service import CharacterService + from services.regex_service import regex_service + from services.task_queue_manager import TaskType, task_queue_manager + from services.token_usage_service import token_usage_service + from core.config import settings + + +_character_service = CharacterService() +_workflow_service = None + + +def _get_workflow_service(): + """Lazy init to avoid circular import with chat_workflow_service.""" + global _workflow_service + if _workflow_service is None: + try: + from backend.services.chat_workflow_service import ChatWorkflowService + except ImportError: + from services.chat_workflow_service import ChatWorkflowService + _workflow_service = ChatWorkflowService() + return _workflow_service + + +async def regex_apply_user_input(ctx: TurnContext) -> None: + processed = regex_service.apply_rules_by_placement( + text=ctx.user_message, + placement=RegexPlacement.USER_INPUT.value, + character_name=ctx.current_role, + preset_name=ctx.preset_name, + message_depth=0, + is_for_llm=True, + is_markdown_rendered=False, + ) + if processed != ctx.user_message: + print("[WorkflowTool] Applied user-input regex rules") + ctx.user_message = processed + + +async def load_character(ctx: TurnContext) -> None: + character_data = ctx.request_data.get("characterData") + if not character_data: + character = _character_service.get_character_by_name(ctx.current_role) + if not character: + raise ValueError(f"角色 '{ctx.current_role}' 不存在") + else: + character = CharacterCard(**character_data) + ctx.character = character + print(f"[WorkflowTool] Loaded character: {character.name}") + + +async def activate_worldbook(ctx: TurnContext) -> None: + svc = _get_workflow_service() + active_entries = await svc._collect_and_activate_worldbooks( + ctx.request_data, + ctx.character, + ) + ctx.active_entries = active_entries + print(f"[WorkflowTool] Activated {len(active_entries)} worldbook entries") + + if ctx.callbacks and ctx.callbacks.on_worldbook_active: + entries_payload = [ + entry.model_dump() if hasattr(entry, "model_dump") else entry + for entry in active_entries + ] + await ctx.callbacks.on_worldbook_active(entries_payload) + + +async def load_chat_history(ctx: TurnContext) -> None: + svc = _get_workflow_service() + chat_history = await svc._load_chat_history( + ctx.current_role, + ctx.current_chat, + ) + ctx.chat_history = chat_history + print(f"[WorkflowTool] Loaded {len(chat_history)} history messages") + + +async def build_prompt_messages(ctx: TurnContext) -> None: + svc = _get_workflow_service() + prompt_messages = svc._assemble_prompt( + ctx.character, + ctx.chat_history, + ctx.user_message, + ctx.active_entries, + ctx.request_data, + ) + ctx.prompt_messages = prompt_messages + print(f"[WorkflowTool] Built {len(prompt_messages)} prompt messages") + + +async def llm_main_reply(ctx: TurnContext) -> None: + svc = _get_workflow_service() + api_config = ctx.request_data.get("apiConfig", {}) + preset_config = ctx.request_data.get("presetConfig", {}) + + if ctx.stream: + if not api_config.get("api_key"): + raise ValueError("API Key 未配置,请先在 API 配置页面保存密钥") + + generated_content = "" + chunk_count = 0 + start_time = time.time() + + async for chunk_dict in svc.llm_client.stream_chat( + messages=ctx.prompt_messages, + api_url=api_config.get("api_url", ""), + api_key=api_config.get("api_key", ""), + model=api_config.get("model", ""), + temperature=preset_config.get("parameters", {}).get("temperature", 1.0), + max_tokens=preset_config.get("parameters", {}).get("max_tokens", 30000), + request_timeout=preset_config.get("parameters", {}).get("request_timeout", 60), + ): + if isinstance(chunk_dict, dict): + if chunk_dict.get("type") == "chunk": + chunk_content = chunk_dict.get("content", "") + elif chunk_dict.get("type") == "usage": + continue + else: + chunk_content = chunk_dict.get("content", str(chunk_dict)) + else: + chunk_content = str(chunk_dict) + + generated_content += chunk_content + chunk_count += 1 + + if ctx.callbacks and ctx.callbacks.on_chunk: + await ctx.callbacks.on_chunk(chunk_content) + + ctx.duration = time.time() - start_time + ctx.generated_content = generated_content + ctx.token_usage = { + "prompt_tokens": len(str(ctx.prompt_messages)) // 4, + "completion_tokens": len(generated_content) // 4, + "total_tokens": (len(str(ctx.prompt_messages)) // 4) + + (len(generated_content) // 4), + } + print( + f"[WorkflowTool] Stream LLM complete: {chunk_count} chunks, " + f"{len(generated_content)} chars" + ) + else: + result = await svc._generate_response( + ctx.prompt_messages, + api_config, + preset_config, + stream=False, + ) + ctx.generated_content = result["content"] + ctx.token_usage = result.get("usage", {}) + ctx.duration = result.get("duration", 0.0) + print(f"[WorkflowTool] LLM complete: {len(ctx.generated_content)} chars") + + +async def regex_apply_ai_output(ctx: TurnContext) -> None: + processed = regex_service.apply_rules_by_placement( + text=ctx.generated_content, + placement=RegexPlacement.AI_OUTPUT.value, + character_name=ctx.current_role, + preset_name=ctx.preset_name, + message_depth=0, + is_for_llm=False, + is_markdown_rendered=False, + ) + if processed != ctx.generated_content: + print("[WorkflowTool] Applied AI-output regex rules") + ctx.generated_content = processed + + +async def record_token_usage(ctx: TurnContext) -> None: + chat_id = f"{ctx.current_role}/{ctx.current_chat}" + floor = ctx.request_data.get("floor", 0) + api_config = ctx.request_data.get("apiConfig", {}) + + try: + await token_usage_service.record_usage( + chat_id=chat_id, + role_name=ctx.current_role, + chat_name=ctx.current_chat, + prompt_tokens=ctx.token_usage.get("prompt_tokens", 0), + completion_tokens=ctx.token_usage.get("completion_tokens", 0), + total_tokens=ctx.token_usage.get("total_tokens", 0), + status=TokenUsageStatus.COMPLETED, + floor=floor + 1, + duration=ctx.duration, + model=api_config.get("model"), + api_provider="openai", + api_url=api_config.get("api_url"), + ) + except Exception as exc: + print(f"[WorkflowTool] Token usage recording failed: {exc}") + + +async def enqueue_parallel_tasks(ctx: TurnContext) -> None: + chat_id = f"{ctx.current_role}/{ctx.current_chat}" + options = ctx.request_data.get("options", {}) + image_task_id = None + table_task_id = None + + if options.get("imageWorkflow", False): + image_task_id = f"img_{uuid.uuid4().hex[:8]}" + await task_queue_manager.add_task(image_task_id, TaskType.IMAGE_WORKFLOW, chat_id) + + if options.get("dynamicTable", False): + table_task_id = f"tbl_{uuid.uuid4().hex[:8]}" + await task_queue_manager.add_task(table_task_id, TaskType.DYNAMIC_TABLE, chat_id) + + ctx.task_ids = { + "imageWorkflow": image_task_id, + "dynamicTable": table_task_id, + } + + if ctx.callbacks and ctx.callbacks.on_tasks_created: + if image_task_id or table_task_id: + await ctx.callbacks.on_tasks_created(ctx.task_ids) + + # Fire-and-forget parallel workers (same as legacy service) + svc = _get_workflow_service() + asyncio.create_task( + svc._start_parallel_tasks( + ctx.request_data, + ctx.generated_content, + image_task_id, + table_task_id, + ) + ) + + +def register_chat_tools(registry) -> None: + """Register all chat workflow tools on the given registry.""" + registry.register("regex_apply_user_input", regex_apply_user_input, description="Apply user-input regex") + registry.register("load_character", load_character, description="Load character card") + registry.register("activate_worldbook", activate_worldbook, description="Activate worldbook entries") + registry.register("load_chat_history", load_chat_history, description="Load chat history") + registry.register("build_prompt_messages", build_prompt_messages, description="Assemble LLM prompt") + registry.register("llm_main_reply", llm_main_reply, description="Call main LLM (supports stream)") + registry.register("regex_apply_ai_output", regex_apply_ai_output, description="Apply AI-output regex") + registry.register("record_token_usage", record_token_usage, description="Persist token usage") + registry.register("enqueue_parallel_tasks", enqueue_parallel_tasks, description="Enqueue parallel tasks") diff --git a/backend/services/workflow_engine.py b/backend/services/workflow_engine.py new file mode 100644 index 0000000..88248f5 --- /dev/null +++ b/backend/services/workflow_engine.py @@ -0,0 +1,170 @@ +""" +Workflow engine – orchestrates template loading, state machine execution, and run persistence. +""" +from __future__ import annotations + +import json +import uuid +from datetime import datetime +from pathlib import Path +from typing import Any, Awaitable, Callable, Dict, List, Optional + +try: + from backend.core.config import settings + from backend.models.agent import ( + ChatRunBinding, + ChatTurnResult, + RunEvent, + RunStatus, + TurnCallbacks, + TurnContext, + WorkflowRun, + WorkflowTemplate, + WorkflowTemplateKind, + ) + from backend.services.state_machine_runner import StateMachineRunner + from backend.services.tool_registry import ToolRegistry, default_tool_registry + from backend.services.tools.chat_tools import register_chat_tools +except ImportError: + from core.config import settings + from models.agent import ( + ChatRunBinding, + ChatTurnResult, + RunEvent, + RunStatus, + TurnCallbacks, + TurnContext, + WorkflowRun, + WorkflowTemplate, + WorkflowTemplateKind, + ) + from services.state_machine_runner import StateMachineRunner + from services.tool_registry import ToolRegistry, default_tool_registry + from services.tools.chat_tools import register_chat_tools + + +class WorkflowEngine: + def __init__(self, registry: Optional[ToolRegistry] = None) -> None: + self.registry = registry or default_tool_registry + if not self.registry.list_specs(): + register_chat_tools(self.registry) + + def _template_dir(self, template_id: str) -> Path: + return settings.AGENT_TEMPLATES_PATH / template_id + + def load_template(self, template_id: str = WorkflowTemplateKind.BUILTIN_CHAT.value) -> WorkflowTemplate: + template_path = self._template_dir(template_id) / "template.json" + with open(template_path, "r", encoding="utf-8") as f: + data = json.load(f) + return WorkflowTemplate(**data) + + def _run_dir(self, role_name: str, chat_name: str) -> Path: + return settings.AGENT_RUNS_PATH / "chat" / role_name / chat_name + + def _persist_run(self, run: WorkflowRun, events: List[RunEvent]) -> None: + run_dir = self._run_dir(run.binding.role_name, run.binding.chat_name) + run_dir.mkdir(parents=True, exist_ok=True) + + run_file = run_dir / "run.json" + run.finished_at = datetime.now().isoformat() + with open(run_file, "w", encoding="utf-8") as f: + json.dump(run.model_dump(), f, ensure_ascii=False, indent=2) + + events_file = run_dir / "events.jsonl" + with open(events_file, "a", encoding="utf-8") as f: + for event in events: + f.write(json.dumps(event.model_dump(), ensure_ascii=False) + "\n") + + async def run_turn( + self, + request_data: Dict[str, Any], + *, + stream: bool = False, + on_chunk: Optional[Callable[[str], Awaitable[None]]] = None, + on_worldbook_active: Optional[Callable[[List[Any]], Awaitable[None]]] = None, + on_tasks_created: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None, + template_id: str = WorkflowTemplateKind.BUILTIN_CHAT.value, + ) -> ChatTurnResult: + current_role = request_data.get("currentRole", "") + current_chat = request_data.get("currentChat", "") + user_message = request_data.get("mes", "") + + if not current_role or not user_message: + return ChatTurnResult( + success=False, + error="缺少必要的参数:currentRole 或 mes", + workflow_template_id=template_id, + ) + + preset_config = request_data.get("presetConfig", {}) + preset_name = preset_config.get("selectedPreset") + + run_id = uuid.uuid4().hex + binding = ChatRunBinding( + role_name=current_role, + chat_name=current_chat or "", + template_id=template_id, + ) + run = WorkflowRun( + id=run_id, + template_id=template_id, + binding=binding, + status=RunStatus.PENDING, + ) + + callbacks = TurnCallbacks( + on_chunk=on_chunk, + on_worldbook_active=on_worldbook_active, + on_tasks_created=on_tasks_created, + ) + + ctx = TurnContext( + request_data=request_data, + template_id=template_id, + run_id=run_id, + stream=stream, + callbacks=callbacks, + current_role=current_role, + current_chat=current_chat or "", + user_message=user_message, + preset_name=preset_name, + ) + + template = self.load_template(template_id) + sm_path = self._template_dir(template_id) / template.state_machine_path + runner = StateMachineRunner.from_file(sm_path, self.registry) + + try: + events = await runner.run(run, ctx) + self._persist_run(run, events) + + active_entries = [ + entry.model_dump() if hasattr(entry, "model_dump") else entry + for entry in ctx.active_entries + ] + + return ChatTurnResult( + success=True, + content=ctx.generated_content, + active_entries=active_entries, + task_ids=ctx.task_ids, + run_id=run_id, + workflow_template_id=template_id, + ) + except Exception as exc: + run.status = RunStatus.FAILED + run.error = str(exc) + try: + self._persist_run(run, []) + except Exception: + pass + return ChatTurnResult( + success=False, + error=f"工作流执行失败: {exc}", + run_id=run_id, + workflow_template_id=template_id, + ) + + +# Module-level singleton +workflow_engine = WorkflowEngine() diff --git a/data/agent/templates/builtin.chat/skill/chat_reply/SKILL.md b/data/agent/templates/builtin.chat/skill/chat_reply/SKILL.md new file mode 100644 index 0000000..30a01f4 --- /dev/null +++ b/data/agent/templates/builtin.chat/skill/chat_reply/SKILL.md @@ -0,0 +1,5 @@ +# Chat Reply Skill + +Minimal skill placeholder for the builtin.chat workflow template. + +This skill orchestrates a single chat turn: load character, activate worldbooks, assemble prompt, call LLM, apply regex, record usage, and enqueue parallel tasks. diff --git a/data/agent/templates/builtin.chat/skill/chat_reply/skill.meta.yaml b/data/agent/templates/builtin.chat/skill/chat_reply/skill.meta.yaml new file mode 100644 index 0000000..4cc87d7 --- /dev/null +++ b/data/agent/templates/builtin.chat/skill/chat_reply/skill.meta.yaml @@ -0,0 +1,3 @@ +id: chat_reply +name: Chat Reply +description: Minimal chat reply skill for builtin.chat template diff --git a/data/agent/templates/builtin.chat/state_machine.json b/data/agent/templates/builtin.chat/state_machine.json new file mode 100644 index 0000000..31e6304 --- /dev/null +++ b/data/agent/templates/builtin.chat/state_machine.json @@ -0,0 +1,41 @@ +{ + "initial": "regex_apply_user_input", + "states": { + "regex_apply_user_input": { + "tool": "regex_apply_user_input", + "next": "load_character" + }, + "load_character": { + "tool": "load_character", + "next": "activate_worldbook" + }, + "activate_worldbook": { + "tool": "activate_worldbook", + "next": "load_chat_history" + }, + "load_chat_history": { + "tool": "load_chat_history", + "next": "build_prompt_messages" + }, + "build_prompt_messages": { + "tool": "build_prompt_messages", + "next": "llm_main_reply" + }, + "llm_main_reply": { + "tool": "llm_main_reply", + "next": "regex_apply_ai_output" + }, + "regex_apply_ai_output": { + "tool": "regex_apply_ai_output", + "next": "record_token_usage" + }, + "record_token_usage": { + "tool": "record_token_usage", + "next": "enqueue_parallel_tasks" + }, + "enqueue_parallel_tasks": { + "tool": "enqueue_parallel_tasks", + "next": "end" + } + } +} diff --git a/data/agent/templates/builtin.chat/template.json b/data/agent/templates/builtin.chat/template.json new file mode 100644 index 0000000..1c97454 --- /dev/null +++ b/data/agent/templates/builtin.chat/template.json @@ -0,0 +1,16 @@ +{ + "id": "builtin.chat", + "kind": "builtin.chat", + "name": "Builtin Chat Reply", + "description": "Default single-turn chat workflow migrated from ChatWorkflowService", + "version": "1.0.0", + "state_machine_path": "state_machine.json", + "skills": [ + { + "id": "chat_reply", + "name": "Chat Reply", + "description": "Main chat reply skill", + "path": "skill/chat_reply" + } + ] +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 72781f7..97d870c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -4,6 +4,7 @@ import TopBar from './components/TopBar'; import { ChatBox } from './components/Mid'; import SideBarLeft from './components/SideBarLeft'; import SideBarRight from './components/SideBarRight'; +import PlaceholderPage from './components/PlaceholderPage'; import useAppLayoutStore from './Store/AppLayoutSlice'; // ✅ 新增 import useApiConfigStore from './Store/SideBarLeft/ApiConfigSlice'; // ✅ 引入 API 配置 Store import usePresetStore from './Store/SideBarLeft/PresetSlice'; // ✅ 引入预设 Store @@ -18,6 +19,7 @@ function App() { sidebarMode, isSidebarHovered, colorTheme, + activePage, setLayoutMode, setSidebarMode, setSidebarHovered, @@ -193,28 +195,34 @@ function App() { {/* 主内容容器 */} -
- {/* 左侧栏 - 智能模式下悬停展开 */} -
- -
+ {activePage === 'chat' ? ( +
+ {/* 左侧栏 - 智能模式下悬停展开 */} +
+ +
- {/* 中间栏:聊天框 */} -
-
- + {/* 中间栏:聊天框 */} +
+
+ +
+
+ + {/* 右侧栏 */} +
+
- - {/* 右侧栏 */} -
- + ) : ( +
+
-
+ )}
); } diff --git a/frontend/src/Store/AppLayoutSlice.jsx b/frontend/src/Store/AppLayoutSlice.jsx index 5f446cb..875d921 100644 --- a/frontend/src/Store/AppLayoutSlice.jsx +++ b/frontend/src/Store/AppLayoutSlice.jsx @@ -22,6 +22,9 @@ const useAppLayoutStore = create( // 颜色主题:'light' | 'dark' colorTheme: 'dark', + // 当前页面:'chat' | 'studio' | 'novel' | 'room' + activePage: 'chat', + // ==================== Actions ==================== /** @@ -48,6 +51,14 @@ const useAppLayoutStore = create( set({ isSidebarHovered: hovered }); }, + /** + * 设置当前页面 + * @param {string} page - 'chat' | 'studio' | 'novel' | 'room' + */ + setActivePage: (page) => { + set({ activePage: page }); + }, + /** * 设置颜色主题 * @param {string} theme - 主题名 @@ -78,7 +89,8 @@ const useAppLayoutStore = create( layoutMode: 'chat', sidebarMode: 'both', isSidebarHovered: false, - colorTheme: 'dark' + colorTheme: 'dark', + activePage: 'chat', }); } }), @@ -87,7 +99,8 @@ const useAppLayoutStore = create( partialize: (state) => ({ layoutMode: state.layoutMode, sidebarMode: state.sidebarMode, - colorTheme: state.colorTheme + colorTheme: state.colorTheme, + activePage: state.activePage, }) } ) diff --git a/frontend/src/components/PlaceholderPage/PlaceholderPage.css b/frontend/src/components/PlaceholderPage/PlaceholderPage.css new file mode 100644 index 0000000..c9008d3 --- /dev/null +++ b/frontend/src/components/PlaceholderPage/PlaceholderPage.css @@ -0,0 +1,46 @@ +.placeholder-page { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + min-height: 0; + padding: var(--spacing-xl); + background: var(--color-bg-primary); +} + +.placeholder-card { + text-align: center; + max-width: 420px; + padding: var(--spacing-xl) var(--spacing-lg); + border-radius: var(--radius-lg); + border: 1px solid var(--color-border-light); + background: var(--color-bg-secondary); + box-shadow: var(--shadow-sm); +} + +.placeholder-icon { + font-size: 3rem; + display: block; + margin-bottom: var(--spacing-md); + opacity: 0.85; +} + +.placeholder-title { + margin: 0 0 var(--spacing-sm); + font-size: 1.5rem; + font-weight: 600; + color: var(--color-text-primary); +} + +.placeholder-subtitle { + margin: 0 0 var(--spacing-md); + font-size: 0.9rem; + color: var(--color-text-secondary); +} + +.placeholder-message { + margin: 0; + font-size: 0.95rem; + color: var(--color-text-tertiary, var(--color-text-secondary)); + letter-spacing: 0.02em; +} diff --git a/frontend/src/components/PlaceholderPage/PlaceholderPage.jsx b/frontend/src/components/PlaceholderPage/PlaceholderPage.jsx new file mode 100644 index 0000000..12de608 --- /dev/null +++ b/frontend/src/components/PlaceholderPage/PlaceholderPage.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import './PlaceholderPage.css'; + +const PLACEHOLDER_META = { + studio: { + title: '角色 / 世界书工作室', + subtitle: '用途1 · Character & Worldbook Studio', + icon: '🎭', + }, + novel: { + title: '爽文模式', + subtitle: '用途3 · Novel Mode', + icon: '📖', + }, + room: { + title: '多角色房间', + subtitle: '用途4 · Multi-Character Room', + icon: '🏠', + }, +}; + +function PlaceholderPage({ page }) { + const meta = PLACEHOLDER_META[page] || { + title: 'Coming Soon', + subtitle: '', + icon: '🚧', + }; + + return ( +
+
+ +

{meta.title}

+ {meta.subtitle && ( +

{meta.subtitle}

+ )} +

Coming soon

+
+
+ ); +} + +export default PlaceholderPage; diff --git a/frontend/src/components/PlaceholderPage/index.js b/frontend/src/components/PlaceholderPage/index.js new file mode 100644 index 0000000..1046506 --- /dev/null +++ b/frontend/src/components/PlaceholderPage/index.js @@ -0,0 +1 @@ +export { default } from './PlaceholderPage'; diff --git a/frontend/src/components/TopBar/TopBar.jsx b/frontend/src/components/TopBar/TopBar.jsx index ef9b063..af2d1f8 100644 --- a/frontend/src/components/TopBar/TopBar.jsx +++ b/frontend/src/components/TopBar/TopBar.jsx @@ -4,6 +4,7 @@ import useAppLayoutStore from '../../Store/AppLayoutSlice'; // ✅ 新增 import useUserStore from '../../Store/UserSlice'; // ✅ 新增 - 用户角色 import useWorldBookStore from '../../Store/SideBarLeft/WorldBookSlice'; import useApiConfigStore from '../../Store/SideBarLeft/ApiConfigSlice'; +import PageModeToggle from './items/PageModeToggle'; import ThemeToggle from './items/ThemeToggle'; import RegexPanel from '../SideBarLeft/tabs/Regex/RegexPanel'; // ✅ 新增:导入正则管理组件 import './TopBar.css'; @@ -17,7 +18,7 @@ const Toolbar = () => { sidebarMode, colorTheme, setSidebarMode, - setColorTheme + setColorTheme, } = useAppLayoutStore(); // ✅ 从 UserStore 获取用户角色和方法 @@ -207,6 +208,9 @@ const Toolbar = () => { ⊞ + {/* 页面模式 */} + + {/* 主题切换 */}
diff --git a/frontend/src/components/TopBar/items/PageModeToggle/PageModeToggle.css b/frontend/src/components/TopBar/items/PageModeToggle/PageModeToggle.css new file mode 100644 index 0000000..e4c8fd9 --- /dev/null +++ b/frontend/src/components/TopBar/items/PageModeToggle/PageModeToggle.css @@ -0,0 +1,88 @@ +.page-mode-toggle-wrapper { + position: relative; +} + +.page-mode-toggle { + /* inherits from .action-btn */ +} + +.page-mode-selector-panel { + position: absolute; + top: calc(100% + 8px); + right: 0; + background-color: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-xl); + min-width: 160px; + z-index: var(--z-popover); + animation: pageModeFadeInScale var(--transition-fast); +} + +@keyframes pageModeFadeInScale { + from { + opacity: 0; + transform: scale(0.95) translateY(-10px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +.page-mode-selector-header { + padding: var(--spacing-sm) var(--spacing-md); + border-bottom: 1px solid var(--color-border-light); +} + +.page-mode-selector-title { + font-size: 0.85rem; + font-weight: 600; + color: var(--color-text-primary); + font-family: var(--font-ui); +} + +.page-mode-list { + padding: var(--spacing-xs); + display: flex; + flex-direction: column; + gap: 2px; +} + +.page-mode-item { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); + background: transparent; + border: none; + border-radius: var(--radius-md); + cursor: pointer; + transition: all var(--transition-fast); + text-align: left; + width: 100%; +} + +.page-mode-item:hover { + background-color: var(--color-bg-tertiary); +} + +.page-mode-item.active { + background-color: var(--color-accent-light); + border: 1px solid var(--color-accent); +} + +.page-mode-item-icon { + font-size: 1.3rem; + line-height: 1; + flex-shrink: 0; + width: 24px; + text-align: center; +} + +.page-mode-item-label { + font-size: 0.9rem; + font-weight: 600; + color: var(--color-text-primary); + font-family: var(--font-ui); +} diff --git a/frontend/src/components/TopBar/items/PageModeToggle/PageModeToggle.jsx b/frontend/src/components/TopBar/items/PageModeToggle/PageModeToggle.jsx new file mode 100644 index 0000000..21a2ae6 --- /dev/null +++ b/frontend/src/components/TopBar/items/PageModeToggle/PageModeToggle.jsx @@ -0,0 +1,79 @@ +import React, { useState, useEffect, useRef } from 'react'; +import useAppLayoutStore from '../../../../Store/AppLayoutSlice'; +import './PageModeToggle.css'; + +const PAGE_MODES = [ + { id: 'chat', label: '聊天', icon: '💬' }, + { id: 'studio', label: '工作室', icon: '🎭' }, + { id: 'novel', label: '爽文', icon: '📖' }, + { id: 'room', label: '房间', icon: '🏠' }, +]; + +const PageModeToggle = () => { + const [isOpen, setIsOpen] = useState(false); + const panelRef = useRef(null); + + const { activePage, setActivePage } = useAppLayoutStore(); + + useEffect(() => { + const handleClickOutside = (event) => { + if (panelRef.current && !panelRef.current.contains(event.target)) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + const handleModeSelect = (modeId) => { + setActivePage(modeId); + setIsOpen(false); + }; + + const currentMode = PAGE_MODES.find((m) => m.id === activePage) || PAGE_MODES[0]; + + return ( +
+ + + {isOpen && ( +
+
+ 应用模式 +
+
+ {PAGE_MODES.map((mode) => ( + + ))} +
+
+ )} +
+ ); +}; + +export default PageModeToggle; diff --git a/frontend/src/components/TopBar/items/PageModeToggle/index.js b/frontend/src/components/TopBar/items/PageModeToggle/index.js new file mode 100644 index 0000000..2c1083b --- /dev/null +++ b/frontend/src/components/TopBar/items/PageModeToggle/index.js @@ -0,0 +1 @@ +export { default } from './PageModeToggle'; diff --git a/frontend/src/index.css b/frontend/src/index.css index a315447..50cf70c 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -27,6 +27,10 @@ margin-top: 0; /* Ensure panels start from top */ } +.main-container.placeholder-container { + flex-direction: column; +} + /* Smooth transitions for theme changes - only apply to specific properties */ .app { transition: background-color var(--transition-normal),