diff --git a/README.md b/README.md index bbd200b..b829c88 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ Agent 运行过程中的关键交互界面,支持文件从任意位置**拖拽 ┌──────────────────────────────────────────────────────────────┐ │ LangGraph ReAct 图 (core/graph.py) │ │ agent 节点 → tools 节点 → process_tool_artifact 节点 │ -│ 内含: 反思注入 · 压缩 · 循环检测 · 路由 │ +│ 内含: 动态尾部注入 · 压缩 · 循环检测 · 路由 │ │ 人机交互: 阻塞式普通工具(core/human_request.py) │ └───────────────────────────────┬──────────────────────────────┘ │ 调用 @@ -171,7 +171,7 @@ START → [agent: call_model] ──有 tool_calls──▶ [tools: 并行工具 END ``` -- **`call_model`**(`core/nodes.py`):注入反思提示 → LLM 调用 → 每步检查压缩(token 超阈值标记)→ 提取 thinking +- **`call_model`**(`core/nodes.py`):追加动态尾部(TodoList 状态 + 循环提醒;固定反思引导已并入系统提示词前缀)→ LLM 调用 → 每步检查压缩(token 超阈值标记)→ 提取 thinking - **`should_continue`**(`core/routing.py`):含 tool_calls → 循环检测 → 路由到 `tools` 或 `END`(需人工确认的工具不在此暂停,而是在工具执行钳点阻塞征求用户意见,见「阻塞式人机交互」) - **`process_tool_artifact`**:gui 截图作为 synthetic HumanMessage 注入,实现视觉闭环 - **`compress`**(`core/nodes.py`):token 超 `窗口×COMPRESS_RATE` 时,将历史工具调用压缩为累积摘要(见「实现细节」) @@ -433,11 +433,23 @@ cloudflared tunnel --url http://localhost:8765 | 层级 | 位置 | 注入时机 | 作用 | |------|------|----------|------| -| **L1 系统级** | `memory/system_prompt.py` | 会话开始 | `MAIN_SYSTEM_PROMPT` 定义角色;`PLAN_MODE_PROMPT` 强制先规划 | +| **L1 系统级** | `memory/system_prompt.py` | 会话开始 | `MAIN_SYSTEM_PROMPT` 定义角色;`PLAN_MODE_PROMPT` 强制先规划;固定反思引导 `REFLECTION_PROMPT` 并入系统提示词前缀(思考关闭档位,前缀缓存友好) | | **L2 工具描述** | 各 `tools/*.py` 的 `description` | 工具绑定时 | 指导 LLM 正确调用(含 one-shot 示例) | -| **L3 运行时注入** | `nodes.py` / `loop_detector.py` | 每轮 ReAct | `REFLECTION_PROMPT` 反思 + 反循环警告,作为 synthetic HumanMessage | +| **L3 运行时动态尾部** | `nodes.py` / `loop_detector.py` | 每轮 ReAct | 变化部分(TodoList 状态 + 循环警告)作为动态尾部追加到消息**末尾**,并持久化为长期记忆(`session_meta.dynamic_tail_history`)供后续轮次查看 | | **L4 内部子模型** | `gui.py` `_build_batch_prompt` | 工具内部 | 批量坐标定位,one-shot 强制纯坐标输出 | +### 1.1 前缀缓存(Prompt Caching) + +上下文按「前缀稳定性」布局,最大化利用 LLM 服务端的前缀缓存: + +``` +[系统提示词(含固定反思引导,稳定)] + [压缩摘要] + [对话历史] + [用户消息] + [动态尾部(每轮变化)] +``` + +- **固定部分放前缀**:反思引导等不变提示文本并入系统提示词、位于上下文最前 —— 前缀稳定即可命中缓存;避免把固定文本逐轮塞在消息中间导致整段缓存失效。 +- **变化部分放末尾**:TodoList 状态、循环警告等每轮变化的内容追加在消息列表**末尾**,变化只影响尾部、不破坏前缀缓存;无 TodoList 且无循环提醒时**不注入任何尾部提示**(省 token)。 +- **动态尾部作为长期记忆**:每轮尾部文本持久化到 `session_meta` 的 `dynamic_tail_history`(连续相同去重),后续轮次把全部历史尾部注入上下文,Agent 能看到之前所有轮的 TodoList 状态与循环提醒。 + ### 2. 上下文分级压缩(图内) @@ -499,7 +511,7 @@ LLM 发出 human_interaction / 需确认工具调用 | 单步执行(多数项目) | 2N + 1 | | **序列合并(本项目)** | **2**(1 次批量定位 + 1 次决策) | -通过 `MAIN_SYSTEM_PROMPT` + `REFLECTION_PROMPT` 双重强调"同页操作必须合并为一次 `gui` 调用"实现。 +通过系统提示词前缀中的固定反思引导(`REFLECTION_PROMPT`)+ `MAIN_SYSTEM_PROMPT` 双重强调"同页操作必须合并为一次 `gui` 调用"实现。 ### 6. 双模式源码共用 diff --git a/src/agent/core/config_manager.py b/src/agent/core/config_manager.py index 4403fa3..9897f5f 100644 --- a/src/agent/core/config_manager.py +++ b/src/agent/core/config_manager.py @@ -16,6 +16,7 @@ from __future__ import annotations import json import threading +import time import uuid from dataclasses import dataclass, field from pathlib import Path @@ -114,7 +115,7 @@ def load_agent_configs() -> list[AgentConfig]: (忽略 JSON 中的静态值),确保提示词可热更新且包含正确的动态路径。 对于 role='sub' 的 Agent,若未指定 system_prompt 则自动使用默认子 Agent 提示词。 """ - from agent.memory.system_prompt import DEFAULT_SUB_AGENT_PROMPT, get_main_system_prompt + from agent.memory.system_prompt import get_default_sub_agent_prompt, get_main_system_prompt raw = _read_json(AGENT_CONFIG_PATH) if not raw or not isinstance(raw, list): raw = _default_agent_config() @@ -128,9 +129,9 @@ def load_agent_configs() -> list[AgentConfig]: system_prompt = get_main_system_prompt() else: system_prompt = item.get("system_prompt", "") - # 子 Agent 未指定提示词时使用默认子 Agent 提示词 + # 子 Agent 未指定提示词时使用默认子 Agent 提示词(含固定反思引导段) if not system_prompt: - system_prompt = DEFAULT_SUB_AGENT_PROMPT + system_prompt = get_default_sub_agent_prompt() configs.append(AgentConfig( name=item.get("name", ""), description=item.get("description", ""), @@ -169,12 +170,25 @@ _TOOL_DEFAULT_AUTO_RULES: dict[str, list[dict]] = { } +# 工具参数信息缓存(工具注册表运行期静态,首次构建后复用,避免每次打开设置都实例化全部工具类) +_tool_parameters_cache: dict[str, list[dict]] | None = None +_tool_parameters_cache_lock = threading.Lock() + + def _get_tool_parameters() -> dict[str, list[dict]]: """获取每个工具的参数名和类型信息,供前端生成 auto_execute_rules 提示。 返回: {tool_name: [{"name": "action", "type": "Literal[open,close]"}, ...], ...} + + 说明: + 结果缓存于模块级(工具注册表运行期静态),避免设置页每次打开都 + 重新实例化全部工具类。 """ + global _tool_parameters_cache + with _tool_parameters_cache_lock: + if _tool_parameters_cache is not None: + return _tool_parameters_cache from agent.tools import _ALL_AVAILABLE_TOOL_CLASSES import typing result: dict[str, list[dict]] = {} @@ -199,6 +213,8 @@ def _get_tool_parameters() -> dict[str, list[dict]]: result[name] = params except Exception: result[name] = [] + with _tool_parameters_cache_lock: + _tool_parameters_cache = result return result @@ -454,10 +470,25 @@ def _serialize_monitors(monitors) -> list[dict]: return result +# 显示器列表短时缓存(5s TTL):设置页 loadAllConfigs 每次 Promise.all 都会请求 +# /api/config/gui,screeninfo 枚举显示器在部分系统上较慢,短时缓存避免重复枚举 +_live_monitors_cache: tuple[float, list[dict]] | None = None +_live_monitors_cache_lock = threading.Lock() +_LIVE_MONITORS_TTL = 5.0 + + def _get_live_monitors() -> list[dict]: - """实时获取当前连接的显示器列表(通过 screeninfo)。""" + """实时获取当前连接的显示器列表(通过 screeninfo),带 5s TTL 缓存。""" + global _live_monitors_cache + now = time.time() + with _live_monitors_cache_lock: + if _live_monitors_cache and now - _live_monitors_cache[0] < _LIVE_MONITORS_TTL: + return _live_monitors_cache[1] from screeninfo import get_monitors - return _serialize_monitors(get_monitors()) + monitors = _serialize_monitors(get_monitors()) + with _live_monitors_cache_lock: + _live_monitors_cache = (time.time(), monitors) + return monitors def load_gui_config() -> dict: diff --git a/src/agent/core/nodes.py b/src/agent/core/nodes.py index 7c7c8ef..51c7c4f 100644 --- a/src/agent/core/nodes.py +++ b/src/agent/core/nodes.py @@ -21,7 +21,7 @@ from langchain_core.messages import AIMessage, HumanMessage, ToolMessage from agent.core.loop_detector import build_loop_reflection_prompt from agent.core.state import AgentState -from agent.memory.system_prompt import REFLECTION_PROMPT, SUB_AGENT_OOM_PROMPT +from agent.memory.system_prompt import SUB_AGENT_OOM_PROMPT from agent.utils.image_utils import image_bytes_to_openai_image_url_part from agent.utils.tool_call_utils import normalize_tool_call, extract_reasoning_text from langchain_core.messages import SystemMessage @@ -115,29 +115,29 @@ def _filter_short_term_memory(messages: list, trajectory_rounds: int) -> list: return cleaned -def _build_reflection_prompt(messages: list, session_id: str = "") -> str | None: - """构建每轮注入模型上下文的提示文本,根据思考档位区分注入内容。 +def _build_dynamic_tail_prompt(messages: list, session_id: str = "") -> str: + """构建每轮追加到消息末尾的动态尾部提示(仅变化部分)。 输入: messages: 当前消息列表(已过滤短期记忆),供循环检测使用。 session_id: 会话 ID,供循环检测与 TodoList 状态重建使用。 输出: - 注入文本;无需注入时返回 None。 + 动态尾部文本;无 TodoList 且无循环提醒时返回空串 + (此时不加任何占位提示,省 token)。 系统定位: - - 思考关闭(low/high):注入反思引导提示词 + TodoList 状态 + 循环警告, - 引导模型先一句话反思再调用工具; - - 思考开启(xhigh/max/ultra):仅注入 TodoList 状态(模型原生深度思考, - 不注入反思引导与循环警告)。 + - 固定反思引导已移入系统提示词前缀(system_prompt._reflection_section), + 不再在节点中间构造固定提示; + - 循环警告:思考关闭档位注入(Level 1 仅提醒,不强制终止); + - TodoList 状态:思考开/关均注入(事实状态,供模型跟踪计划进度)。 """ from agent.utils.env_utils import thinking_enabled parts: list[str] = [] - # 思考关闭:反思引导 + 循环警告(Level 1 仅提醒,不强制终止) + # 思考关闭:循环警告(Level 1 仅提醒,不强制终止) if not thinking_enabled(): - parts.append(REFLECTION_PROMPT) loop_warning = build_loop_reflection_prompt(messages, session_id) if loop_warning: parts.append(loop_warning) @@ -148,11 +148,47 @@ def _build_reflection_prompt(messages: list, session_id: str = "") -> str | None todo_status = get_todo_status_text(session_id) except Exception: todo_status = "" - parts.append(todo_status if todo_status else "当前无 TodoList。") + if todo_status: + parts.append(todo_status) return "\n\n".join(parts) +def _dynamic_tail_agent_key() -> str: + """动态尾部长期记忆的 agent 键:主 Agent 为 "main",子 Agent 用其名称。 + + 主/子 Agent 的动态尾部各自隔离,避免子 Agent 覆盖主 Agent 的长期记忆。 + """ + try: + from agent.core.human_request import get_current_agent_name + name = get_current_agent_name() + except Exception: + name = "" + return "main" if name in ("", "__main__") else name + + +def _persist_dynamic_tail(session_id: str, text: str) -> None: + """持久化当前轮动态尾部到 session_meta(长期记忆,供后续轮次注入查看)。""" + if not session_id: + return + try: + from agent.utils.agent_utils import update_session_dynamic_tail + update_session_dynamic_tail(session_id, _dynamic_tail_agent_key(), text) + except Exception: + pass + + +def _load_dynamic_tail_history(session_id: str) -> list[str]: + """读取当前 Agent 的动态尾部长期记忆历史(时间正序,最新在末尾)。""" + if not session_id: + return [] + try: + from agent.utils.agent_utils import get_session_dynamic_tail_history + return get_session_dynamic_tail_history(session_id, _dynamic_tail_agent_key()) + except Exception: + return [] + + # ---------- ReAct 循环内上下文压缩 ---------- def _extract_usage(response: Any) -> dict: """从模型 response 提取完整 usage 字典(prompt_tokens/completion_tokens/total_tokens)。""" @@ -414,12 +450,21 @@ def make_call_model_node( except Exception: _sid = "" - # 构建提示文本并注入到消息列表(仅作引导,不记录到前端) - reflection = _build_reflection_prompt(filtered_messages, _sid) + # 构建动态尾部提示并注入到消息列表末尾(仅作引导,不记录到前端)。 + # 固定反思引导已并入系统提示词前缀,此处仅注入变化部分: + # TodoList 状态与循环提醒;无 TodoList 且无循环提醒时不注入(省 token)。 messages_for_llm = list(filtered_messages) - if reflection: - reflection_msg = HumanMessage(content=reflection, additional_kwargs={"synthetic": True, "reflection": True}) - messages_for_llm.append(reflection_msg) + tail = _build_dynamic_tail_prompt(filtered_messages, _sid) + if tail: + # 长期记忆:持久化本轮动态尾部,供后续轮次注入查看 + _persist_dynamic_tail(_sid, tail) + # 注入全部历史动态尾部(含本轮),使 Agent 能看到之前所有轮的 + # todo 状态与循环提醒;这些消息仅存在于本次调用,不进图状态。 + for hist_text in _load_dynamic_tail_history(_sid): + messages_for_llm.append(HumanMessage( + content=hist_text, + additional_kwargs={"synthetic": True, "reflection": True}, + )) response = model_with_tools.invoke(messages_for_llm) @@ -475,7 +520,8 @@ def make_call_model_node( from agent.history.tool_call_recorder import record_tool_call_live, get_current_session sid = get_current_session() if sid: - record_tool_call_live(sid, tool_name, tool_args) + # 记录工具调用 ID:并行 call_subagent 按 id 匹配注入子轨迹 + record_tool_call_live(sid, tool_name, tool_args, tc_normalized.get("id", "")) except Exception: pass diff --git a/src/agent/history/tool_call_recorder.py b/src/agent/history/tool_call_recorder.py index c06043e..76fd6b2 100644 --- a/src/agent/history/tool_call_recorder.py +++ b/src/agent/history/tool_call_recorder.py @@ -44,31 +44,57 @@ _current_session_fallback = "" # 同时子 Agent 轨迹仍通过 record_sub_agent_trace 独立存档供持久化注入。 _sub_agent_context: ContextVar[bool] = ContextVar("sub_agent_context", default=False) +# 当前工具调用 ID(tool_call_id):由并行工具节点在调用工具前设置, +# 经上下文传播进入 call_subagent._run,用于并行子 Agent 的轨迹/实时记录隔离。 +# 同一轮内多个 call_subagent(甚至同名)并行时,各自按 tool_call_id 独立存取。 +_current_tool_call_id: ContextVar[str] = ContextVar("current_tool_call_id", default="") + def is_sub_agent_context() -> bool: """当前是否运行在子 Agent 上下文中。""" return _sub_agent_context.get() +def set_current_tool_call_id(tool_call_id: str) -> None: + """设置当前线程的工具调用 ID(并行工具节点在执行工具前调用)。""" + _current_tool_call_id.set(tool_call_id or "") + + +def get_current_tool_call_id() -> str: + """获取当前工具调用 ID(call_subagent._run 内读取,用于轨迹隔离)。""" + return _current_tool_call_id.get() + + +def _sub_key(session_id: str, tool_call_id: str | None = None) -> tuple[str, str]: + """计算子 Agent 记录的隔离 key:(session_id, tool_call_id)。 + + tool_call_id 为空时回退到会话级 key(旧行为,顺序执行子 Agent 场景), + 保证无 tool_call_id 的调用(如人工手动 invoke)也能正常读写。 + """ + tc_id = tool_call_id if tool_call_id is not None else get_current_tool_call_id() + return (session_id or "", tc_id or "") + + @contextmanager -def sub_agent_context(): +def sub_agent_context(tool_call_id: str | None = None): """进入子 Agent 执行上下文:将 record_*_live 路由到 call_subagent 嵌套区域。 - 子 Agent 的工具调用记录写入独立的 _sub_live_records[session_id], - get_live_tool_calls 将其注入到 call_subagent 条目的 sub_tool_calls 中, + 子 Agent 的工具调用记录写入独立的 _sub_live_records[(session_id, tool_call_id)], + get_live_tool_calls 将其注入到对应 call_subagent 条目的 sub_tool_calls 中, 前端在 call_subagent 展开时实时渲染嵌套的子工具调用列表。 - 进入时清空上一子 Agent 遗留的实时记录与待消费思考, - 避免同一轮内多个子 Agent 顺序执行时发生记忆串扰。 + 进入时仅清空当前子 Agent 实例遗留的实时记录与待消费思考, + 多个子 Agent(含同名)并行时按 tool_call_id 隔离,互不串扰。 """ token = _sub_agent_context.set(True) try: _sid = get_current_session() if _sid: + key = _sub_key(_sid, tool_call_id) with _sub_live_lock: - _sub_live_records.pop(_sid, None) + _sub_live_records.pop(key, None) with _sub_pending_thinking_lock: - _sub_pending_thinking.pop(_sid, None) + _sub_pending_thinking.pop(key, None) yield finally: _sub_agent_context.reset(token) @@ -77,13 +103,15 @@ def sub_agent_context(): _live_lock = threading.Lock() _live_records: dict[str, list[dict[str, Any]]] = {} -# 子 Agent 实时工具调用记录(session_id → list),独立存储, -# 在 get_live_tool_calls 中合并到 call_subagent 条目的 sub_tool_calls 中供前端嵌套展开。 -_sub_live_records: dict[str, list[dict[str, Any]]] = {} +# 子 Agent 实时工具调用记录((session_id, tool_call_id) → list),独立存储, +# 在 get_live_tool_calls 中按 tool_call_id 合并到对应 call_subagent 条目的 +# sub_tool_calls 中供前端嵌套展开。多个子 Agent 并行时按 (session, tc_id) 隔离。 +_sub_live_records: dict[tuple[str, str], list[dict[str, Any]]] = {} _sub_live_lock = threading.Lock() -# 子 Agent 待附加 thinking(与主 Agent 的 _pending_thinking 隔离) -_sub_pending_thinking: dict[str, str] = {} +# 子 Agent 待附加 thinking(与主 Agent 的 _pending_thinking 隔离), +# key 与 _sub_live_records 一致:(session_id, tool_call_id)。 +_sub_pending_thinking: dict[tuple[str, str], str] = {} _sub_pending_thinking_lock = threading.Lock() # 轮次开始时间(key = session_id),用于计时 @@ -231,17 +259,30 @@ def init_live_turn_with_id(session_id: str, turn_id: str) -> str: return turn_id -def record_tool_call_live(session_id: str, name: str, args: dict[str, Any]) -> None: - """图节点中调用:记录工具调用开始。同时消费 pending thinking。""" +def record_tool_call_live(session_id: str, name: str, args: dict[str, Any], tool_call_id: str | None = None) -> None: + """图节点中调用:记录工具调用开始。同时消费 pending thinking。 + + 输入: + session_id: 会话 ID。 + name: 工具名。 + args: 工具参数字典。 + tool_call_id: 可选工具调用 ID;子 Agent 上下文中作为隔离 key, + 主 Agent 上下文中存入条目供前端按 call_subagent 匹配子轨迹。 + """ # 子 Agent 上下文:路由到 _sub_live_records,由 get_live_tool_calls 合并到 - # call_subagent 条目的 sub_tool_calls 中供前端嵌套展开。 + # 对应 call_subagent 条目的 sub_tool_calls 中供前端嵌套展开。 + # 隔离 key 取上下文中的 tool_call_id(即父级 call_subagent 调用 ID, + # 由 _execute_single 设置并传播到子 Agent 图线程),而非本条目的子工具 ID; + # 多个并行子 Agent(含同名)因此互不串扰。 if is_sub_agent_context(): + key = _sub_key(session_id) with _sub_pending_thinking_lock: - thinking = _sub_pending_thinking.pop(session_id, None) + thinking = _sub_pending_thinking.pop(key, None) with _sub_live_lock: - if session_id not in _sub_live_records: - _sub_live_records[session_id] = [] + if key not in _sub_live_records: + _sub_live_records[key] = [] entry = { + "id": tool_call_id or "", "name": name, "args": _json_safe(args), "args_summary": format_args_summary(args, max_len=40), @@ -250,7 +291,7 @@ def record_tool_call_live(session_id: str, name: str, args: dict[str, Any]) -> N } if thinking: entry["thinking"] = thinking - _sub_live_records[session_id].append(entry) + _sub_live_records[key].append(entry) _notify_live(session_id) return with _pending_thinking_lock: @@ -259,6 +300,7 @@ def record_tool_call_live(session_id: str, name: str, args: dict[str, Any]) -> N if session_id not in _live_records: _live_records[session_id] = [] entry = { + "id": tool_call_id or "", "name": name, "args": _json_safe(args), "args_summary": format_args_summary(args, max_len=40), @@ -278,10 +320,11 @@ def record_tool_result_live( artifact: Any | None = None, ) -> None: """记录工具返回结果,供前端实时展示文本、图片预览、音频信息与文件信息。""" - # 子 Agent 上下文:路由到 _sub_live_records + # 子 Agent 上下文:路由到 _sub_live_records(按 (session, tool_call_id) 隔离) if is_sub_agent_context(): + key = _sub_key(session_id) with _sub_live_lock: - records = _sub_live_records.get(session_id, []) + records = _sub_live_records.get(key, []) for rec in reversed(records): if rec["name"] == name and rec["status"] == "running": rec["status"] = "done" @@ -472,9 +515,12 @@ def end_live_turn(session_id: str) -> list[dict[str, Any]]: with _pending_thinking_lock: _pending_thinking.pop(session_id, None) with _sub_pending_thinking_lock: - _sub_pending_thinking.pop(session_id, None) + # 清理该会话下所有子 Agent 隔离槽(并行子 Agent 按 (session, tc_id) 分槽) + for k in [k for k in _sub_pending_thinking if k[0] == session_id]: + _sub_pending_thinking.pop(k, None) with _sub_live_lock: - _sub_live_records.pop(session_id, None) + for k in [k for k in _sub_live_records if k[0] == session_id]: + _sub_live_records.pop(k, None) with _live_lock: _active_sessions.discard(session_id) return _live_records.pop(session_id, []) @@ -550,6 +596,7 @@ def save_aborted_turn(session_id: str, turn_id: str) -> None: normalized: list[dict[str, Any]] = [] for r in records: entry = { + "id": r.get("id", ""), "name": r.get("name", ""), "args": r.get("args", {}), "args_summary": r.get("args_summary", ""), @@ -607,15 +654,31 @@ def get_live_tool_calls(session_id: str) -> list[dict[str, Any]]: if not records: return [] - # ---- 注入子 Agent 实时轨迹到 call_subagent 条目 ---- + # ---- 注入子 Agent 实时轨迹到 call_subagent 条目(按 tool_call_id 精确匹配) ---- + # 并行子 Agent(含同名)按 (session, tool_call_id) 分槽,注入到各自对应条目; + # 无 id 的旧数据回退到"最后一条 running"(旧行为兼容)。 with _sub_live_lock: - sub_calls = list(_sub_live_records.get(session_id, [])) - if sub_calls: - # 找到最后一个 running 的 call_subagent 条目(从末尾向前找) - for rec in reversed(records): - if rec.get("name") in ("call_subagent", "agent_call") and rec.get("status") == "running": + sub_map = {k: list(v) for k, v in _sub_live_records.items() if k[0] == session_id} + if sub_map: + matched_ids: set[str] = set() + fallback_rec: dict[str, Any] | None = None + for rec in records: + if rec.get("name") not in ("call_subagent", "agent_call"): + continue + tc_id = rec.get("id") or "" + sub_calls = sub_map.get((session_id, tc_id)) + if sub_calls: rec["sub_tool_calls"] = sub_calls - break + matched_ids.add(tc_id) + elif rec.get("status") == "running" and fallback_rec is None: + fallback_rec = rec + if fallback_rec is not None and not fallback_rec.get("sub_tool_calls"): + leftover: list[dict[str, Any]] = [] + for k, v in sub_map.items(): + if k[1] not in matched_ids: + leftover.extend(v) + if leftover: + fallback_rec["sub_tool_calls"] = leftover return records @@ -654,11 +717,11 @@ def record_reflection_live(session_id: str, content: str) -> None: """ if not session_id or not content: return - # 子 Agent 上下文:thinking 存入 _sub_pending_thinking, + # 子 Agent 上下文:thinking 存入 _sub_pending_thinking(按 (session, tc_id) 隔离), # 由 record_tool_call_live 消费后附加到子工具调用条目。 if is_sub_agent_context(): with _sub_pending_thinking_lock: - _sub_pending_thinking[session_id] = content + _sub_pending_thinking[_sub_key(session_id)] = content # 不添加到 _think_store(子 Agent 思考在主 Agent 思考面板中不独立显示, # 而是嵌套在 call_subagent 展开的各个子工具调用条目的 thinking 字段中) return @@ -718,6 +781,7 @@ def extract_tool_calls_from_messages(messages: list[BaseMessage]) -> list[dict[s for tc in msg.tool_calls: d = normalize_tool_call(tc) entry = { + "id": d.get("id", "") or "", "name": d.get("name", "unknown"), "args": _json_safe(d.get("args", {})), "args_summary": format_args_summary(d.get("args", {}), max_len=40), @@ -924,7 +988,11 @@ def _merge_extracted_with_live( def _inject_sub_traces_to_record(tool_calls: list[dict[str, Any]], session_id: str, turn_id: str) -> None: - """将子 Agent 的工具调用轨迹注入到 tool_calls 的 call_subagent 条目中(用于持久化)。""" + """将子 Agent 的工具调用轨迹注入到 tool_calls 的 call_subagent 条目中(用于持久化)。 + + 优先按条目携带的 tool_call_id 精确匹配(并行/同名子 Agent 各自独立); + 无 id 的旧数据回退到按 agent_name 匹配(旧行为兼容)。 + """ for tc in tool_calls: if tc.get("name") not in ("call_subagent", "agent_call"): continue @@ -936,10 +1004,15 @@ def _inject_sub_traces_to_record(tool_calls: list[dict[str, Any]], session_id: s except Exception: args = {} sub_name = args.get("agent_name", "") - if sub_name: + if not sub_name: + continue + tc_id = tc.get("id") or "" + if tc_id: + sub_tc = get_sub_agent_traces_for_turn(session_id, turn_id, sub_name, tc_id) + else: sub_tc = get_sub_agent_traces_for_turn(session_id, turn_id, sub_name) - if sub_tc: - tc["sub_tool_calls"] = sub_tc + if sub_tc: + tc["sub_tool_calls"] = sub_tc def list_turns(session_id: str, limit: int = 5) -> list[dict[str, Any]]: @@ -1058,7 +1131,17 @@ def _tool_calls_from_turn_record(record: dict[str, Any], session_id: str) -> lis # ---------- 子 Agent 轨迹存取 ---------- -def record_sub_agent_trace(session_id: str, turn_id: str, agent_name: str, tool_calls: list[dict[str, Any]]) -> None: +def _sub_trace_key(session_id: str, turn_id: str, agent_name: str, tool_call_id: str = "") -> str: + """子 Agent 轨迹存储 key:f"{session_id}/{turn_id}/{agent_name}[/{tool_call_id}]"。 + + 携带 tool_call_id 时按调用实例精确隔离(并行/同名子 Agent 各自独立); + 为空时回退到旧的 agent_name 级 key(顺序执行/旧数据兼容)。 + """ + base = f"{session_id}/{turn_id}/{agent_name}" + return f"{base}/{tool_call_id}" if tool_call_id else base + + +def record_sub_agent_trace(session_id: str, turn_id: str, agent_name: str, tool_calls: list[dict[str, Any]], tool_call_id: str = "") -> None: """记录子 Agent 的完整工具调用轨迹,供前端嵌套展开。 输入: @@ -1066,27 +1149,29 @@ def record_sub_agent_trace(session_id: str, turn_id: str, agent_name: str, tool_ turn_id: 当前轮次 ID。 agent_name: 被调用的子 Agent 名称。 tool_calls: extract_tool_calls_from_messages 提取的子 Agent 工具调用列表。 + tool_call_id: 本次 call_subagent 调用的工具调用 ID(并行隔离用,可为空)。 """ if not session_id or not turn_id or not agent_name: return - key = f"{session_id}/{turn_id}/{agent_name}" + key = _sub_trace_key(session_id, turn_id, agent_name, tool_call_id) with _sub_traces_lock: _sub_traces_store[key] = tool_calls -def get_sub_agent_traces_for_turn(session_id: str, turn_id: str, agent_name: str) -> list[dict[str, Any]]: +def get_sub_agent_traces_for_turn(session_id: str, turn_id: str, agent_name: str, tool_call_id: str = "") -> list[dict[str, Any]]: """获取指定会话/轮次/子 Agent 的轨迹。 输入: session_id: 会话 ID。 turn_id: 轮次 ID。 agent_name: 子 Agent 名称。 + tool_call_id: 可选工具调用 ID;传入时精确匹配该次调用,否则匹配 agent_name 级。 输出: 工具调用轨迹列表。 """ if not session_id or not turn_id or not agent_name: return [] - key = f"{session_id}/{turn_id}/{agent_name}" + key = _sub_trace_key(session_id, turn_id, agent_name, tool_call_id) with _sub_traces_lock: return list(_sub_traces_store.get(key, [])) diff --git a/src/agent/memory/system_prompt.py b/src/agent/memory/system_prompt.py index 1b1e7a6..abcca57 100644 --- a/src/agent/memory/system_prompt.py +++ b/src/agent/memory/system_prompt.py @@ -40,7 +40,7 @@ def get_main_system_prompt(workspace_dir: str | None = None) -> str: ws_info = _WS_TAIL.format(workspace_dir=ws_dir) except Exception: ws_info = "" - return _BASE_PROMPT + ws_info + return _BASE_PROMPT + _reflection_section() + ws_info def set_current_workspace(workspace_dir: str | None) -> None: @@ -63,10 +63,11 @@ def get_cron_system_prompt(workspace_dir: str | None = None) -> str: try: from agent.utils.env_utils import get_workspace_dir ws_dir = workspace_dir or _current_workspace_dir or get_workspace_dir() - return CRON_MODE_PROMPT.format(workspace_dir=ws_dir) + base = CRON_MODE_PROMPT.format(workspace_dir=ws_dir) except Exception: # 占位符未填时退回不含工作空间信息的版本,避免 .format 异常中断执行 - return CRON_MODE_PROMPT.replace("{workspace_dir}", "") + base = CRON_MODE_PROMPT.replace("{workspace_dir}", "") + return base + _reflection_section() SUMMARY_SYSTEM_PROMPT =""" 你是一个名为“Summary Agent”的专业摘要助手,专门负责对用户提供的文本内容进行高度凝练、准确的总结。 @@ -157,7 +158,7 @@ def get_plan_system_prompt(workspace_dir: str | None = None) -> str: ws_info = _WS_TAIL.format(workspace_dir=ws_dir) except Exception: ws_info = "" - return PLAN_MODE_PROMPT + ws_info + return PLAN_MODE_PROMPT + _reflection_section() + ws_info CRON_MODE_PROMPT = """ @@ -187,8 +188,10 @@ CRON_MODE_PROMPT = """ - 写操作限制:doc_tool 的创建/修改/删除、terminal_execute 的文件变更目标必须位于工作空间内。无人值守场景下超出范围的访问会被直接拦截,请务必使用工作空间内的路径。 """.strip() -# ---------- 反思注入提示(作为 synthetic HumanMessage 注入到对话上下文)---------- -# 这些不是系统提示词,而是在 call_model 节点中动态拼入消息列表的引导文本。 +# ---------- 反思引导提示(固定部分 → 系统提示词前缀,前缀缓存友好)---------- +# 固定不变的反思引导文本作为系统提示词的一部分拼在最前面(缓存命中), +# 不再在 call_model 节点中间逐轮构造;变化的部分(TodoList 状态、循环提醒) +# 由 call_model 作为动态尾部追加到消息列表末尾,并持久化为长期记忆。 REFLECTION_PROMPT = """ 【重要:如果你判断不需要调用任何工具,请直接回复用户,不要输出任何反思内容!】 @@ -202,6 +205,26 @@ REFLECTION_PROMPT = """ 如果你计划在同一页面/界面执行多个连续的 GUI 操作(例如:依次点击多个按钮、填写多个表单字段、连续输入多项内容),必须将所有这些操作合并为一次 gui_tool 调用,在 actions 列表中一次性传入所有动作。严禁将同一页面的连续操作拆分多次 gui_tool 调用——这样会导致严重的延迟。仅当需要等页面跳转/加载后才能确定后续操作时,才分步调用。 """.strip() + +def _reflection_section() -> str: + """固定反思引导段:思考关闭档位时并入系统提示词前缀(缓存友好), + 思考开启档位(模型原生深度思考)不注入,保持原语义。 + + 惰性导入 env_utils 避免模块级循环依赖。 + """ + try: + from agent.utils.env_utils import thinking_enabled + if thinking_enabled(): + return "" + except Exception: + pass + return "\n\n" + REFLECTION_PROMPT + + +def get_default_sub_agent_prompt() -> str: + """获取默认子 Agent 系统提示词(含按思考档位门控的固定反思引导)。""" + return DEFAULT_SUB_AGENT_PROMPT + _reflection_section() + # 子 Agent 上下文超限提示(由 agent 节点在子 Agent token 超阈值时注入,强制其整理进度并文字返回) SUB_AGENT_OOM_PROMPT = """ 【系统指令 - 上下文超限】 diff --git a/src/agent/tools/agent_call.py b/src/agent/tools/agent_call.py index c39d4a0..ce022d8 100644 --- a/src/agent/tools/agent_call.py +++ b/src/agent/tools/agent_call.py @@ -153,17 +153,21 @@ class CallSubAgentTool(BaseTool): # 设置子 Agent 上下文:agent 名称供前端浮窗按 agent 分组 from agent.core.human_request import set_current_agent_name set_current_agent_name(agent_name) + # 当前 call_subagent 的工具调用 ID:并行多个子 Agent(含同名)时, + # 子 Agent 的实时记录与轨迹存档均按 (session, tool_call_id) 隔离。 + from agent.history.tool_call_recorder import get_current_tool_call_id + tool_call_id = get_current_tool_call_id() # 进入子 Agent 执行上下文:抑制其 record_*_live 调用, # 避免子 Agent 的思考/工具调用混入主 Agent 的实时列表。 # 子 Agent 轨迹仍由 _record_sub_trace 独立存档,持久化时嵌套注入。 from agent.history.tool_call_recorder import sub_agent_context try: - with sub_agent_context(): + with sub_agent_context(tool_call_id): result = runtime.graph.invoke({"messages": messages}, config=config) all_messages = result.get("messages", []) - # 提取子 Agent 工具调用轨迹 → 存档供前端嵌套展开 - _record_sub_trace(agent_name, all_messages) + # 提取子 Agent 工具调用轨迹 → 存档供前端嵌套展开(按 tool_call_id 隔离) + _record_sub_trace(agent_name, all_messages, tool_call_id) # 提取最后一条非工具调用的助手消息作为最终结果 from langchain_core.messages import AIMessage @@ -200,10 +204,11 @@ class CallSubAgentTool(BaseTool): # ---------- 子 Agent 轨迹记录 ---------- -def _record_sub_trace(agent_name: str, messages: list) -> None: +def _record_sub_trace(agent_name: str, messages: list, tool_call_id: str = "") -> None: """记录子 Agent 的工具调用轨迹,供前端嵌套展开。 - 同时写入 tool_call_recorder 的 session/turn 作用域存储。 + 同时写入 tool_call_recorder 的 session/turn 作用域存储, + tool_call_id 用于并行子 Agent(含同名)的轨迹隔离。 """ # 桥接到 tool_call_recorder(按 session/turn 作用域) @@ -218,7 +223,7 @@ def _record_sub_trace(agent_name: str, messages: list) -> None: turn_id = _recorder_turn() if session_id and turn_id: sub_tc = _recorder_extract(list(messages)) - _recorder_record(session_id, turn_id, agent_name, sub_tc) + _recorder_record(session_id, turn_id, agent_name, sub_tc, tool_call_id) except Exception: pass @@ -249,16 +254,24 @@ def make_parallel_tool_node(tools: list[Any], max_workers: int = 5): """在线程池中执行单个工具。 委托 invoke_tool_and_build_message 统一处理返回类型。 + 执行前设置当前 tool_call_id(经上下文传播进入 call_subagent._run, + 供并行子 Agent 的轨迹/实时记录按 tool_call_id 隔离)。 返回 (tool_name, content, artifact),artifact 为 None 表示无产物。 """ - tool = tools_by_name.get(tool_name) - if tool is None: - return tool_name, f"工具 '{tool_name}' 未找到", None + from agent.history.tool_call_recorder import set_current_tool_call_id, get_current_tool_call_id + _prev_tc = get_current_tool_call_id() + set_current_tool_call_id(tool_call_id) try: - content, artifact = invoke_tool_and_build_message(tool, tool_name, tool_args, tool_call_id) - return tool_name, content, artifact - except Exception as e: - return tool_name, f"工具执行出错: {e}", None + tool = tools_by_name.get(tool_name) + if tool is None: + return tool_name, f"工具 '{tool_name}' 未找到", None + try: + content, artifact = invoke_tool_and_build_message(tool, tool_name, tool_args, tool_call_id) + return tool_name, content, artifact + except Exception as e: + return tool_name, f"工具执行出错: {e}", None + finally: + set_current_tool_call_id(_prev_tc) def parallel_tool_node(state: AgentState) -> dict: """并行执行最后一条 AIMessage 中的所有 tool_calls。""" @@ -285,13 +298,18 @@ def make_parallel_tool_node(tools: list[Any], max_workers: int = 5): } # 多个工具调用:线程池并行执行 + # 用 contextvars.copy_context() 包装 submit,使 worker 线程继承 + # 图线程的执行上下文(session/turn/agent 名称/sub_agent_context 等), + # 保证并行工具(含并行子 Agent)之间的状态隔离与前端归属正确。 + import contextvars futures: dict[concurrent.futures.Future, dict] = {} with concurrent.futures.ThreadPoolExecutor(max_workers=min(max_workers, len(tool_calls))) as executor: for tc in tool_calls: tc_name = tc.get("name") if isinstance(tc, dict) else getattr(tc, "name", "") tc_args = tc.get("args") if isinstance(tc, dict) else getattr(tc, "args", {}) tc_id = tc.get("id") if isinstance(tc, dict) else getattr(tc, "id", "") - future = executor.submit(_execute_single, tc_name, tc_args, tc_id) + ctx = contextvars.copy_context() + future = executor.submit(ctx.run, _execute_single, tc_name, tc_args, tc_id) futures[future] = {"id": tc_id, "name": tc_name} # 收集结果,保持顺序 diff --git a/src/agent/utils/agent_utils.py b/src/agent/utils/agent_utils.py index e655a7f..50a15f9 100644 --- a/src/agent/utils/agent_utils.py +++ b/src/agent/utils/agent_utils.py @@ -182,6 +182,49 @@ def update_session_meta(session_id: str, agent_name: str, _save_session_meta(session_id, meta) +# ---------- 动态尾部长期记忆(TodoList 状态 + 循环提醒) ---------- +def update_session_dynamic_tail(session_id: str, agent_key: str, text: str, max_entries: int = 100) -> None: + """持久化当前轮的动态尾部提示(TodoList 状态 + 循环提醒)为长期记忆。 + + 输入: + session_id: 会话 ID。 + agent_key: session_meta 中的 agent 键(主 Agent 为 "main",子 Agent 用其名称)。 + text: 本轮动态尾部文本。 + max_entries: 历史条目上限(防止无界增长),超出丢弃最旧。 + + 说明: + 与 compressed_content 合并保存(不覆盖);连续相同内容去重, + 使 todo 进度等状态变更形成可追溯的历史记录,供后续轮次注入查看 + (充分利用前缀缓存:变化部分始终位于消息末尾)。 + """ + if not session_id or not agent_key or not text: + return + meta = _load_session_meta(session_id) + entry = dict(meta.get(agent_key) or {}) + history = list(entry.get("dynamic_tail_history") or []) + if history and history[-1] == text: + return # 去重:内容未变化则不重复记录 + history.append(text) + if len(history) > max_entries: + history = history[-max_entries:] + entry["dynamic_tail_history"] = history + meta[agent_key] = entry + _save_session_meta(session_id, meta) + + +def get_session_dynamic_tail_history(session_id: str, agent_key: str) -> list[str]: + """读取指定 Agent 的动态尾部长期记忆历史(时间正序,最新在末尾)。 + + 输出: + [tail_text, ...];无记录返回空列表。 + """ + if not session_id or not agent_key: + return [] + meta = _load_session_meta(session_id) + entry = meta.get(agent_key) or {} + return list(entry.get("dynamic_tail_history") or []) + + def build_tool_history_messages(session_id: str, agent_key: str = "main") -> list: """构建压缩游标之后的"历史工具调用与返回值"合成消息列表。 diff --git a/src/agent/utils/tool_call_utils.py b/src/agent/utils/tool_call_utils.py index 2df5b13..4a9b259 100644 --- a/src/agent/utils/tool_call_utils.py +++ b/src/agent/utils/tool_call_utils.py @@ -10,6 +10,7 @@ """ from __future__ import annotations +import contextvars import json import re import threading @@ -24,6 +25,12 @@ def _invoke_with_timeout(tool, tool_call_input: dict, timeout: float) -> Any: 超时后工作线程仍在后台运行(Python 无法强杀线程),结果被丢弃。 工具内部抛出的异常会在主线程 re-raise(保留 ABORTED_BY_USER 冒泡)。 + 上下文传播: + 通过 ``contextvars.copy_context()`` 把调用方上下文(session/turn、 + agent 名称、sub_agent_context、tool_call_id 等 ContextVar)复制进 + 超时线程,使工具实现(如 call_subagent 的 _run)在独立线程中也能 + 读到正确的执行上下文,保证并行工具/并行子 Agent 的隔离性。 + 输入: tool: LangChain BaseTool 实例。 tool_call_input: 传给 tool.invoke 的输入字典。 @@ -40,7 +47,8 @@ def _invoke_with_timeout(tool, tool_call_input: dict, timeout: float) -> Any: except BaseException as e: # noqa: BLE001 - 需透传 ABORTED_BY_USER 等所有异常 container["error"] = e - t = threading.Thread(target=_worker, daemon=True) + ctx = contextvars.copy_context() + t = threading.Thread(target=lambda: ctx.run(_worker), daemon=True) t.start() t.join(timeout=timeout) if t.is_alive(): diff --git a/src/web/fonts/fonts.css b/src/web/fonts/fonts.css new file mode 100644 index 0000000..6b7a21a --- /dev/null +++ b/src/web/fonts/fonts.css @@ -0,0 +1,90 @@ +/* 本地中文字体(fontsource 子集),离线可用;font-display: swap 保证不阻塞渲染 */ +/* generated from @fontsource chinese-simplified + latin subsets */ + +/* zcool-kuaile - chinese-simplified */ +/* zcool-kuaile-chinese-simplified-400-normal */ +@font-face { + font-family: 'ZCOOL KuaiLe'; + font-style: normal; + font-display: swap; + font-weight: 400; + unicode-range: U+3400-4DBF, U+4E00-9FFF, U+F900-FAFF, U+20000-2A6DF; + src: url(./zcool-kuaile-chinese-simplified-400-normal.woff2) format('woff2'); +} + +/* zcool-kuaile - latin */ +/* zcool-kuaile-latin-400-normal */ +@font-face { + font-family: 'ZCOOL KuaiLe'; + font-style: normal; + font-display: swap; + font-weight: 400; + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; + src: url(./zcool-kuaile-latin-400-normal.woff2) format('woff2'); +} + +/* ma-shan-zheng - chinese-simplified */ +/* ma-shan-zheng-chinese-simplified-400-normal */ +@font-face { + font-family: 'Ma Shan Zheng'; + font-style: normal; + font-display: swap; + font-weight: 400; + unicode-range: U+3400-4DBF, U+4E00-9FFF, U+F900-FAFF, U+20000-2A6DF; + src: url(./ma-shan-zheng-chinese-simplified-400-normal.woff2) format('woff2'); +} + +/* ma-shan-zheng - latin */ +/* ma-shan-zheng-latin-400-normal */ +@font-face { + font-family: 'Ma Shan Zheng'; + font-style: normal; + font-display: swap; + font-weight: 400; + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; + src: url(./ma-shan-zheng-latin-400-normal.woff2) format('woff2'); +} + +/* long-cang - chinese-simplified */ +/* long-cang-chinese-simplified-400-normal */ +@font-face { + font-family: 'Long Cang'; + font-style: normal; + font-display: swap; + font-weight: 400; + unicode-range: U+3400-4DBF, U+4E00-9FFF, U+F900-FAFF, U+20000-2A6DF; + src: url(./long-cang-chinese-simplified-400-normal.woff2) format('woff2'); +} + +/* long-cang - latin */ +/* long-cang-latin-400-normal */ +@font-face { + font-family: 'Long Cang'; + font-style: normal; + font-display: swap; + font-weight: 400; + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; + src: url(./long-cang-latin-400-normal.woff2) format('woff2'); +} + +/* zcool-qingke-huangyou - chinese-simplified */ +/* zcool-qingke-huangyou-chinese-simplified-400-normal */ +@font-face { + font-family: 'ZCOOL QingKe HuangYou'; + font-style: normal; + font-display: swap; + font-weight: 400; + unicode-range: U+3400-4DBF, U+4E00-9FFF, U+F900-FAFF, U+20000-2A6DF; + src: url(./zcool-qingke-huangyou-chinese-simplified-400-normal.woff2) format('woff2'); +} + +/* zcool-qingke-huangyou - latin */ +/* zcool-qingke-huangyou-latin-400-normal */ +@font-face { + font-family: 'ZCOOL QingKe HuangYou'; + font-style: normal; + font-display: swap; + font-weight: 400; + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; + src: url(./zcool-qingke-huangyou-latin-400-normal.woff2) format('woff2'); +} diff --git a/src/web/fonts/long-cang-chinese-simplified-400-normal.woff2 b/src/web/fonts/long-cang-chinese-simplified-400-normal.woff2 new file mode 100644 index 0000000..895a801 Binary files /dev/null and b/src/web/fonts/long-cang-chinese-simplified-400-normal.woff2 differ diff --git a/src/web/fonts/long-cang-latin-400-normal.woff2 b/src/web/fonts/long-cang-latin-400-normal.woff2 new file mode 100644 index 0000000..f863d2c Binary files /dev/null and b/src/web/fonts/long-cang-latin-400-normal.woff2 differ diff --git a/src/web/fonts/ma-shan-zheng-chinese-simplified-400-normal.woff2 b/src/web/fonts/ma-shan-zheng-chinese-simplified-400-normal.woff2 new file mode 100644 index 0000000..72493ef Binary files /dev/null and b/src/web/fonts/ma-shan-zheng-chinese-simplified-400-normal.woff2 differ diff --git a/src/web/fonts/ma-shan-zheng-latin-400-normal.woff2 b/src/web/fonts/ma-shan-zheng-latin-400-normal.woff2 new file mode 100644 index 0000000..17c1468 Binary files /dev/null and b/src/web/fonts/ma-shan-zheng-latin-400-normal.woff2 differ diff --git a/src/web/fonts/zcool-kuaile-chinese-simplified-400-normal.woff2 b/src/web/fonts/zcool-kuaile-chinese-simplified-400-normal.woff2 new file mode 100644 index 0000000..138d289 Binary files /dev/null and b/src/web/fonts/zcool-kuaile-chinese-simplified-400-normal.woff2 differ diff --git a/src/web/fonts/zcool-kuaile-latin-400-normal.woff2 b/src/web/fonts/zcool-kuaile-latin-400-normal.woff2 new file mode 100644 index 0000000..51a86fd Binary files /dev/null and b/src/web/fonts/zcool-kuaile-latin-400-normal.woff2 differ diff --git a/src/web/fonts/zcool-qingke-huangyou-chinese-simplified-400-normal.woff2 b/src/web/fonts/zcool-qingke-huangyou-chinese-simplified-400-normal.woff2 new file mode 100644 index 0000000..e8fc12c Binary files /dev/null and b/src/web/fonts/zcool-qingke-huangyou-chinese-simplified-400-normal.woff2 differ diff --git a/src/web/fonts/zcool-qingke-huangyou-latin-400-normal.woff2 b/src/web/fonts/zcool-qingke-huangyou-latin-400-normal.woff2 new file mode 100644 index 0000000..acb1e05 Binary files /dev/null and b/src/web/fonts/zcool-qingke-huangyou-latin-400-normal.woff2 differ diff --git a/src/web/index.html b/src/web/index.html index 198a8de..e8ba948 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -3,12 +3,8 @@
- - - + + @@ -543,8 +539,8 @@ - - + +