diff --git a/electron/main.js b/electron/main.js index 0528db5..1c015f7 100644 --- a/electron/main.js +++ b/electron/main.js @@ -129,7 +129,7 @@ function writeEnvConfig(userInput) { fs.mkdirSync(configDir, { recursive: true }); fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8'); - console.log('[Electron] 配置已写入:', CONFIG_PATH); + console.log('[Electron] CONFIG_PATH:', CONFIG_PATH); } // ==================== Python 检测 ==================== @@ -217,15 +217,15 @@ function startFastAPI() { }); pythonProcess.on('error', (err) => { - console.error('[Electron] FastAPI 启动失败:', err.message); + console.error('[Electron] FastAPI Failed:', err.message); reject(err); }); pythonProcess.on('exit', (code) => { - console.log(`[Electron] FastAPI 退出,码: ${code}`); + console.log(`[Electron] FastAPI Exit Code: ${code}`); pythonProcess = null; if (!isQuitting && code !== 0 && code !== null && mainWindow) { - dialog.showErrorBox('后端服务异常', `FastAPI 意外退出(退出码: ${code})。`); + dialog.showErrorBox('Backend Service Exception', `FastAPI Exit Code: ${code}`); } }); @@ -242,7 +242,7 @@ function waitForReady(resolve, reject, retriesLeft) { } const req = http.get(`${BASE_URL}/api/health`, (res) => { if (res.statusCode === 200) { - console.log('[Electron] FastAPI 就绪'); + console.log('[Electron] FastAPI Ready'); resolve(); } else { retry(); @@ -260,7 +260,7 @@ function stopFastAPI() { killPortProcess(PORT); // 再通过 PID 杀一次(确保主进程被杀) if (pythonProcess) { - console.log('[Electron] 关闭 FastAPI...'); + console.log('[Electron] Killing FastAPI Process...'); try { if (process.platform === 'win32') { execSync(`taskkill /pid ${pythonProcess.pid} /f /t 2>nul`, { stdio: 'ignore' }); diff --git a/src/agent/agents/agent_runtime.py b/src/agent/agents/agent_runtime.py index a0790fb..cfa4200 100644 --- a/src/agent/agents/agent_runtime.py +++ b/src/agent/agents/agent_runtime.py @@ -85,6 +85,14 @@ def _build_agent_runtime(cfg: AgentConfig) -> AgentRuntime: else: agent_tools = all_tools + # 2.1 强制为主 Agent 加载 doc_tool(不可通过配置移除) + if cfg.role == "main": + if not any(t.name == "doc_tool" for t in agent_tools): + for t in all_tools: + if t.name == "doc_tool": + agent_tools.append(t) + break + # 3. bind_tools model_with_tools = agent_llm.bind_tools(agent_tools, tool_choice="auto") @@ -109,6 +117,29 @@ def _build_agent_runtime(cfg: AgentConfig) -> AgentRuntime: ) +def _refresh_subagent_tool_description() -> None: + """在运行时构建完成后刷新 CallSubAgentTool 的描述,使主 Agent 知晓可委派的子 Agent。 + + 通过传入 sub_names 和 sub_descriptions 参数避免从 agent_runtime 自身重新导入(循环导入)。 + 在 build_all_agent_runtimes() 和 reload_all_agent_runtimes() 之后调用。 + """ + sub_names = [] + sub_descriptions = {} + for name, rt in _all_agent_runtimes.items(): + if name != _main_agent_name: + sub_names.append(name) + if rt.description: + sub_descriptions[name] = rt.description + from agent.tools.agent_call import CallSubAgentTool + main_rt = _all_agent_runtimes.get(_main_agent_name) + if main_rt is None: + return + for tool in main_rt.tools: + if isinstance(tool, CallSubAgentTool): + tool._refresh_description(sub_names, sub_descriptions) + break + + def build_all_agent_runtimes() -> dict[str, AgentRuntime]: """从 agent_config.json 读取所有启用 Agent 并构建运行时字典。 @@ -140,6 +171,16 @@ def build_all_agent_runtimes() -> dict[str, AgentRuntime]: _main_agent_name = first_name print(f"[AgentFactory] 未找到 role='main' 的 Agent,自动将 '{first_name}' 设为主 Agent。") + # 所有子 Agent 就绪后,刷新 call_subagent 工具描述并重建主 Agent, + # 使主 Agent bind_tools 时携带最新的子 Agent 列表与能力描述 + _refresh_subagent_tool_description() + main_cfg = next((c for c in configs if c.enabled and c.role == "main"), None) + if main_cfg: + try: + _all_agent_runtimes[_main_agent_name] = _build_agent_runtime(main_cfg) + except Exception as e: + print(f"[AgentFactory] 重建主 Agent '{_main_agent_name}' 失败: {e}") + return _all_agent_runtimes @@ -248,25 +289,3 @@ agent_graph = main_agent_runtime.graph tools = main_agent_runtime.tools TOOLS_BY_NAME = main_agent_runtime.tools_by_name llm = main_agent_runtime.llm - - -def _refresh_subagent_tool_description() -> None: - """在运行时构建完成后刷新 CallSubAgentTool 的描述,使主 Agent 知晓可委派的子 Agent。 - - 通过传入 sub_names 和 sub_descriptions 参数避免从 agent_runtime 自身重新导入(循环导入)。 - 在 build_all_agent_runtimes() 和 reload_all_agent_runtimes() 之后调用。 - """ - sub_names = [] - sub_descriptions = {} - for name, rt in _all_agent_runtimes.items(): - if name != _main_agent_name: - sub_names.append(name) - if rt.description: - sub_descriptions[name] = rt.description - from agent.tools.agent_call import CallSubAgentTool - for tool in tools: - if isinstance(tool, CallSubAgentTool): - tool._refresh_description(sub_names, sub_descriptions) - break - -_refresh_subagent_tool_description() diff --git a/src/agent/config/agent_config_dist.json b/src/agent/config/agent_config_dist.json index 9321400..5e60346 100644 --- a/src/agent/config/agent_config_dist.json +++ b/src/agent/config/agent_config_dist.json @@ -6,6 +6,8 @@ "max_iterations": 200, "llm_model_id": "affiaa7r", "tools": [ + "doc_tool", + "call_subagent", "date_query" ], "enabled": true, diff --git a/src/agent/config/theme_config.json b/src/agent/config/theme_config.json index 7133956..d0548ee 100644 --- a/src/agent/config/theme_config.json +++ b/src/agent/config/theme_config.json @@ -1,3 +1,8 @@ { - "bg_image": "" + "theme": "solarized-light", + "bg_image": "", + "font_family": "\"ZCOOL KuaiLe\", \"Microsoft YaHei\", sans-serif", + "font_size": "14px", + "bg_blur": "", + "bg_vignette": "" } \ No newline at end of file diff --git a/src/agent/config/tool_config.json b/src/agent/config/tool_config.json index c92685c..c2bbdb0 100644 --- a/src/agent/config/tool_config.json +++ b/src/agent/config/tool_config.json @@ -105,7 +105,7 @@ "permission": "direct", "auto_execute_rules": [], "enabled": true, - "timeout": 300 + "timeout": 900 }, { "name": "doc_tool", diff --git a/src/agent/config/workspace_config.json b/src/agent/config/workspace_config.json index 8608fe6..b25f95f 100644 --- a/src/agent/config/workspace_config.json +++ b/src/agent/config/workspace_config.json @@ -1,7 +1,6 @@ { - "current": "C:\\Users\\86166\\Desktop\\新建文件夹", + "current": "", "list": [ - "C:\\Users\\86166\\Desktop\\Agent_Learning_minor\\Agent\\src\\workspace", - "C:\\Users\\86166\\Desktop\\新建文件夹" + "C:\\Users\\86166\\Desktop\\Agent_Learning_minor\\Agent\\src\\workspace" ] } \ No newline at end of file diff --git a/src/agent/core/llm.py b/src/agent/core/llm.py index 9794af5..4f0627e 100644 --- a/src/agent/core/llm.py +++ b/src/agent/core/llm.py @@ -175,17 +175,8 @@ class ChatQwen(ChatOpenAI): if ext in IMAGE_FILE_EXTENSIONS: new_content.append(self._process_image_content({"type": "image_url", "image_url": {"url": file_path}})) else: - try: - file_text, file_images = Documents_process.process(file_path) - new_content.append({"type": "text", "text": make_attachment_text("文件", normalized_path, f"以下是文件 {normalized_path} 的内容:\n{file_text.strip()}" if file_text.strip() else "")}) - for image_bytes in file_images or []: - image_part = image_bytes_to_openai_image_url_part(image_bytes) - if image_part is not None: - new_content.append(image_part) - if not file_text.strip() and not file_images: - new_content.append({"type": "text", "text": make_attachment_text("附件文件", normalized_path)}) - except Exception as e: - new_content.append({"type": "text", "text": f"[附件处理失败: {normalized_path}: {e}]"}) + # 非图片附件只注入文件路径提示,内容由 Agent 通过 doc_tool 读取 + new_content.append({"type": "text", "text": make_attachment_text("附件文件", normalized_path)}) else: text = assistant_text(part) if text: diff --git a/src/agent/core/nodes.py b/src/agent/core/nodes.py index a4dd4a5..f38cbd2 100644 --- a/src/agent/core/nodes.py +++ b/src/agent/core/nodes.py @@ -60,27 +60,40 @@ def _filter_short_term_memory(messages: list, trajectory_rounds: int) -> list: 规则: - 保留所有 SystemMessage、HumanMessage(非合成图片)、AIMessage、ToolMessage - 仅 process_tool_artifact 生成的合成图片 HumanMessage 按 trajectory_rounds 裁剪 - - 每轮由一个 AIMessage(含 tool_calls)定义 + - 只对实际包含图片截图的轮次计数,无截图的轮次跳过不计数 """ if trajectory_rounds <= 0: return list(messages) - # 从后向前遍历,统计已遇到的含 tool_calls 的 AIMessage 轮数 - rounds_seen = 0 + # 从后向前遍历,先标记哪些轮次包含图片截图 + # 一"轮"由一个 AIMessage(含 tool_calls)之后的消息(ToolMessage + synthetic HumanMessage)组成 + img_rounds_seen = 0 kept_indices = set() for i in range(len(messages) - 1, -1, -1): msg = messages[i] - if isinstance(msg, AIMessage) and getattr(msg, "tool_calls", None): - rounds_seen += 1 - - # 合成图片消息:仅保留最近 trajectory_rounds 轮 + # 合成图片消息:仅保留最近 trajectory_rounds 轮(有图片的轮才计数) if isinstance(msg, HumanMessage) and msg.additional_kwargs.get("synthetic") and _has_image_content(msg): - if rounds_seen < trajectory_rounds: + if img_rounds_seen < trajectory_rounds: kept_indices.add(i) - else: - kept_indices.add(i) + continue + + # 遇到 AIMessage(含 tool_calls)时,检查本轮是否有图片来决定是否增加计数 + if isinstance(msg, AIMessage) and getattr(msg, "tool_calls", None): + # 向后搜索本轮是否有截图(从该 AIMessage 到下一个 AIMessage 之间) + has_image = False + for j in range(i + 1, len(messages)): + mj = messages[j] + if isinstance(mj, AIMessage) and getattr(mj, "tool_calls", None): + break + if isinstance(mj, HumanMessage) and mj.additional_kwargs.get("synthetic") and _has_image_content(mj): + has_image = True + break + if has_image: + img_rounds_seen += 1 + + kept_indices.add(i) return [messages[i] for i in sorted(kept_indices)] @@ -209,12 +222,12 @@ def _compress_messages_in_loop(state: dict, response: Any) -> None: if total <= 0: return - # 存储最新 token 用量供前端轮询 + # 存储最新 token 用量供前端轮询(仅统计主 Agent,子 Agent 上下文不计入) _sid = "" try: - from agent.history.tool_call_recorder import get_current_session, set_session_tokens + from agent.history.tool_call_recorder import get_current_session, is_sub_agent_context, set_session_tokens _sid = get_current_session() or "" - if _sid: + if _sid and not is_sub_agent_context(): set_session_tokens(_sid, total) except Exception: pass diff --git a/src/agent/core/runtime.py b/src/agent/core/runtime.py index f028722..afaac20 100644 --- a/src/agent/core/runtime.py +++ b/src/agent/core/runtime.py @@ -71,24 +71,31 @@ _OOM_MESSAGE = "OOM: 已达到最大迭代次数限制,任务未能完成。 def _inject_previous_turn_context(session_id: str, messages: list) -> None: - """从上一轮 tool_calling 记录中读取工具调用上下文,注入为合成消息。""" + """从最近5轮(不含当前轮)tool_calling 记录中读取工具调用上下文,注入为合成消息。""" try: - from agent.history.tool_call_recorder import get_latest_turn - latest = get_latest_turn(session_id) - if not latest: + from agent.history.tool_call_recorder import list_turns + # 取6轮(当前轮 + 前5轮历史) + turns = list_turns(session_id, limit=6) + if not turns: return - tool_calls = latest.get("tool_calls", []) - if not tool_calls: - return - for tc in tool_calls: - tn = tc.get("name", "unknown") - ta = json.dumps(tc.get("args", {}), ensure_ascii=False)[:300] - tr = str(tc.get("result_text", ""))[:500] - if tn and tr: - messages.append(HumanMessage( - content=f"[上轮工具调用] {tn}({ta}) → {tr}", - additional_kwargs={"synthetic": True}, - )) + # 跳过最近一轮(当前正在执行或刚结束的 turn),取前面最多5轮历史 + recent_turns = turns[1:6] if len(turns) > 1 else [] + if not recent_turns: + # 首次对话场景:只有1轮,用唯一那一轮 + recent_turns = [turns[0]] + for turn in recent_turns: + tool_calls = turn.get("tool_calls", []) + if not tool_calls: + continue + for tc in tool_calls: + tn = tc.get("name", "unknown") + ta = json.dumps(tc.get("args", {}), ensure_ascii=False)[:300] + tr = str(tc.get("result_text", ""))[:500] + if tn and tr: + messages.append(HumanMessage( + content=f"[历史工具调用] {tn}({ta}) → {tr}", + additional_kwargs={"synthetic": True}, + )) except Exception: pass @@ -703,7 +710,7 @@ def _resume_sub_agent( sub_call_id = sub_tool_call.get("id") or f"sub_{approval_id}" # 1) 构造子图续跑的 ToolMessage - if decision == "skip": + if decision == "skip" and sub_type != "human_interaction": sub_messages.append(ToolMessage( content=f"用户已跳过此工具调用({sub_tool_name}),未执行。", tool_call_id=sub_call_id, name=sub_tool_name, @@ -812,8 +819,8 @@ def continue_after_human_action( sync_turn_from_chat_history(session_id, chat_history, "") return chat_history - # ===== 跳过:不执行工具,写跳过 ToolMessage 后续跑图(tool_call / human_interaction 通用)===== - if decision == "skip": + # ===== 跳过:仅 tool_call 类型支持,human_interaction 不支持跳过 ===== + if decision == "skip" and pending.get("type") != "human_interaction": tool_call = dict(pending.get("tool_call") or {}) tool_name = tool_call.get("name") or "unknown_tool" call_id = tool_call.get("id") or f"manual_{approval_id}" diff --git a/src/agent/cron/scheduler.py b/src/agent/cron/scheduler.py index c86ab96..30d0875 100644 --- a/src/agent/cron/scheduler.py +++ b/src/agent/cron/scheduler.py @@ -65,7 +65,7 @@ def set_cron_time_period(minutes: float) -> float: global _CRON_TIME_PERIOD_MINUTES val = float(minutes) if val <= 0: - raise ValueError("时间段长度必须为正数") + raise ValueError("Time period must be a positive number") _CRON_TIME_PERIOD_MINUTES = val try: from agent.core.config_manager import load_env_config, save_env_config @@ -73,7 +73,7 @@ def set_cron_time_period(minutes: float) -> float: cfg["CRON_TIME_PERIOD_MINUTES"] = int(val) if float(val).is_integer() else val save_env_config(cfg) except Exception as e: - print(f"[CronScheduler] 保存 CRON_TIME_PERIOD_MINUTES 失败: {e}") + print(f"[CronScheduler] Failed to save CRON_TIME_PERIOD_MINUTES: {e}") return val @@ -228,7 +228,7 @@ class CronScheduler: _load_time_period_minutes() self._thread = threading.Thread(target=self._run_loop, name="cron-scheduler", daemon=True) self._thread.start() - print("[CronScheduler] 调度线程已启动") + print("[CronScheduler] Scheduler thread started") def shutdown(self) -> None: if not self._started: @@ -245,7 +245,7 @@ class CronScheduler: if self._thread: self._thread.join(timeout=3.0) self._started = False - print("[CronScheduler] 调度线程已停止") + print("[CronScheduler] Scheduler thread stopped") # ---------- 主循环 ---------- def _run_loop(self) -> None: @@ -253,13 +253,13 @@ class CronScheduler: try: self._on_startup() except Exception as e: - print(f"[CronScheduler] 启动扫描异常: {e}") + print(f"[CronScheduler] Startup scan error: {e}") while not self._stop_event.is_set(): try: self._scan_once() except Exception as e: - print(f"[CronScheduler] 扫描异常: {e}") + print(f"[CronScheduler] Scan error: {e}") self._stop_event.wait(_SCAN_INTERVAL) def _on_startup(self) -> None: @@ -288,12 +288,12 @@ class CronScheduler: "started_at": trigger.run_at, "ended_at": now_iso(), "status": "expired", - "error": "应用未运行,已错过执行时间", + "error": "App was not running, missed execution time", "trigger": "schedule", "turn_id": "", }) repo.update_status(task.task_id, "expired", - error="应用未运行,已错过执行时间", + error="App was not running, missed execution time", next_run_at="") task.enabled = False task.last_status = "expired" @@ -318,7 +318,7 @@ class CronScheduler: "started_at": task.next_run_at, "ended_at": now_iso(), "status": "expired", - "error": "应用未运行错过该次触发", + "error": "App was not running, missed this trigger", "trigger": "schedule", "turn_id": "", }) @@ -390,10 +390,10 @@ class CronScheduler: # 2) 上一个任务启动后仍在 CRON_TIME_PERIOD_MINUTES 时段窗口内 → 跳过。 now_ts = time.time() if self._running_procs: - print(f"[CronScheduler] 时段内已有任务运行,跳过 {task.task_id}") + print(f"[CronScheduler] Task already running in period, skipping {task.task_id}") return False if self._period_start_ts is not None and (now_ts - self._period_start_ts) < _CRON_TIME_PERIOD_MINUTES * 60: - print(f"[CronScheduler] 时段内已有任务,跳过 {task.task_id}") + print(f"[CronScheduler] Task exists in period, skipping {task.task_id}") return False # 占用新时段 self._period_start_ts = now_ts @@ -426,8 +426,8 @@ class CronScheduler: creationflags=getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) if os.name == "nt" else 0, ) except Exception as e: - print(f"[CronScheduler] 拉起子进程失败 task={task.task_id}: {e}") - get_task_repository().update_status(task.task_id, "failed", error=f"拉起子进程失败: {e}") + print(f"[CronScheduler] Failed to spawn subprocess task={task.task_id}: {e}") + get_task_repository().update_status(task.task_id, "failed", error=f"Failed to spawn subprocess: {e}") # 子进程未启动成功,释放占用的时段(避免空占时段阻塞后续任务) with self._procs_lock: if not self._running_procs: @@ -478,7 +478,7 @@ class CronScheduler: error = "" else: status = "failed" - error = f"子进程异常退出(exit code {exit_code})" + error = f"Subprocess exited abnormally (exit code {exit_code})" repo.update_status(task_id, status, error=error, last_run_at=started_iso) repo.append_execution(task_id, { "started_at": started_iso, diff --git a/src/agent/history/session_storage.py b/src/agent/history/session_storage.py index 5a3f48c..3fe89f0 100644 --- a/src/agent/history/session_storage.py +++ b/src/agent/history/session_storage.py @@ -82,6 +82,9 @@ class SessionRecordRepository(Protocol): def list_session_ids(self) -> list[str]: ... def get_turn_record(self, session_id: str, turn_id: str) -> dict[str, Any] | None: ... def get_latest_turn_record(self, session_id: str) -> dict[str, Any] | None: ... + def list_turn_records(self, session_id: str, limit: int) -> list[dict[str, Any]]: ... + def get_session_extra(self, session_id: str) -> dict[str, Any]: ... + def save_session_extra(self, session_id: str, extra: dict[str, Any]) -> None: ... def get_last_turn_id(self, session_id: str) -> str: ... def delete_turns_after(self, session_id: str, keep_until_turn_id: str) -> None: ... def delete_session(self, session_id: str) -> None: ... @@ -97,6 +100,23 @@ class MysqlSessionRecordRepository: def __init__(self) -> None: ensure_tables(_SESSION_TABLE_DDLS) + # 向后兼容:为已有 sessions 表补充 extra 列 + self._ensure_extra_column() + + def _ensure_extra_column(self) -> None: + """幂等添加 sessions.extra JSON 列(存储 tokens + agent_meta)。""" + conn = mysql_conn() + try: + with conn.cursor() as cur: + cur.execute( + "ALTER TABLE sessions ADD COLUMN extra JSON DEFAULT NULL" + ) + conn.commit() + except Exception: + # 列已存在 → 忽略 + pass + finally: + conn.close() def save_turn(self, session_id: str, turn_id: str, messages: list[dict]) -> None: if not session_id or not turn_id: @@ -210,6 +230,54 @@ class MysqlSessionRecordRepository: finally: conn.close() + def list_turn_records(self, session_id: str, limit: int) -> list[dict[str, Any]]: + """按 turn_id 倒序返回最近 limit 条 turn 记录(与 tool_*.json 同构)。""" + conn = mysql_conn() + try: + with conn.cursor() as cur: + cur.execute( + "SELECT turn_id, tool_calls, meta FROM session_tool_calls " + "WHERE session_id=%s ORDER BY turn_id DESC LIMIT %s", + (session_id, limit), + ) + rows = cur.fetchall() or [] + return [self._row_to_record(r, session_id, r["turn_id"]) for r in rows] + finally: + conn.close() + + def get_session_extra(self, session_id: str) -> dict[str, Any]: + """读取 sessions.extra JSON 列(含 tokens + agent_meta),不存在时返回 {}。""" + conn = mysql_conn() + try: + with conn.cursor() as cur: + cur.execute( + "SELECT extra FROM sessions WHERE session_id=%s", + (session_id,), + ) + row = cur.fetchone() + raw = (row or {}).get("extra") + return self._loads(raw) if raw is not None else {} + finally: + conn.close() + + def save_session_extra(self, session_id: str, extra: dict[str, Any]) -> None: + """写入 sessions.extra JSON 列;session 行不存在时先创建占位行。""" + conn = mysql_conn() + try: + with conn.cursor() as cur: + now = _now_iso() + cur.execute( + """ + INSERT INTO sessions (session_id, created_at, updated_at, extra) + VALUES (%s,%s,%s,%s) + ON DUPLICATE KEY UPDATE updated_at=%s, extra=VALUES(extra) + """, + (session_id, now, now, json.dumps(extra, ensure_ascii=False), now), + ) + conn.commit() + finally: + conn.close() + def get_last_turn_id(self, session_id: str) -> str: conn = mysql_conn() try: @@ -381,6 +449,38 @@ def get_latest_turn_record(session_id: str) -> dict[str, Any] | None: return None +def list_turn_records(session_id: str, limit: int = 5) -> list[dict[str, Any]] | None: + """mysql 后端返回最近 limit 条 turn 记录(turn_id 倒序);json 后端返回 None。""" + if not is_mysql(): + return None + try: + return get_session_record_repository().list_turn_records(session_id, limit) + except Exception as e: + print(f"[session_storage] list_turn_records 失败: {e}", file=sys.stderr) + return None + + +def load_session_extra(session_id: str) -> dict[str, Any] | None: + """mysql 后端返回 sessions.extra(tokens + agent_meta);json 后端返回 None。""" + if not is_mysql(): + return None + try: + return get_session_record_repository().get_session_extra(session_id) + except Exception as e: + print(f"[session_storage] load_session_extra 失败: {e}", file=sys.stderr) + return None + + +def save_session_extra(session_id: str, extra: dict[str, Any]) -> None: + """mysql 后端下写入 sessions.extra;json 后端 no-op。""" + if not is_mysql(): + return + try: + get_session_record_repository().save_session_extra(session_id, extra) + except Exception as e: + print(f"[session_storage] save_session_extra 失败: {e}", file=sys.stderr) + + def get_last_turn_id(session_id: str) -> str | None: """mysql 后端返回最后 turn_id;json 后端返回 None。""" if not is_mysql(): diff --git a/src/agent/history/tool_call_recorder.py b/src/agent/history/tool_call_recorder.py index 8e2d557..f1f16b4 100644 --- a/src/agent/history/tool_call_recorder.py +++ b/src/agent/history/tool_call_recorder.py @@ -57,9 +57,18 @@ def sub_agent_context(): 子 Agent 的工具调用记录写入独立的 _sub_live_records[session_id], get_live_tool_calls 将其注入到 call_subagent 条目的 sub_tool_calls 中, 前端在 call_subagent 展开时实时渲染嵌套的子工具调用列表。 + + 进入时清空上一子 Agent 遗留的实时记录与待消费思考, + 避免同一轮内多个子 Agent 顺序执行时发生记忆串扰。 """ token = _sub_agent_context.set(True) try: + _sid = get_current_session() + if _sid: + with _sub_live_lock: + _sub_live_records.pop(_sid, None) + with _sub_pending_thinking_lock: + _sub_pending_thinking.pop(_sid, None) yield finally: _sub_agent_context.reset(token) @@ -100,14 +109,34 @@ def _token_file(session_id: str) -> Path: def _persist_token(session_id: str, tokens: int) -> None: - """将 token 写入磁盘文件。""" + """将 token 写入存储(优先 MySQL,回退 JSON 文件)。""" + # mysql 后端:写入 sessions.extra.tokens + try: + from agent.history import session_storage + if session_storage.is_mysql(): + existing = session_storage.load_session_extra(session_id) or {} + existing["tokens"] = tokens + session_storage.save_session_extra(session_id, existing) + return + except Exception: + pass + # json 后端:写入文件 tf = _token_file(session_id) tf.parent.mkdir(parents=True, exist_ok=True) tf.write_text(json.dumps({"session_id": session_id, "tokens": tokens}), encoding="utf-8") def _load_token_from_disk(session_id: str) -> int: - """从磁盘读取 token(若文件不存在返回 0)。""" + """从磁盘/数据库读取 token(若不存在返回 0)。""" + # mysql 后端:从 sessions.extra 读取 + try: + from agent.history import session_storage + _db_extra = session_storage.load_session_extra(session_id) + if _db_extra is not None: + return int(_db_extra.get("tokens", 0)) + except Exception: + pass + # json 后端:从文件读取 tf = _token_file(session_id) if not tf.exists(): return 0 @@ -142,9 +171,20 @@ def get_session_tokens(session_id: str) -> int: def clear_session_tokens(session_id: str) -> None: - """清除指定会话的 token 记录(内存 + 磁盘)。""" + """清除指定会话的 token 记录(内存 + 存储)。""" with _session_tokens_lock: _session_tokens.pop(session_id, None) + # mysql 后端:设置 tokens=0 + try: + from agent.history import session_storage + if session_storage.is_mysql(): + existing = session_storage.load_session_extra(session_id) or {} + existing["tokens"] = 0 + session_storage.save_session_extra(session_id, existing) + return + except Exception: + pass + # json 后端:删除文件 tf = _token_file(session_id) if tf.exists(): tf.unlink(missing_ok=True) @@ -977,8 +1017,23 @@ def _inject_sub_traces_to_record(tool_calls: list[dict[str, Any]], session_id: s tc["sub_tool_calls"] = sub_tc -def list_turns(session_id: str) -> list[dict[str, Any]]: - """列出会话下所有 turn(按时间倒序,最近5轮)。""" +def list_turns(session_id: str, limit: int = 5) -> list[dict[str, Any]]: + """列出会话下所有 turn(按时间倒序,默认最近5轮)。 + + 输入: + session_id: 会话 ID。 + limit: 返回的最大轮数,默认 5。 + """ + # mysql 后端:从数据库读取 + try: + from agent.history import session_storage + _db_records = session_storage.list_turn_records(session_id, limit) + if _db_records is not None: + return _db_records + except Exception as _e: + import sys as _sys + print(f"[tool_call_recorder] list_turns DB 读取失败: {_e}", file=_sys.stderr) + # json 后端:从文件读取 session_dir = session_tool_dir(session_id) if not session_dir.is_dir(): return [] @@ -989,7 +1044,7 @@ def list_turns(session_id: str) -> list[dict[str, Any]]: turns.append(json.load(fh)) except (json.JSONDecodeError, OSError): pass - return turns[:5] + return turns[:limit] def get_latest_turn(session_id: str) -> dict[str, Any] | None: diff --git a/src/agent/memory/system_prompt.py b/src/agent/memory/system_prompt.py index 34a52b4..9386cf1 100644 --- a/src/agent/memory/system_prompt.py +++ b/src/agent/memory/system_prompt.py @@ -2,14 +2,9 @@ _BASE_PROMPT = """你是一个具备多模态理解能力的桌面操作助手 【核心行为准则】 - 你的首要职责是理解用户的意图,并高效地完成用户的任务。 - 对于任何用户上传的图片、音频或文件,直接利用你的多模态能力分析并回答,不要为此调用截图等无关工具。 -- 只有在信息确实无法从当前对话或上传内容中获取,且必须通过外部工具才能获得时,才调用工具。例如:需要查看当前屏幕状态、需要执行 GUI 操作、需要搜索网络信息等。 -- **【强制规则】面对涉及 PPT 制作、代码项目等复杂产出类或系统操作类任务时,你必须先调用 skill_router 工具查询是否有对应的 Skill 操作指南。 -如果 skill_router 返回了匹配的 Skill,你必须将其视为最高优先级的执行规范,严格遵循 Skill 中定义的完整工作流,严禁跳过任何步骤, -严禁用你自身的编程经验(如直接写 python-pptx 代码、用终端脚本批量生成等)替代 Skill 规范。Skill 规范的地位高于任何工具描述中的建议,当工具描述与 Skill 规范冲突时,以 Skill 规范为准。** +- 只有在信息确实无法从当前对话或上传内容中获取,且必须通过外部工具才能获得时,才调用工具。 - 当你调用工具收到截图或返回结果后,必须仔细观察返回内容,基于实际结果继续推进任务,严禁脱离实际结果凭空编造。 -- 进行浏览器操作时,慎用刷新工具,避免刷新导致页面状态丢失。 -- 对于同一个页面下的GUI操作,必须将连续操作合并为一次 gui_tool 调用(actions 列表传入多个动作),严禁逐个单步调用。仅当页面发生跳转/加载后需要重新观察时,才允许分步调用,注意单次调用最多包含5个动作。 -- 如果不确定是否需要工具,优先选择不调用,用已有信息给出最佳答案。 +- 对于同一个页面下的GUI操作,必须将连续操作合并为一次 gui_tool 调用(actions 列表传入多个动作),严禁逐个单步调用。仅当页面发生跳转/加载后需要重新观察时,才允许分步调用。 - 对于需要多次操作才能完成的任务,应主动使用 todo_list 工具规划步骤并逐步推进,并在任务完成后将todo_list中的所有项标记为已完成。 - 当任务完成时,向用户总结执行过程和结果。 """.strip() diff --git a/src/agent/skills/ppt_maker/attachments/01-workflow.md b/src/agent/skills/ppt_maker/attachments/01-workflow.md index 6e27015..289ed9a 100644 --- a/src/agent/skills/ppt_maker/attachments/01-workflow.md +++ b/src/agent/skills/ppt_maker/attachments/01-workflow.md @@ -161,7 +161,12 @@ **交付**: - 使用 `doc_tool create` 逐页写出 `{N}.html` - 运行 `python {SKILL_DIR}/attachments/complete_htmls_to_base64.py {workspace_dir}` 将外部图片引用转为 base64,产出 `{N}_complete.html` -- 向用户汇报最终交付:`{N}_complete.html`(所有图片已内联,每页可在浏览器中独立打开预览) +- **(可选)合并为单文件 deck**:如果用户需要将多页合并为一个 HTML 文件(方便演示 / 导入 fange-ai-toolbox 编辑器导出 PPTX / 上传飞书),询问用户是否需要合并。若需要,运行: + ``` + python {SKILL_DIR}/attachments/merge_to_deck.py {workspace_dir} --complete + ``` + 产出 `deck.html`,支持键盘 ← → 翻页、鼠标点击前进、触摸滑动。合并后向用户说明操作方式。 +- 向用户汇报最终交付:`{N}_complete.html`(所有图片已内联,每页可在浏览器中独立打开预览),以及可选的 `deck.html`(单文件翻页版) - 提示用户打开 `{N}_complete.html` 即可逐页浏览全部幻灯片 --- @@ -179,6 +184,7 @@ | 动效引擎(Executor) | `08-animation-system.md` | | QA & 交付 | `09-quality-checklist.md` | | 图片内联固化 | `attachments/complete_htmls_to_base64.py`(运行即可) | +| 单文件合并 | `attachments/merge_to_deck.py`(可选,交付前最后一步) | --- @@ -212,3 +218,4 @@ | `09-quality-checklist.md` | 质检清单 — HTML 质量检查项与交付规范 | QA 阶段 | | `templates/` | 模板库 — 预置 HTML 模板(封面、过渡页、内容页、结束页) | Executor 参考复用 | | `attachments/complete_htmls_to_base64.py` | 固化脚本 — 将外部图片引用转为 base64 内联 | 交付前最后一步 | +| `attachments/merge_to_deck.py` | 合并脚本 — 将多个独立 HTML 合并为单文件 deck(可选) | 交付前最后一步(用户需要合并时) | diff --git a/src/agent/skills/ppt_maker/attachments/merge_to_deck.py b/src/agent/skills/ppt_maker/attachments/merge_to_deck.py new file mode 100644 index 0000000..4a252b0 --- /dev/null +++ b/src/agent/skills/ppt_maker/attachments/merge_to_deck.py @@ -0,0 +1,511 @@ +#!/usr/bin/env python3 +""" +merge_to_deck.py + +将 ppt_maker 生成的多个独立 HTML 文件(1.html, 2.html, ...)合并为单个 deck 格式 HTML。 +合并后支持键盘(← →)翻页 + 鼠标点击前进 + 触摸滑动,同时兼容 fange-ai-toolbox 编辑器。 + +用法: + python merge_to_deck.py workspace/ppt_my_topic/ + python merge_to_deck.py workspace/ppt_my_topic/ --output my_deck.html + python merge_to_deck.py workspace/ppt_my_topic/ --complete # 使用 _complete.html 文件 + +输出: + 单个 deck.html,每个原页面作为
嵌入 +""" + +import os +import sys +import re +import argparse +from datetime import datetime +from pathlib import Path + + +def find_html_files(directory: str, use_complete: bool = False) -> list: + """查找目录下所有纯数字命名的 HTML 文件,按页码排序。""" + html_dir = Path(directory) + if not html_dir.is_dir(): + # 尝试 workspace 下查找 + workspace = Path(os.getcwd()) / "workspace" / directory + if workspace.is_dir(): + html_dir = workspace + else: + raise FileNotFoundError(f"目录不存在: {directory}") + + suffix = "_complete.html" if use_complete else ".html" + files = [] + for f in sorted(html_dir.iterdir(), key=lambda x: x.name): + name = f.name + if use_complete: + if name.endswith(suffix): + try: + num = int(name.replace(suffix, "")) + files.append((num, str(f))) + except ValueError: + pass + else: + # 匹配纯数字 .html(如 1.html, 2.html),排除 _complete 等 + m = re.match(r'^(\d+)\.html$', name) + if m: + files.append((int(m.group(1)), str(f))) + files.sort(key=lambda x: x[0]) + return files + + +def extract_root_content(html: str) -> str: + """ + 提取页面核心内容。 + 优先提取 .ppt-root 或 body > div(1280x720 容器)的内部内容, + 去掉外层的 body 灰底包装和独立 script。 + """ + # 1) 尝试匹配
...
或
+ ppt_root_re = re.compile( + r']*\b(?:class\s*=\s*["\']ppt-root["\']|style\s*=\s*["\'][^"\']*width\s*:\s*1280px[^"\']*height\s*:\s*720px[^"\']*)' + r'[^>]*>(.*?)
\s*(?:\s*)?\s*', + re.DOTALL | re.IGNORECASE + ) + m = ppt_root_re.search(html) + if m: + inner = m.group(1) + # 去掉页面内嵌的 ', '', inner, flags=re.DOTALL) + return inner.strip() + + # 2) 回退:提取 内第一个宽高固定的 div 的内容 + body_re = re.compile(r']*>(.*?)', re.DOTALL | re.IGNORECASE) + m = body_re.search(html) + if not m: + return "" + + body = m.group(1) + # 找第一个 1280x720 的 div(可能嵌套在灰色背景 div 内) + container_re = re.compile( + r']*style\s*=\s*["\'][^"\']*width\s*:\s*1280px[^"\']*height\s*:\s*720px[^"\']*["\'][^>]*>(.*?)\s*', + re.DOTALL | re.IGNORECASE + ) + m2 = container_re.search(body) + if m2: + inner = m2.group(1) + inner = re.sub(r'', '', inner, flags=re.DOTALL) + return inner.strip() + + # 3) 最后回退:body 内去掉 script 后的全部内容 + body_clean = re.sub(r'', '', body, flags=re.DOTALL) + return body_clean.strip() + + +def extract_css_variables(html: str) -> str: + """提取 :root 中的 CSS 变量定义。""" + m = re.search(r':root\s*\{([^}]*)\}', html, re.DOTALL) + if m: + return m.group(1).strip() + return "" + + +def scope_css_rules(css_text: str, slide_id: str) -> str: + """给 CSS 规则加上 slide 作用域前缀,避免不同页面间的样式冲突。""" + def scope_rule(match): + rule = match.group(0) + brace_pos = rule.index('{') + selector_text = rule[:brace_pos] + body_text = rule[brace_pos:] + + parts = [s.strip() for s in selector_text.split(',')] + scoped_parts = [] + for part in parts: + if not part: + continue + scoped = f'#{slide_id} {part}' + # body.loaded 针对的是 deck 的 ,需放在 #slide-N 外面 + scoped = re.sub(rf'^#{slide_id}\s+body\.loaded\b', f'body.loaded #{slide_id}', scoped) + scoped_parts.append(scoped) + + return ', '.join(scoped_parts) + ' ' + body_text + + result = re.sub(r'([^{}]+\{[^{}]*\})', lambda m: scope_rule(m) + '\n', css_text) + return result.strip() + + +def build_deck(html_dir: str, use_complete: bool = False, title: str = None) -> str: + """构建合并后的 deck HTML。""" + files = find_html_files(html_dir, use_complete) + if not files: + raise ValueError(f"在 {html_dir} 中未找到 HTML 文件") + + total = len(files) + slides_html = [] + css_variables = "" + if title is None: + title = datetime.now().strftime("演示文稿 %Y-%m-%d %H:%M:%S") + + for idx, (num, filepath) in enumerate(files, 1): + with open(filepath, "r", encoding="utf-8") as f: + html = f.read() + + # 使用第一个页面的 CSS 变量作为全局变量 + if not css_variables: + css_variables = extract_css_variables(html) + + content = extract_root_content(html) + if not content: + print(f" [警告] {filepath}: 未能提取内容,跳过") + continue + + # 提取页面内的 ', html, re.DOTALL | re.IGNORECASE) + page_styles = "" + for sb in style_blocks: + # 去掉 :root 块(已全局统一),去掉 body {} 规则(deck 自行管理) + cleaned = re.sub(r':root\s*\{[^}]*\}', '', sb, re.DOTALL) + cleaned = re.sub(r'\bbody\s*\{[^}]*\}', '', cleaned, flags=re.DOTALL) + cleaned = cleaned.strip() + if cleaned: + page_styles += cleaned + "\n" + + # 给 CSS 规则加上 slide 作用域,避免不同页面同名 class 冲突 + slide_id = f"slide-{idx}" + if page_styles: + page_styles = scope_css_rules(page_styles, slide_id) + + slide = f'
\n' + if page_styles: + slide += f" \n" + slide += f" {content}\n" + slide += "
" + + slides_html.append(slide) + + if not slides_html: + raise ValueError("未能提取任何页面内容") + + # 构建完整 deck + root_block = f":root {{\n {css_variables}\n}}" if css_variables.strip() else "" + + deck = f''' + + + + +{title} + + + + +
+{chr(10).join(slides_html)} +
+ +
1 / {total}
+ + + + + + +''' + + return deck + + +def main(): + parser = argparse.ArgumentParser( + description="将 ppt_maker 生成的多个 HTML 合并为单文件 deck", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +示例: + python merge_to_deck.py workspace/ppt_my_topic/ + python merge_to_deck.py workspace/ppt_my_topic/ --output my_deck.html + python merge_to_deck.py workspace/ppt_my_topic/ --complete + """ + ) + parser.add_argument("directory", help="HTML 文件所在目录") + parser.add_argument("--output", "-o", default=None, + help="输出文件名(默认: deck.html,放在输入目录下)") + parser.add_argument("--title", "-t", default=None, + help="deck 标题(默认: 当前时间戳)") + parser.add_argument("--complete", action="store_true", + help="使用 _complete.html 文件(而非原始 .html)") + args = parser.parse_args() + + html_dir = Path(args.directory) + if not html_dir.is_dir(): + workspace = Path(os.getcwd()) / "workspace" / args.directory + if workspace.is_dir(): + html_dir = workspace + else: + print(f"错误: 目录不存在: {args.directory}") + sys.exit(1) + + output_path = args.output or str(html_dir / "deck.html") + if not os.path.isabs(output_path): + output_path = str(html_dir / output_path) + + try: + print(f"合并目录: {html_dir}") + deck = build_deck(str(html_dir), args.complete, args.title) + with open(output_path, "w", encoding="utf-8") as f: + f.write(deck) + print(f"已生成: {output_path}") + print(f"共 {deck.count('
str: def _load_session_meta(session_id: str) -> dict: - """加载整个 session_meta.json 文件。""" + """加载整个 session_meta 数据(优先 MySQL,回退 JSON 文件)。""" + # mysql 后端:从 sessions.extra.agent_meta 读取 + try: + from agent.history import session_storage + _db_extra = session_storage.load_session_extra(session_id) + if _db_extra is not None: + return _db_extra.get("agent_meta", {}) + except Exception: + pass + # json 后端:从文件读取 mp = _session_meta_path(session_id) if not os.path.isfile(mp): return {} @@ -170,7 +179,18 @@ def _load_session_meta(session_id: str) -> dict: def _save_session_meta(session_id: str, meta: dict) -> None: - """保存整个 session_meta.json 文件。""" + """保存整个 session_meta 数据(优先 MySQL,回退 JSON 文件)。""" + # mysql 后端:写入 sessions.extra.agent_meta(不覆盖 tokens) + try: + from agent.history import session_storage + if session_storage.is_mysql(): + existing = session_storage.load_session_extra(session_id) or {} + existing["agent_meta"] = meta + session_storage.save_session_extra(session_id, existing) + return + except Exception: + pass + # json 后端:写入文件 mp = _session_meta_path(session_id) os.makedirs(os.path.dirname(mp), exist_ok=True) with open(mp, "w", encoding="utf-8") as f: diff --git a/src/agent/utils/ppt_utils.py b/src/agent/utils/ppt_utils.py index 7b4e400..e7f5397 100644 --- a/src/agent/utils/ppt_utils.py +++ b/src/agent/utils/ppt_utils.py @@ -1,7 +1,6 @@ """PPT 读取与预览工具:自主实现 PPTX 解析,不依赖外部 ppt-master 项目。 支持: - pptx → SVG: 将 PPTX 每页转为自包含 SVG(扁平渲染,用于前端预览) - - pptx → HTML: 将 PPTX 转为带翻页导航的 HTML 预览页 渲染范围(聚焦核心功能): - 形状: rect, roundRect, ellipse, line, text, image, path, polygon, group @@ -17,7 +16,6 @@ from __future__ import annotations import base64 -import json import os import xml.etree.ElementTree as ET import zipfile @@ -739,282 +737,3 @@ def _convert_group_to_svg(node: ShapeNode, slide: _SlideRef, pkg: OoxmlPackage, parts.append(child_svg) parts.append("") return "\n".join(parts) - - -# ============================================================ -# 14. PPTX → HTML 预览(python-pptx) -# ============================================================ - -def convert_pptx_to_html(pptx_path: Path) -> str: - """将 PPTX 文件转为带翻页导航的完整 HTML 页面。 - - 使用 python-pptx 解析,提取文本/图片/形状/表格, - 生成绝对定位 HTML + 翻页控件 + 键盘/滚轮导航 + 拖动/缩放。 - """ - from pptx import Presentation # noqa: F811 - from pptx.enum.text import PP_ALIGN - - def _emu2px(emu): - if emu is None: - return 0 - return round(float(emu) / 914400 * 96, 1) - - def _fill_style(shape): - try: - f = shape.fill - if f.type is not None and f.type != 0: - if hasattr(f, 'fore_color') and f.fore_color.type is not None: - try: - return f"background:{f.fore_color.rgb};" - except Exception: - pass - except Exception: - pass - return "" - - def _line_style(shape): - try: - ln = shape.line - if ln.fill.type is not None and ln.fill.type != 0: - w = _emu2px(ln.width) if ln.width else 0.5 - c = "#999" - try: - if hasattr(ln, 'color') and ln.color.type is not None: - c = str(ln.color.rgb) - except Exception: - pass - return f"border:{w}px solid {c};" - except Exception: - pass - return "" - - def _run_style(run): - s = [] - f = run.font - if f.size: - s.append(f"font-size:{_emu2px(f.size)}px") - if f.bold: - s.append("font-weight:bold") - if f.italic: - s.append("font-style:italic") - if f.underline: - s.append("text-decoration:underline") - try: - if f.color and f.color.type is not None: - s.append(f"color:{f.color.rgb}") - except Exception: - pass - if f.name: - s.append(f'font-family:"{f.name}",sans-serif') - return ";".join(s) - - def _align(para): - a = para.alignment - if a == PP_ALIGN.CENTER: - return "text-align:center;" - elif a == PP_ALIGN.RIGHT: - return "text-align:right;" - elif a == PP_ALIGN.JUSTIFY: - return "text-align:justify;" - return "" - - def _esc(s): - return s.replace("&", "&").replace("<", "<").replace(">", ">") - - def _render_text(tf, l, t, w, h): - parts = [] - for para in tf.paragraphs: - al = _align(para) - runs = [] - for run in para.runs: - st = _run_style(run) - txt = _esc(run.text).replace("\n", "
") - runs.append(f'{txt}' if st else txt) - line = "".join(runs) or " " - m = "" - if para.space_after: - m += f"margin-bottom:{_emu2px(para.space_after)}px;" - if para.space_before: - m += f"margin-top:{_emu2px(para.space_before)}px;" - parts.append(f'

{line}

') - inner = "".join(parts) - pad = "" - try: - pad = f"padding-left:{_emu2px(tf.margin_left or 0)}px;padding-right:{_emu2px(tf.margin_right or 0)}px;padding-top:{_emu2px(tf.margin_top or 0)}px;" - except Exception: - pass - return ( - f'
{inner}
' - ) - - def _render_pic(shape): - l, t, w, h = _emu2px(shape.left), _emu2px(shape.top), _emu2px(shape.width), _emu2px(shape.height) - try: - img = shape.image - ext = img.content_type.split("/")[-1] - if ext == "jpeg": - ext = "jpg" - b64 = base64.b64encode(img.blob).decode() - src = f"data:image/{ext};base64,{b64}" - except Exception: - src = "" - return f'' - - def _render_shape(shape): - l, t, w, h = _emu2px(shape.left), _emu2px(shape.top), _emu2px(shape.width), _emu2px(shape.height) - fill = _fill_style(shape) - line = _line_style(shape) - rad = "" - try: - if shape.auto_shape_type and "ROUNDED" in str(shape.auto_shape_type): - rad = "border-radius:6px;" - except Exception: - pass - if shape.has_text_frame: - inner = _render_text(shape.text_frame, 0, 0, w, h) - return ( - f'
{inner.replace("position:absolute;", "position:relative;")}
' - ) - return f'
' - - def _render_table(shape): - l, t, w = _emu2px(shape.left), _emu2px(shape.top), _emu2px(shape.width) - rows = [] - for row in shape.table.rows: - cells = [] - for cell in row.cells: - txt = _esc(cell.text) or " " - cells.append(f'{txt}') - rows.append(f'{"".join(cells)}') - return f'{"".join(rows)}
' - - def _render_group(shape): - l, t, w, h = _emu2px(shape.left), _emu2px(shape.top), _emu2px(shape.width), _emu2px(shape.height) - kids = [] - try: - for child in shape.shapes: - kids.append(_el(child)) - except Exception: - pass - return f'
{"".join(kids)}
' - - def _el(shape): - if shape.shape_type is None: - return "" - st = str(shape.shape_type) - if "PICTURE" in st: - return _render_pic(shape) - elif "TABLE" in st: - return _render_table(shape) - elif "GROUP" in st: - return _render_group(shape) - elif "PLACEHOLDER" in st or "TEXT_BOX" in st: - if shape.has_text_frame: - return _render_text(shape.text_frame, _emu2px(shape.left), _emu2px(shape.top), _emu2px(shape.width), _emu2px(shape.height)) - return "" - elif "AUTO_SHAPE" in st or "FREEFORM" in st or "RECTANGLE" in st or "OVAL" in st: - return _render_shape(shape) - else: - if hasattr(shape, 'has_text_frame') and shape.has_text_frame and shape.text_frame.text.strip(): - return _render_text(shape.text_frame, _emu2px(shape.left), _emu2px(shape.top), _emu2px(shape.width), _emu2px(shape.height)) - return "" - - # ---- main ---- - prs = Presentation(str(pptx_path)) - sw = _emu2px(prs.slide_width) - sh = _emu2px(prs.slide_height) - - slides_data = [] - for slide in prs.slides: - elems = [] - for shape in slide.shapes: - h = _el(shape) - if h: - elems.append(h) - slides_data.append( - f'
' - f'{"".join(elems)}
' - ) - - total = len(slides_data) - slides_json = json.dumps(slides_data) - name = pptx_path.name - - return f""" - - -{name} - - -
- -""" diff --git a/src/web/css/composer.css b/src/web/css/composer.css index 517ffb2..0d1fa69 100644 --- a/src/web/css/composer.css +++ b/src/web/css/composer.css @@ -221,6 +221,30 @@ position: relative; } +/* 拖拽附件到输入区时:显示"添加附件"提示 */ +.composer-input-shell.is-drag-attach { + outline: 2px dashed color-mix(in srgb, var(--accent) 55%, transparent); + outline-offset: 3px; + border-radius: 12px; + transition: outline-color 0.15s ease; +} +.composer-input-shell.is-drag-attach::after { + content: "添加附件"; + position: absolute; + inset: 0; + z-index: 5; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.95rem; + font-weight: 600; + letter-spacing: 0.05em; + color: var(--accent); + background: color-mix(in srgb, var(--accent) 10%, transparent); + border-radius: 12px; + pointer-events: none; +} + .mm-input textarea { width: 100%; resize: vertical; @@ -1086,6 +1110,18 @@ background: color-mix(in srgb, var(--accent) 20%, transparent); } +/* 文件预览:表格按列着色(覆盖上面的 display:block,单元格保持 inline) */ +.fp-table-wrap .fp-code-lines { + word-break: normal; +} +.fp-code-lines .fp-tbl-cell { + display: inline; + padding-right: 0.5em; +} +.fp-code-lines .fp-tbl-cell--header { + font-weight: 600; +} + .file-preview-body svg { max-width: 100%; max-height: 75vh; @@ -1319,18 +1355,18 @@ pointer-events: none; } .token-bar-seg--green { - background: var(--accent, #0e7490); + background: var(--success, #22c55e); opacity: 0.45; z-index: 1; border-radius: 3px 0 0 3px; } .token-bar-seg--yellow { - background: var(--accent, #0e7490); + background: var(--warning, #f59e0b); opacity: 0.7; z-index: 1; } .token-bar-seg--red { - background: var(--accent, #0e7490); + background: var(--danger, #ef4444); opacity: 0.85; z-index: 1; border-radius: 0 3px 3px 0; @@ -1374,16 +1410,16 @@ box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 25%, transparent); } .token-handle--hard .token-handle-dot { - background: #d4756b; - box-shadow: 0 0 0 2px rgba(212, 117, 107, 0.2); + background: var(--danger, #d4756b); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--danger) 25%, transparent); } .token-handle.overshot .token-handle-dot { animation: handlePulse 0.6s ease-in-out infinite alternate; } @keyframes handlePulse { - from { box-shadow: 0 0 0 2px rgba(212, 117, 107, 0.15); } - to { box-shadow: 0 0 0 5px rgba(212, 117, 107, 0.1); } + from { box-shadow: 0 0 0 2px color-mix(in srgb, var(--danger) 15%, transparent); } + to { box-shadow: 0 0 0 5px color-mix(in srgb, var(--danger) 10%, transparent); } } .token-label { @@ -1397,4 +1433,6 @@ transition: opacity 0.25s, color 0.25s; } -.token-label--safe { color: #22c55e; } +.token-label--safe { color: var(--success, #22c55e); } +.token-label--warn { color: var(--warning, #f59e0b); } +.token-label--danger { color: var(--danger, #ef4444); } diff --git a/src/web/css/settings.css b/src/web/css/settings.css index 953bc72..3a48146 100644 --- a/src/web/css/settings.css +++ b/src/web/css/settings.css @@ -87,6 +87,41 @@ margin-top: 0.4rem; } +/* ===== 背景图片选择器 ===== */ +.bg-input-row { + display: flex; + gap: 0.4rem; + align-items: center; +} + +.bg-input-row input[type="text"] { + flex: 1; +} + +.bg-picker-btn { + flex-shrink: 0; + width: 2.2rem; + height: 2.2rem; + padding: 0; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text-tertiary); + font-size: 1.1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + display: flex; + align-items: center; + justify-content: center; +} + +.bg-picker-btn:hover { + background: color-mix(in srgb, var(--accent) 8%, transparent); + color: var(--accent); + border-color: var(--accent); +} + /* ===== 设置按钮 ===== */ .settings-btn { display: inline-flex; diff --git a/src/web/css/toolcalls.css b/src/web/css/toolcalls.css index 5dd1479..5b1392a 100644 --- a/src/web/css/toolcalls.css +++ b/src/web/css/toolcalls.css @@ -43,7 +43,13 @@ overflow: hidden; } .tool-call-list-wrap--expanded .tc-list-toggle-body { - max-height: 8000px; + overflow-y: auto; + max-height: 75vh; +} +/* flex 子项保持自然高度,避免工具记录多时被 flex-shrink 压缩导致内容不可见, + 由 .tc-list-toggle-body 自身滚动展示全部记录 */ +.tc-list-toggle-body > * { + flex-shrink: 0; } /* 单条工具调用行(无框) */ @@ -179,9 +185,14 @@ overflow: hidden; } .tool-call-row--expanded + .tc-detail { - max-height: 600px; + max-height: 45vh; + overflow-y: auto; padding: 0.3rem 0.45rem; } +/* .tc-detail 同为 flex column,内部子项保持自然高度,避免被压缩导致结果不可见 */ +.tc-detail > * { + flex-shrink: 0; +} .tc-detail-text { max-height: 200px; overflow-y: auto; diff --git a/src/web/image/background.jpg b/src/web/image/background.jpg index 9dc3669..a3d3455 100644 Binary files a/src/web/image/background.jpg and b/src/web/image/background.jpg differ diff --git a/src/web/image/background2.jpg b/src/web/image/background2.jpg index e677df8..9d7b999 100644 Binary files a/src/web/image/background2.jpg and b/src/web/image/background2.jpg differ diff --git a/src/web/index.html b/src/web/index.html index d19611c..89d1f30 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -457,7 +457,11 @@

自定义背景

输入本地图片路径或 URL(支持 jpg/png/webp),留空恢复默认。

- +
+ + + +
diff --git a/src/web/js/edit-mode.js b/src/web/js/edit-mode.js index 9b59bf4..4c8489f 100644 --- a/src/web/js/edit-mode.js +++ b/src/web/js/edit-mode.js @@ -59,6 +59,12 @@ function isPreviewableHtml(name) { return (name || '').toLowerCase().endsWith('.html') || (name || '').toLowerCase().endsWith('.htm'); } +/** 二进制表格(XLSX/XLS):由后端 /api/table/text 提取为 TSV 后按列着色预览 */ +function isPreviewableTable(name) { + const ext = (name || '').toLowerCase(); + return ext.endsWith('.xlsx') || ext.endsWith('.xls'); +} + const BINARY_EXTS = new Set([ '.exe','.dll','.so','.dylib','.bin','.dat','.zip','.rar','.7z','.tar','.gz','.bz2','.xz', '.png','.jpg','.jpeg','.gif','.bmp','.ico','.webp','.tiff','.tif','.psd','.ai','.eps', @@ -78,7 +84,19 @@ function isBinaryFile(name) { } // ===== 模式切换 ===== +let _modeSwitchCooldown = false; + export function switchToMode(mode) { + // 防连点:短时间内禁止重复切换 + if (_modeSwitchCooldown) return; + _modeSwitchCooldown = true; + setTimeout(() => { _modeSwitchCooldown = false; }, 300); + + // Agent 运行中禁止切换到 cron/edit 模式,避免 chat 区域被替换导致工具调用和思考历史消失 + if (state.sending && mode !== "chat") { + showToast("Agent 正在运行中,请等待完成后再切换模式"); + return; + } state.mode = mode; if (mode === "edit") { state.editMode = true; @@ -155,6 +173,15 @@ export async function openFolder(folderPath) { } if (!rootPath) return; + // 先校验目录存在并列出文件;文件夹被删除/不可访问时给出明确提示,避免把失效路径写入状态 + let listRes; + try { + listRes = await listDir(rootPath, 10); + } catch (e) { + showToast("打开文件夹失败: " + (e.message || e)); + return; + } + state._docRootPath = rootPath; state._docRootName = rootPath.split(/[/\\]/).pop() || rootPath; @@ -166,7 +193,6 @@ export async function openFolder(folderPath) { if (!state._docTreeFolders) state._docTreeFolders = {}; state._docTreeFolders[state._docRootName] = true; - const listRes = await listDir(rootPath, 10); const files = listRes.files || []; const supportedFiles = files.filter((f) => !f.is_dir && isSupportedFile(f.name)); // 去重:按 path 去重 @@ -257,6 +283,19 @@ export async function loadDocContent(filePath) { openFilePreview(filePath, fileName, res.content || '', { status, onDownload: () => downloadFile(filePath, fileName) }); } + } else if (isPreviewableTable(fileName)) { + // XLSX/XLS:后端提取为 TSV 文本,走与 CSV/TSV 一致的按列着色预览 + const absPath = (/^[A-Za-z]:[\\/]/.test(filePath) || filePath.startsWith('/') || filePath.startsWith('\\')) + ? filePath + : state._docRootPath.replace(/[\\/]+$/, '') + '/' + filePath.replace(/^[\\/]+/, ''); + try { + const tsv = await api('/api/table/text?path=' + encodeURIComponent(absPath)); + openFilePreview(absPath, fileName, (tsv && tsv.content) || '', + { status, onDownload: () => downloadFile(absPath, fileName) }); + } catch (e) { + openFilePreview(absPath, fileName, '(加载失败: ' + (e.message || e) + ')', + { status, onDownload: () => downloadFile(absPath, fileName) }); + } } else if (isBinaryFile(fileName)) { showToast('二进制文件不支持在线查看', 'warning'); } else { @@ -309,6 +348,20 @@ async function downloadFile(filePath, fileName) { a.href = url; a.download = fileName; document.body.appendChild(a); a.click(); document.body.removeChild(a); } + } else if (isBinaryFile(fileName)) { + // 二进制文件(xlsx/docx/pdf 等):按 base64 还原字节后下载 + const res = await readBinaryFile(filePath, state._docRootPath); + if (res && res.status === 'ok' && res.data) { + const bin = atob(res.data); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + const blob = new Blob([bytes], { type: res.mime || 'application/octet-stream' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = fileName; + document.body.appendChild(a); a.click(); document.body.removeChild(a); + URL.revokeObjectURL(url); + } } else { const res = await readTextFile(filePath, state._docRootPath); if (res && res.status === 'ok') { @@ -737,17 +790,37 @@ function setupInputDropHandler() { const target = composerInput || $('textInput'); if (!target) return; + // 子元素间移动会成对触发 dragenter/dragleave,用计数器避免状态误清除 + let dragDepth = 0; + const setDragState = (on) => target.classList.toggle('is-drag-attach', on); + const isAcceptedDrag = (types) => types.includes('application/doc-path') || types.includes('Files'); + + target.addEventListener('dragenter', (e) => { + if (isAcceptedDrag(Array.from(e.dataTransfer.types))) { + dragDepth++; + setDragState(true); + } + }); + target.addEventListener('dragover', (e) => { const types = Array.from(e.dataTransfer.types); // 接受应用内文件/文件夹引用拖拽 或 外部文件拖拽 if (types.includes('application/doc-path') || types.includes('Files')) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; + setDragState(true); } }); + target.addEventListener('dragleave', () => { + dragDepth = Math.max(0, dragDepth - 1); + if (dragDepth === 0) setDragState(false); + }); + target.addEventListener('drop', async (e) => { e.preventDefault(); + dragDepth = 0; + setDragState(false); // 1. 内部拖拽(doc-tree 文件/文件夹引用) // app.js 检测到 application/doc-path 也会 return,无需 stopPropagation diff --git a/src/web/js/file-preview.js b/src/web/js/file-preview.js index c9df344..03b0b71 100644 --- a/src/web/js/file-preview.js +++ b/src/web/js/file-preview.js @@ -6,8 +6,9 @@ import { $, escapeHtml } from './utils.js'; const IMG_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.ico']); const HTML_EXTS = new Set(['.html', '.htm']); const TEXT_EXTS = new Set(['.txt', '.json', '.md', '.py', '.js', '.ts', '.css', '.xml', '.yaml', '.yml', '.toml', - '.csv', '.log', '.env', '.ini', '.cfg', '.sh', '.bat', '.ps1', '.sql', '.r', '.go', '.rs', '.rb', + '.csv', '.tsv', '.log', '.env', '.ini', '.cfg', '.sh', '.bat', '.ps1', '.sql', '.r', '.go', '.rs', '.rb', '.php', '.swift', '.kt', '.scala', '.cpp', '.c', '.h', '.java', '.m', '.svg']); +const TABLE_EXTS = new Set(['.csv', '.tsv', '.xlsx', '.xls']); let _currentFilePath = null; let _currentContent = null; @@ -33,6 +34,13 @@ function isTextFile(fileName) { return dot >= 0 && TEXT_EXTS.has(ext.substring(dot)); } +/** 判断表格文件(CSV/TSV 文本按列着色;XLSX/XLS 由后端提取为 TSV 后同样按列着色) */ +function isTableFile(fileName) { + const ext = (fileName || '').toLowerCase(); + const dot = ext.lastIndexOf('.'); + return dot >= 0 && TABLE_EXTS.has(ext.substring(dot)); +} + /** * 打开文件预览浮窗 * @param {string} filePath - 文件路径 @@ -84,6 +92,8 @@ export function openFilePreview(filePath, fileName, content, opts = {}) { _renderImage(body, content, fileName); } else if (isHtml(fileName) && !opts.asText) { _renderPlainText(body, content); + } else if (isTableFile(fileName)) { + _renderTable(body, content, fileName); } else if (isTextFile(fileName)) { _renderPlainText(body, content); } else if (content && content.startsWith && content.startsWith('data:')) { @@ -162,6 +172,96 @@ function _renderPlainText(body, content) { codeLines.addEventListener('contextmenu', (e) => _onCodeContextMenu(e)); } +// ===== 表格预览(复用 plaintext 预览 UI,不同列不同颜色) ===== + +/** 按主题区分的列色板(暗色 / 亮色各一套,保证可读性) */ +const TABLE_COLUMN_COLORS = { + dark: ['#e06c75', '#61afef', '#98c379', '#e5c07b', '#c678dd', '#56b6c2', '#d19a66', '#f472b6', '#7f9cf5', '#4ade80'], + light: ['#c0392b', '#2471a3', '#1e8449', '#b7950b', '#7d3c98', '#117a65', '#ca6f1e', '#a93226', '#2e86c1', '#229954'], +}; + +/** 判断分隔符:TSV 用 tab,CSV 用逗号,XLSX/XLS(后端已提取为 TSV)用 tab,其他按首行内容猜测 */ +function _getDelimiter(fileName, content) { + const ext = (fileName || '').toLowerCase(); + if (ext.endsWith('.tsv') || ext.endsWith('.xlsx') || ext.endsWith('.xls')) return '\t'; + if (ext.endsWith('.csv')) return ','; + const firstLine = (content || '').split('\n')[0] || ''; + return firstLine.includes('\t') ? '\t' : ','; +} + +/** 解析单行分隔符文本(支持引号包裹的字段,如 "a,b") */ +function _splitDelimited(line, delim) { + const cells = []; + let cur = ''; + let inQuotes = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (inQuotes) { + if (ch === '"') { + if (line[i + 1] === '"') { cur += '"'; i++; } + else inQuotes = false; + } else cur += ch; + } else if (ch === '"') { + inQuotes = true; + } else if (ch === delim) { + cells.push(cur); + cur = ''; + } else { + cur += ch; + } + } + cells.push(cur); + return cells; +} + +/** 表格预览:保留行号/选中引用交互,单元格按列着色 */ +function _renderTable(body, content, fileName) { + const text = content || ''; + const delim = _getDelimiter(fileName, text); + const lines = text.split('\n'); + const colors = document.body.classList.contains('theme-dark') ? TABLE_COLUMN_COLORS.dark : TABLE_COLUMN_COLORS.light; + + const wrap = document.createElement('div'); + wrap.className = 'fp-code-wrap fp-table-wrap'; + + // 行号列 + const lineNos = document.createElement('div'); + lineNos.className = 'fp-line-nos'; + for (let i = 1; i <= lines.length; i++) { + const span = document.createElement('span'); + span.textContent = i; + lineNos.appendChild(span); + } + + // 数据列:每行一个 span,单元格按列序号着色,首行作为表头加粗 + const codeLines = document.createElement('div'); + codeLines.className = 'fp-code-lines'; + lines.forEach((line, i) => { + const row = document.createElement('span'); + row.setAttribute('data-line', i + 1); + const cells = _splitDelimited(line, delim); + if (cells.length === 1 && !line.trim()) { + row.textContent = '\u00A0'; + } else { + cells.forEach((cell, ci) => { + const cellEl = document.createElement('span'); + cellEl.className = 'fp-tbl-cell' + (i === 0 ? ' fp-tbl-cell--header' : ''); + cellEl.textContent = cell; + cellEl.style.color = colors[ci % colors.length]; + row.appendChild(cellEl); + }); + } + codeLines.appendChild(row); + }); + + wrap.appendChild(lineNos); + wrap.appendChild(codeLines); + body.appendChild(wrap); + + // 右键菜单:引用选中行 + codeLines.addEventListener('contextmenu', (e) => _onCodeContextMenu(e)); +} + function _onCodeContextMenu(e) { const sel = window.getSelection(); if (!sel || sel.isCollapsed || !sel.toString().trim()) return; diff --git a/src/web/js/pending-overlay.js b/src/web/js/pending-overlay.js index 124f56a..6a02867 100644 --- a/src/web/js/pending-overlay.js +++ b/src/web/js/pending-overlay.js @@ -211,12 +211,6 @@ function buildPendingCard(pending, isSubAgent) { inputRow.appendChild(sendBtn); cardBody.appendChild(inputRow); - - // 跳过按钮 - const btnRow = document.createElement("div"); - btnRow.className = "pending-overlay-btn-row"; - btnRow.appendChild(makeDecisionBtn("跳过", "skip", "", () => submitSingle(pending.id, "skip", "", card))); - cardBody.appendChild(btnRow); } else if (interactionType === "selection") { if (pending.questions && Array.isArray(pending.questions) && pending.questions.length > 0) { pending.questions.forEach((q) => { @@ -289,12 +283,6 @@ function buildPendingCard(pending, isSubAgent) { }); cardBody.appendChild(pillsRow); } - - // 跳过按钮 - const btnRow = document.createElement("div"); - btnRow.className = "pending-overlay-btn-row"; - btnRow.appendChild(makeDecisionBtn("跳过", "skip", "", () => submitSingle(pending.id, "skip", "", card))); - cardBody.appendChild(btnRow); } card.appendChild(cardBody); diff --git a/src/web/js/settings.js b/src/web/js/settings.js index 0cc0d7c..964eceb 100644 --- a/src/web/js/settings.js +++ b/src/web/js/settings.js @@ -139,16 +139,19 @@ function buildAgentCard(agent, idx) { header.className = "agent-card-header"; const h4 = document.createElement("h4"); h4.textContent = `Agent: ${agent.name || "(\u672A\u547D\u540D)"}`; - const removeBtn = document.createElement("button"); - removeBtn.className = "agent-card-remove"; - removeBtn.textContent = "\u2715"; - removeBtn.title = "\u5220\u9664\u6B64 Agent"; - removeBtn.addEventListener("click", () => { - state.cachedAgentConfigs.splice(idx, 1); - renderAgentConfigs(); - }); header.appendChild(h4); - header.appendChild(removeBtn); + // 主 Agent 不可删除 + if (agent.role !== "main") { + const removeBtn = document.createElement("button"); + removeBtn.className = "agent-card-remove"; + removeBtn.textContent = "\u2715"; + removeBtn.title = "\u5220\u9664\u6B64 Agent"; + removeBtn.addEventListener("click", () => { + state.cachedAgentConfigs.splice(idx, 1); + renderAgentConfigs(); + }); + header.appendChild(removeBtn); + } const nameField = makeField("\u540D\u79F0", "text", agent.name || "", (v) => { agent.name = v; }); const descField = makeField("\u80FD\u529B\u63CF\u8FF0 (description)", "text", agent.description || "", (v) => { agent.description = v; }); @@ -1135,6 +1138,8 @@ function renderThemePanel() { const bgApplyBtn = $("bgImageApplyBtn"); const bgResetBtn = $("bgImageResetBtn"); const bgStatus = $("bgPreviewStatus"); + const bgPickerBtn = $("bgImagePickerBtn"); + const bgFileInput = $("bgImageFileInput"); if (bgInput) { bgInput.value = getBgImage(); const applyBg = () => { @@ -1154,6 +1159,37 @@ function renderThemePanel() { } // 回车也应用 bgInput.onkeydown = (e) => { if (e.key === 'Enter') applyBg(); }; + + // 图片选择器按钮点击事件 + if (bgPickerBtn && bgFileInput) { + bgPickerBtn.onclick = () => { + bgFileInput.click(); + }; + + // 文件选择事件 + bgFileInput.onchange = (e) => { + const file = e.target.files?.[0]; + if (file) { + // 使用文件路径(Electron 环境)或创建临时 URL(Web 环境) + if (window.electronAPI && window.electronAPI.getFilePath) { + // Electron 环境:获取文件路径 + const filePath = window.electronAPI.getFilePath(file); + bgInput.value = filePath; + applyBg(); + } else { + // Web 环境:创建临时 URL + const reader = new FileReader(); + reader.onload = (event) => { + bgInput.value = event.target?.result || ""; + applyBg(); + }; + reader.readAsDataURL(file); + } + } + // 清空 input 以便重复选择同一文件 + bgFileInput.value = ""; + }; + } } // 背景模糊度 diff --git a/src/web/js/themes.js b/src/web/js/themes.js index b573f69..3374e9d 100644 --- a/src/web/js/themes.js +++ b/src/web/js/themes.js @@ -583,12 +583,40 @@ export const THEMES = { }; const DEFAULT_THEME = "vscode-dark"; -const STORAGE_KEY = "minor_theme"; -const STORAGE_BG_KEY = "minor_bg_image"; -const STORAGE_FONT_KEY = "minor_font"; -const STORAGE_FONT_SIZE_KEY = "minor_font_size"; -const STORAGE_BLUR_KEY = "minor_bg_blur"; -const STORAGE_VIGNETTE_KEY = "minor_bg_vignette"; + +// 主题设置统一内存态:全部落盘到服务端 theme_config.json(不使用 localStorage) +let _settings = { + theme: DEFAULT_THEME, + bg_image: "", + font_family: "", + font_size: "", + bg_blur: "", + bg_vignette: "", +}; +let _loadingTheme = false; // 从服务端加载期间不写回,避免覆盖已有配置 +let _persistTimer = null; + +/** 防抖写回服务端 theme_config.json(仅保存用户主动修改,加载期间跳过) */ +function _persistThemeConfig() { + if (_loadingTheme) return; + if (_persistTimer) clearTimeout(_persistTimer); + _persistTimer = setTimeout(() => { + _persistTimer = null; + try { + api("/api/config/theme", { + method: "POST", + body: JSON.stringify({ + theme: _settings.theme, + bg_image: _settings.bg_image, + font_family: _settings.font_family, + font_size: _settings.font_size, + bg_blur: _settings.bg_blur, + bg_vignette: _settings.bg_vignette, + }), + }); + } catch { /* ignore */ } + }, 300); +} // 字体预设 export const FONT_PRESETS = { @@ -611,14 +639,14 @@ export const FONT_SIZE_PRESETS = { "20px": "20px", }; -const getFontKey = (k) => ({ family: STORAGE_FONT_KEY, size: STORAGE_FONT_SIZE_KEY }[k]); - export function getFontSetting(key) { - try { return localStorage.getItem(getFontKey(key)) || ""; } catch { return ""; } + return key === "family" ? _settings.font_family : key === "size" ? _settings.font_size : ""; } export function setFontSetting(key, val) { - try { localStorage.setItem(getFontKey(key), val || ""); } catch {} + if (key === "family") _settings.font_family = val || ""; + else if (key === "size") _settings.font_size = val || ""; applyFontSetting(key, val); + _persistThemeConfig(); } export function applyFontSetting(key, val) { const r = document.documentElement; @@ -638,15 +666,12 @@ function localPathToUrl(localPath) { /** 获取/设置自定义背景图 */ export function getBgImage() { - try { return localStorage.getItem(STORAGE_BG_KEY) || ""; } - catch { return ""; } + return _settings.bg_image; } export function setBgImage(url) { - try { localStorage.setItem(STORAGE_BG_KEY, url || ""); } - catch {} - applyBgImage(url); - // 异步同步到服务端 - try { api("/api/config/theme", { method: "POST", body: JSON.stringify({ bg_image: url || "" }) }); } catch {} + _settings.bg_image = url || ""; + applyBgImage(_settings.bg_image); + _persistThemeConfig(); } export function applyBgImage(url) { const r = document.documentElement; @@ -666,8 +691,7 @@ export function applyBgImage(url) { /** 获取当前保存的主题 */ export function getCurrentTheme() { - try { return localStorage.getItem(STORAGE_KEY) || DEFAULT_THEME; } - catch { return DEFAULT_THEME; } + return _settings.theme; } /** 应用主题到页面 */ @@ -685,8 +709,9 @@ export function applyTheme(themeId) { Object.entries(theme.edit).forEach(([k, v]) => r.style.setProperty(k, v)); } - // 持久化 - try { localStorage.setItem(STORAGE_KEY, themeId); } catch {} + // 更新内存态并落盘(加载期间跳过,避免覆盖服务端已有配置) + _settings.theme = THEMES[themeId] ? themeId : DEFAULT_THEME; + _persistThemeConfig(); // 触发 body 类名切换(用于全局暗/亮模式判断) // 优先使用主题对象的 dark 字段;缺失时回退到 id 字符串推断 @@ -695,28 +720,27 @@ export function applyTheme(themeId) { document.body.classList.toggle("theme-light", !isDark); } -/** 初始化:从 localStorage 恢复主题,并从服务端同步背景图 */ +/** 初始化:先用默认主题渲染,再从服务端 theme_config.json 恢复全部设置 */ export function initTheme() { - const id = getCurrentTheme(); - applyTheme(id); - // 先从 localStorage 快读,再从服务端同步(异步) - applyBgImage(getBgImage()); - applyFontSetting('family', getFontSetting('family')); - applyFontSetting('size', getFontSetting('size')); - applyBgBlur(getBgBlur()); - applyBgVignette(getBgVignette()); - // 异步从服务端同步主题配置 - loadThemeConfig(); - return id; + _loadingTheme = true; + applyTheme(DEFAULT_THEME); + applyBgImage(""); + applyFontSetting("family", ""); + applyFontSetting("size", ""); + applyBgBlur(""); + applyBgVignette(""); + loadThemeConfig(); // 完成后在 finally 中解除 _loadingTheme + return DEFAULT_THEME; } // ===== 背景模糊度 ===== export function getBgBlur() { - try { return localStorage.getItem(STORAGE_BLUR_KEY) || ""; } catch { return ""; } + return _settings.bg_blur; } export function setBgBlur(val) { - try { localStorage.setItem(STORAGE_BLUR_KEY, val || ""); } catch {} - applyBgBlur(val); + _settings.bg_blur = val || ""; + applyBgBlur(_settings.bg_blur); + _persistThemeConfig(); } export function applyBgBlur(val) { // val 为空字符串时使用主题默认值(由 applyTheme 设置),否则覆盖 @@ -730,11 +754,12 @@ export function applyBgBlur(val) { // ===== 背景暗角 ===== export function getBgVignette() { - try { return localStorage.getItem(STORAGE_VIGNETTE_KEY) || ""; } catch { return ""; } + return _settings.bg_vignette; } export function setBgVignette(val) { - try { localStorage.setItem(STORAGE_VIGNETTE_KEY, val || ""); } catch {} - applyBgVignette(val); + _settings.bg_vignette = val || ""; + applyBgVignette(_settings.bg_vignette); + _persistThemeConfig(); } export function applyBgVignette(val) { const r = document.documentElement; @@ -745,14 +770,39 @@ export function applyBgVignette(val) { } } -/** 从服务端加载主题配置(覆盖 localStorage 缓存) */ +/** 从服务端 theme_config.json 加载主题配置并应用(不使用 localStorage) */ export async function loadThemeConfig() { try { const res = await api("/api/config/theme"); - if (res.bg_image !== undefined) { - const img = res.bg_image || ""; - try { localStorage.setItem(STORAGE_BG_KEY, img); } catch {} - applyBgImage(img); + _loadingTheme = true; + if (res.theme && THEMES[res.theme]) { + applyTheme(res.theme); } - } catch {} + if (res.bg_image !== undefined) { + _settings.bg_image = res.bg_image || ""; + applyBgImage(_settings.bg_image); + } + if (res.font_family !== undefined) { + // 过滤旧版默认占位值(msjh 等非真实字体) + const fam = (res.font_family || "").trim(); + _settings.font_family = (fam && fam !== "msjh" && fam !== "msjhbd") ? fam : ""; + applyFontSetting("family", _settings.font_family); + } + if (res.font_size !== undefined) { + _settings.font_size = res.font_size || ""; + applyFontSetting("size", _settings.font_size); + } + if (res.bg_blur !== undefined) { + _settings.bg_blur = res.bg_blur || ""; + applyBgBlur(_settings.bg_blur); + } + if (res.bg_vignette !== undefined) { + _settings.bg_vignette = res.bg_vignette || ""; + applyBgVignette(_settings.bg_vignette); + } + } catch { + // 服务端不可达:保持默认设置 + } finally { + _loadingTheme = false; + } } diff --git a/src/web/js/toolcalls.js b/src/web/js/toolcalls.js index ff47cd1..1d54954 100644 --- a/src/web/js/toolcalls.js +++ b/src/web/js/toolcalls.js @@ -626,7 +626,9 @@ function renderToolCallRow(call, options = {}) { icon.title = isRunning ? "点击折叠" : "点击展开工具返回值"; icon.addEventListener("click", (e) => { e.stopPropagation(); - row.classList.toggle("tool-call-row--expanded"); + const nowExpanded = row.classList.toggle("tool-call-row--expanded"); + // 展开后若详情在可视区外,滚动到可见位置(block:nearest 已可见时不滚动) + if (nowExpanded) detailEl.scrollIntoView({ block: "nearest" }); }); } } diff --git a/src/web/server.py b/src/web/server.py index 89ec1db..818afc9 100644 --- a/src/web/server.py +++ b/src/web/server.py @@ -24,7 +24,7 @@ from typing import Any from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse, HTMLResponse, Response, StreamingResponse +from fastapi.responses import FileResponse, StreamingResponse _SRC = Path(__file__).resolve().parent.parent.parent if str(_SRC) not in sys.path: sys.path.insert(0, str(_SRC)) @@ -82,6 +82,23 @@ def _init_workspace(): """从 workspace_config.json 恢复上次选择的工作空间。""" cfg = _read_ws_config() current = cfg.get("current", "") + ws_list = cfg.get("list", []) + changed = False + + # 清理列表中已不存在的文件夹 + valid = [p for p in ws_list if os.path.isdir(p)] + if len(valid) != len(ws_list): + cfg["list"] = valid + changed = True + + # 上次选择的工作空间已不存在时,回退到默认工作空间 + if current and not os.path.isdir(current): + current = "" + cfg["current"] = "" + changed = True + + if changed: + _write_ws_config(cfg) if current: from agent.memory.system_prompt import set_current_workspace set_current_workspace(current) @@ -1145,9 +1162,17 @@ def api_get_workspace() -> dict[str, Any]: import os cfg = _read_ws_config() current = _current_workspace_dir or cfg.get("current", "") + ws_list = cfg.get("list", []) + + # 清理列表中已不存在的文件夹 + valid = [p for p in ws_list if os.path.isdir(p)] + if len(valid) != len(ws_list): + cfg["list"] = valid + _write_ws_config(cfg) + return { "current": current, - "list": cfg.get("list", []), + "list": valid, "default": get_workspace_dir(), "snapshots_base": os.path.join(SESSIONS_ROOT, ".minor_snapshots").replace("\\", "/"), } @@ -1340,87 +1365,6 @@ if ($folder) { Write-Output $folder.Self.Path } return {"path": None} -@app.get("/api/fs/list") -def api_fs_list(path: str = "", depth: int = 10) -> dict[str, Any]: - """列出目录下的所有文件(递归)。 - - 输入: path=str (必填,绝对路径), depth=int (最大递归深度,默认 10) - - 输出: {"files": [{path, name, is_dir, size}], "root": str} - """ - p = Path(path).resolve() - if not p.exists(): - raise HTTPException(404, f"路径不存在: {path}") - if not p.is_dir(): - raise HTTPException(400, f"不是目录: {path}") - - files = [] - _walk_dir(p, files, p, depth) - return {"files": files, "root": str(p)} - - -def _walk_dir(base: Path, out: list, root: Path, max_depth: int, current_depth: int = 0): - if current_depth > max_depth: - return - try: - for entry in sorted(base.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower())): - if entry.name.startswith(".") or entry.name == "node_modules" or entry.name == "__pycache__": - continue - rel = str(entry.relative_to(root)).replace("\\", "/") - if entry.is_dir(): - out.append({"path": rel, "name": entry.name, "is_dir": True, "size": 0}) - _walk_dir(entry, out, root, max_depth, current_depth + 1) - else: - out.append({"path": rel, "name": entry.name, "is_dir": False, "size": entry.stat().st_size}) - except PermissionError: - pass - - -@app.get("/api/fs/read") -def api_fs_read(path: str = "", raw: str = "") -> Any: - """读取文件内容。 - - 输入: path=str (必填,绝对路径), raw=str (可选,'1'=返回原始字节流) - """ - p = Path(path).resolve() - if not p.exists(): - raise HTTPException(404, f"文件不存在: {path}") - if not p.is_file(): - raise HTTPException(400, f"不是文件: {path}") - # 原始流模式:返回文件字节(用于图片/HTML 等预览) - if raw == "1": - try: - content_type = mimetypes.guess_type(str(p))[0] or "application/octet-stream" - return FileResponse(str(p), media_type=content_type) - except Exception: - raise HTTPException(500, "读取文件失败") - # 限制大小 5MB - if p.stat().st_size > 5 * 1024 * 1024: - raise HTTPException(413, "文件过大(>5MB)") - try: - content = p.read_text(encoding="utf-8", errors="replace") - except Exception: - content = p.read_text(encoding="latin-1", errors="replace") - return {"content": content, "path": str(p), "size": len(content)} - - -@app.post("/api/fs/write") -def api_fs_write(path: str = Form(""), content: str = Form("")) -> dict[str, Any]: - """写入文件内容。 - - 输入: path=str, content=str - - 输出: {"success": bool, "path": str} - """ - p = Path(path).resolve() - try: - p.parent.mkdir(parents=True, exist_ok=True) - p.write_text(content, encoding="utf-8") - return {"success": True, "path": str(p)} - except Exception as e: - raise HTTPException(500, f"写入失败: {e}") - - @app.post("/api/config/reload") def api_reload_configs() -> dict[str, Any]: """热重载所有配置并重建 Agent 运行时,无需重启服务。 @@ -1886,198 +1830,65 @@ def serve_image_asset(asset_name: str) -> FileResponse: return FileResponse(path, media_type=mime or "application/octet-stream") -@app.get("/api/pptx/preview") -def pptx_preview(path: str = "") -> Response: - """将 PPTX 文件转为带翻页导航的 HTML 预览页面。 +# ---------- 表格文本提取(XLSX/XLS → TSV,供前端按列着色的纯文本预览) ---------- + +@app.get("/api/table/text") +def table_text(path: str = "", sheet: str = "") -> dict[str, Any]: + """将表格文件(XLSX / XLS)内容提取为 TSV 文本。 输入: - path: PPTX 文件的绝对路径。 + path: 表格文件绝对路径。 + sheet: 可选的工作表名(不传时取第一个工作表)。 输出: - 包含所有幻灯片 + 翻页控件的 HTML 页面。 + {"content": str, "sheet": str, "sheets": list[str]} + content: TSV 分隔的表格文本(供前端按列着色渲染)。 """ if not path or not os.path.isfile(path): - return HTMLResponse("

PPTX 文件不存在

", status_code=404) - - from agent.utils.ppt_utils import convert_pptx_to_html - - try: - html_content = convert_pptx_to_html(Path(path)) - except Exception as e: - return HTMLResponse(f"

PPT 解析失败: {e}

", status_code=500) - - if not html_content: - return HTMLResponse("

PPTX 无幻灯片内容

") - - return HTMLResponse(html_content) - - -@app.get("/api/table/preview") -def table_preview(path: str = "", sheet: str = "") -> Response: - """将表格文件(CSV / XLSX)转为 HTML 表格预览页面。 - - 输入: - path: 表格文件的绝对路径。 - sheet: 可选的 sheet 名称(仅 XLSX)。 - - 输出: - 包含表格数据的 HTML 页面。 - """ - if not path or not os.path.isfile(path): - return HTMLResponse("

文件不存在

", status_code=404) - + raise HTTPException(status_code=404, detail=f"文件不存在: {path}") ext = os.path.splitext(path)[1].lower() - try: - if ext == ".csv": - html_content = _csv_to_html(path) - elif ext == ".xlsx": - html_content = _xlsx_to_html(path, sheet) - elif ext == ".xls": - html_content = _xls_to_html(path, sheet) - else: - return HTMLResponse("

不支持的表格格式

", status_code=400) - except Exception as e: - return HTMLResponse(f"

表格解析失败: {e}

", status_code=500) - - return HTMLResponse(html_content) + if ext == ".xlsx": + return _xlsx_to_tsv(path, sheet) + if ext == ".xls": + return _xls_to_tsv(path, sheet) + raise HTTPException(status_code=400, detail="不支持的表格格式") -def _csv_to_html(filepath: str) -> str: - """将 CSV 文件转为 HTML 表格。""" - import csv - - with open(filepath, "r", encoding="utf-8", errors="replace") as f: - sample = f.read(4096) - f.seek(0) - dialect = csv.Sniffer().sniff(sample) - reader = csv.reader(f, dialect) - rows = list(reader) - - if not rows: - return "

CSV 文件为空

" - - name = os.path.basename(filepath) - cols = max(len(r) for r in rows) - rows_html = [] - for i, row in enumerate(rows): - cells = [] - for j in range(cols): - val = row[j] if j < len(row) else "" - val = val.replace("&", "&").replace("<", "<").replace(">", ">") - tag = "th" if i == 0 else "td" - cells.append(f"<{tag}>{val or ' '}") - rows_html.append(f"{''.join(cells)}") - - return f""" - - -{name} - -{"".join(rows_html)}
""" - - -def _xlsx_to_html(filepath: str, sheet_name: str = "") -> str: - """将 XLSX 文件转为 HTML 表格。""" +def _xlsx_to_tsv(filepath: str, sheet_name: str = "") -> dict[str, Any]: + """将 XLSX 工作表内容提取为 TSV 文本。""" try: import openpyxl except ImportError: - return "

需要安装 openpyxl: pip install openpyxl

" - + return {"content": "(需要安装 openpyxl 才能预览 XLSX: pip install openpyxl)", "sheet": "", "sheets": []} wb = openpyxl.load_workbook(filepath, data_only=True, read_only=True) - name = os.path.basename(filepath) - - sheets = wb.sheetnames - if not sheets: - return "

XLSX 无工作表

" - - selected = sheet_name if sheet_name and sheet_name in sheets else sheets[0] - ws = wb[selected] - - rows_data = [] - max_col = 0 - for row in ws.iter_rows(values_only=True): - vals = [str(c) if c is not None else "" for c in row] - max_col = max(max_col, len(vals)) - rows_data.append(vals) - wb.close() - - if not rows_data: - return "

工作表为空

" - - sheet_tabs = "".join( - f'' - for s in sheets - ) if len(sheets) > 1 else "" - - rows_html = [] - for i, row in enumerate(rows_data): - cells = [] - for j in range(max_col): - val = row[j] if j < len(row) else "" - val = val.replace("&", "&").replace("<", "<").replace(">", ">") - tag = "th" if i == 0 else "td" - cells.append(f"<{tag}>{val or ' '}") - rows_html.append(f"{''.join(cells)}") - - import json as _json - sheets_json = _json.dumps(sheets) - enc_path = _json.dumps(filepath) - - return f""" - - -{name} - - -{sheet_tabs} -{"".join(rows_html)}
- -""" + try: + sheets = wb.sheetnames + if not sheets: + return {"content": "(无工作表)", "sheet": "", "sheets": []} + selected = sheet_name if sheet_name and sheet_name in sheets else sheets[0] + ws = wb[selected] + lines = [] + for row in ws.iter_rows(values_only=True): + vals = ["" if c is None else str(c) for c in row] + lines.append("\t".join(vals)) + finally: + wb.close() + return {"content": "\n".join(lines), "sheet": selected, "sheets": sheets} -def _xls_to_html(filepath: str, sheet_name: str = "") -> str: - """将 XLS 文件转为 HTML 表格(使用 xlrd)。""" +def _xls_to_tsv(filepath: str, sheet_name: str = "") -> dict[str, Any]: + """将 XLS 工作表内容提取为 TSV 文本。""" try: import xlrd except ImportError: - return "

需要安装 xlrd: pip install xlrd

" - + return {"content": "(需要安装 xlrd 才能预览 XLS: pip install xlrd)", "sheet": "", "sheets": []} wb = xlrd.open_workbook(filepath) - name = os.path.basename(filepath) - sheets = wb.sheet_names() if not sheets: - return "

XLS 无工作表

" - + return {"content": "(无工作表)", "sheet": "", "sheets": []} selected = sheet_name if sheet_name and sheet_name in sheets else sheets[0] ws = wb.sheet_by_name(selected) - - rows_data = [] - max_col = ws.ncols + lines = [] for r in range(ws.nrows): vals = [] for c in range(ws.ncols): @@ -2087,58 +1898,9 @@ def _xls_to_html(filepath: str, sheet_name: str = "") -> str: v = xlrd.xldate_as_datetime(v, wb.datemode) except Exception: pass - vals.append(str(v) if v is not None else "") - rows_data.append(vals) - - if not rows_data: - return "

工作表为空

" - - import json as _json - enc_path = _json.dumps(filepath) - - sheet_tabs = "".join( - f'' - for s in sheets - ) if len(sheets) > 1 else "" - - rows_html = [] - for i, row in enumerate(rows_data): - cells = [] - for j in range(max_col): - val = row[j] if j < len(row) else "" - val = val.replace("&", "&").replace("<", "<").replace(">", ">") - tag = "th" if i == 0 else "td" - cells.append(f"<{tag}>{val}") - rows_html.append(f"{''.join(cells)}") - - return f""" - - -{name} - - -{sheet_tabs} -{"".join(rows_html)}
- -""" + vals.append("" if v is None else str(v)) + lines.append("\t".join(vals)) + return {"content": "\n".join(lines), "sheet": selected, "sheets": sheets} @app.get("/")