diff --git a/README.md b/README.md index d7c5f8d..7710538 100644 --- a/README.md +++ b/README.md @@ -86,14 +86,6 @@ git push minor master └─ 图结束后 (runtime.py / execute_agent) └─ 检测到强制终止 → 向前端输出: "检测到异常循环,工具 `xxx` 已连续调用 5 次..." - - -1、打开https://erpdemo.fanpusoft.com/main.aspx# -2、点击采购管理 -3、点击新增供应商 -4、在新增供应商页面进行以下操作:输入供应商名称:“minor”、输入供应商类别:“工程材料供应商”、输入部门:“采购部门B”、输入职务:“CEO”、输入联系人:“赵永顺”、输入生日:“2004/07/09” -5、点击保存、点击确定 - --- ## 提示词工程文档 @@ -418,9 +410,36 @@ You are a visual grounding model for desktop GUI control. ssh -o ServerAliveInterval=60 -o ServerAliveCountMax=3 -p 60007 minor123@semi-ai.tech #### 本地端口映射 -ssh -fN -L 8901:127.0.0.1:8901 -L 8902:127.0.0.1:8902 -L 8903:127.0.0.1:8903 minor123@semi-ai.tech -p 60007 - +ssh -fN -o ServerAliveInterval=60 -o ServerAliveCountMax=3 -L 8901:127.0.0.1:8901 -L 8902:127.0.0.1:8902 -L 8903:127.0.0.1:8903 minor123@semi-ai.tech -p 60007 +ssh -fN -o ServerAliveInterval=60 -o ServerAliveCountMax=3 -L 8903:127.0.0.1:8903 minor123@semi-ai.tech -p 60007 # 查看状态码(200表示健康) +curl.exe -o nul -s -w "%{http_code}\n" http://localhost:8901/health +curl.exe -o nul -s -w "%{http_code}\n" http://localhost:8902/health +curl.exe -o nul -s -w "%{http_code}\n" http://localhost:8903/health + curl -o /dev/null -s -w "%{http_code}\n" http://localhost:8901/health curl -o /dev/null -s -w "%{http_code}\n" http://localhost:8902/health -curl -o /dev/null -s -w "%{http_code}\n" http://localhost:8903/health \ No newline at end of file +curl -o /dev/null -s -w "%{http_code}\n" http://localhost:8903/health + +(email、sendfile) +今天和昨天有什么邮件 +发封邮件给19553226590@163.com,内容就是我桌面的图片"C:\Users\86166\Desktop\风景\8.png" + +(websearch、terminal、sendfile) +查一下济南和青岛近10年的GDP并画个图表 + +(gui、browser、human_interaction、改成英文) +进行以下gui任务,严格按照我的指令操作: +1、打开https://erpdemo.fanpusoft.com/main.aspx# +2、进行登录(账号密码调用human_interaction工具来问我) +3、点击销售管理 +4、点击新增客户 +5、在新增客户界面输入: +客户名称:minor;客户类别:零售商;公司地址:中国·青岛;公司简介:大狗叫叫叫;联系人姓名:叮咚鸡; +点击性别选项:女;部门:抽象部;生日:2004-07-09; +6、点击客户档案并连按两次空格(用来下滑到网页底部) +7、点击批量上传 +8、在弹出的文件选择器的文件名输入框中输入"C:\Users\86166\Desktop\风景\8.png"并回车 +9、输入跟进日期:2026-07-16 +10、点击保存 +11、点击确定 diff --git a/dongtai.py b/dongtai.py deleted file mode 100644 index 90c1050..0000000 --- a/dongtai.py +++ /dev/null @@ -1,660 +0,0 @@ -import os -import json -import argparse -import subprocess -import time -import socket -import re -from pathlib import Path -from urllib.parse import urljoin, urlparse -from typing import List, Dict, Any, Optional - -from selenium import webdriver -from selenium.webdriver.edge.options import Options -from selenium.webdriver.edge.service import Service -from selenium.webdriver.common.by import By -from selenium.webdriver.support.ui import WebDriverWait -from selenium.webdriver.support import expected_conditions as EC -from webdriver_manager.microsoft import EdgeChromiumDriverManager - - -# ---------------------------- 工具函数 ---------------------------- -def get_absolute_url(base_url: str, href: str) -> Optional[str]: - if not href: - return None - href = href.strip() - if href.startswith(('javascript:', 'mailto:', 'tel:', '#')): - return None - absolute = urljoin(base_url, href) - parsed = urlparse(absolute) - if parsed.scheme in ('http', 'https', 'file'): - return absolute - return None - - -def sanitize_filename(url: str) -> str: - """根据 URL 生成合法的文件名(不含扩展名)""" - parsed = urlparse(url) - path = parsed.path.strip('/') - if not path: - path = "index" - safe = re.sub(r'[\\/*?:"<>|]', '_', path) - if len(safe) > 100: - safe = safe[:100] - return safe - - -# ---------------------------- 语义上下文推断(加强版) ---------------------------- -def infer_context_from_attrs(tag_name: str, attrs: Dict[str, str], text: str = "") -> str: - class_list = attrs.get("class", "").split() - id_ = attrs.get("id", "") - href = attrs.get("href", "") - features = " ".join(class_list) + " " + id_ - - # 对 标签的判断 - if tag_name == "a": - if any(w in features for w in ["btn", "button", "tab", "nav-btn", "menu-item", "action-btn"]): - return "按钮" - - # 通用特征判断 - if "search" in features or "nav-search" in features: - return "搜索框" - if any(w in features for w in ["header", "top-bar", "masthead", "nav"]): - return "顶部导航" - if any(w in features for w in ["sidebar", "aside", "right-entry"]): - return "侧边栏" - if "footer" in features: - return "页脚" - if any(w in features for w in ["channel", "partition", "channel-icons"]): - return "频道/分区" - if any(w in features for w in ["carousel", "banner", "slider"]): - return "轮播/横幅" - if any(w in features for w in ["video-card", "feed-card", "card-item", "card"]): - return "视频卡片" - if "main" in features or "content" in features: - return "主要内容区" - if tag_name == "a": - if "cm.bilibili.com" in href or "ad" in features: - return "广告链接" - return "普通链接" - if tag_name in ("input", "textarea"): - return "输入控件" - if tag_name == "button": - return "按钮" - if tag_name == "select": - return "下拉选择" - # 对新加入的交互元素(div/li/span/img等)做一些推断 - if tag_name in ("div", "li", "span", "img", "td", "label"): - if any(w in features for w in ["btn", "button", "action", "clickable", "tab", "menu-item"]): - return "按钮" - if any(w in features for w in ["card", "item", "cell", "list-item"]): - return "卡片/列表项" - if "dropdown" in features or "menu" in features: - return "菜单" - if "dialog" in features or "modal" in features: - return "弹窗" - if "icon" in features: - return "图标" - if tag_name == "img": - return "图片" - return "交互区域" - return "其他" - - -# ---------------------------- 事件绑定与递归(增强版:模拟点击所有可交互控件) ---------------------------- -def add_events_and_recurse(controls: List[Dict[str, Any]], base_url: str, - depth: int, max_depth: int, visited: set, driver): - """ - 增强版:对于点击后 URL 不变的控件,会检测页面中动态出现的新控件, - 并对新控件(如下拉菜单项)进行递归导航抓取。 - """ - def get_control_signature(ctrl): - """生成控件的唯一签名""" - tp = ctrl["type"] - attrs = ctrl["attributes"] - text = ctrl.get("text", "") - cls = attrs.get("class", "") - id_ = attrs.get("id", "") - return f"{tp}|{text}|{cls}|{id_}" - - original_signatures = {get_control_signature(c) for c in controls} - - for ctrl in controls: - tp = ctrl["type"] - attrs = ctrl["attributes"] - idx = ctrl.get("index") - events = [] - navigate_url = None - reason = "" - clicked_for_navigation = False - - # ---------- URL 预判 ---------- - if tp == "a": - href = attrs.get("href") - if href: - abs_url = get_absolute_url(base_url, href) - if abs_url: - navigate_url = abs_url - reason = "点击链接跳转" - - if tp == "button" or (tp == "input" and attrs.get("type", "").lower() in ("submit", "button", "image")): - form_action = ctrl.get("form_action") - if form_action: - abs_action = get_absolute_url(base_url, form_action) - if abs_action: - navigate_url = abs_action - reason = "提交表单跳转" - else: - events.append({"type": "submit", "description": "提交表单(无明确跳转)"}) - else: - events.append({"type": "submit", "description": "提交表单(无 action)"}) - - if "alert" in attrs.get("onclick", "") or "confirm" in attrs.get("onclick", ""): - events.append({"type": "popup", "description": "触发弹窗"}) - - if tp == "input": - input_type = attrs.get("type", "text").lower() - if input_type in ("text", "password", "email", "search", "number", "tel", "url", ""): - events.append({"type": "input", "description": "输入文字"}) - events.append({"type": "change", "description": "内容改变"}) - elif input_type == "range": - events.append({"type": "input", "description": "滑动改变值"}) - elif input_type in ("checkbox", "radio"): - events.append({"type": "change", "description": "切换状态"}) - - if tp == "select": - events.append({"type": "change", "description": "选择项改变"}) - - if tp == "textarea": - events.append({"type": "input", "description": "输入多行文字"}) - events.append({"type": "change", "description": "内容改变"}) - - if tp in ("div", "li", "span", "img", "td", "label") and ctrl.get("is_interactive"): - onclick = attrs.get("onclick", "") - data_href = attrs.get("data-href", "") - if data_href: - abs_url = get_absolute_url(base_url, data_href) - if abs_url: - navigate_url = abs_url - reason = "点击交互跳转" - elif "location.href" in onclick or "window.open" in onclick: - match = re.search(r"""['"]([^'"]+)['"]""", onclick) - if match: - abs_url = get_absolute_url(base_url, match.group(1)) - if abs_url: - navigate_url = abs_url - reason = "点击交互跳转" - - # 弹窗兜底 - if tp in ("input", "button") or (tp in ("div", "li", "span", "img") and ctrl.get("is_interactive")): - if ("alert" in attrs.get("onclick", "") or "confirm" in attrs.get("onclick", "")) and \ - not any(e["type"] == "popup" for e in events): - events.append({"type": "popup", "description": "触发弹窗"}) - - # ========== 模拟点击与动态内容探索 ========== - if depth < max_depth and idx is not None: - # 情况1:有预知 URL - if navigate_url and navigate_url not in visited: - try: - target_element = driver.find_element(By.CSS_SELECTOR, f'[data-ctrl-idx="{idx}"]') - pre_url = driver.current_url - target_element.click() - time.sleep(1) - WebDriverWait(driver, 10).until( - lambda d: d.execute_script("return document.readyState") == "complete" - ) - - handles = driver.window_handles - if len(handles) > 1: - original_handle = driver.current_window_handle - for handle in handles: - if handle != original_handle: - driver.switch_to.window(handle) - break - new_url = driver.current_url - if new_url not in visited: - visited.add(new_url) - target_page = process_source(new_url, depth + 1, max_depth, visited, driver) - event = {"type": "navigate", "url": new_url, "description": reason, - "target_page": target_page} - events.append(event) - driver.close() - driver.switch_to.window(original_handle) - else: - new_url = driver.current_url - if new_url != pre_url and new_url not in visited: - visited.add(new_url) - target_page = process_source(new_url, depth + 1, max_depth, visited, driver) - event = {"type": "navigate", "url": new_url, "description": reason, - "target_page": target_page} - events.append(event) - driver.back() - WebDriverWait(driver, 10).until( - lambda d: d.execute_script("return document.readyState") == "complete" - ) - clicked_for_navigation = True - except Exception as e: - events.append({"type": "navigate", "url": navigate_url, "description": reason, - "target_page": {"url": navigate_url, "error": str(e)}}) - clicked_for_navigation = True - - # 情况2:无预知 URL,但可交互 → 尝试点击并检查是否出现下拉菜单 - elif ctrl.get("is_interactive") and not clicked_for_navigation: - try: - target_element = driver.find_element(By.CSS_SELECTOR, f'[data-ctrl-idx="{idx}"]') - pre_url = driver.current_url - target_element.click() - time.sleep(0.8) - WebDriverWait(driver, 10).until( - lambda d: d.execute_script("return document.readyState") == "complete" - ) - - new_url = driver.current_url - if new_url != pre_url and new_url not in visited: - # 直接跳转了 - visited.add(new_url) - target_page = process_source(new_url, depth + 1, max_depth, visited, driver) - event = {"type": "navigate", "url": new_url, "description": "点击交互跳转(运行时发现)", - "target_page": target_page} - events.append(event) - driver.back() - WebDriverWait(driver, 10).until( - lambda d: d.execute_script("return document.readyState") == "complete" - ) - clicked_for_navigation = True - else: - # URL 没变,检测动态出现的下拉菜单项 - # 使用更可靠的方法:直接查找可能是下拉项的
  • 或 ,并排除原始控件 - sub_items = driver.execute_script(""" - let candidates = []; - // 查找常见菜单结构:ul.submenu, div.dropdown-menu 下的 li/a - let menus = document.querySelectorAll('ul, div[class*="menu"], div[class*="dropdown"]'); - for (let menu of menus) { - let items = menu.querySelectorAll('li, a'); - for (let item of items) { - // 跳过隐藏元素 - if (item.offsetParent === null) continue; - let text = item.innerText.trim().substring(0, 50); - if (!text) continue; - // 排除与原始控件文本相同的元素(父菜单本身可能被包含) - candidates.push({ - tag: item.tagName.toLowerCase(), - text: text, - outerHTML: item.outerHTML.substring(0, 200) - }); - } - } - return candidates; - """) - - # 去重,只保留之前没有的项 - clicked_sub_items = set() - for item in sub_items: - sig = f"{item['tag']}|{item['text']}" - if sig not in original_signatures and sig not in clicked_sub_items: - # 尝试定位并点击 - try: - # 用文本查找具体元素(更稳定) - elem = driver.find_element(By.XPATH, - f"//{item['tag']}[normalize-space()='{item['text']}']") - elem.click() - time.sleep(0.8) - WebDriverWait(driver, 10).until( - lambda d: d.execute_script("return document.readyState") == "complete" - ) - new_url2 = driver.current_url - if new_url2 != pre_url and new_url2 not in visited: - visited.add(new_url2) - sub_target = process_source(new_url2, depth + 1, max_depth, visited, driver) - event = {"type": "navigate", "url": new_url2, - "description": f"下拉菜单项: {item['text']}", - "target_page": sub_target} - events.append(event) - driver.back() - WebDriverWait(driver, 10).until( - lambda d: d.execute_script("return document.readyState") == "complete" - ) - # 回退后重新点击父菜单,确保下拉菜单仍然展开 - target_element = driver.find_element(By.CSS_SELECTOR, f'[data-ctrl-idx="{idx}"]') - target_element.click() - time.sleep(0.5) - clicked_sub_items.add(sig) - except Exception: - continue - except Exception as e: - # 可以在这里打印调试信息,但不影响流程 - pass - - if not events: - events.append({"type": "unknown", "description": "未识别的事件"}) - - ctrl["events"] = events - - -# ---------------------------- 核心抓取逻辑(增强版:去重+注入索引) ---------------------------- -def build_page_tree_from_url(driver, url: str, depth: int, max_depth: int, - visited: set) -> Dict[str, Any]: - print(f"正在抓取 (深度 {depth}/{max_depth}): {url}") - visited.add(url) - - try: - driver.get(url) - WebDriverWait(driver, 15).until( - lambda d: d.execute_script("return document.readyState") == "complete" - ) - driver.implicitly_wait(2) - except Exception as e: - return {"url": url, "error": str(e)} - - # ---------- 提取所有交互元素(去重:只保留最外层交互元素,并注入索引) ---------- - try: - raw_controls = driver.execute_script(""" - const tags = ['a', 'input', 'button', 'select', 'textarea', - 'div', 'li', 'span', 'img', 'td', 'label']; - let allElements = []; - for (let tag of tags) { - let elems = document.querySelectorAll(tag); - for (let elem of elems) { - if (tag === 'input' && elem.type === 'hidden') continue; - if (tag === 'a' && !elem.href && !elem.onclick) continue; - - let isInteractive = false; - if (['div', 'li', 'span', 'img', 'td', 'label'].includes(tag)) { - if (elem.onclick) isInteractive = true; - let role = elem.getAttribute('role'); - if (role && ['button', 'link', 'tab', 'menuitem'].includes(role)) isInteractive = true; - if (elem.hasAttribute('tabindex') && elem.getAttribute('tabindex') !== '-1') isInteractive = true; - if (elem.hasAttribute('data-action') || elem.hasAttribute('data-href')) isInteractive = true; - let cls = elem.className || ''; - if (/(btn|button|clickable|action|link|tab|menu-item|card|cell)/i.test(cls)) { - isInteractive = true; - } - if (!isInteractive) continue; - } - - allElements.push({element: elem, tag: tag, isInteractive: isInteractive}); - } - } - - // 去重:如果某元素被另一个更外层的交互元素包含,则丢弃它 - let filtered = []; - let idx = 0; - for (let item of allElements) { - let elem = item.element; - let isChild = false; - // 检查是否被之前的某个元素包含 - for (let prev of filtered) { - if (prev.element.contains(elem) && prev.element !== elem) { - isChild = true; - break; - } - } - if (!isChild) { - // 注入唯一索引 - elem.setAttribute('data-ctrl-idx', idx); - let attrs = {}; - for (let attr of elem.attributes) { - attrs[attr.name] = attr.value; - } - let formAction = null; - if (item.tag === 'button' || (item.tag === 'input' && ['submit','button','image'].includes(elem.type))) { - let form = elem.closest('form'); - if (form) formAction = form.getAttribute('action') || ''; - } - let text = elem.innerText ? elem.innerText.trim().substring(0, 50) : ''; - filtered.push({ - element: elem, // 用于后续 contains 检查 - tag: item.tag, - attributes: attrs, - text: text, - form_action: formAction, - is_interactive: item.isInteractive, - index: idx - }); - idx++; - } - } - // 只返回需要的数据(去掉 DOM 引用) - return filtered.map(({tag, attributes, text, form_action, is_interactive, index}) => ({ - tag, attributes, text, form_action, is_interactive, index - })); - """) - except Exception as e: - return {"url": url, "error": f"JavaScript提取失败: {str(e)}"} - - # 转换为统一格式 - controls = [] - for rc in raw_controls: - ctrl = { - "type": rc["tag"], - "attributes": rc["attributes"], - "text": rc.get("text", ""), - "context": infer_context_from_attrs(rc["tag"], rc["attributes"], rc.get("text", "")), - "events": [], - "form_action": rc.get("form_action"), - "is_interactive": rc.get("is_interactive", False), - "index": rc.get("index") # 保存索引,用于模拟点击 - } - controls.append(ctrl) - - # 填充事件和递归(将 driver 传入,实现模拟点击) - add_events_and_recurse(controls, url, depth, max_depth, visited, driver) - - # 删除内部使用的字段 - for ctrl in controls: - ctrl.pop("form_action", None) - if not ctrl["is_interactive"]: - del ctrl["is_interactive"] - - return {"url": url, "controls": controls} - - -def process_source(source: str, depth: int, max_depth: int, - visited: set, driver) -> Dict[str, Any]: - if source.startswith(('http://', 'https://', 'file://')): - url = source - else: - abs_path = os.path.abspath(source) - url = Path(abs_path).as_uri() - return build_page_tree_from_url(driver, url, depth, max_depth, visited) - - -# ---------------------------- 调试浏览器管理 ---------------------------- -def is_port_open(host, port): - try: - with socket.create_connection((host, port), timeout=1): - return True - except OSError: - return False - - -def ensure_debug_browser(): - debug_host = "127.0.0.1" - debug_port = 9222 - - if not is_port_open(debug_host, debug_port): - print("未检测到调试浏览器,正在启动 Edge 调试窗口...") - edge_path = r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" - user_data_dir = r"C:\temp\edge_debug_profile" - try: - subprocess.Popen([ - edge_path, - f"--remote-debugging-port={debug_port}", - f"--user-data-dir={user_data_dir}" - ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - except FileNotFoundError: - edge_path = "msedge" - subprocess.Popen([ - edge_path, - f"--remote-debugging-port={debug_port}", - f"--user-data-dir={user_data_dir}" - ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - print("请在新打开的 Edge 窗口中访问目标网站并完成登录,然后回到这里按 Enter 继续...") - input() - else: - print("检测到已有的调试浏览器,将直接使用。") - - if not is_port_open(debug_host, debug_port): - raise RuntimeError("无法连接到调试端口,请检查 Edge 是否正常启动。") - - -# ---------------------------- 人性化报告生成 ---------------------------- -def short_text_for_control(ctrl: Dict[str, Any]) -> str: - tp = ctrl["type"] - attrs = ctrl["attributes"] - is_interactive = ctrl.get("is_interactive", False) - base = "" - - if tp == "a": - href = attrs.get("href", "") - text = ctrl.get("text") or attrs.get("title") or attrs.get("aria-label") or href[:60] - base = f"链接 →{text} : {href}" - elif tp == "button": - text = ctrl.get("text") or attrs.get("value") or attrs.get("aria-label") or "按钮" - base = f"按钮: {text}" - elif tp == "input": - input_type = attrs.get("type", "text") - text = ctrl.get("text") or attrs.get("value") or attrs.get("placeholder") or "" - base = f"输入框 ({input_type})" - if text: - base += f" [{text}]" - elif tp == "textarea": - text = ctrl.get("text") or attrs.get("placeholder") or "" - base = "多行文本框" - if text: - base += f" [{text}]" - elif tp == "select": - text = ctrl.get("text") or "" - base = "下拉选择框" - if text: - base += f" [{text}]" - elif is_interactive: - text = ctrl.get("text") or "" - role = attrs.get("role", "") - tag_desc = f"{tp}控件" - if role == "button": - tag_desc = "按钮(ARIA)" - elif role == "link": - tag_desc = "链接(ARIA)" - elif "card" in (attrs.get("class", "") + attrs.get("id", "")): - tag_desc = "卡片" - base = f"可交互{tag_desc}" - if text: - base += f" [{text}]" - if attrs.get("data-href"): - base += f" (目标: {attrs['data-href']})" - else: - text = ctrl.get("text") or "" - base = f"{tp} 控件" - if text: - base += f" [{text}]" - return base - - -def describe_page_tree(page: Dict[str, Any], prefix: str = "", - max_depth: int = 1, current_depth: int = 1, - is_last: bool = True) -> str: - url = page.get("url", "未知页面") - if "error" in page: - return f"{prefix}页面 {url} 抓取失败: {page['error']}\n" - - controls = page.get("controls", []) - lines = [] - connector = "└── " if is_last else "├── " - lines.append(f"{prefix}{connector}页面: {url}") - - groups: Dict[str, List[Dict]] = {} - for ctrl in controls: - ctx = ctrl["context"] - groups.setdefault(ctx, []).append(ctrl) - - sorted_groups = sorted(groups.items(), key=lambda x: x[0]) - group_count = len(sorted_groups) - - for idx, (context, items) in enumerate(sorted_groups): - is_last_group = (idx == group_count - 1) - group_connector = "└── " if is_last_group else "├── " - group_line = f"{prefix}{' ' if is_last else '│ '}{group_connector}【{context}】({len(items)}个)" - lines.append(group_line) - - item_prefix = prefix + (" " if is_last else "│ ") + (" " if is_last_group else "│ ") - - for j, ctrl in enumerate(items): - is_last_item = (j == len(items) - 1) - item_connector = "└── " if is_last_item else "├── " - desc = short_text_for_control(ctrl) - lines.append(f"{item_prefix}{item_connector}{desc}") - - for event in ctrl.get("events", []): - if "target_page" in event and current_depth < max_depth: - sub_page = event["target_page"] - new_prefix = item_prefix + (" " if is_last_item else "│ ") - sub_desc = describe_page_tree( - sub_page, - prefix=new_prefix, - max_depth=max_depth, - current_depth=current_depth + 1, - is_last=True - ) - lines.append(sub_desc) - - return "\n".join(lines) + "\n" - - -def generate_human_report(root_page: Dict[str, Any], max_depth: int) -> str: - header = f"🌐 页面交互控件分析报告(增强版)\n深度限制: {max_depth}\n" + "=" * 40 + "\n" - tree = describe_page_tree(root_page, prefix="", max_depth=max_depth, - current_depth=1, is_last=True) - return header + tree - - -# ---------------------------- 命令行入口 ---------------------------- -def main(): - parser = argparse.ArgumentParser(description="通用网页交互控件分析工具(增强版:模拟点击、去重)") - parser.add_argument("source", help="起始 URL 或本地 HTML 文件路径") - parser.add_argument("-N", "--depth", type=int, default=1, help="最大递归深度(默认1)") - parser.add_argument("-o", "--output", help="输出文本文件路径,默认打印到控制台") - parser.add_argument("--json", action="store_true", help="同时保存机器可读的 JSON 文件(与 -o 同名 .json)") - args = parser.parse_args() - - max_depth = max(1, args.depth) - - if args.source.startswith(('http://', 'https://')): - start_url = args.source - else: - abs_path = os.path.abspath(args.source) - start_url = Path(abs_path).as_uri() - visited = {start_url} - - ensure_debug_browser() - - service = Service(EdgeChromiumDriverManager().install()) - options = Options() - options.add_experimental_option("debuggerAddress", "127.0.0.1:9222") - - driver = webdriver.Edge(service=service, options=options) - driver.implicitly_wait(5) - - try: - tree = process_source(start_url, depth=1, max_depth=max_depth, - visited=visited, driver=driver) - finally: - driver.quit() - - report = generate_human_report(tree, max_depth) - - if args.output: - with open(args.output, 'w', encoding='utf-8') as f: - f.write(report) - print(f"人性化报告已保存到 {args.output}") - if args.json: - json_path = args.output.rsplit('.', 1)[0] + '.json' - with open(json_path, 'w', encoding='utf-8') as jf: - json.dump(tree, jf, ensure_ascii=False, indent=2) - print(f"机器可读 JSON 已保存到 {json_path}") - else: - print(report) - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/jingtai.py b/jingtai.py deleted file mode 100644 index 863382d..0000000 --- a/jingtai.py +++ /dev/null @@ -1,306 +0,0 @@ -import os -import re -import json -import argparse -from pathlib import Path -from typing import Dict, Any, List, Optional -from urllib.parse import urljoin, urlparse - -from selenium import webdriver -from selenium.webdriver.edge.options import Options -from selenium.webdriver.edge.service import Service -from selenium.webdriver.support.ui import WebDriverWait -from webdriver_manager.microsoft import EdgeChromiumDriverManager - - -# -------------------- 工具函数 -------------------- -def get_absolute_url(base_url: str, href: str) -> Optional[str]: - if not href: - return None - href = href.strip() - if href.startswith(('javascript:', 'mailto:', 'tel:', '#')): - return None - absolute = urljoin(base_url, href) - parsed = urlparse(absolute) - if parsed.scheme in ('http', 'https', 'file'): - return absolute - return None - - -# -------------------- 语义上下文推断 -------------------- -def infer_context_from_attrs(tag_name: str, attrs: Dict[str, str], text: str = "") -> str: - class_list = attrs.get("class", "").split() - id_ = attrs.get("id", "") - href = attrs.get("href", "") - features = " ".join(class_list) + " " + id_ - - if tag_name == "a": - if any(w in features for w in ["btn", "button", "tab", "nav-btn", "menu-item", "action-btn"]): - return "按钮" - if "cm.bilibili.com" in href or "ad" in features: - return "广告链接" - return "普通链接" - - if "search" in features or "nav-search" in features: - return "搜索框" - if any(w in features for w in ["header", "top-bar", "masthead", "nav"]): - return "顶部导航" - if any(w in features for w in ["sidebar", "aside", "right-entry"]): - return "侧边栏" - if "footer" in features: - return "页脚" - if any(w in features for w in ["channel", "partition", "channel-icons"]): - return "频道/分区" - if any(w in features for w in ["carousel", "banner", "slider"]): - return "轮播/横幅" - if any(w in features for w in ["video-card", "feed-card", "card-item", "card"]): - return "视频卡片" - if "main" in features or "content" in features: - return "主要内容区" - if tag_name in ("input", "textarea"): - return "输入控件" - if tag_name == "button": - return "按钮" - if tag_name == "select": - return "下拉选择" - if tag_name in ("div", "li", "span", "img", "td", "label"): - if any(w in features for w in ["btn", "button", "action", "clickable", "tab", "menu-item"]): - return "按钮" - if any(w in features for w in ["card", "item", "cell", "list-item"]): - return "卡片/列表项" - if "dropdown" in features or "menu" in features: - return "菜单" - if "dialog" in features or "modal" in features: - return "弹窗" - if "icon" in features: - return "图标" - if tag_name == "img": - return "图片" - return "交互区域" - return "其他" - - -# -------------------- 控件提取(去重 + 仅保留元素大小) -------------------- -def extract_controls(driver) -> List[Dict[str, Any]]: - """ - 无头模式下提取所有可交互元素(去重), - 仅返回元素的尺寸(CSS 像素),不包含坐标。 - """ - raw = driver.execute_script(""" - const tags = ['a', 'input', 'button', 'select', 'textarea', - 'div', 'li', 'span', 'img', 'td', 'label']; - let allElements = []; - - // 1. 收集候选元素 - for (let tag of tags) { - let elems = document.querySelectorAll(tag); - for (let elem of elems) { - if (tag === 'input' && elem.type === 'hidden') continue; - if (tag === 'a' && !elem.href && !elem.onclick) continue; - - let isInteractive = false; - if (['div', 'li', 'span', 'img', 'td', 'label'].includes(tag)) { - if (elem.onclick) isInteractive = true; - let role = elem.getAttribute('role'); - if (role && ['button', 'link', 'tab', 'menuitem'].includes(role)) isInteractive = true; - if (elem.hasAttribute('tabindex') && elem.getAttribute('tabindex') !== '-1') isInteractive = true; - if (elem.hasAttribute('data-action') || elem.hasAttribute('data-href')) isInteractive = true; - let cls = elem.className || ''; - if (/(btn|button|clickable|action|link|tab|menu-item|card|cell)/i.test(cls)) { - isInteractive = true; - } - if (!isInteractive) continue; - } - allElements.push(elem); - } - } - - // 2. 去重:只保留不被其他任何元素包含的最外层元素 - let uniqueElements = []; - for (let elem of allElements) { - let isChild = false; - for (let other of allElements) { - if (other !== elem && other.contains(elem)) { - isChild = true; - break; - } - } - if (!isChild) { - uniqueElements.push(elem); - } - } - - // 3. 获取每个元素的属性、文本和尺寸 - return uniqueElements.map(elem => { - let tag = elem.tagName.toLowerCase(); - let attrs = {}; - for (let attr of elem.attributes) { - attrs[attr.name] = attr.value; - } - let formAction = null; - if (tag === 'button' || (tag === 'input' && ['submit','button','image'].includes(elem.type))) { - let form = elem.closest('form'); - if (form) formAction = form.getAttribute('action') || ''; - } - let text = elem.innerText ? elem.innerText.trim().substring(0, 50) : ''; - let rect = elem.getBoundingClientRect(); - - return { - tag: tag, - attributes: attrs, - text: text, - form_action: formAction, - is_interactive: true, - size: { - width: rect.width, - height: rect.height - } - }; - }); - """) - - controls = [] - for item in raw: - ctrl = { - "type": item["tag"], - "attributes": item["attributes"], - "text": item.get("text", ""), - "context": infer_context_from_attrs(item["tag"], item["attributes"], item.get("text", "")), - "form_action": item.get("form_action"), - "is_interactive": item.get("is_interactive", False), - "size": item.get("size", {}) - } - controls.append(ctrl) - return controls - - -# -------------------- 人性化报告生成 -------------------- -def short_text_for_control(ctrl: Dict[str, Any]) -> str: - tp = ctrl["type"] - attrs = ctrl["attributes"] - is_interactive = ctrl.get("is_interactive", False) - size = ctrl.get("size", {}) - size_str = "" - if size: - w, h = size.get("width", 0), size.get("height", 0) - size_str = f" [大小:{w:.0f}x{h:.0f}]" - - base = "" - if tp == "a": - href = attrs.get("href", "") - text = ctrl.get("text") or attrs.get("title") or attrs.get("aria-label") or href[:60] - base = f"链接 →{text}" - if href: - base += f" : {href}" - elif tp == "button": - text = ctrl.get("text") or attrs.get("value") or attrs.get("aria-label") or "按钮" - base = f"按钮: {text}" - elif tp == "input": - input_type = attrs.get("type", "text") - text = ctrl.get("text") or attrs.get("value") or attrs.get("placeholder") or "" - base = f"输入框 ({input_type})" - if text: - base += f" [{text}]" - elif tp == "textarea": - text = ctrl.get("text") or attrs.get("placeholder") or "" - base = "多行文本框" - if text: - base += f" [{text}]" - elif tp == "select": - text = ctrl.get("text") or "" - base = "下拉选择框" - if text: - base += f" [{text}]" - elif is_interactive: - text = ctrl.get("text") or "" - role = attrs.get("role", "") - tag_desc = f"{tp}控件" - if role == "button": - tag_desc = "按钮(ARIA)" - elif role == "link": - tag_desc = "链接(ARIA)" - elif "card" in (attrs.get("class", "") + attrs.get("id", "")): - tag_desc = "卡片" - base = f"可交互{tag_desc}" - if text: - base += f" [{text}]" - if attrs.get("data-href"): - base += f" (目标: {attrs['data-href']})" - else: - text = ctrl.get("text") or "" - base = f"{tp} 控件" - if text: - base += f" [{text}]" - return base + size_str - - -def generate_report(url: str, controls: List[Dict[str, Any]]) -> str: - lines = [f"🌐 页面交互控件分析报告(静态单页·无头模式)\n页面: {url}\n" + "=" * 50] - groups: Dict[str, List[Dict]] = {} - for ctrl in controls: - ctx = ctrl["context"] - groups.setdefault(ctx, []).append(ctrl) - - sorted_groups = sorted(groups.items(), key=lambda x: x[0]) - for context, items in sorted_groups: - lines.append(f"├── 【{context}】({len(items)}个)") - for item in items: - desc = short_text_for_control(item) - lines.append(f"│ ├── {desc}") - return "\n".join(lines) - - -# -------------------- 主程序 -------------------- -def main(): - parser = argparse.ArgumentParser(description="静态单页交互控件分析(Edge·无头模式·仅元素大小)") - parser.add_argument("source", help="起始 URL 或本地 HTML 文件路径") - parser.add_argument("-o", "--output", help="输出报告文件路径,默认打印到控制台") - parser.add_argument("--json", action="store_true", help="同时保存机器可读的 JSON 文件(与 -o 同名 .json)") - args = parser.parse_args() - - # 构造 URL - if args.source.startswith(('http://', 'https://')): - url = args.source - else: - abs_path = os.path.abspath(args.source) - url = Path(abs_path).as_uri() - - # 无头模式 Edge,设置窗口大小 - options = Options() - options.add_argument("--headless=new") # 新版无头模式 - options.add_argument("--window-size=1920,1080") # 固定视口大小 - options.add_argument("--disable-gpu") # 禁用 GPU 加速(无头下建议) - - service = Service(EdgeChromiumDriverManager().install()) - driver = webdriver.Edge(service=service, options=options) - - try: - driver.get(url) - WebDriverWait(driver, 15).until( - lambda d: d.execute_script("return document.readyState") == "complete" - ) - controls = extract_controls(driver) - finally: - driver.quit() - - report = generate_report(url, controls) - - # 输出结果 - if args.output: - with open(args.output, 'w', encoding='utf-8') as f: - f.write(report) - print(f"人性化报告已保存到 {args.output}") - if args.json: - json_path = args.output.rsplit('.', 1)[0] + '.json' - with open(json_path, 'w', encoding='utf-8') as jf: - json.dump({ - "url": url, - "controls": controls - }, jf, ensure_ascii=False, indent=2) - print(f"机器可读 JSON 已保存到 {json_path}") - else: - print(report) - - -if __name__ == "__main__": - main() diff --git a/src/agent/core/__pycache__/config_manager.cpython-312.pyc b/src/agent/core/__pycache__/config_manager.cpython-312.pyc index cd16cb2..43740b2 100644 Binary files a/src/agent/core/__pycache__/config_manager.cpython-312.pyc and b/src/agent/core/__pycache__/config_manager.cpython-312.pyc differ diff --git a/src/agent/core/__pycache__/llm.cpython-312.pyc b/src/agent/core/__pycache__/llm.cpython-312.pyc index a773611..fa75b0c 100644 Binary files a/src/agent/core/__pycache__/llm.cpython-312.pyc and b/src/agent/core/__pycache__/llm.cpython-312.pyc differ diff --git a/src/agent/core/__pycache__/nodes.cpython-312.pyc b/src/agent/core/__pycache__/nodes.cpython-312.pyc index 2faf777..7bf6d08 100644 Binary files a/src/agent/core/__pycache__/nodes.cpython-312.pyc and b/src/agent/core/__pycache__/nodes.cpython-312.pyc differ diff --git a/src/agent/core/__pycache__/runtime.cpython-312.pyc b/src/agent/core/__pycache__/runtime.cpython-312.pyc index 1a01369..d0da7fc 100644 Binary files a/src/agent/core/__pycache__/runtime.cpython-312.pyc and b/src/agent/core/__pycache__/runtime.cpython-312.pyc differ diff --git a/src/agent/core/agent_config.json b/src/agent/core/agent_config.json index 05490ef..1a00e64 100644 --- a/src/agent/core/agent_config.json +++ b/src/agent/core/agent_config.json @@ -5,11 +5,11 @@ "max_iterations": 200, "llm_model_id": "b53afakf", "tools": [ - "send_file", - "directory_listing", - "terminal_execute", "doc_tool", - "web_search_class" + "date_query", + "web_search", + "human_interaction", + "todo_list" ], "enabled": true, "role": "main", diff --git a/src/agent/core/config_manager.py b/src/agent/core/config_manager.py index 643f2e3..f94dd36 100644 --- a/src/agent/core/config_manager.py +++ b/src/agent/core/config_manager.py @@ -154,6 +154,9 @@ _TOOL_DEFAULT_AUTO_RULES: dict[str, list[dict]] = { "skill_router": [ {"parameter": "query", "operator": "exists", "value": ""}, ], + "mail_tool": [ + {"parameter": "action", "operator": "equals", "value": "read"}, + ], } @@ -321,6 +324,8 @@ def _default_env_config() -> dict: "LOOP_DETECT_REPEATED_TOOL_WARN": "3", "LOOP_DETECT_REPEATED_TOOL_END": "5", "SEND_FILE_SIZE_LIMIT": "10", + "EMAIL_ADDRESS": "", + "EMAIL_AUTH_CODE": "", } diff --git a/src/agent/core/env_config.json b/src/agent/core/env_config.json index c91f779..6d8bc7e 100644 --- a/src/agent/core/env_config.json +++ b/src/agent/core/env_config.json @@ -1,9 +1,9 @@ { "WORKING_DIR": "c:\\Users\\86166\\Desktop\\Agent_Learning_minor\\Agent", "WORKSPACE_DIR": "src\\agent\\workspace", - "TTS_BASE_URL": "127.0.0.0:8902", - "ASR_BASE_URL": "127.0.0.0:8901", - "RAG_BASE_URL": "127.0.0.0:8903", + "TTS_BASE_URL": "http://localhost:8902", + "ASR_BASE_URL": "http://localhost:8901", + "RAG_BASE_URL": "http://localhost:8903", "TAVILY_API_KEY": "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", "LLM_CONTEXT_WINDOW": "262144", "MINI_COMPRESS_RATE": "0.4", @@ -17,7 +17,9 @@ "LOOP_DETECT_TODOLIST_STALE_ROUNDS": "20", "LOOP_DETECT_REPEATED_TOOL_WARN": "3", "LOOP_DETECT_REPEATED_TOOL_END": "5", - "SEND_FILE_SIZE_LIMIT": "10", + "SEND_FILE_SIZE_LIMIT": "30", + "EMAIL_ADDRESS": "3139785954@qq.com", + "EMAIL_AUTH_CODE": "hfjkjqnlhjvqdeef", "models": [ { "id": "a44b226e", diff --git a/src/agent/core/llm.py b/src/agent/core/llm.py index 356d641..de6fe7e 100644 --- a/src/agent/core/llm.py +++ b/src/agent/core/llm.py @@ -41,7 +41,7 @@ class ChatQwen(ChatOpenAI): """Qwen 系列模型的 LangChain 封装,扩展多模态与 TTS 能力。 属性: - output_type: 默认输出模式 text | audio | both。 + output_type: 默认输出模式 text | voice。 stream_play: TTS 后是否本地播放。 last_audio_kwargs: 最近一次 TTS 的文件路径与时长等元数据。 @@ -213,7 +213,7 @@ class ChatQwen(ChatOpenAI): } ] } - endpoint = f"{env_utils.ASR_BASE_URL.rstrip('/')}/chat/completions" + endpoint = f"{env_utils.ASR_BASE_URL.rstrip('/')}/v1/chat/completions" resp = requests.post( endpoint, headers={"Content-Type": "application/json"}, @@ -370,11 +370,11 @@ class ChatQwen(ChatOpenAI): 输入: result: ChatResult 含 AIMessage。 - output_type: audio | both。 + output_type: voice。 audio_output_dir: WAV 保存目录。 输出: - 可能清空 audio 模式下 content 的 ChatResult;设置 last_audio_kwargs。 + 设置 last_audio_kwargs 的 ChatResult。 系统定位: _generate 在模型返回后的后处理。 @@ -382,7 +382,7 @@ class ChatQwen(ChatOpenAI): 可扩展性: 可上传对象存储而非本地路径。 """ - if output_type not in ("audio", "both") or not audio_output_dir: + if output_type != "voice" or not audio_output_dir: return result os.makedirs(audio_output_dir, exist_ok=True) for idx, gen in enumerate(result.generations): @@ -391,6 +391,9 @@ class ChatQwen(ChatOpenAI): msg = gen.message if not isinstance(msg, AIMessage): continue + # 跳过含 tool_calls 的中间决策消息,仅对最终回复合成语音 + if msg.tool_calls: + continue text = assistant_text(msg.content) if not text.strip(): continue @@ -414,8 +417,6 @@ class ChatQwen(ChatOpenAI): } if self.stream_play: self.play_audio_bytes(audio_bytes) - if output_type == "audio": - msg.content = "" except Exception as e: print(f"[TTS ERROR] 合成语音失败: {e}") # 发生错误时也清空 last_audio_kwargs,避免污染 @@ -459,12 +460,8 @@ class ChatQwen(ChatOpenAI): result = super()._generate(messages, stop=stop, run_manager=run_manager, **kwargs) - if output_type in ("audio", "both"): + if output_type == "voice": result = self._apply_tts(result, output_type, audio_output_dir=audio_output_dir) - if output_type == "audio": - for gen in result.generations: - if isinstance(gen, ChatGeneration) and isinstance(gen.message, AIMessage): - gen.message.content = "" return result def _create_chat_result(self, response, generation_info=None) -> ChatResult: diff --git a/src/agent/core/nodes.py b/src/agent/core/nodes.py index d984bf5..18eade9 100644 --- a/src/agent/core/nodes.py +++ b/src/agent/core/nodes.py @@ -27,12 +27,29 @@ from agent.utils.image_utils import image_bytes_to_openai_image_url_part from agent.utils.tool_call_utils import normalize_tool_call +def _has_image_content(msg: HumanMessage) -> bool: + """判断 HumanMessage 是否包含图片内容(截图等 data URL)。 + + 输入: + msg: 待检查的 HumanMessage。 + + 输出: + True 表示 content 中包含 image_url 类型的内容块。 + """ + content = msg.content + if isinstance(content, list): + for part in content: + if isinstance(part, dict) and part.get("type") == "image_url": + return True + return False + + def _filter_short_term_memory(messages: list, trajectory_rounds: int) -> list: - """过滤短期记忆:保留所有工具调用,工具返回结果仅保留最近 trajectory_rounds 轮。 + """过滤短期记忆:保留所有工具调用记录和工具文字返回值,图片截图仅保留最近 trajectory_rounds 轮。 输入: messages: 完整消息列表。 - trajectory_rounds: 保留最近 N 轮的工具返回结果;或负数表示不过滤。 + trajectory_rounds: 保留最近 N 轮的图片截图(合成 HumanMessage);<=0 表示不过滤任何消息。 输出: 过滤后的消息列表。 @@ -42,9 +59,9 @@ def _filter_short_term_memory(messages: list, trajectory_rounds: int) -> list: 场景下上下文过长。 规则: - - 保留所有非 ToolMessage 消息(SystemMessage、HumanMessage、AIMessage) - - ToolMessage 仅保留最近 trajectory_rounds 轮工具调用对应的返回结果 - - 每轮由一个 AIMessage(含 tool_calls)定义,其后的 ToolMessage 属于该轮 + - 保留所有 SystemMessage、HumanMessage(非合成图片)、AIMessage、ToolMessage + - 仅 process_tool_artifact 生成的合成图片 HumanMessage 按 trajectory_rounds 裁剪 + - 每轮由一个 AIMessage(含 tool_calls)定义 """ if trajectory_rounds <= 0: return list(messages) @@ -59,7 +76,8 @@ def _filter_short_term_memory(messages: list, trajectory_rounds: int) -> list: if isinstance(msg, AIMessage) and getattr(msg, "tool_calls", None): rounds_seen += 1 - if isinstance(msg, ToolMessage): + # 合成图片消息:仅保留最近 trajectory_rounds 轮 + if isinstance(msg, HumanMessage) and msg.additional_kwargs.get("synthetic") and _has_image_content(msg): if rounds_seen < trajectory_rounds: kept_indices.add(i) else: @@ -274,9 +292,18 @@ def process_tool_artifact(state: AgentState) -> dict: artifact = last_msg.artifact img_bytes_list: list[bytes] = [] + source_path = "" - if isinstance(artifact, dict) and "image_bytes" in artifact: - img_bytes_list.append(artifact["image_bytes"]) + if isinstance(artifact, dict): + if "image_bytes" in artifact: + img_bytes_list.append(artifact["image_bytes"]) + if "images" in artifact and isinstance(artifact["images"], list): + for item in artifact["images"]: + if isinstance(item, bytes): + img_bytes_list.append(item) + elif isinstance(item, dict) and "image_bytes" in item: + img_bytes_list.append(item["image_bytes"]) + source_path = artifact.get("file_path", "") or artifact.get("source_path", "") elif isinstance(artifact, list): for item in artifact: if isinstance(item, dict) and "image_bytes" in item: @@ -305,7 +332,7 @@ def process_tool_artifact(state: AgentState) -> dict: ) return {"messages": [human_msg]} - content_parts.append({"type": "text", "text": "上面是工具返回的截图。"}) + content_parts.append({"type": "text", "text": "上面是工具返回的截图。" + (f"(来源:{source_path})" if source_path else "")}) if failed_count > 0: content_parts[-1]["text"] += f"(其中 {failed_count} 张图片处理失败,请忽略)" diff --git a/src/agent/core/runtime.py b/src/agent/core/runtime.py index d8808b8..8713f8c 100644 --- a/src/agent/core/runtime.py +++ b/src/agent/core/runtime.py @@ -282,7 +282,7 @@ def execute_agent( runtime: Agent 运行时配置。 chat_history: Web UI 聊天历史(dict 列表);末条须为 user。 session_id: 会话 ID,用于历史、文件目录、manifest 同步。 - output_type: text | audio | both,传入图 metadata。 + output_type: text | voice,传入图 metadata。 agent_mode: agent(默认)| team(占位提示)。 输出: @@ -317,6 +317,10 @@ def execute_agent( # Plan 模式使用专用系统提示词 system_prompt = PLAN_MODE_PROMPT if agent_mode == "plan" else runtime.system_prompt + # 语音模式下追加简洁性提示 + if output_type == "voice": + system_prompt = system_prompt + "\n\n【回复风格要求】\n当前处于语音输出模式,你的文字回复必须尽可能简短精炼,一句话能说清楚绝不用两句。直接给出核心信息,省略寒暄和冗余说明。" + # 当 skill_enabled 开启时,在每次用户输入后先做 skill 路由: # 由 SkillRouter 根据当前 query 匹配相关 skill 指南,注入到系统提示词中 if runtime.skill_enabled: @@ -451,9 +455,7 @@ def execute_agent( "turn_id": turn_id, } - if output_type_from_config == "audio" and assistant_meta.get("audio"): - assistant_content = [] - elif output_type_from_config == "both" and assistant_meta.get("audio"): + if output_type_from_config == "voice" and assistant_meta.get("audio"): assistant_content = text_content else: assistant_content = text_content if text_content else "(空回复)" diff --git a/src/agent/core/tool_config.json b/src/agent/core/tool_config.json index 92349d1..9905774 100644 --- a/src/agent/core/tool_config.json +++ b/src/agent/core/tool_config.json @@ -1,6 +1,6 @@ [ { - "name": "web_search_class", + "name": "web_search", "display_name": "WebSearch", "description": "一个网络搜索工具,可以根据用户的查询返回相关的搜索结果。仅当需要查询近期知识或已有知识无法解答需要联网查询时调用,其余任何情况严禁调用。", "permission": "direct", @@ -8,26 +8,18 @@ "enabled": true }, { - "name": "date_query_class", + "name": "date_query", "display_name": "DateQuery", "description": "一个日期查询工具,可以返回当前的日期信息。仅当需要查询当前时间时调用,其余任何情况严禁调用。", "permission": "direct", "auto_execute_rules": [], "enabled": true }, - { - "name": "screen_capture", - "display_name": "ScreenCapture", - "description": "截取当前主屏幕画面。除了返回文字描述外,也会提供原始截图给模型进行视觉分析。注意:这个工具仅用于获取当前屏幕的实时画面,其余任何情况严禁调用。", - "permission": "direct", - "auto_execute_rules": [], - "enabled": true - }, { "name": "browser_control", "display_name": "BrowserControl", "description": "统一控制浏览器:打开/关闭浏览器、刷新/关闭标签页、打开指定 URL。", - "permission": "direct", + "permission": "confirm", "auto_execute_rules": [ { "parameter": "action", @@ -84,7 +76,7 @@ { "name": "todo_list", "display_name": "TodoList", - "description": "可选的任务管理工具。当你需要拆分复杂任务为多个步骤并跟踪进度时调用。输入 steps (list[str]) 为每一步的描述,step (int) 为已完成的步骤索引(从0开始)。任务默认状态为 pending,根据 step 自动标记完成。工具返回当前 todo list 文本。仅在需要跟踪多步骤任务进度时调用,简单任务不需要调用。", + "description": "任务管理工具。用于将复杂任务拆分为多个步骤并跟踪执行进度。", "permission": "direct", "auto_execute_rules": [], "enabled": true @@ -92,7 +84,7 @@ { "name": "gui_tool", "display_name": "GUITool", - "description": "GUI automation tool. Visually locates desktop elements from a screenshot, maps coordinates to the real screen, and executes pyautogui actions. Supported actions: click, type, drag_and_drop, scroll, ho", + "description": "GUI 自动化工具。通过截图视觉定位桌面元素,将坐标映射到真实屏幕,并执行 pyautogui 动作。接收一个有序的动作列表,按顺序依次执行。每个动作项支持的操作:click(点击)、type(输入文本,可通过 element_description 指定目标输入框自动聚焦)、drag_and_drop(拖拽)、scroll(滚动)、hotkey(快捷键)、hold_and_press(按住组合键", "permission": "direct", "auto_execute_rules": [], "enabled": true @@ -100,7 +92,7 @@ { "name": "call_subagent", "display_name": "CallSubAgentTool", - "description": "调用已启用的子 Agent 执行指定任务。子 Agent 会独立运行并返回最终结果。", + "description": "调用一个已启用的子 Agent 执行指定任务。子 Agent 会独立运行并返回最终结果。适用于需要专业能力(如代码分析、文档处理等)进行辅助的场景。可用的子 Agent 名称取决于 agent_config.json 中已启用且 role='sub' 的配置。", "permission": "direct", "auto_execute_rules": [], "enabled": true @@ -108,15 +100,7 @@ { "name": "doc_tool", "display_name": "DocTool", - "description": "文档操作工具。支持纯文本(plain_text)、表格(tabular: CSV/XLSX)、Word(docx)、PDF 文档的增删改查。", - "permission": "direct", - "auto_execute_rules": [], - "enabled": true - }, - { - "name": "gui_tool(test)", - "display_name": "GUIToolTest", - "description": "GUI 自动化工具(测试版)。通过截图视觉定位桌面元素,将坐标映射到真实屏幕,并执行 pyautogui 动作。接收一个有序的动作列表,按顺序依次执行。每个动作项支持的操作:click(点击)、type(输入文本,可通过 element_description 指定目标输入框自动聚焦)、drag_and_drop(拖拽)、scroll(滚动)、hotkey(快捷键)、hold_and_press(", + "description": "文档操作工具。支持纯文本(plain_text)、表格(tabular: CSV/XLSX)、Word(docx)、PowerPoint(pptx)、PDF 文档的增删改查,以及图片(image)、音频(audio)的读取。", "permission": "direct", "auto_execute_rules": [], "enabled": true @@ -138,7 +122,7 @@ { "name": "send_file", "display_name": "SendFileTool", - "description": "向用户发送文件的工具。根据文件类型自动处理:图片前端直接展示,音频前端播放+ASR转文字,压缩包和各类文件供用户下载(超过大小限制时提示发送到邮箱)。", + "description": "向用户发送文件的工具。根据文件类型自动处理:", "permission": "direct", "auto_execute_rules": [], "enabled": true @@ -146,9 +130,23 @@ { "name": "terminal_execute", "display_name": "TerminalExecute", - "description": "终端命令执行工具。在本地终端(PowerShell)中执行 shell 命令并返回执行结果。支持执行 Python 脚本、安装依赖、运行程序、文件操作等任意终端命令。", + "description": "终端命令执行工具。在本地终端(PowerShell)中执行 shell 命令并返回执行结果。", "permission": "direct", "auto_execute_rules": [], "enabled": true + }, + { + "name": "mail_tool", + "display_name": "MailTool", + "description": "QQ email tool. Supports sending and reading email without GUI.", + "permission": "direct", + "auto_execute_rules": [ + { + "parameter": "action", + "operator": "equals", + "value": "read" + } + ], + "enabled": true } ] \ No newline at end of file diff --git a/src/agent/history/__pycache__/tool_call_recorder.cpython-312.pyc b/src/agent/history/__pycache__/tool_call_recorder.cpython-312.pyc index 6fbc5a4..fc5c93c 100644 Binary files a/src/agent/history/__pycache__/tool_call_recorder.cpython-312.pyc and b/src/agent/history/__pycache__/tool_call_recorder.cpython-312.pyc differ diff --git a/src/agent/history/data_base/chat_history.db b/src/agent/history/data_base/chat_history.db index 1f3e08f..528df90 100644 Binary files a/src/agent/history/data_base/chat_history.db and b/src/agent/history/data_base/chat_history.db differ diff --git a/src/agent/history/multi_inputs/20260708_155304/ui_messages.json b/src/agent/history/multi_inputs/20260708_155304/ui_messages.json deleted file mode 100644 index 91260cb..0000000 --- a/src/agent/history/multi_inputs/20260708_155304/ui_messages.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "version": 3, - "messages": [] -} \ No newline at end of file diff --git a/src/agent/history/multi_inputs/20260708_160449/ui_messages.json b/src/agent/history/multi_inputs/20260708_160449/ui_messages.json deleted file mode 100644 index 91260cb..0000000 --- a/src/agent/history/multi_inputs/20260708_160449/ui_messages.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "version": 3, - "messages": [] -} \ No newline at end of file diff --git a/src/agent/history/tool_call_recorder.py b/src/agent/history/tool_call_recorder.py index 5886421..ebaa04d 100644 --- a/src/agent/history/tool_call_recorder.py +++ b/src/agent/history/tool_call_recorder.py @@ -127,6 +127,13 @@ def _artifact_image_data_urls(artifact: Any | None) -> list[str]: return if isinstance(item.get("image_bytes"), bytes): image_bytes_list.append(item["image_bytes"]) + # 支持 doc_tool 的 "images" 键(多图片列表) + if isinstance(item.get("images"), list): + for img in item["images"]: + if isinstance(img, bytes): + image_bytes_list.append(img) + elif isinstance(img, dict) and isinstance(img.get("image_bytes"), bytes): + image_bytes_list.append(img["image_bytes"]) for key in ("image_url", "url", "data_url"): value = item.get(key) if isinstance(value, str) and (value.startswith("data:image/") or value.startswith("http")): @@ -342,6 +349,16 @@ def _save_artifact_files(artifact: Any, base_name: str) -> tuple[list[str], dict fname = f"_artifact_{base_name}_0.png" files.append(fname) bytes_map[fname] = artifact["image_bytes"] + if "images" in artifact and isinstance(artifact["images"], list): + for i, img in enumerate(artifact["images"]): + if isinstance(img, bytes): + fname = f"_artifact_{base_name}_{i}.png" + files.append(fname) + bytes_map[fname] = img + elif isinstance(img, dict) and "image_bytes" in img: + fname = f"_artifact_{base_name}_{i}.png" + files.append(fname) + bytes_map[fname] = img["image_bytes"] if artifact.get("file_type") == "file" and "file_bytes" in artifact: file_name = artifact.get("file_name", "file") ext = _os.path.splitext(file_name)[1] or ".bin" diff --git a/src/agent/memory/__pycache__/system_prompt.cpython-312.pyc b/src/agent/memory/__pycache__/system_prompt.cpython-312.pyc index f63d2c8..92504c1 100644 Binary files a/src/agent/memory/__pycache__/system_prompt.cpython-312.pyc and b/src/agent/memory/__pycache__/system_prompt.cpython-312.pyc differ diff --git a/src/agent/memory/chroma_rag_db/93b83aa9-b3da-41a4-b34c-90fcd21bda19/data_level0.bin b/src/agent/memory/chroma_rag_db/93b83aa9-b3da-41a4-b34c-90fcd21bda19/data_level0.bin index 5e59e28..7292420 100644 Binary files a/src/agent/memory/chroma_rag_db/93b83aa9-b3da-41a4-b34c-90fcd21bda19/data_level0.bin and b/src/agent/memory/chroma_rag_db/93b83aa9-b3da-41a4-b34c-90fcd21bda19/data_level0.bin differ diff --git a/src/agent/memory/chroma_rag_db/93b83aa9-b3da-41a4-b34c-90fcd21bda19/length.bin b/src/agent/memory/chroma_rag_db/93b83aa9-b3da-41a4-b34c-90fcd21bda19/length.bin index 804579b..a8a9ff6 100644 Binary files a/src/agent/memory/chroma_rag_db/93b83aa9-b3da-41a4-b34c-90fcd21bda19/length.bin and b/src/agent/memory/chroma_rag_db/93b83aa9-b3da-41a4-b34c-90fcd21bda19/length.bin differ diff --git a/src/agent/memory/chroma_rag_db/chroma.sqlite3 b/src/agent/memory/chroma_rag_db/chroma.sqlite3 index ffecd46..33f84d9 100644 Binary files a/src/agent/memory/chroma_rag_db/chroma.sqlite3 and b/src/agent/memory/chroma_rag_db/chroma.sqlite3 differ diff --git a/src/agent/memory/system_prompt.py b/src/agent/memory/system_prompt.py index fdccc92..484eb26 100644 --- a/src/agent/memory/system_prompt.py +++ b/src/agent/memory/system_prompt.py @@ -14,12 +14,13 @@ _BASE_PROMPT = """你是一个具备多模态理解能力的桌面操作助手 _WS_TAIL = """\n\n【工作空间配置】 -- 工作目录 (WORKING_DIR): {working_dir} +- 项目根目录 (PROJECT_DIR): {working_dir} - 工作空间 (WORKSPACE_DIR): {workspace_dir} +- Skills 目录 (SKILLS_DIR): {skills_dir} - 你产生的代码、脚本、图片、文档等文件,默认保存到工作空间目录下。 - 使用 terminal_execute 工具时,如果不指定 working_dir,默认在此工作空间执行命令。 -- 使用 doc_tool 创建/修改文件时,文件路径请使用工作空间的绝对路径。""" - +- 使用 doc_tool 创建/修改文件时,文件路径请使用工作空间的绝对路径。 +""" def get_main_system_prompt() -> str: """动态构建主系统提示词(含工作空间路径)。 @@ -32,9 +33,12 @@ def get_main_system_prompt() -> str: """ try: from agent.utils.env_utils import WORKING_DIR, get_workspace_dir + from pathlib import Path + skills_dir = str(Path(__file__).resolve().parents[1] / "skills") ws_info = _WS_TAIL.format( working_dir=WORKING_DIR, workspace_dir=get_workspace_dir(), + skills_dir=skills_dir, ) except Exception: ws_info = "" @@ -143,12 +147,12 @@ PLAN_FIRST_CALL_PROMPT = """ """.strip() REFLECTION_PROMPT = """ -【请先在你的回复中尽可能简短地输出你的反思,然后再调用工具或给出最终回复】 -你需要先用尽可能简短的一句话在文字中思考上一步做了什么?下一步该做什么?然后进行相应的工具调用。格式如下: -1. 先分析上一轮执行结果:上一个调用了什么工具?结果是成功还是失败? -2. 如果上一轮是 GUI 操作(点击、输入等)但未命中目标或未达预期效果,请根据最新的截图重新估算目标位置,调整坐标或参数后重试。 -3. 如果上一轮操作成功,说明你根据分析结果进行下一步操作。 -4. 然后调用对应的工具执行下一步,或如果任务已完成则直接回复用户(注意只需要回复用户最终结果即可)。 +【重要:如果你判断不需要调用任何工具,请直接回复用户,不要输出任何反思内容!】 +只有在需要调用工具时,才先进行简短反思(一句话即可),然后调用工具。格式如下: +1. 如果不需要调用工具(任务已完成或无需工具即可回答),直接给出最终回复,不要输出反思。 +2. 如果需要调用工具:先用一句话思考上一步结果和下一步计划,然后调用工具。 +3. 如果上一轮是 GUI 操作(点击、输入等)但未命中目标或未达预期效果,请根据最新的截图重新估算目标位置,调整坐标或参数后重试。 +4. 如果上一轮操作成功,根据分析结果进行下一步操作。 【gui_tool 批量调用规则 - 务必遵守】 如果你计划在同一页面/界面执行多个连续的 GUI 操作(例如:依次点击多个按钮、填写多个表单字段、连续输入多项内容),必须将所有这些操作合并为一次 gui_tool 调用,在 actions 列表中一次性传入所有动作。严禁将同一页面的连续操作拆分多次 gui_tool 调用——这样会导致严重的延迟。仅当需要等页面跳转/加载后才能确定后续操作时,才分步调用。 diff --git a/src/agent/skills/ppt_maker/attachments/01-workflow-overview.md b/src/agent/skills/ppt_maker/attachments/01-workflow-overview.md new file mode 100644 index 0000000..0db7c7a --- /dev/null +++ b/src/agent/skills/ppt_maker/attachments/01-workflow-overview.md @@ -0,0 +1,123 @@ +# 01 — 总调度:SVG 幻灯片生成工作流总览 + +> **角色**:主调度器(Master Orchestrator) +> **读取时机**:任何幻灯片生成任务启动时,**最先读取此文件** +> **职责**:定义完整的 4 步串行流水线、全局执行纪律、入口路由规则 + +--- + +## 核心流水线 + +``` +源内容 → Strategist 设计规划 ⛔ → Executor 逐页生成 SVG → 质检 → 交付 SVG 文件 +``` + +4 个步骤**严格串行**,每步的输出是下一步的输入。 + +**本 Skill 的终点是生成 SVG 文件**。SVG → PPTX 转换通过独立脚本 `10-svg-to-pptx.md` 完成,仅在用户明确要求 PPT 格式时运行。 + +--- + +## 全局执行纪律 + +| # | 规则 | 说明 | +|---|------|------| +| 1 | **串行执行** | 步骤必须按顺序,不可跳跃 | +| 2 | **BLOCKING = 硬停止** | 标记为 ⛔ BLOCKING 的步骤必须等待用户显式确认 | +| 3 | **禁止跨阶段捆绑** | Strategist 确认阶段是唯一 BLOCKING 点,确认后后续步骤自动顺序执行 | +| 4 | **入口前验证 (GATE)** | 每步进入前验证前置条件 | +| 5 | **禁止推测执行** | 不要"提前准备"后续步骤内容 | +| 6 | **禁止子代理生成 SVG** | SVG 必须由当前主 Agent 端到端完成 | +| 7 | **仅支持顺序页面生成** | SVG 页面必须逐页生成,禁止批处理 | +| 8 | **每页重读 spec_lock** | 生成每个 SVG 前必须重新读取锁定设计规范 | +| 9 | **SVG 必须手写** | 禁止脚本批量生成 | +| 10 | **遵循确定性路由** | 不要额外添加阻塞性路由问题 | + +--- + +## 五步详解 + +### Step 1 — 源内容处理 + +- 有源文件或用户提供的文本内容 → 理解并整理为结构化内容大纲 +- 仅有主题、无详细材料 → 基于主题研究,整理为 Markdown 大纲 + +**GATE**:存在可用的文本内容源 + +### Step 2 — Strategist 策略师阶段 ⛔ BLOCKING + +> **进入前必须先读取 `02-strategist.md`** + +三阶段交互确认: + +| 阶段 | 确认内容 | 输出 | +|------|----------|------| +| Stage 1 — 方向锚定 | 画布格式、受众/场合/核心信息/交付目的、模式+视觉风格 | 方向确认 | +| Stage 2 — 设计系统 | 页数、配色方案、图标策略、字体方案 | 设计确认 | +| Stage 3 — 图片/内容 | 用户提供的图片清单(哪些页需要图片、图片尺寸与用途) | 图片清单 | + +最终输出:`design_spec.md`(人工可读)+ `spec_lock.md`(机器可读执行合约) + +**⛔ 必须等待用户在每个阶段确认后才进入下一阶段。** + +### Step 3 — Executor 执行师阶段 + +> **进入前必须先读取 `05-executor.md` + `06-svg-standards.md` + `08-layout-patterns.md`** +> **如有用户图片,还需读取 `07-image-embedding.md`** + +核心流程: +1. 逐页生成 SVG(**每页前重读 `spec_lock.md`**) +2. 按 `page_rhythm`(anchor / dense / breathing)控制节奏 +3. 将用户提供的图片以 Base64 嵌入对应页面 +4. 生成演讲备注 +5. 运行 SVG 质量检查 + +**硬约束**:禁止子代理生成 SVG;必须逐页顺序;每页重读 spec_lock。 + +### Step 4 — 质检与交付 SVG + +> **进入前必须先读取 `09-quality-and-export.md`** + +1. 逐页 SVG 质量检查(对照 `spec_lock.md` 一致性验证) +2. 视觉自检(软规则) +3. 使用 `doc_tool create` 将每页 SVG 写入文件: + - 格式:`doc_tool create <文件名>.svg content="..."` + - 每页一个独立 SVG 文件 +4. 生成演讲备注(如有需要) + +**doc_tool 在本 Skill 中仅用于写入 SVG 文件,不生成 PPTX。** + +--- + +## SVG → PPTX 转换(可选,独立步骤) + +> 仅在用户**明确表示**"不要 SVG,要 PPTX 格式"或"导出为 PPT"时触发。 +> 进入前必须先读取 `10-svg-to-pptx.md` + +独立脚本:将已生成的 SVG 文件批量转换为原生 PPTX。 + +--- + +## 入口路由 + +根据用户意图选择执行路径: + +| 用户意图 | 执行路径 | +|----------|----------| +| 从零生成幻灯片 | 完整 4 步流水线 → 交付 SVG | +| 只有主题无材料 | Step 1 走主题研究 → 完整后续 | +| 仅生成设计规范 | Step 1-2,止于 design_spec | +| 从 spec 继续生成 | 跳过 Step 1-2,直接 Step 3-4 | +| 已有 SVG 要导出 PPTX | 读取 `10-svg-to-pptx.md`,运行转换脚本 | + +--- + +## 角色切换协议 + +| 切换到 | 必须先读取 | +|--------|-----------| +| Strategist | `02-strategist.md` | +| Executor | `05-executor.md` + `06-svg-standards.md` | +| 图片嵌入参考 | `07-image-embedding.md` | +| 质检与 SVG 交付 | `09-quality-and-export.md` | +| SVG→PPTX 转换 | `10-svg-to-pptx.md` | diff --git a/src/agent/skills/ppt_maker/attachments/02-strategist.md b/src/agent/skills/ppt_maker/attachments/02-strategist.md new file mode 100644 index 0000000..8aacdc0 --- /dev/null +++ b/src/agent/skills/ppt_maker/attachments/02-strategist.md @@ -0,0 +1,177 @@ +# 02 — Strategist(策略师):设计规划与三阶段确认 + +> **角色**:策略师 — 内容分析、设计规划、用户确认 +> **读取时机**:Step 4 进入策略阶段前 +> **产出**:`design_spec.md` + `spec_lock.md` + +--- + +## 核心使命 + +接收源内容 → 分析 → 规划 → 三阶段交互确认 → 输出设计规范与内容大纲。 + +--- + +## 三阶段确认流程 + +### Stage 1 — 方向锚定 + +#### a) 画布格式 + +| ID | 格式 | viewBox | 宽高比 | 适用场景 | +|----|------|---------|--------|----------| +| `ppt169` | PPT 16:9 | `0 0 1280 720` | 16:9 | **默认**,演示/会议 | +| `ppt43` | PPT 4:3 | `0 0 1024 768` | 4:3 | 传统投影 | +| `story` | 竖屏 Story | `0 0 1080 1920` | 9:16 | 抖音/TikTok | +| `banner` | 横幅 | `0 0 1920 1080` | 16:9 | 公众号头图 | + +#### b) 关键信息 + +向用户确认: +- **受众**:高管 / 技术团队 / 客户 / 学生 / 公众 +- **场合**:正式汇报 / 路演 / 培训 / 发布会 / 内部会议 +- **核心信息**:一句话总结 PPT 要传递的核心观点 +- **交付目的**:`text`(阅读型,正文20px)/ `balanced`(商务型,默认24px)/ `presentation`(演讲型32px) +- **内容发散度**:`focused`(1个主题) / `moderate`(2-3个子主题) / `broad`(多主题概览) + +#### c) 模式(Mode) + +> **详细定义见 `03-modes-and-styles.md`** + +5 种沟通模式: +- `pyramid` — 结论先行,MECE 论证(决策/分析/策略) +- `narrative` — 故事弧线:情境→张力→解决(路演/案例/品牌) +- `instructional` — 概念分解,逐步递进(培训/教程) +- `showcase` — 视觉主导,情感节奏(发布会/品牌揭示) +- `briefing` — 中立完整可扫描(状态更新/参考/目录) + +#### d) 视觉风格(Visual Style) + +> **详细定义见 `03-modes-and-styles.md`** + +17 种视觉风格供选择,分类为: + +| 类目 | 风格 | +|------|------| +| 企业/产品 | `swiss-minimal` `soft-rounded` `glassmorphism` `dark-tech` `blueprint` | +| 编辑/出版 | `editorial` `photo-editorial` `data-journalism` `brutalist` | +| 表现力/印刷 | `memphis` `zine` `vintage-poster` `paper-cut` | +| 手绘/画笔 | `sketch-notes` `ink-notes` `chalkboard` `ink-wash` | +| 特殊 | `pixel-art` | + +--- + +### Stage 2 — 设计系统 + +#### e) 页数 + +根据内容量 + 交付目的给出建议页数范围,由用户确认。 +- 阅读型 / 商务型参考每页信息密度 +- 不超过 2 页连续的 dense 类型 + +#### f) 配色方案 + +> **详细规范见 `04-design-system.md`** + +提供 **3 个候选方案**,每个包含: +- 背景色 / 主色 / 辅色 / 强调色 / 文字色 / 弱化色 +- 适用场景一句话 +- 候选方案必须差异化(如:科技蓝 vs 温暖橙 vs 沉静墨绿) + +**60-30-10 原则**:主色占视觉面积 60%,辅色 30%,强调色 10%。 + +#### g) 图标策略 + +三选一:Emoji / AI 生成 / 内置图标库 +推荐策略:内容页用图标库 + 装饰页按需 AI 生成 + +#### h) 字体方案 + +字体配对示例: + +| 场景 | 标题字体 | 正文字体 | +|------|---------|---------| +| 中文商务 | 微软雅黑 Bold | 微软雅黑 | +| 中文正式 | 思源黑体 Bold | 宋体 | +| 英文现代 | Calibri Bold | Calibri | +| 中英混排 | 微软雅黑 Bold | Arial | + +**字号层级(基于 1280x720)**: +- Title: 42-48px | Subtitle: 24-28px | Body: 16-18px | Annotation: 12-14px +- 整份 PPT 中同一角色字号锁定不变 + +--- + +### Stage 3 — 图片/内容确认 + +#### h) 图片使用(用户提供) + +图片全部由用户提供。在此阶段需要确认: + +- **哪些页面需要图片**:逐页确认(封面、内容页等) +- **每张图片的用途**:背景 / 插图 / 图表 / Logo / 头像 +- **图片尺寸建议**:根据页面布局给出推荐尺寸(如封面背景 1280×720、卡片配图 540×300 等) +- **图片格式**:jpg / png / webp(推荐 png 以保证透明通道可用) + +#### 无图片时的处理 + +若用户无需图片,则全部采用纯文字排版 + 装饰图形(色块、线条、图标)。 + +--- + +## 输出规范 + +### design_spec.md(人工可读) + +必须包含 11 个章节: +1. 项目信息 +2. 画布格式 +3. 关键信息(受众/场合/核心信息/交付目的) +4. 页数与页码分配 +5. 模式与视觉风格 +6. 配色方案(HEX 色板) +7. 字体方案 +8. 图标策略 +9. 图片资源清单 +10. 内容大纲(每页:页面类型 + 标题 + 核心消息 + 关键要点) +11. 技术约束 + +### spec_lock.md(机器可读) + +精简的 YAML 格式锁定数据,Contains only machine-consumable values: + +```yaml +canvas: ppt169 +palette: + bg: '#FAFBFC' + primary: '#1A73E8' + accent: '#EA4335' + text: '#1F2937' + muted: '#6B7280' +typography: + title_size: 42 + subtitle_size: 24 + body_size: 16 + annotation_size: 12 + font_family: 'Microsoft YaHei, sans-serif' +mode: pyramid +visual_style: swiss-minimal +page_rhythm: + - { page: 1, rhythm: anchor } + - { page: 2, rhythm: dense } + ... +pages: + - { index: 1, title: '封面', core_message: '...', rhythm: anchor } + - { index: 2, title: '背景', core_message: '...', rhythm: dense } + ... +``` + +--- + +## 确认 UI 规则 + +1. **每阶段一次呈现**:将该阶段所有待确认项打包为一个结构化选项清单 +2. **必须提供候选**:配色 ≥ 3 个候选、字体 ≥ 3 个候选 +3. **默认推荐**:每个选项标注推荐值(`(Recommended)`),基于受众和场合智能推荐 +4. **允许用户覆盖**:每个选项都可以被用户自定义值覆盖 +5. **禁止跳过**:每个项都必须显式确认或接受默认值 diff --git a/src/agent/skills/ppt_maker/attachments/03-modes-and-styles.md b/src/agent/skills/ppt_maker/attachments/03-modes-and-styles.md new file mode 100644 index 0000000..35e5fc7 --- /dev/null +++ b/src/agent/skills/ppt_maker/attachments/03-modes-and-styles.md @@ -0,0 +1,141 @@ +# 03 — 模式与视觉风格索引 + +> **角色**:设计参考速查 — 提供 5 种沟通模式 + 17 种视觉风格的快速决策表 +> **读取时机**:Strategist 阶段需要锁定 mode / visual_style 时 +> **关系**:mode = 如何论证(叙事骨架);visual_style = 长什么样(视觉外观)。二者相互独立,同时锁定。 + +--- + +## 一、沟通模式(Mode) + +模式定义 PPT 的**叙事 + 说服骨架**——论点如何被组织和推进。 + +### 1. `pyramid` — 金字塔模式 + +**结论先行论证**。先给答案,再用互斥且穷尽 (MECE) 的证据支撑。 + +| 特征 | 说明 | +|------|------| +| 开场 | SCQA 结构(情境→冲突→问题→答案) | +| 标题风格 | 断言式(每页标题即核心结论) | +| 数据规则 | 数据永不孤立,每个图表必须带比较维度 | +| 页面结构 | 严格逻辑层级,逐层展开 | +| 适用 | 决策支持、分析报告、策略、董事会/高管汇报 | + +### 2. `narrative` — 叙事模式 + +**故事弧线说服**。情境→冲突→解决,用悬念、转折和人性化框架让观点在逻辑之前先触动情感。 + +| 特征 | 说明 | +|------|------| +| 弧线结构 | 至少一个转折;标题读起来像推进弧线的节拍 | +| 情感策略 | 人性化框架、案例聚焦、代入感 | +| 视觉配合 | 图胜于表,情绪图像优先于数据图表 | +| 适用 | 路演、案例研究、品牌叙事、融资 | + +### 3. `instructional` — 教学模式 + +**教学引导式阐述**。将概念分解为有序、可消化的部分,逐步建立理解。 + +| 特征 | 说明 | +|------|------| +| 组织逻辑 | 每页一个概念、先展示后讲述 | +| 路标系统 | 设置进度路标(如 "第3/7步") | +| 语言风格 | 解释性、引导性,避免专业黑话 | +| 重复强化 | 关键概念跨页重复强调 | +| 适用 | 培训、教程、知识分享 | + +### 4. `showcase` — 展示模式 + +**视觉主导冲击**。让图像、比例和节奏承载信息——最小化文案、最大化存在感。 + +| 特征 | 说明 | +|------|------| +| 视觉策略 | 每页一个主导视觉元素 | +| 情绪结构 | 按情感节奏推进(预告→揭示→高潮→回味) | +| 文案策略 | 极少文字,每页不超过 30 字 | +| 转场节奏 | 快节奏,每页停留不超过 30 秒 | +| 适用 | 发布会、品牌揭示、活动/推广 | + +### 5. `briefing` — 简报模式 + +**中立信息传递**。平实、完整地列出事实,按可扫描和查找的方式组织——无论点、无故事、无课程。 + +| 特征 | 说明 | +|------|------| +| 标题风格 | 主题标签而非断言 | +| core_message | 描述覆盖范围而非主张 | +| 组织原则 | 按主题/时间/部门分类,便于按需查阅 | +| 适用 | 状态更新、参考资料、目录、会议材料 | + +### 模式自动选择参考 + +| 场景信号 | 推荐模式 | +|----------|----------| +| 需要决策 > 数据分析 > 数据含对比 | `pyramid` | +| 有故事/案例 > 需情感共鸣 > 品牌/融资 | `narrative` | +| 教学目标 > 步骤清晰 > 知识传递 | `instructional` | +| 视觉 > 文字 > 冲击力优先 | `showcase` | +| 信息罗列 > 无论点 > 后续查阅 | `briefing` | + +--- + +## 二、视觉风格(Visual Style) + +视觉风格定义 PPT 的**美学外观**——形状语言、装饰密度、留白节奏、字体个性、纹理/层级。整份 PPT 只锁定一种风格。 + +### 风格速查表 + +#### 企业 / 产品类 + +| 风格 | 关键词 | 适合场景 | 插画倾向 | +|------|--------|----------|----------| +| `swiss-minimal` | 瑞士网格、极简、大量留白、零装饰 | 科技、咨询、金融 | sparse | +| `soft-rounded` | 圆角卡片 `rx:12-16`、柔和阴影、友好 | SaaS、产品、培训 | supportive | +| `glassmorphism` | 毛玻璃、半透明层叠、渐变光流、暗底 | 科技/设计感发布 | sparse | +| `dark-tech` | 暗色画布、发光强调、几何精密 | AI/科技产品发布 | sparse | +| `blueprint` | 工程绘图纸、等轴测、数据标注语言 | 架构讲解、技术简报 | supportive | + +#### 编辑 / 出版类 + +| 风格 | 关键词 | 适合场景 | 插画倾向 | +|------|--------|----------|----------| +| `editorial` | 杂志层级、栏位、衬线/无衬线交替 | 行业报告、白皮书 | supportive | +| `photo-editorial` | 大图主导、满版摄影 | 品牌画册、摄影集 | sparse | +| `data-journalism` | 多媒体信息图、行内微图表 | 数据报告、新闻 | sparse | +| `brutalist` | 小字号满版、粗框线、原始结构 | 独立出版物 | supportive | + +#### 表现力 / 印刷类 + +| 风格 | 关键词 | 适合场景 | 插画倾向 | +|------|--------|----------|----------| +| `memphis` | 碰撞色块、几何碎屑、粗黑描边、80s | 创意/音乐/潮流 | core | +| `zine` | Riso印刷、颜色层错位、网点纹理 | 独立杂志/艺术 | core | +| `vintage-poster` | 中世纪印刷、平涂色块、网点/纸纹 | 文化活动/复古 | core | +| `paper-cut` | 剪纸层次、剪刀裁剪形状、层叠投影 | 传统文化/手工艺 | core | + +#### 手绘 / 画笔类 + +| 风格 | 关键词 | 适合场景 | 插画倾向 | +|------|--------|----------|----------| +| `sketch-notes` | 暖纸底、黑色墨线涂鸦、柔和色块 | 培训/笔记/复盘 | core | +| `ink-notes` | 白板墨水极简、黑白为主、少量语义色 | 白板/头脑风暴 | supportive | +| `chalkboard` | 深色石板、粉笔线条、粉彩点缀 | 教学/怀旧 | core | +| `ink-wash` | 宣纸底、文人留白、单色印章、水墨 | 文化/艺术/中式 | supportive | + +#### 特殊 + +| 风格 | 关键词 | 适合场景 | 插画倾向 | +|------|--------|----------|----------| +| `pixel-art` | 8-bit 复古、严格像素网格、有限调色板 | 游戏/怀旧/趣味 | core | + +### 风格自动选择参考 + +| 场景信号 | 推荐风格 | +|----------|----------| +| 科技 > 数据 > 分析 | `swiss-minimal` 或 `dark-tech` | +| 产品 > 用户 > 易用性 | `soft-rounded` | +| 文化 > 艺术 > 手工 | `zine` 或 `memphis` 或 `ink-wash` | +| 教育 > 培训 > 亲和力 | `sketch-notes` | +| 建筑 > 工程 > 精确 | `blueprint` 或 `editorial` | +| 金融 > 正式 > 信任 | `swiss-minimal` 或 `editorial` | diff --git a/src/agent/skills/ppt_maker/attachments/04-design-system.md b/src/agent/skills/ppt_maker/attachments/04-design-system.md new file mode 100644 index 0000000..49102f1 --- /dev/null +++ b/src/agent/skills/ppt_maker/attachments/04-design-system.md @@ -0,0 +1,131 @@ +# 04 — 设计系统:配色 / 字体 / 画布 / 图标规范 + +> **角色**:设计参考手册 — 规范所有视觉参数的取值空间与决策依据 +> **读取时机**:Strategist 阶段 Stage 2 定调设计时,Executor 阶段生成 SVG 前 + +--- + +## 一、配色方案 + +### 60-30-10 规则 + +| 角色 | 占比 | 用途 | +|------|------|------| +| 主色 (Primary) | 60% | 背景大面积区域、卡片填充 | +| 辅色 (Secondary) | 30% | 标题栏、装饰元素、图标底色 | +| 强调色 (Accent) | 10% | 关键数据、CTA、高亮文本 | + +### 色板结构 + +每套配色需提供 6 个色值: + +| 色值 | 角色 | 说明 | +|------|------|------| +| `bg` | 背景色 | 页面底色,浅色方案建议 #FFFFFF ~ #F5F7FA | +| `primary` | 主色 | 标题栏、主按钮、强调区域 | +| `secondary` | 辅色 | 次要区域、图表副系列 | +| `accent` | 强调色 | 高亮标记、关键数字、CTA | +| `text` | 正文色 | 深色背景用浅色文字,浅色背景用深色文字 | +| `muted` | 弱化色 | 副文本、脚注、辅助线 | + +### 行业色参考(仅作启发,非硬性绑定) + +| 行业 | 倾向色调 | +|------|----------| +| 科技/AI | 蓝 #1A73E8、深蓝 #0D47A1、青 #00BCD4 | +| 金融/商务 | 深蓝 #1B3A5C、金 #C8A951、深灰 | +| 医疗/健康 | 绿 #2E7D32、蓝绿 #009688、白 | +| 教育/培训 | 橙 #F57C00、蓝 #1976D2、黄 | +| 环保/能源 | 绿 #4CAF50、大地色 | +| 政府/政务 | 红 #C62828、深蓝、金 | + +### 候选方案要求 + +提供 ≥ 3 个候选方案,必须差异化: +- 至少跨越 2 个不同色相(如:冷蓝 vs 暖橙 vs 中性灰绿) +- 每个候选附带一句话适用场景描述 + +### 中性色层级 + +| 层级 | 日间模式 | 用途 | +|------|----------|------| +| 背景 | `#FFFFFF` ~ `#FAFBFC` | 页面背景 | +| 表面 | `#F3F4F6` ~ `#E5E7EB` | 卡片、面板 | +| 分割线 | `#D1D5DB` | 分隔线 | +| 禁用文本 | `#9CA3AF` | 占位符 | +| 弱化文本 | `#6B7280` | 副文本 | +| 正文 | `#1F2937` | 正文 | +| 加粗 | `#111827` | 标题 | + +--- + +## 二、字体方案 + +### 系统预装字体(无需嵌入) + +| 分类 | 中文字体 | 英文字体 | +|------|----------|----------| +| 黑体/无衬线 | 微软雅黑、黑体 | Arial、Calibri、Segoe UI | +| 宋体/衬线 | 宋体、仿宋 | Times New Roman、Georgia | +| 等宽 | 微软雅黑 | Consolas、Courier New | + +### 推荐字体配对 + +| 场景 | 标题 | 正文 | 数据/代码 | +|------|------|------|-----------| +| 中文商务 | 微软雅黑 Bold | 微软雅黑 | Consolas | +| 中文正式 | 黑体 | 宋体 | — | +| 英文现代 | Calibri Bold | Calibri | Consolas | +| 中英混排 | 微软雅黑 Bold | Arial/Calibri | Consolas | +| 科技/极简 | Segoe UI Bold | Segoe UI | Consolas | + +### 字号层级(16:9 / 1280x720 基准) + +| 角色 | 字号 | 说明 | +|------|------|------| +| **Hero Title** | 56-64px | 封面专用超大标题 | +| **Title** | 42-48px | 封面/章节标题 | +| **Section Title** | 32-36px | 内容页标题 | +| **Subtitle** | 24-28px | 副标题 | +| **Body** | 16-18px | 正文 | +| **Caption** | 14px | 图表标注 | +| **Annotation** | 12px | 脚注/来源 | + +**硬规则**:整份 PPT 中同一角色的字号锁定不变。 + +--- + +## 三、画布格式 + +| ID | viewBox | 宽高比 | 用途 | +|----|---------|--------|------| +| `ppt169` | `0 0 1280 720` | 16:9 | **默认** — 演示/会议 | +| `ppt43` | `0 0 1024 768` | 4:3 | 传统投影 | + +**硬规则**:viewBox 是唯一尺寸来源,SVG 上不写 `width`/`height` 属性。 + +--- + +## 四、图标策略 + +### 三选一 + +| 选项 | 优点 | 缺点 | 建议 | +|------|------|------|------| +| **Emoji** | 零成本、跨平台一致 | 风格局限、无法自定义 | 小项目/快速原型 | +| **AI 生成** | 完全定制 | 质量不稳定、需反复调整 | 核心装饰元素 | +| **内置图标库** | 质量稳定、SVG 原生 | 选择有限 | 内容页常规图标 | + +**推荐组合**:内容页用内置图标库,关键装饰页按需 AI 生成。 + +--- + +## 五、页面节奏(Page Rhythm) + +| 标签 | 策略 | 单页信息量 | +|------|------|-----------| +| `anchor` | 封面/章节/目录/结尾 — 高冲击力、标题主导、大量留白 | ≤ 30 字 | +| `dense` | 数据/分析页 — 卡片网格、图表、KPI、多栏 | ≤ 80 字 | +| `breathing` | 过渡/强调页 — 单句金句、关键结论,不堆砌内容 | ≤ 20 字 | + +**原则**:不要连续两页 dense,用 breathing 做节奏换气。 diff --git a/src/agent/skills/ppt_maker/attachments/05-executor.md b/src/agent/skills/ppt_maker/attachments/05-executor.md new file mode 100644 index 0000000..00a1a6a --- /dev/null +++ b/src/agent/skills/ppt_maker/attachments/05-executor.md @@ -0,0 +1,176 @@ +# 05 — Executor(执行师):SVG 页面生成规范 + +> **角色**:执行师 — 将 design_spec + spec_lock 转化为逐页 SVG +> **读取时机**:Step 3 (Executor) 进入执行阶段前 +> **前置依赖**:`06-svg-standards.md`(SVG 技术约束)、`08-layout-patterns.md`(布局模式)、`07-image-embedding.md`(图片嵌入,有图片时) + +--- + +## 核心使命 + +读取 `spec_lock.md` → 逐页手写 SVG → 质量检查 → 生成演讲备注 + +--- + +## 生成前强制步骤 + +### 1. 批量预读 + +在生成第一页前,一次性加载: +- `spec_lock.md` — 设计合约 +- `design_spec.md` — 设计叙事(了解完整上下文) +- 如果使用模板:全部模板 SVG 文件 + +### 2. 每页生成前 + +**每页生成前必须重新读取 `spec_lock.md`**。禁止在锁之外取值(颜色、图标、字体、字号、图片路径)。 + +--- + +## 每页布局节奏 + +从 `spec_lock.md` 获取 `page_rhythm` 字段: + +| rhythm | 策略 | 每页文字量 | +|--------|------|-----------| +| `anchor` | 封面/章节页 — 标题主导、高冲击力、大量留白 | ≤ 30 字 | +| `dense` | 内容页 — 卡片网格、图表、KPI、多栏 | ≤ 80 字 | +| `breathing` | 过渡页 — 单句金句、关键结论 | ≤ 20 字 | + +**强制规则**:不连续两页 dense。 + +--- + +## 封面设计 `anchor` + +- **Cover impact**:一句抓人的主标题 + 一句补充副标题 +- **视觉层次**:主标题 >> 副标题 >> 作者/日期 +- **常用布局**: + - 大标题居中偏上 + 底部装饰色块/图形 + - 左对齐大标题 + 右侧装饰图像 + - 全幅背景 + 居中标题 + +```xml + + + + + 主标题最多两行 + + + 副标题补充说明 + + + 作者 / 日期 + + +``` + +--- + +## 内容页 `dense` + +- **一行一个核心消息**:每页提炼一个 <15 字的标题 +- **内容组织**:优先用卡片网格(2×2 或 3×2),其次用编号列表 +- **文字量控制**:每页正文不超过 80 字 +- **文本高亮**:关键数值、对比结论、核心名词用加粗或强调色 + +### 双卡片模板 + +```xml + + + + 页面主标题 + + + + 卡片一标题 + + + 卡片一要点 + + + + 卡片二标题 + + + 卡片二要点 + + + + + + + +``` + +--- + +## 过渡页 `breathing` + +- 单句金句或关键结论 +- 极少元素,大量留白 +- 可配一个装饰图形或图标 + +--- + +## 结构规范 + +### 语义分组(强制) + +每页 3-8 个顶层 `` 分组: + +```xml +... +... +... +... +... +``` + +### 文本规范 + +- **一行一个 ``**:同行不同样式用 `` 内联 +- 禁止多个 `` 并排模拟一行 + +```xml + + 正常文字 加粗高亮 继续正常 + +``` + +--- + +## 图片处理 + +> **详细规范见 `07-image-embedding.md`** + +图片全部由用户提供。嵌入方式:将用户图片转为 Base64 直接写在 SVG 中: + +```xml + +``` + +**关键规则**: +- 背景图叠加半透明遮罩(`fill="#000000" opacity="0.25-0.45"`),保证文字可读 +- 图片上的文字用 `#FFFFFF` +- 根据图片宽高比选择左右分割或上下分割布局 + +--- + +## 演讲备注生成 + +为每页生成 200-500 字的中文演讲备注。格式要求: +- 口语化、可在演讲时自然朗读 +- 关键数字可读性("三百五十万"而非"350万") +- 每页独立成段,不跨页引用 + +--- + +## 禁止事项 + +- 禁止子代理生成 SVG +- 禁止脚本批量生成 +- 禁止在 spec_lock 之外取值 +- 禁止跳过任何一页 diff --git a/src/agent/skills/ppt_maker/attachments/06-svg-standards.md b/src/agent/skills/ppt_maker/attachments/06-svg-standards.md new file mode 100644 index 0000000..2672ef5 --- /dev/null +++ b/src/agent/skills/ppt_maker/attachments/06-svg-standards.md @@ -0,0 +1,151 @@ +# 06 — SVG 技术约束与 PPT 兼容性 + +> **角色**:技术规范手册 — 定义所有 SVG 编写时必须遵守的硬约束 +> **读取时机**:Executor 阶段写 SVG 前查阅 + +--- + +## 一、画布 + +| 格式 | viewBox | 用途 | +|------|---------|------| +| `ppt169` | `0 0 1280 720` | 16:9 演示(默认) | +| `ppt43` | `0 0 1024 768` | 4:3 传统 | + +**硬规则**:viewBox 是唯一尺寸来源,不要写 `width`/`height` 属性。 + +--- + +## 二、禁止特性黑名单 + +### 禁止使用 + +| 类别 | 禁止项 | 替代方案 | +|------|--------|----------| +| **字符** | HTML 命名实体 (` ` `—` `©`) | 原始 Unicode(` ` `—` `©`) | +| **元素** | `` | 用 `` + `` | +| **元素** | `