实现邮件发送(支持QQ和网易.126.163)PPT制作全流程(SVG路线)
This commit is contained in:
1 parent
a5e5aff63f
commit
1111d208c4
60 files changed
+3911
-1394
No files matched your search
@@ -86,14 +86,6 @@ git push minor master
|
|||||||
└─ 图结束后 (runtime.py / execute_agent)
|
└─ 图结束后 (runtime.py / execute_agent)
|
||||||
└─ 检测到强制终止 → 向前端输出:
|
└─ 检测到强制终止 → 向前端输出:
|
||||||
"检测到异常循环,工具 `xxx` 已连续调用 5 次..."
|
"检测到异常循环,工具 `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 -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表示健康)
|
# 查看状态码(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: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:8902/health
|
||||||
curl -o /dev/null -s -w "%{http_code}\n" http://localhost:8903/health
|
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、点击确定
|
||||||
-660
@@ -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_
|
|
||||||
|
|
||||||
# 对 <a> 标签的判断
|
|
||||||
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 没变,检测动态出现的下拉菜单项
|
|
||||||
# 使用更可靠的方法:直接查找可能是下拉项的 <li> 或 <a>,并排除原始控件
|
|
||||||
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()
|
|
||||||
-306
@@ -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()
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -5,11 +5,11 @@
|
|||||||
"max_iterations": 200,
|
"max_iterations": 200,
|
||||||
"llm_model_id": "b53afakf",
|
"llm_model_id": "b53afakf",
|
||||||
"tools": [
|
"tools": [
|
||||||
"send_file",
|
|
||||||
"directory_listing",
|
|
||||||
"terminal_execute",
|
|
||||||
"doc_tool",
|
"doc_tool",
|
||||||
"web_search_class"
|
"date_query",
|
||||||
|
"web_search",
|
||||||
|
"human_interaction",
|
||||||
|
"todo_list"
|
||||||
],
|
],
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"role": "main",
|
"role": "main",
|
||||||
|
|||||||
@@ -154,6 +154,9 @@ _TOOL_DEFAULT_AUTO_RULES: dict[str, list[dict]] = {
|
|||||||
"skill_router": [
|
"skill_router": [
|
||||||
{"parameter": "query", "operator": "exists", "value": ""},
|
{"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_WARN": "3",
|
||||||
"LOOP_DETECT_REPEATED_TOOL_END": "5",
|
"LOOP_DETECT_REPEATED_TOOL_END": "5",
|
||||||
"SEND_FILE_SIZE_LIMIT": "10",
|
"SEND_FILE_SIZE_LIMIT": "10",
|
||||||
|
"EMAIL_ADDRESS": "",
|
||||||
|
"EMAIL_AUTH_CODE": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"WORKING_DIR": "c:\\Users\\86166\\Desktop\\Agent_Learning_minor\\Agent",
|
"WORKING_DIR": "c:\\Users\\86166\\Desktop\\Agent_Learning_minor\\Agent",
|
||||||
"WORKSPACE_DIR": "src\\agent\\workspace",
|
"WORKSPACE_DIR": "src\\agent\\workspace",
|
||||||
"TTS_BASE_URL": "127.0.0.0:8902",
|
"TTS_BASE_URL": "http://localhost:8902",
|
||||||
"ASR_BASE_URL": "127.0.0.0:8901",
|
"ASR_BASE_URL": "http://localhost:8901",
|
||||||
"RAG_BASE_URL": "127.0.0.0:8903",
|
"RAG_BASE_URL": "http://localhost:8903",
|
||||||
"TAVILY_API_KEY": "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",
|
"TAVILY_API_KEY": "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",
|
||||||
"LLM_CONTEXT_WINDOW": "262144",
|
"LLM_CONTEXT_WINDOW": "262144",
|
||||||
"MINI_COMPRESS_RATE": "0.4",
|
"MINI_COMPRESS_RATE": "0.4",
|
||||||
@@ -17,7 +17,9 @@
|
|||||||
"LOOP_DETECT_TODOLIST_STALE_ROUNDS": "20",
|
"LOOP_DETECT_TODOLIST_STALE_ROUNDS": "20",
|
||||||
"LOOP_DETECT_REPEATED_TOOL_WARN": "3",
|
"LOOP_DETECT_REPEATED_TOOL_WARN": "3",
|
||||||
"LOOP_DETECT_REPEATED_TOOL_END": "5",
|
"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": [
|
"models": [
|
||||||
{
|
{
|
||||||
"id": "a44b226e",
|
"id": "a44b226e",
|
||||||
|
|||||||
+9
-12
@@ -41,7 +41,7 @@ class ChatQwen(ChatOpenAI):
|
|||||||
"""Qwen 系列模型的 LangChain 封装,扩展多模态与 TTS 能力。
|
"""Qwen 系列模型的 LangChain 封装,扩展多模态与 TTS 能力。
|
||||||
|
|
||||||
属性:
|
属性:
|
||||||
output_type: 默认输出模式 text | audio | both。
|
output_type: 默认输出模式 text | voice。
|
||||||
stream_play: TTS 后是否本地播放。
|
stream_play: TTS 后是否本地播放。
|
||||||
last_audio_kwargs: 最近一次 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(
|
resp = requests.post(
|
||||||
endpoint,
|
endpoint,
|
||||||
headers={"Content-Type": "application/json"},
|
headers={"Content-Type": "application/json"},
|
||||||
@@ -370,11 +370,11 @@ class ChatQwen(ChatOpenAI):
|
|||||||
|
|
||||||
输入:
|
输入:
|
||||||
result: ChatResult 含 AIMessage。
|
result: ChatResult 含 AIMessage。
|
||||||
output_type: audio | both。
|
output_type: voice。
|
||||||
audio_output_dir: WAV 保存目录。
|
audio_output_dir: WAV 保存目录。
|
||||||
|
|
||||||
输出:
|
输出:
|
||||||
可能清空 audio 模式下 content 的 ChatResult;设置 last_audio_kwargs。
|
设置 last_audio_kwargs 的 ChatResult。
|
||||||
|
|
||||||
系统定位:
|
系统定位:
|
||||||
_generate 在模型返回后的后处理。
|
_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
|
return result
|
||||||
os.makedirs(audio_output_dir, exist_ok=True)
|
os.makedirs(audio_output_dir, exist_ok=True)
|
||||||
for idx, gen in enumerate(result.generations):
|
for idx, gen in enumerate(result.generations):
|
||||||
@@ -391,6 +391,9 @@ class ChatQwen(ChatOpenAI):
|
|||||||
msg = gen.message
|
msg = gen.message
|
||||||
if not isinstance(msg, AIMessage):
|
if not isinstance(msg, AIMessage):
|
||||||
continue
|
continue
|
||||||
|
# 跳过含 tool_calls 的中间决策消息,仅对最终回复合成语音
|
||||||
|
if msg.tool_calls:
|
||||||
|
continue
|
||||||
text = assistant_text(msg.content)
|
text = assistant_text(msg.content)
|
||||||
if not text.strip():
|
if not text.strip():
|
||||||
continue
|
continue
|
||||||
@@ -414,8 +417,6 @@ class ChatQwen(ChatOpenAI):
|
|||||||
}
|
}
|
||||||
if self.stream_play:
|
if self.stream_play:
|
||||||
self.play_audio_bytes(audio_bytes)
|
self.play_audio_bytes(audio_bytes)
|
||||||
if output_type == "audio":
|
|
||||||
msg.content = ""
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[TTS ERROR] 合成语音失败: {e}")
|
print(f"[TTS ERROR] 合成语音失败: {e}")
|
||||||
# 发生错误时也清空 last_audio_kwargs,避免污染
|
# 发生错误时也清空 last_audio_kwargs,避免污染
|
||||||
@@ -459,12 +460,8 @@ class ChatQwen(ChatOpenAI):
|
|||||||
|
|
||||||
result = super()._generate(messages, stop=stop, run_manager=run_manager, **kwargs)
|
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)
|
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
|
return result
|
||||||
|
|
||||||
def _create_chat_result(self, response, generation_info=None) -> ChatResult:
|
def _create_chat_result(self, response, generation_info=None) -> ChatResult:
|
||||||
|
|||||||
+36
-9
@@ -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
|
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:
|
def _filter_short_term_memory(messages: list, trajectory_rounds: int) -> list:
|
||||||
"""过滤短期记忆:保留所有工具调用,工具返回结果仅保留最近 trajectory_rounds 轮。
|
"""过滤短期记忆:保留所有工具调用记录和工具文字返回值,图片截图仅保留最近 trajectory_rounds 轮。
|
||||||
|
|
||||||
输入:
|
输入:
|
||||||
messages: 完整消息列表。
|
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)
|
- 保留所有 SystemMessage、HumanMessage(非合成图片)、AIMessage、ToolMessage
|
||||||
- ToolMessage 仅保留最近 trajectory_rounds 轮工具调用对应的返回结果
|
- 仅 process_tool_artifact 生成的合成图片 HumanMessage 按 trajectory_rounds 裁剪
|
||||||
- 每轮由一个 AIMessage(含 tool_calls)定义,其后的 ToolMessage 属于该轮
|
- 每轮由一个 AIMessage(含 tool_calls)定义
|
||||||
"""
|
"""
|
||||||
if trajectory_rounds <= 0:
|
if trajectory_rounds <= 0:
|
||||||
return list(messages)
|
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):
|
if isinstance(msg, AIMessage) and getattr(msg, "tool_calls", None):
|
||||||
rounds_seen += 1
|
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:
|
if rounds_seen < trajectory_rounds:
|
||||||
kept_indices.add(i)
|
kept_indices.add(i)
|
||||||
else:
|
else:
|
||||||
@@ -274,9 +292,18 @@ def process_tool_artifact(state: AgentState) -> dict:
|
|||||||
|
|
||||||
artifact = last_msg.artifact
|
artifact = last_msg.artifact
|
||||||
img_bytes_list: list[bytes] = []
|
img_bytes_list: list[bytes] = []
|
||||||
|
source_path = ""
|
||||||
|
|
||||||
if isinstance(artifact, dict) and "image_bytes" in artifact:
|
if isinstance(artifact, dict):
|
||||||
img_bytes_list.append(artifact["image_bytes"])
|
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):
|
elif isinstance(artifact, list):
|
||||||
for item in artifact:
|
for item in artifact:
|
||||||
if isinstance(item, dict) and "image_bytes" in item:
|
if isinstance(item, dict) and "image_bytes" in item:
|
||||||
@@ -305,7 +332,7 @@ def process_tool_artifact(state: AgentState) -> dict:
|
|||||||
)
|
)
|
||||||
return {"messages": [human_msg]}
|
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:
|
if failed_count > 0:
|
||||||
content_parts[-1]["text"] += f"(其中 {failed_count} 张图片处理失败,请忽略)"
|
content_parts[-1]["text"] += f"(其中 {failed_count} 张图片处理失败,请忽略)"
|
||||||
|
|
||||||
|
|||||||
@@ -282,7 +282,7 @@ def execute_agent(
|
|||||||
runtime: Agent 运行时配置。
|
runtime: Agent 运行时配置。
|
||||||
chat_history: Web UI 聊天历史(dict 列表);末条须为 user。
|
chat_history: Web UI 聊天历史(dict 列表);末条须为 user。
|
||||||
session_id: 会话 ID,用于历史、文件目录、manifest 同步。
|
session_id: 会话 ID,用于历史、文件目录、manifest 同步。
|
||||||
output_type: text | audio | both,传入图 metadata。
|
output_type: text | voice,传入图 metadata。
|
||||||
agent_mode: agent(默认)| team(占位提示)。
|
agent_mode: agent(默认)| team(占位提示)。
|
||||||
|
|
||||||
输出:
|
输出:
|
||||||
@@ -317,6 +317,10 @@ def execute_agent(
|
|||||||
# Plan 模式使用专用系统提示词
|
# Plan 模式使用专用系统提示词
|
||||||
system_prompt = PLAN_MODE_PROMPT if agent_mode == "plan" else runtime.system_prompt
|
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 路由:
|
# 当 skill_enabled 开启时,在每次用户输入后先做 skill 路由:
|
||||||
# 由 SkillRouter 根据当前 query 匹配相关 skill 指南,注入到系统提示词中
|
# 由 SkillRouter 根据当前 query 匹配相关 skill 指南,注入到系统提示词中
|
||||||
if runtime.skill_enabled:
|
if runtime.skill_enabled:
|
||||||
@@ -451,9 +455,7 @@ def execute_agent(
|
|||||||
"turn_id": turn_id,
|
"turn_id": turn_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
if output_type_from_config == "audio" and assistant_meta.get("audio"):
|
if output_type_from_config == "voice" and assistant_meta.get("audio"):
|
||||||
assistant_content = []
|
|
||||||
elif output_type_from_config == "both" and assistant_meta.get("audio"):
|
|
||||||
assistant_content = text_content
|
assistant_content = text_content
|
||||||
else:
|
else:
|
||||||
assistant_content = text_content if text_content else "(空回复)"
|
assistant_content = text_content if text_content else "(空回复)"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"name": "web_search_class",
|
"name": "web_search",
|
||||||
"display_name": "WebSearch",
|
"display_name": "WebSearch",
|
||||||
"description": "一个网络搜索工具,可以根据用户的查询返回相关的搜索结果。仅当需要查询近期知识或已有知识无法解答需要联网查询时调用,其余任何情况严禁调用。",
|
"description": "一个网络搜索工具,可以根据用户的查询返回相关的搜索结果。仅当需要查询近期知识或已有知识无法解答需要联网查询时调用,其余任何情况严禁调用。",
|
||||||
"permission": "direct",
|
"permission": "direct",
|
||||||
@@ -8,26 +8,18 @@
|
|||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "date_query_class",
|
"name": "date_query",
|
||||||
"display_name": "DateQuery",
|
"display_name": "DateQuery",
|
||||||
"description": "一个日期查询工具,可以返回当前的日期信息。仅当需要查询当前时间时调用,其余任何情况严禁调用。",
|
"description": "一个日期查询工具,可以返回当前的日期信息。仅当需要查询当前时间时调用,其余任何情况严禁调用。",
|
||||||
"permission": "direct",
|
"permission": "direct",
|
||||||
"auto_execute_rules": [],
|
"auto_execute_rules": [],
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "screen_capture",
|
|
||||||
"display_name": "ScreenCapture",
|
|
||||||
"description": "截取当前主屏幕画面。除了返回文字描述外,也会提供原始截图给模型进行视觉分析。注意:这个工具仅用于获取当前屏幕的实时画面,其余任何情况严禁调用。",
|
|
||||||
"permission": "direct",
|
|
||||||
"auto_execute_rules": [],
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "browser_control",
|
"name": "browser_control",
|
||||||
"display_name": "BrowserControl",
|
"display_name": "BrowserControl",
|
||||||
"description": "统一控制浏览器:打开/关闭浏览器、刷新/关闭标签页、打开指定 URL。",
|
"description": "统一控制浏览器:打开/关闭浏览器、刷新/关闭标签页、打开指定 URL。",
|
||||||
"permission": "direct",
|
"permission": "confirm",
|
||||||
"auto_execute_rules": [
|
"auto_execute_rules": [
|
||||||
{
|
{
|
||||||
"parameter": "action",
|
"parameter": "action",
|
||||||
@@ -84,7 +76,7 @@
|
|||||||
{
|
{
|
||||||
"name": "todo_list",
|
"name": "todo_list",
|
||||||
"display_name": "TodoList",
|
"display_name": "TodoList",
|
||||||
"description": "可选的任务管理工具。当你需要拆分复杂任务为多个步骤并跟踪进度时调用。输入 steps (list[str]) 为每一步的描述,step (int) 为已完成的步骤索引(从0开始)。任务默认状态为 pending,根据 step 自动标记完成。工具返回当前 todo list 文本。仅在需要跟踪多步骤任务进度时调用,简单任务不需要调用。",
|
"description": "任务管理工具。用于将复杂任务拆分为多个步骤并跟踪执行进度。",
|
||||||
"permission": "direct",
|
"permission": "direct",
|
||||||
"auto_execute_rules": [],
|
"auto_execute_rules": [],
|
||||||
"enabled": true
|
"enabled": true
|
||||||
@@ -92,7 +84,7 @@
|
|||||||
{
|
{
|
||||||
"name": "gui_tool",
|
"name": "gui_tool",
|
||||||
"display_name": "GUITool",
|
"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",
|
"permission": "direct",
|
||||||
"auto_execute_rules": [],
|
"auto_execute_rules": [],
|
||||||
"enabled": true
|
"enabled": true
|
||||||
@@ -100,7 +92,7 @@
|
|||||||
{
|
{
|
||||||
"name": "call_subagent",
|
"name": "call_subagent",
|
||||||
"display_name": "CallSubAgentTool",
|
"display_name": "CallSubAgentTool",
|
||||||
"description": "调用已启用的子 Agent 执行指定任务。子 Agent 会独立运行并返回最终结果。",
|
"description": "调用一个已启用的子 Agent 执行指定任务。子 Agent 会独立运行并返回最终结果。适用于需要专业能力(如代码分析、文档处理等)进行辅助的场景。可用的子 Agent 名称取决于 agent_config.json 中已启用且 role='sub' 的配置。",
|
||||||
"permission": "direct",
|
"permission": "direct",
|
||||||
"auto_execute_rules": [],
|
"auto_execute_rules": [],
|
||||||
"enabled": true
|
"enabled": true
|
||||||
@@ -108,15 +100,7 @@
|
|||||||
{
|
{
|
||||||
"name": "doc_tool",
|
"name": "doc_tool",
|
||||||
"display_name": "DocTool",
|
"display_name": "DocTool",
|
||||||
"description": "文档操作工具。支持纯文本(plain_text)、表格(tabular: CSV/XLSX)、Word(docx)、PDF 文档的增删改查。",
|
"description": "文档操作工具。支持纯文本(plain_text)、表格(tabular: CSV/XLSX)、Word(docx)、PowerPoint(pptx)、PDF 文档的增删改查,以及图片(image)、音频(audio)的读取。",
|
||||||
"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(",
|
|
||||||
"permission": "direct",
|
"permission": "direct",
|
||||||
"auto_execute_rules": [],
|
"auto_execute_rules": [],
|
||||||
"enabled": true
|
"enabled": true
|
||||||
@@ -138,7 +122,7 @@
|
|||||||
{
|
{
|
||||||
"name": "send_file",
|
"name": "send_file",
|
||||||
"display_name": "SendFileTool",
|
"display_name": "SendFileTool",
|
||||||
"description": "向用户发送文件的工具。根据文件类型自动处理:图片前端直接展示,音频前端播放+ASR转文字,压缩包和各类文件供用户下载(超过大小限制时提示发送到邮箱)。",
|
"description": "向用户发送文件的工具。根据文件类型自动处理:",
|
||||||
"permission": "direct",
|
"permission": "direct",
|
||||||
"auto_execute_rules": [],
|
"auto_execute_rules": [],
|
||||||
"enabled": true
|
"enabled": true
|
||||||
@@ -146,9 +130,23 @@
|
|||||||
{
|
{
|
||||||
"name": "terminal_execute",
|
"name": "terminal_execute",
|
||||||
"display_name": "TerminalExecute",
|
"display_name": "TerminalExecute",
|
||||||
"description": "终端命令执行工具。在本地终端(PowerShell)中执行 shell 命令并返回执行结果。支持执行 Python 脚本、安装依赖、运行程序、文件操作等任意终端命令。",
|
"description": "终端命令执行工具。在本地终端(PowerShell)中执行 shell 命令并返回执行结果。",
|
||||||
"permission": "direct",
|
"permission": "direct",
|
||||||
"auto_execute_rules": [],
|
"auto_execute_rules": [],
|
||||||
"enabled": true
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
Binary file not shown.
Binary file not shown.
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"version": 3,
|
|
||||||
"messages": []
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"version": 3,
|
|
||||||
"messages": []
|
|
||||||
}
|
|
||||||
@@ -127,6 +127,13 @@ def _artifact_image_data_urls(artifact: Any | None) -> list[str]:
|
|||||||
return
|
return
|
||||||
if isinstance(item.get("image_bytes"), bytes):
|
if isinstance(item.get("image_bytes"), bytes):
|
||||||
image_bytes_list.append(item["image_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"):
|
for key in ("image_url", "url", "data_url"):
|
||||||
value = item.get(key)
|
value = item.get(key)
|
||||||
if isinstance(value, str) and (value.startswith("data:image/") or value.startswith("http")):
|
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"
|
fname = f"_artifact_{base_name}_0.png"
|
||||||
files.append(fname)
|
files.append(fname)
|
||||||
bytes_map[fname] = artifact["image_bytes"]
|
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:
|
if artifact.get("file_type") == "file" and "file_bytes" in artifact:
|
||||||
file_name = artifact.get("file_name", "file")
|
file_name = artifact.get("file_name", "file")
|
||||||
ext = _os.path.splitext(file_name)[1] or ".bin"
|
ext = _os.path.splitext(file_name)[1] or ".bin"
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -14,12 +14,13 @@ _BASE_PROMPT = """你是一个具备多模态理解能力的桌面操作助手
|
|||||||
|
|
||||||
|
|
||||||
_WS_TAIL = """\n\n【工作空间配置】
|
_WS_TAIL = """\n\n【工作空间配置】
|
||||||
- 工作目录 (WORKING_DIR): {working_dir}
|
- 项目根目录 (PROJECT_DIR): {working_dir}
|
||||||
- 工作空间 (WORKSPACE_DIR): {workspace_dir}
|
- 工作空间 (WORKSPACE_DIR): {workspace_dir}
|
||||||
|
- Skills 目录 (SKILLS_DIR): {skills_dir}
|
||||||
- 你产生的代码、脚本、图片、文档等文件,默认保存到工作空间目录下。
|
- 你产生的代码、脚本、图片、文档等文件,默认保存到工作空间目录下。
|
||||||
- 使用 terminal_execute 工具时,如果不指定 working_dir,默认在此工作空间执行命令。
|
- 使用 terminal_execute 工具时,如果不指定 working_dir,默认在此工作空间执行命令。
|
||||||
- 使用 doc_tool 创建/修改文件时,文件路径请使用工作空间的绝对路径。"""
|
- 使用 doc_tool 创建/修改文件时,文件路径请使用工作空间的绝对路径。
|
||||||
|
"""
|
||||||
|
|
||||||
def get_main_system_prompt() -> str:
|
def get_main_system_prompt() -> str:
|
||||||
"""动态构建主系统提示词(含工作空间路径)。
|
"""动态构建主系统提示词(含工作空间路径)。
|
||||||
@@ -32,9 +33,12 @@ def get_main_system_prompt() -> str:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from agent.utils.env_utils import WORKING_DIR, get_workspace_dir
|
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(
|
ws_info = _WS_TAIL.format(
|
||||||
working_dir=WORKING_DIR,
|
working_dir=WORKING_DIR,
|
||||||
workspace_dir=get_workspace_dir(),
|
workspace_dir=get_workspace_dir(),
|
||||||
|
skills_dir=skills_dir,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
ws_info = ""
|
ws_info = ""
|
||||||
@@ -143,12 +147,12 @@ PLAN_FIRST_CALL_PROMPT = """
|
|||||||
""".strip()
|
""".strip()
|
||||||
|
|
||||||
REFLECTION_PROMPT = """
|
REFLECTION_PROMPT = """
|
||||||
【请先在你的回复中尽可能简短地输出你的反思,然后再调用工具或给出最终回复】
|
【重要:如果你判断不需要调用任何工具,请直接回复用户,不要输出任何反思内容!】
|
||||||
你需要先用尽可能简短的一句话在文字中思考上一步做了什么?下一步该做什么?然后进行相应的工具调用。格式如下:
|
只有在需要调用工具时,才先进行简短反思(一句话即可),然后调用工具。格式如下:
|
||||||
1. 先分析上一轮执行结果:上一个调用了什么工具?结果是成功还是失败?
|
1. 如果不需要调用工具(任务已完成或无需工具即可回答),直接给出最终回复,不要输出反思。
|
||||||
2. 如果上一轮是 GUI 操作(点击、输入等)但未命中目标或未达预期效果,请根据最新的截图重新估算目标位置,调整坐标或参数后重试。
|
2. 如果需要调用工具:先用一句话思考上一步结果和下一步计划,然后调用工具。
|
||||||
3. 如果上一轮操作成功,说明你根据分析结果进行下一步操作。
|
3. 如果上一轮是 GUI 操作(点击、输入等)但未命中目标或未达预期效果,请根据最新的截图重新估算目标位置,调整坐标或参数后重试。
|
||||||
4. 然后调用对应的工具执行下一步,或如果任务已完成则直接回复用户(注意只需要回复用户最终结果即可)。
|
4. 如果上一轮操作成功,根据分析结果进行下一步操作。
|
||||||
|
|
||||||
【gui_tool 批量调用规则 - 务必遵守】
|
【gui_tool 批量调用规则 - 务必遵守】
|
||||||
如果你计划在同一页面/界面执行多个连续的 GUI 操作(例如:依次点击多个按钮、填写多个表单字段、连续输入多项内容),必须将所有这些操作合并为一次 gui_tool 调用,在 actions 列表中一次性传入所有动作。严禁将同一页面的连续操作拆分多次 gui_tool 调用——这样会导致严重的延迟。仅当需要等页面跳转/加载后才能确定后续操作时,才分步调用。
|
如果你计划在同一页面/界面执行多个连续的 GUI 操作(例如:依次点击多个按钮、填写多个表单字段、连续输入多项内容),必须将所有这些操作合并为一次 gui_tool 调用,在 actions 列表中一次性传入所有动作。严禁将同一页面的连续操作拆分多次 gui_tool 调用——这样会导致严重的延迟。仅当需要等页面跳转/加载后才能确定后续操作时,才分步调用。
|
||||||
|
|||||||
@@ -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 viewBox=\"0 0 1280 720\" xmlns=\"...\">...</svg>"`
|
||||||
|
- 每页一个独立 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` |
|
||||||
@@ -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. **禁止跳过**:每个项都必须显式确认或接受默认值
|
||||||
@@ -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` |
|
||||||
@@ -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 做节奏换气。
|
||||||
@@ -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
|
||||||
|
<svg viewBox="0 0 1280 720" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="1280" height="720" fill="#FAFBFC"/>
|
||||||
|
<rect x="0" y="580" width="1280" height="140" fill="#1A73E8"/>
|
||||||
|
<text x="80" y="280" font-size="48" font-family="Microsoft YaHei" font-weight="bold" fill="#1F2937">
|
||||||
|
主标题最多两行
|
||||||
|
</text>
|
||||||
|
<text x="80" y="340" font-size="24" font-family="Microsoft YaHei" fill="#6B7280">
|
||||||
|
副标题补充说明
|
||||||
|
</text>
|
||||||
|
<text x="80" y="650" font-size="16" font-family="Microsoft YaHei" fill="#FFFFFF">
|
||||||
|
作者 / 日期
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 内容页 `dense`
|
||||||
|
|
||||||
|
- **一行一个核心消息**:每页提炼一个 <15 字的标题
|
||||||
|
- **内容组织**:优先用卡片网格(2×2 或 3×2),其次用编号列表
|
||||||
|
- **文字量控制**:每页正文不超过 80 字
|
||||||
|
- **文本高亮**:关键数值、对比结论、核心名词用加粗或强调色
|
||||||
|
|
||||||
|
### 双卡片模板
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<svg viewBox="0 0 1280 720" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="1280" height="720" fill="#FAFBFC"/>
|
||||||
|
<text x="80" y="80" font-size="32" font-family="Microsoft YaHei" font-weight="bold" fill="#1F2937">
|
||||||
|
页面主标题
|
||||||
|
</text>
|
||||||
|
<rect x="80" y="140" width="540" height="400" rx="12" fill="#FFFFFF" filter="url(#cardShadow)"/>
|
||||||
|
<text x="120" y="200" font-size="20" font-family="Microsoft YaHei" font-weight="bold" fill="#1A73E8">
|
||||||
|
卡片一标题
|
||||||
|
</text>
|
||||||
|
<text x="120" y="240" font-size="16" font-family="Microsoft YaHei" fill="#1F2937">
|
||||||
|
卡片一要点
|
||||||
|
</text>
|
||||||
|
<rect x="660" y="140" width="540" height="400" rx="12" fill="#FFFFFF" filter="url(#cardShadow)"/>
|
||||||
|
<text x="700" y="200" font-size="20" font-family="Microsoft YaHei" font-weight="bold" fill="#1A73E8">
|
||||||
|
卡片二标题
|
||||||
|
</text>
|
||||||
|
<text x="700" y="240" font-size="16" font-family="Microsoft YaHei" fill="#1F2937">
|
||||||
|
卡片二要点
|
||||||
|
</text>
|
||||||
|
<defs>
|
||||||
|
<filter id="cardShadow">
|
||||||
|
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-opacity="0.08"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 过渡页 `breathing`
|
||||||
|
|
||||||
|
- 单句金句或关键结论
|
||||||
|
- 极少元素,大量留白
|
||||||
|
- 可配一个装饰图形或图标
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 结构规范
|
||||||
|
|
||||||
|
### 语义分组(强制)
|
||||||
|
|
||||||
|
每页 3-8 个顶层 `<g id="...">` 分组:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<g id="background">...</g>
|
||||||
|
<g id="title">...</g>
|
||||||
|
<g id="content">...</g>
|
||||||
|
<g id="footer">...</g>
|
||||||
|
<g id="decor">...</g>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 文本规范
|
||||||
|
|
||||||
|
- **一行一个 `<text>`**:同行不同样式用 `<tspan>` 内联
|
||||||
|
- 禁止多个 `<text>` 并排模拟一行
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<text x="80" y="200" font-size="16" font-family="Microsoft YaHei" fill="#1F2937">
|
||||||
|
正常文字 <tspan font-weight="bold" fill="#1A73E8">加粗高亮</tspan> 继续正常
|
||||||
|
</text>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 图片处理
|
||||||
|
|
||||||
|
> **详细规范见 `07-image-embedding.md`**
|
||||||
|
|
||||||
|
图片全部由用户提供。嵌入方式:将用户图片转为 Base64 直接写在 SVG 中:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<image href="data:image/png;base64,iVBORw0KGgo..." x="0" y="0" width="1280" height="720" preserveAspectRatio="xMidYMid slice"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键规则**:
|
||||||
|
- 背景图叠加半透明遮罩(`fill="#000000" opacity="0.25-0.45"`),保证文字可读
|
||||||
|
- 图片上的文字用 `#FFFFFF`
|
||||||
|
- 根据图片宽高比选择左右分割或上下分割布局
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 演讲备注生成
|
||||||
|
|
||||||
|
为每页生成 200-500 字的中文演讲备注。格式要求:
|
||||||
|
- 口语化、可在演讲时自然朗读
|
||||||
|
- 关键数字可读性("三百五十万"而非"350万")
|
||||||
|
- 每页独立成段,不跨页引用
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 禁止事项
|
||||||
|
|
||||||
|
- 禁止子代理生成 SVG
|
||||||
|
- 禁止脚本批量生成
|
||||||
|
- 禁止在 spec_lock 之外取值
|
||||||
|
- 禁止跳过任何一页
|
||||||
@@ -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(` ` `—` `©`) |
|
||||||
|
| **元素** | `<foreignObject>` | 用 `<text>` + `<tspan>` |
|
||||||
|
| **元素** | `<style>` 标签 / 外部 CSS / `class` | 内联属性 `fill="..."` `font-size="..."` |
|
||||||
|
| **元素** | `<mask>` | 用 `clipPath` 或叠加矩形 |
|
||||||
|
| **元素** | `<script>` `<animate>` | 禁止 |
|
||||||
|
| **元素** | `<symbol>` + `<use>` | 复制实际元素 |
|
||||||
|
| **CSS** | `rgba()` 函数 | `fill-opacity` / `stroke-opacity` |
|
||||||
|
| **CSS** | `<g opacity="...">` | 设到每个子元素上 |
|
||||||
|
| **字体** | `@font-face` 自定义字体 | 只用系统预装字体 |
|
||||||
|
| **字体** | `textPath` | 禁止 |
|
||||||
|
|
||||||
|
### 图像裁剪(有条件允许)
|
||||||
|
|
||||||
|
仅当 `<clipPath>` 作用于 `<image>` 时允许使用:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<defs>
|
||||||
|
<clipPath id="crop">
|
||||||
|
<rect x="0" y="0" width="400" height="300" rx="8"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<image href="img.png" x="0" y="0" width="400" height="300" clip-path="url(#crop)"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、PPT 兼容性替代方案
|
||||||
|
|
||||||
|
| SVG 原始写法 | PPT 兼容写法 |
|
||||||
|
|-------------|-------------|
|
||||||
|
| `fill="rgba(26,115,232,0.15)"` | `fill="#1A73E8" fill-opacity="0.15"` |
|
||||||
|
| `<g opacity="0.5">` | 为每个子元素单独设 `opacity` |
|
||||||
|
| `<image opacity="0.3">` | 用半透明叠加矩形替代 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、阴影规范(克制即设计)
|
||||||
|
|
||||||
|
### 三档阴影
|
||||||
|
|
||||||
|
| 档位 | `stdDeviation` | 场景 |
|
||||||
|
|------|---------------|------|
|
||||||
|
| Floor | 无阴影 | 默认,元素嵌在页面中 |
|
||||||
|
| Resting | 2-3 | 卡片、面板轻微浮起 |
|
||||||
|
| Raised | 4-6 | 核心结论、CTA 强突出 |
|
||||||
|
|
||||||
|
### 使用模式
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<defs>
|
||||||
|
<filter id="shadow">
|
||||||
|
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-opacity="0.1"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<rect x="..." y="..." width="..." height="..." rx="12" fill="#FFFFFF" filter="url(#shadow)"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 阴影原则
|
||||||
|
|
||||||
|
- 每页单一光源方向(统一 `dx`/`dy` 方向)
|
||||||
|
- 最多两层高度差(如 Resting 卡片 + Raised CTA)
|
||||||
|
- 装饰性色块、扁平化页面不使用阴影
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、渐变
|
||||||
|
|
||||||
|
支持 `<linearGradient>` 和 `<radialGradient>`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g1" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#1A73E8"/>
|
||||||
|
<stop offset="100%" stop-color="#4285F4"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="..." y="..." width="..." height="..." fill="url(#g1)"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、描边与文本效果
|
||||||
|
|
||||||
|
### 虚线
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<line x1="80" y1="400" x2="1200" y2="400" stroke="#D1D5DB" stroke-width="1" stroke-dasharray="4,4"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 文本装饰
|
||||||
|
|
||||||
|
- `text-decoration="underline"` — 下划线
|
||||||
|
- `text-decoration="line-through"` — 删除线
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、元素映射(SVG → PPTX)
|
||||||
|
|
||||||
|
Agent 生成的 SVG 会经过引擎转为 PowerPoint 原生形状:
|
||||||
|
|
||||||
|
| SVG | PPTX |
|
||||||
|
|-----|------|
|
||||||
|
| `<rect rx="8">` | 圆角矩形 |
|
||||||
|
| `<circle>` / `<ellipse>` | 椭圆 |
|
||||||
|
| `<text>` + `<tspan>` | 文本框 |
|
||||||
|
| `<image href="data:...">` | 内嵌图片 |
|
||||||
|
| `<line>` | 线条 |
|
||||||
|
| `<path>` / `<polygon>` | 降级为矩形(避免使用复杂路径) |
|
||||||
|
| `<linearGradient>` | 渐变填充 |
|
||||||
|
| `filter="url(#shadow)"` | PPT 阴影效果 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、文本内联规则
|
||||||
|
|
||||||
|
**一个逻辑行 = 一个 `<text>` 元素**。不同样式用 `<tspan>` 实现:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- 正确 -->
|
||||||
|
<text x="80" y="200" font-size="16" font-family="Microsoft YaHei" fill="#1F2937">
|
||||||
|
<tspan font-weight="bold">Q1 营收</tspan>同比增长 <tspan fill="#EA4335" font-weight="bold">23.5%</tspan>
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<!-- 错误 -->
|
||||||
|
<text x="80" y="200">Q1 营收</text>
|
||||||
|
<text x="180" y="200">同比增长</text>
|
||||||
|
```
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# 07 — 图片嵌入规范
|
||||||
|
|
||||||
|
> **角色**:图片嵌入参考 — 用户图片的 Base64 嵌入、宽高比适配、图文布局比例
|
||||||
|
> **读取时机**:Executor 阶段需要将用户提供的图片嵌入 SVG 时
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、图片来源
|
||||||
|
|
||||||
|
所有图片**由用户提供**。不存在 AI 生成、网络搜索、图片切片等自动获取步骤。
|
||||||
|
|
||||||
|
Strategist 阶段与用户确认:
|
||||||
|
- 哪些页面需要图片
|
||||||
|
- 每张图片的用途(背景 / 插图 / Logo 等)
|
||||||
|
- 推荐尺寸(如封面背景 1280×720、卡片配图 540×300)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、SVG 图片嵌入方式
|
||||||
|
|
||||||
|
### 方式:Base64 内嵌(推荐)
|
||||||
|
|
||||||
|
直接将用户提供的图片转为 Base64 嵌入 SVG:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<image href="data:image/png;base64,iVBORw0KGgo..." x="0" y="0" width="1280" height="720"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### MIME 类型对照
|
||||||
|
|
||||||
|
| 格式 | MIME |
|
||||||
|
|------|------|
|
||||||
|
| PNG | `data:image/png;base64,...` |
|
||||||
|
| JPEG | `data:image/jpeg;base64,...` |
|
||||||
|
| WebP | `data:image/webp;base64,...` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、preserveAspectRatio 控制
|
||||||
|
|
||||||
|
| 值 | 效果 | 适用场景 |
|
||||||
|
|----|------|----------|
|
||||||
|
| `xMidYMid meet` | 完整显示,等比例适配,可能留黑边 | Logo、图标 |
|
||||||
|
| `xMidYMid slice` | 裁剪填充,无黑边 | 封面背景、全幅图 |
|
||||||
|
| `none` | 拉伸填满 | 避免使用 |
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- 封面背景:裁剪填充 -->
|
||||||
|
<image href="data:image/png;base64,..." x="0" y="0" width="1280" height="720" preserveAspectRatio="xMidYMid slice"/>
|
||||||
|
|
||||||
|
<!-- 卡片内配图:完整显示 -->
|
||||||
|
<image href="data:image/png;base64,..." x="80" y="160" width="540" height="300" preserveAspectRatio="xMidYMid meet"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、图片与文字布局比例
|
||||||
|
|
||||||
|
根据图片原始宽高比动态分配空间:
|
||||||
|
|
||||||
|
| 图片比例 | 推荐布局 | 说明 |
|
||||||
|
|----------|----------|------|
|
||||||
|
| > 2:1(超宽) | 上下分割 | 图片占 35-45% 高度 |
|
||||||
|
| 1.5:1 ~ 2:1(宽图) | 上下分割 | 图片占 50% 高度 |
|
||||||
|
| 1:1 ~ 1.5:1(标准) | 左右分割 | 图片占 45-55% 宽度 |
|
||||||
|
| < 1:1(竖图) | 左右分割 | 图片占 30-40% 宽度 |
|
||||||
|
|
||||||
|
**禁止**:拉伸变形、固定死比例、压缩文字区域。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、常用图文组合
|
||||||
|
|
||||||
|
### 模式 1:全幅背景图
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<image href="data:image/png;base64,..." x="0" y="0" width="1280" height="720" preserveAspectRatio="xMidYMid slice"/>
|
||||||
|
<!-- 暗色遮罩保证文字可读 -->
|
||||||
|
<rect x="0" y="0" width="1280" height="720" fill="#000000" opacity="0.35"/>
|
||||||
|
<text x="80" y="360" font-size="48" fill="#FFFFFF" font-weight="bold">标题</text>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模式 2:左侧 1/3 图 + 右侧文字
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<image href="data:image/png;base64,..." x="0" y="0" width="427" height="720" preserveAspectRatio="xMidYMid slice"/>
|
||||||
|
<!-- 右侧 853px 为文字区域 -->
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模式 3:上方 40% 图 + 下方文字
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<image href="data:image/png;base64,..." x="0" y="0" width="1280" height="400" preserveAspectRatio="xMidYMid slice"/>
|
||||||
|
<!-- 下方 320px 为文字区域 -->
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模式 4:圆形裁剪头像
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<defs>
|
||||||
|
<clipPath id="avatarClip">
|
||||||
|
<circle cx="300" cy="300" r="120"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<image href="data:image/png;base64,..." x="180" y="180" width="240" height="240" clip-path="url(#avatarClip)"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模式 5:渐变遮罩叠加
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<image href="data:image/png;base64,..." x="0" y="0" width="1280" height="720" preserveAspectRatio="xMidYMid slice"/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="fadeLeft">
|
||||||
|
<stop offset="0%" stop-color="#1A73E8" stop-opacity="0.85"/>
|
||||||
|
<stop offset="100%" stop-color="#1A73E8" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="0" y="200" width="640" height="520" fill="url(#fadeLeft)"/>
|
||||||
|
<!-- 渐变区域上放置文字 -->
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、图片与配色协调
|
||||||
|
|
||||||
|
- 背景图建议叠加半透明遮罩(`opacity="0.25-0.45"`),保证文字对比度
|
||||||
|
- 遮罩色使用 spec_lock 中的 primary 色
|
||||||
|
- 图片上的文字颜色以 `#FFFFFF` 为主
|
||||||
|
- 避免在复杂图片区域放置正文
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
# 08 — 页面布局模式与图文组合
|
||||||
|
|
||||||
|
> **角色**:布局参考手册 — 提供节奏控制、卡片网格、图文组合的标准模板
|
||||||
|
> **读取时机**:Executor 阶段规划每页布局时
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、页面节奏(Page Rhythm)
|
||||||
|
|
||||||
|
从 `spec_lock.md` 获取每页的 `rhythm` 标签。
|
||||||
|
|
||||||
|
| rhythm | 策略 | 文字量 | 典型用途 |
|
||||||
|
|--------|------|--------|----------|
|
||||||
|
| `anchor` | 高冲击力、标题主导、大量留白 | ≤ 30 字 | 封面 / 章节页 / 结尾 |
|
||||||
|
| `dense` | 卡片网格、图表、KPI、多栏 | ≤ 80 字 | 数据 / 分析 / 内容页 |
|
||||||
|
| `breathing` | 单句金句、关键结论、极少元素 | ≤ 20 字 | 过渡 / 强调页 |
|
||||||
|
|
||||||
|
**原则**:不连续两页 dense,用 breathing 做节奏换气。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、anchor 页面布局模板
|
||||||
|
|
||||||
|
### 封面 — 大标题居中偏上 + 底部色块
|
||||||
|
|
||||||
|
```
|
||||||
|
+-------------------------------+
|
||||||
|
| |
|
||||||
|
| [主标题 48px] |
|
||||||
|
| [副标题 24px muted] |
|
||||||
|
| |
|
||||||
|
| |
|
||||||
|
| ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ | ← 底部 140px 主色条
|
||||||
|
| 作者 / 日期 |
|
||||||
|
+-------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
### 封面 — 左对齐大标题 + 右侧图形
|
||||||
|
|
||||||
|
```
|
||||||
|
+-------------------------------+
|
||||||
|
| |
|
||||||
|
| [主标题 48px] +-------+ |
|
||||||
|
| [副标题 24px] | 装饰 | |
|
||||||
|
| | 图形 | |
|
||||||
|
| +-------+ |
|
||||||
|
| |
|
||||||
|
| 作者 / 日期 |
|
||||||
|
+-------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
### 章节过渡页
|
||||||
|
|
||||||
|
```
|
||||||
|
+-------------------------------+
|
||||||
|
| [编号 64px muted] |
|
||||||
|
| |
|
||||||
|
| [章节标题 42px bold] |
|
||||||
|
| [副标题 24px] |
|
||||||
|
| |
|
||||||
|
| ───────────────────── 装饰线 |
|
||||||
|
+-------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、dense 页面布局模板
|
||||||
|
|
||||||
|
### 2×2 卡片网格
|
||||||
|
|
||||||
|
```
|
||||||
|
+-------------------------------+
|
||||||
|
| [页面标题 32px] |
|
||||||
|
| |
|
||||||
|
| +-----------+ +-----------+ |
|
||||||
|
| | 卡片 1 | | 卡片 2 | |
|
||||||
|
| | 标题 | | 标题 | |
|
||||||
|
| | - 要点1 | | - 要点1 | |
|
||||||
|
| | - 要点2 | | - 要点2 | |
|
||||||
|
| +-----------+ +-----------+ |
|
||||||
|
| +-----------+ +-----------+ |
|
||||||
|
| | 卡片 3 | | 卡片 4 | |
|
||||||
|
| +-----------+ +-----------+ |
|
||||||
|
+-------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
卡片尺寸:540 × 240,间距 40px,`rx="12"`。
|
||||||
|
|
||||||
|
### 3×2 卡片网格
|
||||||
|
|
||||||
|
卡片尺寸:360 × 220,间距 30px。
|
||||||
|
|
||||||
|
### 左文右图
|
||||||
|
|
||||||
|
```
|
||||||
|
+-------------------------------+
|
||||||
|
| [页面标题 32px] |
|
||||||
|
| |
|
||||||
|
| [要点 1] +-----------+ |
|
||||||
|
| [要点 2] | | |
|
||||||
|
| [要点 3] | 图片 | |
|
||||||
|
| [要点 4] | 560×420 | |
|
||||||
|
| +-----------+ |
|
||||||
|
+-------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
### 上图下文
|
||||||
|
|
||||||
|
```
|
||||||
|
+-------------------------------+
|
||||||
|
| +---------------------------+ |
|
||||||
|
| | 图片 1160×280 | |
|
||||||
|
| +---------------------------+ |
|
||||||
|
| [页面标题 32px] |
|
||||||
|
| [要点 1] [要点 2] [要点 3] |
|
||||||
|
+-------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
### KPI 仪表盘
|
||||||
|
|
||||||
|
```
|
||||||
|
+-------------------------------+
|
||||||
|
| [页面标题 32px] |
|
||||||
|
| |
|
||||||
|
| +------+ +------+ +------+ |
|
||||||
|
| | 23% | | 156 | | 8.5 | |
|
||||||
|
| | 增长 | | 项目 | | 评分 | |
|
||||||
|
| +------+ +------+ +------+ |
|
||||||
|
| |
|
||||||
|
| +---------------------------+ |
|
||||||
|
| | 图表区域 1160×340 | |
|
||||||
|
| +---------------------------+ |
|
||||||
|
+-------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
KPI 卡片尺寸:360 × 100,间距 40px。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、breathing 页面布局模板
|
||||||
|
|
||||||
|
### 居中金句
|
||||||
|
|
||||||
|
```
|
||||||
|
+-------------------------------+
|
||||||
|
| |
|
||||||
|
| |
|
||||||
|
| "核心金句或关键结论" |
|
||||||
|
| |
|
||||||
|
| — 来源 |
|
||||||
|
| |
|
||||||
|
| |
|
||||||
|
+-------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
### 大字 + 图标
|
||||||
|
|
||||||
|
```
|
||||||
|
+-------------------------------+
|
||||||
|
| |
|
||||||
|
| [图标] |
|
||||||
|
| |
|
||||||
|
| [大字标题] |
|
||||||
|
| |
|
||||||
|
+-------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、卡片设计规范
|
||||||
|
|
||||||
|
### 标准卡片
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<rect x="80" y="140" width="540" height="240" rx="12" fill="#FFFFFF" filter="url(#cardShadow)"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 卡片内边距
|
||||||
|
|
||||||
|
| 区域 | 内边距 |
|
||||||
|
|------|--------|
|
||||||
|
| 左右边距 | 40px |
|
||||||
|
| 标题到顶部 | 40px 或更少 |
|
||||||
|
| 标题到内容 | 24px |
|
||||||
|
| 行间距 | 8-12px |
|
||||||
|
|
||||||
|
### 阴影参考
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<filter id="cardShadow">
|
||||||
|
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-opacity="0.08"/>
|
||||||
|
</filter>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、常用图文组合模式
|
||||||
|
|
||||||
|
### 模式 1:全幅背景图
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<image href="bg.jpg" x="0" y="0" width="1280" height="720"/>
|
||||||
|
<rect x="0" y="0" width="1280" height="720" fill="#000000" opacity="0.3"/>
|
||||||
|
<text x="80" y="360" font-size="48" fill="#FFFFFF" font-weight="bold">标题</text>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模式 2:左侧 1/3 图
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<image href="img.jpg" x="0" y="0" width="427" height="720"/>
|
||||||
|
<!-- 右侧 853px 为文字区 -->
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模式 3:上图 40% + 下文
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<image href="img.jpg" x="0" y="0" width="1280" height="400"/>
|
||||||
|
<!-- 下方 320px 为文字区 -->
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模式 4:圆形裁剪头像
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<defs>
|
||||||
|
<clipPath id="circle">
|
||||||
|
<circle cx="400" cy="300" r="150"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<image href="avatar.jpg" x="250" y="150" width="300" height="300" clip-path="url(#circle)"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 图像叠加渐变遮罩
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<image href="bg.jpg" x="0" y="0" width="1280" height="720"/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="fade">
|
||||||
|
<stop offset="0%" stop-color="#1A73E8" stop-opacity="0.9"/>
|
||||||
|
<stop offset="100%" stop-color="#1A73E8" stop-opacity="0"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="0" y="300" width="600" height="420" fill="url(#fade)"/>
|
||||||
|
```
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# 09 — 质量检查与 SVG 交付
|
||||||
|
|
||||||
|
> **角色**:QA 与交付 — SVG 质量验证、视觉自检、doc_tool 写入 SVG 文件
|
||||||
|
> **读取时机**:Step 4 质检与交付阶段
|
||||||
|
> **注意**:本 Skill 只交付 SVG。PPTX 转换见 `10-svg-to-pptx.md`(仅在用户明确要求 PPT 格式时触发)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、SVG 质量检查清单
|
||||||
|
|
||||||
|
### 自动化检查(每页生成后必须验证)
|
||||||
|
|
||||||
|
| # | 检查项 | 标准 |
|
||||||
|
|---|--------|------|
|
||||||
|
| 1 | viewBox | 必须为 `0 0 1280 720`(或 4:3 的 `0 0 1024 768`) |
|
||||||
|
| 2 | width/height | **不得存在** `width`/`height` 属性 |
|
||||||
|
| 3 | HTML 实体 | 无 ` ` `—` `©` 等(用原始 Unicode) |
|
||||||
|
| 4 | 禁止元素 | 无 `<foreignObject>` `<style>` `<mask>` `<animate>` `<script>` `<symbol>+<use>` |
|
||||||
|
| 5 | rgba() | 无 `rgba()`(用 `fill-opacity`/`stroke-opacity` 替代) |
|
||||||
|
| 6 | 组透明度 | 无 `<g opacity="...">`(设到每个子元素) |
|
||||||
|
| 7 | 语义分组 | 3-8 个 `<g id="...">` 顶层分组 |
|
||||||
|
| 8 | 文本内联 | 一个逻辑行一个 `<text>`,多样式用 `<tspan>` |
|
||||||
|
| 9 | 颜色引用 | 所有颜色值来自 `spec_lock.md` 的 palette |
|
||||||
|
| 10 | 字体 | 只用系统预装字体(微软雅黑/Arial/Calibri 等) |
|
||||||
|
| 11 | 字号 | 对应角色字号与 spec_lock 一致 |
|
||||||
|
| 12 | 图片引用 | 外部引用路径正确,或 Base64 有效 |
|
||||||
|
| 13 | 画布元素 | 所有元素在 viewBox 范围内 |
|
||||||
|
|
||||||
|
### 每页 spec_lock 一致性检查
|
||||||
|
|
||||||
|
- 配色:`fill`/`stroke` 色值必须来自 `spec_lock.palette`
|
||||||
|
- 字体:`font-family` 必须在 `spec_lock.typography.font_family` 范围内
|
||||||
|
- 字号:角色字号锁定(title 42-48 / body 16-18 / annotation 12-14)
|
||||||
|
- 布局:`rhythm` 类型与页面实际匹配
|
||||||
|
- 图片路径:与 `spec_lock` 中图片资源清单一致
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、视觉自检(软规则)
|
||||||
|
|
||||||
|
> 仅当视觉问题 "明显不佳" 时修复,不过度调整。
|
||||||
|
|
||||||
|
| # | 检查项 | 标准 |
|
||||||
|
|---|--------|------|
|
||||||
|
| S1 | 垂直节奏 | 各区块间距均匀,不拥挤也不过松 |
|
||||||
|
| S2 | 视觉重心 | 页面视觉重心不偏移 |
|
||||||
|
| S3 | 对齐 | 同类元素对齐一致 |
|
||||||
|
| S4 | 网格 | 多卡片网格均匀分布 |
|
||||||
|
| S5 | 强调色 | 强调色不超过页面面积 10% |
|
||||||
|
| S6 | 图文关系 | 图片与文字不冲突、不重叠 |
|
||||||
|
| S7 | breathing 页 | 不堆砌内容、不添加非必要元素 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、SVG 文件交付
|
||||||
|
|
||||||
|
使用 `doc_tool create` 将每页 SVG 写入独立文件:
|
||||||
|
|
||||||
|
```
|
||||||
|
doc_tool create page_01.svg content="<svg viewBox=\"0 0 1280 720\" xmlns=\"http://www.w3.org/2000/svg\">...</svg>"
|
||||||
|
doc_tool create page_02.svg content="<svg viewBox=\"0 0 1280 720\" xmlns=\"http://www.w3.org/2000/svg\">...</svg>"
|
||||||
|
```
|
||||||
|
|
||||||
|
每页一个独立 `.svg` 文件,文件名包含页码便于排序。
|
||||||
|
|
||||||
|
**doc_tool 在本 Skill 中仅用于写入 SVG 文件,不生成 PPTX。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、SVG → PPTX 转换(可选)
|
||||||
|
|
||||||
|
> **仅当用户明确要求 PPT 格式时**,才读取 `10-svg-to-pptx.md` 并运行转换脚本。
|
||||||
|
|
||||||
|
转换脚本将已生成的 SVG 文件批量转为原生 PowerPoint 元素(圆角矩形、文本框、椭圆等可编辑形状),而非扁平图片。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、常见错误排查
|
||||||
|
|
||||||
|
| 错误 | 原因 | 修复 |
|
||||||
|
|------|------|------|
|
||||||
|
| SVG 转 PPTX 空白 | viewBox 缺失或错误 | 检查 `viewBox="0 0 1280 720"` |
|
||||||
|
| 文字位置错乱 | 同一样式用了多个 `<text>` | 合并为一个 `<text>` + `<tspan>` |
|
||||||
|
| 透明度不生效 | 用了 `rgba()` | 改为 `fill-opacity` |
|
||||||
|
| 图片不显示 | 外部路径错误 | 检查相对路径或改用 Base64 |
|
||||||
|
| 字体不一致 | 未设 font-family | 每个 `<text>` 显式指定 `font-family` |
|
||||||
|
| 阴影丢失 | filter id 未引用或 defs 位置错误 | 确保 `<defs>` 在 `<filter>` 使用前定义 |
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# 10 — SVG 转 PPTX 转换脚本
|
||||||
|
|
||||||
|
> **角色**:格式转换 — 将已生成的 SVG 幻灯片批量转换为原生 PPTX
|
||||||
|
> **读取时机**:仅当用户**明确表示**"不要 SVG,要 PPTX 格式"或"导出为 PPT"时
|
||||||
|
> **前置条件**:SVG 文件已通过 Step 4 生成完毕
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 触发规则
|
||||||
|
|
||||||
|
**仅在以下情况触发**:
|
||||||
|
- 用户说:"导出为 PPT" / "给我 PPTX" / "不要 SVG 格式"
|
||||||
|
- 用户说:"转成 PowerPoint"
|
||||||
|
|
||||||
|
**默认不执行**:普通流程下只交付 SVG 文件,不运行此步骤。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 转换流程
|
||||||
|
|
||||||
|
### 1. 确认 SVG 文件位置
|
||||||
|
|
||||||
|
找到 Step 4 生成的所有 `.svg` 文件。
|
||||||
|
|
||||||
|
### 2. 运行转换
|
||||||
|
|
||||||
|
使用 `doc_tool create` 将所有 SVG 页面拼接为一个 PPTX:
|
||||||
|
|
||||||
|
```
|
||||||
|
doc_tool create <输出文件.pptx> content="<slide>1</slide><svg viewBox=\"0 0 1280 720\" xmlns=\"http://www.w3.org/2000/svg\">...</svg><slide>2</slide><svg>...</svg>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**格式要求**:
|
||||||
|
- 每页格式:`<slide>页码</slide>` + `<svg viewBox="0 0 1280 720" xmlns="http://www.w3.org/2000/svg">...</svg>`
|
||||||
|
- 所有 `<slide> + <svg>` 对被拼接到一个 `content` 字符串中
|
||||||
|
- SVG 内元素会被引擎转为 PowerPoint 原生形状(圆角矩形、文本框、椭圆等可编辑形状)
|
||||||
|
|
||||||
|
### 3. 元素映射(SVG → PPTX)
|
||||||
|
|
||||||
|
| SVG | PPTX |
|
||||||
|
|-----|------|
|
||||||
|
| `<rect rx="8">` | 圆角矩形 |
|
||||||
|
| `<circle>` / `<ellipse>` | 椭圆 |
|
||||||
|
| `<text>` + `<tspan>` | 文本框 |
|
||||||
|
| `<image href="data:...">` | 内嵌图片 |
|
||||||
|
| `<line>` | 线条 |
|
||||||
|
| `<linearGradient>` | 渐变填充 |
|
||||||
|
| `filter="url(#shadow)"` | PPT 阴影效果 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 转换后 PPTX 中的形状可逐个编辑,不是扁平图片
|
||||||
|
- `<path>` / `<polygon>` 可能降级为矩形,避免使用复杂路径
|
||||||
|
- 动画、旁白等高级特性不在本 Skill 范围内
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "ppt_maker",
|
||||||
|
"description": "PPT 制作 Skill — 通过多角色协作将用户意图转化为高质量 SVG 页面并导出 PPTX。触发词: 做PPT、生成PPT、写幻灯片、PPTX、presentation、演示文稿。详细工作流与各角色职责见 attachment/ 目录下的子 Skill 文件。",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"author": "minor",
|
||||||
|
"tags": ["ppt", "svg", "演示文稿", "幻灯片", "生产力"],
|
||||||
|
"enable": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# PPT Maker Skill(表层索引)
|
||||||
|
|
||||||
|
本 skill 采用 **分级分工** 架构。此文件仅为入口索引,所有详细指令、角色定义、工作流规范均拆分至 `attachments/` 目录下的独立 md 文件。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## attachment 子 Skill 清单
|
||||||
|
|
||||||
|
| 文件 | 角色/职责 | 何时读取 |
|
||||||
|
|------|-----------|----------|
|
||||||
|
| `01-workflow-overview.md` | **总调度**:4 步串行流水线、全局执行纪律、入口路由规则 | 任务启动时(最先读) |
|
||||||
|
| `02-strategist.md` | **策略师**:内容分析→三阶段确认(方向→设计→图片)→输出 design_spec | Step 2 进入策略阶段前 |
|
||||||
|
| `03-modes-and-styles.md` | **模式+风格索引**:5 种沟通模式 + 17 种视觉风格的速查表 | 策略师阶段需要锁定 mode/style 时 |
|
||||||
|
| `04-design-system.md` | **设计系统**:配色方案、字体层级、画布格式、图标策略 | 策略师阶段定调设计时 |
|
||||||
|
| `05-executor.md` | **执行师**:逐页 SVG 生成规范、spec_lock 约束、模板遵循、备注生成 | Step 3 进入执行阶段前 |
|
||||||
|
| `06-svg-standards.md` | **SVG 技术约束**:禁止特性黑名单、PPT 兼容性替代方案、阴影/渐变/描边规则 | 执行师写 SVG 前查阅 |
|
||||||
|
| `07-image-embedding.md` | **图片嵌入**:用户图片的 Base64 嵌入规范、preserveAspectRatio、图文布局比例 | 有用户图片需要嵌入 SVG 时 |
|
||||||
|
| `08-layout-patterns.md` | **布局模式**:页面节奏(anchor/dense/breathing)、图文组合布局、卡片网格规范 | 执行师规划每页布局时 |
|
||||||
|
| `09-quality-and-export.md` | **质检与交付**:SVG 质量检查、视觉自检、doc_tool 写入 SVG 文件 | Step 4 |
|
||||||
|
| `10-svg-to-pptx.md` | **SVG→PPTX 转换**:将已生成的 SVG 批量转为原生 PPTX(可选独立步骤) | 仅用户明确要 PPT 格式时 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 架构说明
|
||||||
|
|
||||||
|
```
|
||||||
|
skill.json + skill.md ← 表层:元数据 + 附件索引
|
||||||
|
└── attachments/
|
||||||
|
├── 01-workflow-overview.md ← 总调度(最先读取)
|
||||||
|
├── 02-strategist.md ← 策略师角色
|
||||||
|
├── 03-modes-and-styles.md ← 模式/风格速查
|
||||||
|
├── 04-design-system.md ← 设计系统规范
|
||||||
|
├── 05-executor.md ← 执行师角色
|
||||||
|
├── 06-svg-standards.md ← SVG 技术约束
|
||||||
|
├── 07-image-embedding.md ← 图片嵌入
|
||||||
|
├── 08-layout-patterns.md ← 布局模式
|
||||||
|
├── 09-quality-and-export.md ← 质检与 SVG 交付
|
||||||
|
└── 10-svg-to-pptx.md ← SVG→PPTX 转换(可选)
|
||||||
|
```
|
||||||
|
|
||||||
|
Agent 按需读取对应文件。脚本文件 `10-svg-to-pptx.md` 仅在用户明确要求 PPT 格式时读取。
|
||||||
@@ -12,6 +12,7 @@ from .doc import DocTool
|
|||||||
from .skill_router import SkillRouter
|
from .skill_router import SkillRouter
|
||||||
from .send_file import SendFileTool
|
from .send_file import SendFileTool
|
||||||
from .terminal_execute import TerminalExecute
|
from .terminal_execute import TerminalExecute
|
||||||
|
from .email import MailTool
|
||||||
|
|
||||||
_all_tools = [
|
_all_tools = [
|
||||||
WebSearch(),
|
WebSearch(),
|
||||||
@@ -28,6 +29,7 @@ _all_tools = [
|
|||||||
SkillRouter(),
|
SkillRouter(),
|
||||||
SendFileTool(),
|
SendFileTool(),
|
||||||
TerminalExecute(),
|
TerminalExecute(),
|
||||||
|
MailTool(),
|
||||||
]
|
]
|
||||||
|
|
||||||
# 注册所有可用工具名称(供前端配置面板展示),从 _all_tools 实例推导
|
# 注册所有可用工具名称(供前端配置面板展示),从 _all_tools 实例推导
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -8,7 +8,7 @@ class DateQuery(BaseTool):
|
|||||||
|
|
||||||
# 定义工具的输入参数结构,这里没有输入参数,所以是一个空的模型
|
# 定义工具的输入参数结构,这里没有输入参数,所以是一个空的模型
|
||||||
args_schema : type[BaseModel] = create_model("NoneInput")
|
args_schema : type[BaseModel] = create_model("NoneInput")
|
||||||
name : str = "date_query_class"
|
name : str = "date_query"
|
||||||
description : str = "一个日期查询工具,可以返回当前的日期信息。仅当需要查询当前时间时调用,其余任何情况严禁调用。"
|
description : str = "一个日期查询工具,可以返回当前的日期信息。仅当需要查询当前时间时调用,其余任何情况严禁调用。"
|
||||||
|
|
||||||
def _run(self) -> str:
|
def _run(self) -> str:
|
||||||
|
|||||||
+80
-7
@@ -1,8 +1,9 @@
|
|||||||
"""文档操作工具:对 plain_text / tabular / docx / pdf 文件进行增删改查。
|
"""文档操作工具:对 plain_text / tabular / docx / pptx / pdf 文件进行增删改查。
|
||||||
|
|
||||||
plain_text: 行级粒度(与代码 agent 一致)
|
plain_text: 行级粒度(与代码 agent 一致)
|
||||||
tabular: 单元格级粒度(CSV / XLSX)
|
tabular: 单元格级粒度(CSV / XLSX)
|
||||||
docx: 段落级粒度(文本段落);表格操作粒度到单元格
|
docx: 段落级粒度(文本段落);表格操作粒度到单元格
|
||||||
|
pptx: 只读 + 删除(按幻灯片提取文本+内嵌图片+表格)
|
||||||
pdf: 只读 + 删除
|
pdf: 只读 + 删除
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -13,7 +14,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
from typing import Any, Literal, Optional
|
from typing import Literal, Optional
|
||||||
|
|
||||||
from langchain.tools import BaseTool
|
from langchain.tools import BaseTool
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -371,6 +372,71 @@ def _update_docx(file_path: str, paragraph_index: int | None, new_paragraph_text
|
|||||||
return "[ERROR] docx update 操作缺少必要参数"
|
return "[ERROR] docx update 操作缺少必要参数"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- pptx ----------
|
||||||
|
def _read_pptx(file_path: str, max_chars: int | None = None, from_end: bool = False,
|
||||||
|
max_images: int | None = None) -> tuple[str, list[bytes]]:
|
||||||
|
text, images = Documents_process.process_pptx(file_path)
|
||||||
|
if max_images is not None and max_images >= 0 and len(images) > max_images:
|
||||||
|
images = images[:max_images] if not from_end else images[-max_images:]
|
||||||
|
img_note = f"\n(含 {len(images)} 张内嵌图片)" if images else ""
|
||||||
|
result = f"=== {file_path} ===\n{text}{img_note}"
|
||||||
|
result = _apply_text_truncation(result, max_chars, from_end)
|
||||||
|
return result, images
|
||||||
|
|
||||||
|
|
||||||
|
def _create_pptx(file_path: str, content: str) -> str:
|
||||||
|
"""用 SVG 代码创建 PPTX 文件。
|
||||||
|
|
||||||
|
content 格式与 _read_pptx 返回一致:
|
||||||
|
多个 <slide>N</slide> 块,每块包含一个 <svg>...</svg>。
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# 提取每个 slide 的 SVG
|
||||||
|
pattern = r'<slide>(\d+)</slide>\s*\n(.*?)(?=<slide>\d+</slide>|$)'
|
||||||
|
matches = re.findall(pattern, content, re.DOTALL)
|
||||||
|
|
||||||
|
if not matches:
|
||||||
|
return "[ERROR] PPTX create: content 中未找到 <slide>N</slide> 格式的 SVG 内容。" \
|
||||||
|
" 请确保 content 包含完整的 SVG 幻灯片代码(与 doc_tool read pptx 输出格式一致)。"
|
||||||
|
|
||||||
|
# 写入临时 SVG 文件
|
||||||
|
svg_files: list[Path] = []
|
||||||
|
tmpdir = tempfile.mkdtemp()
|
||||||
|
for idx, svg_code in matches:
|
||||||
|
# 清理 SVG 代码:去掉可能的前后空白
|
||||||
|
svg_code = svg_code.strip()
|
||||||
|
if not svg_code.startswith("<svg"):
|
||||||
|
# 尝试提取 <svg>...</svg>
|
||||||
|
svg_match = re.search(r'(<svg\b.*?</svg>)', svg_code, re.DOTALL | re.IGNORECASE)
|
||||||
|
if svg_match:
|
||||||
|
svg_code = svg_match.group(1)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
svg_path = Path(tmpdir) / f"slide_{int(idx):02d}.svg"
|
||||||
|
svg_path.write_text(svg_code, encoding="utf-8")
|
||||||
|
svg_files.append(svg_path)
|
||||||
|
|
||||||
|
if not svg_files:
|
||||||
|
return "[ERROR] PPTX create: 未找到有效的 SVG 内容"
|
||||||
|
|
||||||
|
try:
|
||||||
|
from agent.utils.ppt_utils import create_pptx_from_svgs
|
||||||
|
output_path = Path(file_path)
|
||||||
|
success = create_pptx_from_svgs(svg_files, output_path)
|
||||||
|
except Exception as e:
|
||||||
|
return f"[ERROR] PPTX create 失败: {e}"
|
||||||
|
finally:
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return f"[OK] 已创建 PPTX 文件: {file_path}\n共 {len(svg_files)} 页幻灯片"
|
||||||
|
return "[ERROR] PPTX create 失败"
|
||||||
|
|
||||||
|
|
||||||
# ---------- pdf ----------
|
# ---------- pdf ----------
|
||||||
def _read_pdf(file_path: str, max_chars: int | None = None, from_end: bool = False,
|
def _read_pdf(file_path: str, max_chars: int | None = None, from_end: bool = False,
|
||||||
max_images: int | None = None) -> tuple[str, list[bytes]]:
|
max_images: int | None = None) -> tuple[str, list[bytes]]:
|
||||||
@@ -437,6 +503,8 @@ def _dispatch_read(doc_type: str, file_path: str, **kwargs) -> tuple[str, list[b
|
|||||||
return _read_tabular(file_path, kwargs.get("sheet_name"), max_chars=max_chars, from_end=from_end), []
|
return _read_tabular(file_path, kwargs.get("sheet_name"), max_chars=max_chars, from_end=from_end), []
|
||||||
elif doc_type == "docx":
|
elif doc_type == "docx":
|
||||||
return _read_docx(file_path, max_chars=max_chars, from_end=from_end, max_images=max_images)
|
return _read_docx(file_path, max_chars=max_chars, from_end=from_end, max_images=max_images)
|
||||||
|
elif doc_type == "pptx":
|
||||||
|
return _read_pptx(file_path, max_chars=max_chars, from_end=from_end, max_images=max_images)
|
||||||
elif doc_type == "pdf":
|
elif doc_type == "pdf":
|
||||||
return _read_pdf(file_path, max_chars=max_chars, from_end=from_end, max_images=max_images)
|
return _read_pdf(file_path, max_chars=max_chars, from_end=from_end, max_images=max_images)
|
||||||
elif doc_type == "image":
|
elif doc_type == "image":
|
||||||
@@ -453,7 +521,9 @@ def _dispatch_create(doc_type: str, file_path: str, content: str) -> str:
|
|||||||
return _create_tabular(file_path, content)
|
return _create_tabular(file_path, content)
|
||||||
elif doc_type == "docx":
|
elif doc_type == "docx":
|
||||||
return _create_docx(file_path, content)
|
return _create_docx(file_path, content)
|
||||||
return f"[ERROR] create 不支持文档类型: {doc_type} (仅支持 plain_text、tabular、docx)"
|
elif doc_type == "pptx":
|
||||||
|
return _create_pptx(file_path, content)
|
||||||
|
return f"[ERROR] create 不支持文档类型: {doc_type} (仅支持 plain_text、tabular、docx、pptx)"
|
||||||
|
|
||||||
|
|
||||||
def _dispatch_update(doc_type: str, file_path: str, **kwargs) -> str:
|
def _dispatch_update(doc_type: str, file_path: str, **kwargs) -> str:
|
||||||
@@ -492,16 +562,17 @@ class DocTool(BaseTool):
|
|||||||
args_schema: type[BaseModel] = DocToolInput
|
args_schema: type[BaseModel] = DocToolInput
|
||||||
name: str = "doc_tool"
|
name: str = "doc_tool"
|
||||||
description: str = (
|
description: str = (
|
||||||
"文档操作工具。支持纯文本(plain_text)、表格(tabular: CSV/XLSX)、Word(docx)、PDF 文档的增删改查,"
|
"文档操作工具。支持纯文本(plain_text)、表格(tabular: CSV/XLSX)、Word(docx)、PowerPoint(pptx)、PDF 文档的增删改查,"
|
||||||
"以及图片(image)、音频(audio)的读取。\n\n"
|
"以及图片(image)、音频(audio)的读取。\n\n"
|
||||||
"【支持的操作】\n"
|
"【支持的操作】\n"
|
||||||
"- read: 读取文档内容并带行号返回。支持所有类型。\n"
|
"- read: 读取文档内容并带行号返回。支持所有类型。\n"
|
||||||
" * 内容控制: max_chars(最大字符数)、from_end(True从末尾截取)、max_images(docx/pdf 最大图片数)。\n"
|
" * 内容控制: max_chars(最大字符数)、from_end(True从末尾截取)、max_images(docx/pdf/pptx 最大图片数)。\n"
|
||||||
" * plain_text grep: grep_pattern 为正则表达式,仅返回匹配的行(含原始行号)。\n"
|
" * plain_text grep: grep_pattern 为正则表达式,仅返回匹配的行(含原始行号)。\n"
|
||||||
"- create: 创建新文档。仅支持 plain_text、tabular、docx。\n"
|
"- create: 创建新文档。仅支持 plain_text、tabular、docx、pptx。\n"
|
||||||
" * plain_text/docx: content 为要写入的文本。\n"
|
" * plain_text/docx: content 为要写入的文本。\n"
|
||||||
" * tabular(.csv): content 为 CSV 格式文本。\n"
|
" * tabular(.csv): content 为 CSV 格式文本。\n"
|
||||||
" * tabular(.xlsx): content 为 JSON 二维数组字符串,如 '[['A','B'],['1','2']]'。\n"
|
" * tabular(.xlsx): content 为 JSON 二维数组字符串,如 '[['A','B'],['1','2']]'。\n"
|
||||||
|
" * pptx: content 为 SVG 幻灯片代码(与 read pptx 输出格式一致,含 <slide>N</slide> 和 <svg> 标签)。\n"
|
||||||
"- update: 修改文档。仅支持 plain_text、tabular、docx。\n"
|
"- update: 修改文档。仅支持 plain_text、tabular、docx。\n"
|
||||||
" * plain_text 行级操作: line_number + new_line_text 替换行;\n"
|
" * plain_text 行级操作: line_number + new_line_text 替换行;\n"
|
||||||
" line_number 单独使用删除行;\n"
|
" line_number 单独使用删除行;\n"
|
||||||
@@ -513,8 +584,9 @@ class DocTool(BaseTool):
|
|||||||
" * docx 表格操作: table_index + docx_table_row + docx_table_col + docx_table_cell_value。\n"
|
" * docx 表格操作: table_index + docx_table_row + docx_table_col + docx_table_cell_value。\n"
|
||||||
"- delete: 删除文档。支持所有类型。\n\n"
|
"- delete: 删除文档。支持所有类型。\n\n"
|
||||||
"【文档类型识别】\n"
|
"【文档类型识别】\n"
|
||||||
" plain_text: .txt .json .md .cpp .c .py .m .java 等\n"
|
" plain_text: .txt .json .md .cpp .c .py .m .java .html .svg 等\n"
|
||||||
" docx: .docx\n"
|
" docx: .docx\n"
|
||||||
|
" pptx: .pptx(read 返回 SVG 代码;create 用 SVG 代码生成;delete 删除)\n"
|
||||||
" pdf: .pdf\n"
|
" pdf: .pdf\n"
|
||||||
" tabular: .csv .xlsx\n"
|
" tabular: .csv .xlsx\n"
|
||||||
" image: .png .jpg .jpeg .bmp .gif .webp(仅 read,图片返回到 artifact)\n"
|
" image: .png .jpg .jpeg .bmp .gif .webp(仅 read,图片返回到 artifact)\n"
|
||||||
@@ -541,6 +613,7 @@ class DocTool(BaseTool):
|
|||||||
import base64
|
import base64
|
||||||
from agent.utils.image_utils import image_bytes_to_openai_image_url_part
|
from agent.utils.image_utils import image_bytes_to_openai_image_url_part
|
||||||
artifact["images"] = images
|
artifact["images"] = images
|
||||||
|
artifact["file_path"] = file_path
|
||||||
# 为前端展示生成 data URL 列表
|
# 为前端展示生成 data URL 列表
|
||||||
artifact["image_urls"] = [
|
artifact["image_urls"] = [
|
||||||
f"data:image/png;base64,{base64.b64encode(img).decode('ascii')}"
|
f"data:image/png;base64,{base64.b64encode(img).decode('ascii')}"
|
||||||
|
|||||||
@@ -0,0 +1,665 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import email
|
||||||
|
import imaplib
|
||||||
|
import json as _json
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import smtplib
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, time as dt_time
|
||||||
|
from email.header import decode_header, make_header
|
||||||
|
from email.message import EmailMessage, Message
|
||||||
|
from email.utils import formataddr, parsedate_to_datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from langchain.tools import BaseTool
|
||||||
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||||
|
|
||||||
|
from agent.core.config_manager import load_env_config
|
||||||
|
from agent.history.tool_call_recorder import (
|
||||||
|
get_current_session,
|
||||||
|
get_current_turn,
|
||||||
|
session_tool_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFAULT_READ_MAX_COUNT = 5
|
||||||
|
SEND_TIMEOUT = 30
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 邮箱提供商配置 ----------
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _ProviderConfig:
|
||||||
|
"""邮箱提供商的 SMTP / IMAP 连接参数。"""
|
||||||
|
name: str # 显示名称,如 "QQ邮箱"
|
||||||
|
smtp_host: str
|
||||||
|
smtp_port: int # SSL 端口
|
||||||
|
imap_host: str
|
||||||
|
imap_port: int # SSL 端口
|
||||||
|
|
||||||
|
|
||||||
|
_PROVIDERS: dict[str, _ProviderConfig] = {
|
||||||
|
"qq.com": _ProviderConfig("QQ邮箱", "smtp.qq.com", 465, "imap.qq.com", 993),
|
||||||
|
"163.com": _ProviderConfig("网易163邮箱", "smtp.163.com", 465, "imap.163.com", 993),
|
||||||
|
"126.com": _ProviderConfig("网易126邮箱", "smtp.126.com", 465, "imap.126.com", 993),
|
||||||
|
"yeah.net": _ProviderConfig("网易Yeah邮箱", "smtp.yeah.net", 465, "imap.yeah.net", 993),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_provider(email_address: str) -> _ProviderConfig:
|
||||||
|
"""根据邮箱地址自动检测提供商。
|
||||||
|
|
||||||
|
输入:
|
||||||
|
email_address: 如 "user@qq.com"、"user@163.com"。
|
||||||
|
|
||||||
|
输出:
|
||||||
|
_ProviderConfig 实例。
|
||||||
|
|
||||||
|
异常:
|
||||||
|
ValueError: 不支持的邮箱域名。
|
||||||
|
"""
|
||||||
|
email_address = (email_address or "").strip().lower()
|
||||||
|
if "@" not in email_address:
|
||||||
|
raise ValueError(
|
||||||
|
f"无法识别的邮箱地址格式: '{email_address}',"
|
||||||
|
f"请使用完整的邮箱地址(如 user@qq.com)。"
|
||||||
|
)
|
||||||
|
domain = email_address.split("@")[-1]
|
||||||
|
provider = _PROVIDERS.get(domain)
|
||||||
|
if provider is not None:
|
||||||
|
return provider
|
||||||
|
supported = ", ".join(_PROVIDERS.keys())
|
||||||
|
raise ValueError(
|
||||||
|
f"不支持的邮箱域名: @{domain}。"
|
||||||
|
f"当前支持: {supported}。"
|
||||||
|
f"请在环境配置中设置对应域名的邮箱地址。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_mime_header(value: str | None) -> str:
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
return str(make_header(decode_header(value)))
|
||||||
|
except Exception:
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_filename(name: str) -> str:
|
||||||
|
cleaned = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name or "").strip()
|
||||||
|
return cleaned or "attachment.bin"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_time_value(value: str | None, field_name: str) -> datetime | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
normalized = value.strip()
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
normalized = normalized.replace("/", "-")
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(normalized)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
for fmt in ("%Y-%m-%d", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"):
|
||||||
|
try:
|
||||||
|
return datetime.strptime(normalized, fmt)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
raise ValueError(f"{field_name} format is invalid. Use YYYY-MM-DD or YYYY-MM-DD HH:MM[:SS]")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_date_only(value: str | None) -> bool:
|
||||||
|
if not value:
|
||||||
|
return False
|
||||||
|
normalized = value.strip().replace("/", "-")
|
||||||
|
return bool(re.fullmatch(r"\d{4}-\d{1,2}-\d{1,2}", normalized))
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_read_window(
|
||||||
|
start_time: str | None,
|
||||||
|
end_time: str | None,
|
||||||
|
) -> tuple[datetime | None, datetime | None, str]:
|
||||||
|
start_dt = _parse_time_value(start_time, "start_time")
|
||||||
|
end_dt = _parse_time_value(end_time, "end_time")
|
||||||
|
mode = "explicit_range"
|
||||||
|
if start_dt and not end_dt and _is_date_only(start_time):
|
||||||
|
end_dt = start_dt + timedelta(days=1)
|
||||||
|
mode = "single_day_from_start_time"
|
||||||
|
elif start_dt and end_dt and _is_date_only(start_time) and _is_date_only(end_time) and start_dt == end_dt:
|
||||||
|
# 同一天(如 start="2026-07-08", end="2026-07-08"):视为查询该整天
|
||||||
|
end_dt = start_dt + timedelta(days=1)
|
||||||
|
mode = "single_day_explicit"
|
||||||
|
elif not start_dt and not end_dt:
|
||||||
|
mode = "default_window"
|
||||||
|
if start_dt and end_dt and end_dt <= start_dt:
|
||||||
|
raise ValueError("end_time must be later than start_time.")
|
||||||
|
return start_dt, end_dt, mode
|
||||||
|
|
||||||
|
|
||||||
|
def _get_mail_config_value(name: str, default: str = "") -> str:
|
||||||
|
env_value = os.getenv(name)
|
||||||
|
if env_value is not None and str(env_value).strip():
|
||||||
|
return str(env_value).strip()
|
||||||
|
try:
|
||||||
|
config = load_env_config()
|
||||||
|
value = config.get(name)
|
||||||
|
if value is not None and str(value).strip():
|
||||||
|
return str(value).strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_mail_config() -> tuple[str, str, _ProviderConfig, Path]:
|
||||||
|
address = _get_mail_config_value("EMAIL_ADDRESS")
|
||||||
|
auth_code = _get_mail_config_value("EMAIL_AUTH_CODE")
|
||||||
|
if not address:
|
||||||
|
raise ValueError("EMAIL_ADDRESS 未配置,请在设置 → 环境变量中填写邮箱地址。")
|
||||||
|
if not auth_code:
|
||||||
|
raise ValueError("EMAIL_AUTH_CODE 未配置,请在设置 → 环境变量中填写邮箱授权码。")
|
||||||
|
provider = _detect_provider(address)
|
||||||
|
session_id = (get_current_session() or "").strip()
|
||||||
|
turn_id = (get_current_turn() or "").strip()
|
||||||
|
if session_id and turn_id:
|
||||||
|
save_dir = session_tool_dir(session_id) / turn_id
|
||||||
|
else:
|
||||||
|
save_dir = Path(__file__).resolve().parents[1] / "history" / "tool_calling" / "mail_fallback"
|
||||||
|
save_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
return address, auth_code, provider, save_dir
|
||||||
|
|
||||||
|
|
||||||
|
def _format_mail_date(msg: Message) -> str:
|
||||||
|
raw_date = msg.get("Date")
|
||||||
|
if not raw_date:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
dt = parsedate_to_datetime(raw_date)
|
||||||
|
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
except Exception:
|
||||||
|
return raw_date
|
||||||
|
|
||||||
|
|
||||||
|
def _mail_datetime(msg: Message) -> datetime | None:
|
||||||
|
raw_date = msg.get("Date")
|
||||||
|
if not raw_date:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
dt = parsedate_to_datetime(raw_date)
|
||||||
|
if dt.tzinfo is not None:
|
||||||
|
return dt.astimezone().replace(tzinfo=None)
|
||||||
|
return dt
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_body_and_attachments(
|
||||||
|
msg: Message,
|
||||||
|
save_dir: Path,
|
||||||
|
) -> tuple[str, list[bytes], list[str]]:
|
||||||
|
text_parts: list[str] = []
|
||||||
|
image_bytes_list: list[bytes] = []
|
||||||
|
saved_attachment_paths: list[str] = []
|
||||||
|
|
||||||
|
for part in msg.walk():
|
||||||
|
if part.is_multipart():
|
||||||
|
continue
|
||||||
|
|
||||||
|
content_type = part.get_content_type()
|
||||||
|
disposition = (part.get_content_disposition() or "").lower()
|
||||||
|
filename = _decode_mime_header(part.get_filename())
|
||||||
|
payload = part.get_payload(decode=True) or b""
|
||||||
|
|
||||||
|
if disposition in {"attachment", "inline"} and filename:
|
||||||
|
safe_name = _sanitize_filename(filename)
|
||||||
|
target = save_dir / safe_name
|
||||||
|
stem = target.stem
|
||||||
|
suffix = target.suffix
|
||||||
|
counter = 1
|
||||||
|
while target.exists():
|
||||||
|
target = save_dir / f"{stem}_{counter}{suffix}"
|
||||||
|
counter += 1
|
||||||
|
target.write_bytes(payload)
|
||||||
|
saved_attachment_paths.append(str(target))
|
||||||
|
if content_type.startswith("image/"):
|
||||||
|
image_bytes_list.append(payload)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if content_type == "text/plain":
|
||||||
|
charset = part.get_content_charset() or "utf-8"
|
||||||
|
try:
|
||||||
|
text_parts.append(payload.decode(charset, errors="replace").strip())
|
||||||
|
except Exception:
|
||||||
|
text_parts.append(payload.decode("utf-8", errors="replace").strip())
|
||||||
|
elif content_type == "text/html" and not text_parts:
|
||||||
|
charset = part.get_content_charset() or "utf-8"
|
||||||
|
try:
|
||||||
|
html = payload.decode(charset, errors="replace")
|
||||||
|
except Exception:
|
||||||
|
html = payload.decode("utf-8", errors="replace")
|
||||||
|
html_text = re.sub(r"<[^>]+>", " ", html)
|
||||||
|
html_text = re.sub(r"\s+", " ", html_text).strip()
|
||||||
|
if html_text:
|
||||||
|
text_parts.append(html_text)
|
||||||
|
|
||||||
|
text = "\n".join(part for part in text_parts if part).strip()
|
||||||
|
return text, image_bytes_list, saved_attachment_paths
|
||||||
|
|
||||||
|
|
||||||
|
def _build_read_summary(
|
||||||
|
msg: Message,
|
||||||
|
body_text: str,
|
||||||
|
attachment_paths: list[str],
|
||||||
|
) -> str:
|
||||||
|
subject = _decode_mime_header(msg.get("Subject"))
|
||||||
|
sender = _decode_mime_header(msg.get("From"))
|
||||||
|
recipient = _decode_mime_header(msg.get("To"))
|
||||||
|
date_text = _format_mail_date(msg)
|
||||||
|
lines = [
|
||||||
|
f"Subject: {subject or '(no subject)'}",
|
||||||
|
f"From: {sender or '(unknown)'}",
|
||||||
|
f"To: {recipient or '(unknown)'}",
|
||||||
|
f"Date: {date_text or '(unknown)'}",
|
||||||
|
]
|
||||||
|
if body_text:
|
||||||
|
lines.append("Body:")
|
||||||
|
lines.append(body_text)
|
||||||
|
if attachment_paths:
|
||||||
|
lines.append("Attachments:")
|
||||||
|
lines.extend(f"- {path}" for path in attachment_paths)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_debug_block(lines: list[str]) -> str:
|
||||||
|
return "Read Debug:\n" + "\n".join(f"- {line}" for line in lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _send_mail(
|
||||||
|
sender_address: str,
|
||||||
|
auth_code: str,
|
||||||
|
provider: _ProviderConfig,
|
||||||
|
recipient: str,
|
||||||
|
subject: str,
|
||||||
|
content: str,
|
||||||
|
attachments: list[str] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""通过提供商 SMTP 发送邮件,支持附件。
|
||||||
|
|
||||||
|
输入:
|
||||||
|
sender_address: 发件人邮箱。
|
||||||
|
auth_code: 邮箱授权码(非登录密码)。
|
||||||
|
provider: 提供商配置。
|
||||||
|
recipient: 收件人。
|
||||||
|
subject: 主题。
|
||||||
|
content: 正文。
|
||||||
|
attachments: 可选附件文件路径列表。
|
||||||
|
|
||||||
|
输出:
|
||||||
|
成功信息字符串。
|
||||||
|
|
||||||
|
异常:
|
||||||
|
ConnectionError: 网络连接失败(含 DNS 解析失败、超时等)。
|
||||||
|
smtplib.SMTPAuthenticationError: 授权码错误。
|
||||||
|
smtplib.SMTPException: 其他 SMTP 错误。
|
||||||
|
"""
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg["From"] = formataddr(("Agent", sender_address))
|
||||||
|
msg["To"] = recipient
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg.set_content(content)
|
||||||
|
|
||||||
|
# 添加附件
|
||||||
|
attached_files: list[str] = []
|
||||||
|
for file_path in (attachments or []):
|
||||||
|
path = Path(file_path)
|
||||||
|
if not path.is_file():
|
||||||
|
return f"[ERROR] 附件不存在: {file_path}"
|
||||||
|
ctype, _encoding = mimetypes.guess_type(str(path))
|
||||||
|
if ctype is None:
|
||||||
|
ctype = "application/octet-stream"
|
||||||
|
main_type, sub_type = ctype.split("/", 1)
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
file_data = f.read()
|
||||||
|
msg.add_attachment(file_data, maintype=main_type, subtype=sub_type, filename=path.name)
|
||||||
|
attached_files.append(path.name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with smtplib.SMTP_SSL(provider.smtp_host, provider.smtp_port, timeout=SEND_TIMEOUT) as server:
|
||||||
|
server.login(sender_address, auth_code)
|
||||||
|
server.send_message(msg)
|
||||||
|
except smtplib.SMTPAuthenticationError:
|
||||||
|
raise smtplib.SMTPAuthenticationError(
|
||||||
|
-1,
|
||||||
|
f"[SMTP认证失败] {provider.name}({sender_address})授权码错误,"
|
||||||
|
f"请在设置 → 环境变量中检查 EMAIL_AUTH_CODE。"
|
||||||
|
)
|
||||||
|
except (socket.gaierror, socket.timeout, ConnectionRefusedError, OSError) as e:
|
||||||
|
raise ConnectionError(
|
||||||
|
f"[网络连接失败] 无法连接到 {provider.name} SMTP 服务器 "
|
||||||
|
f"({provider.smtp_host}:{provider.smtp_port})。\n"
|
||||||
|
f"原始错误: {e}\n"
|
||||||
|
f"请检查: 1) 网络是否正常 2) VPN/代理设置 3) 防火墙是否阻止连接。"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
result = f"[OK] 邮件发送成功({provider.name})\nTo: {recipient}\nSubject: {subject}"
|
||||||
|
if attached_files:
|
||||||
|
result += f"\n附件({len(attached_files)}): " + ", ".join(attached_files)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _search_read_targets(
|
||||||
|
mail: imaplib.IMAP4_SSL,
|
||||||
|
contact: str | None,
|
||||||
|
start_dt: datetime | None,
|
||||||
|
end_dt: datetime | None,
|
||||||
|
) -> tuple[list[bytes], str, bool]:
|
||||||
|
"""搜索匹配的邮件 ID 列表。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
(ids, search_query, has_time_filter): ids 列表、IMAP 查询字符串、是否指定了时间范围。
|
||||||
|
当指定了时间范围时,不再限制 UNSEEN,以覆盖已读邮件。
|
||||||
|
"""
|
||||||
|
criteria: list[str] = []
|
||||||
|
has_time_filter = start_dt is not None or end_dt is not None
|
||||||
|
if contact:
|
||||||
|
criteria.extend(['FROM', f'"{contact}"'])
|
||||||
|
elif not has_time_filter:
|
||||||
|
# 仅在未指定时间范围时才限制 UNSEEN;指定了时间范围的查询应覆盖全部邮件(含已读)
|
||||||
|
criteria.append("UNSEEN")
|
||||||
|
if start_dt is not None:
|
||||||
|
criteria.extend(["SINCE", start_dt.strftime("%d-%b-%Y")])
|
||||||
|
if end_dt is not None:
|
||||||
|
# IMAP BEFORE 是日期粒度的(不含当天 00:00),
|
||||||
|
# 若 end_dt 带时间分量(非 00:00:00)则需 +1 天以覆盖 end 当天
|
||||||
|
before_date = end_dt.date()
|
||||||
|
if end_dt.time() != dt_time(0, 0, 0):
|
||||||
|
before_date += timedelta(days=1)
|
||||||
|
criteria.extend(["BEFORE", before_date.strftime("%d-%b-%Y")])
|
||||||
|
search_query = " ".join(criteria) if criteria else "ALL"
|
||||||
|
if criteria:
|
||||||
|
status, data = mail.search(None, *criteria)
|
||||||
|
else:
|
||||||
|
status, data = mail.search(None, "ALL")
|
||||||
|
if status != "OK":
|
||||||
|
raise RuntimeError(f"IMAP search failed: {status}")
|
||||||
|
ids = data[0].split() if data and data[0] else []
|
||||||
|
return ids, search_query, has_time_filter
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_internaldate(fetch_data: list[tuple[bytes, bytes]] | list[bytes | tuple]) -> datetime | None:
|
||||||
|
"""从 IMAP fetch 响应中提取 INTERNALDATE 并解析为 datetime。
|
||||||
|
|
||||||
|
INTERNALDATE 格式示例: "08-Jul-2026 10:30:00 +0800"
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 将响应数据拼接为字符串,提取 INTERNALDATE 值
|
||||||
|
raw = b""
|
||||||
|
for item in fetch_data:
|
||||||
|
if isinstance(item, tuple):
|
||||||
|
raw += b" ".join(item)
|
||||||
|
elif isinstance(item, bytes):
|
||||||
|
raw += item
|
||||||
|
text = raw.decode("utf-8", errors="replace")
|
||||||
|
match = re.search(r'INTERNALDATE\s+"([^"]+)"', text)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
date_str = match.group(1)
|
||||||
|
# IMAP INTERNALDATE 格式: "DD-Mon-YYYY HH:MM:SS +ZZZZ"
|
||||||
|
return datetime.strptime(date_str, "%d-%b-%Y %H:%M:%S %z")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _read_mail(
|
||||||
|
address: str,
|
||||||
|
auth_code: str,
|
||||||
|
provider: _ProviderConfig,
|
||||||
|
save_dir: Path,
|
||||||
|
contact: str | None,
|
||||||
|
start_time: str | None,
|
||||||
|
end_time: str | None,
|
||||||
|
max_count: int,
|
||||||
|
) -> tuple[str, dict]:
|
||||||
|
stage = "prepare read filters"
|
||||||
|
started_at = time.perf_counter()
|
||||||
|
debug_lines: list[str] = []
|
||||||
|
try:
|
||||||
|
start_dt, end_dt, window_mode = _resolve_read_window(start_time, end_time)
|
||||||
|
if not contact and start_dt is None and end_dt is None:
|
||||||
|
start_dt = datetime.now() - timedelta(days=30)
|
||||||
|
window_mode = "default_last_30_days_unread"
|
||||||
|
debug_lines.append(f"window_mode={window_mode}")
|
||||||
|
debug_lines.append(f"contact_filter={contact or '(none)'}")
|
||||||
|
debug_lines.append(f"start_time={start_dt.strftime('%Y-%m-%d %H:%M:%S') if start_dt else '(none)'}")
|
||||||
|
debug_lines.append(f"end_time={end_dt.strftime('%Y-%m-%d %H:%M:%S') if end_dt else '(none)'}")
|
||||||
|
debug_lines.append(f"max_count={max_count}")
|
||||||
|
|
||||||
|
with imaplib.IMAP4_SSL(provider.imap_host, provider.imap_port) as mail:
|
||||||
|
stage = "login IMAP"
|
||||||
|
mail.login(address, auth_code)
|
||||||
|
debug_lines.append("imap_login=ok")
|
||||||
|
stage = "open inbox"
|
||||||
|
select_status, _ = mail.select("INBOX")
|
||||||
|
if select_status != "OK":
|
||||||
|
raise RuntimeError("Failed to open INBOX.")
|
||||||
|
debug_lines.append("open_inbox=ok")
|
||||||
|
|
||||||
|
stage = "search messages"
|
||||||
|
ids, search_query, has_time_filter = _search_read_targets(mail, contact, start_dt, end_dt)
|
||||||
|
debug_lines.append(f"imap_query={search_query}")
|
||||||
|
debug_lines.append(f"matched_count={len(ids)}")
|
||||||
|
if not ids:
|
||||||
|
debug_lines.append(f"elapsed_seconds={time.perf_counter() - started_at:.2f}")
|
||||||
|
return "[OK] No matching emails found.\n\n" + _format_debug_block(debug_lines), {}
|
||||||
|
|
||||||
|
# 当有时间范围时 IMAP 搜索可能不生效,需在 Python 侧兜底过滤
|
||||||
|
# 从最新往旧遍历,直到找够 max_count 条或遍历完所有 ID
|
||||||
|
summaries: list[str] = []
|
||||||
|
image_bytes_list: list[bytes] = []
|
||||||
|
fetched_count = 0
|
||||||
|
skipped_count = 0
|
||||||
|
date_fallback_count = 0
|
||||||
|
scanned_count = 0
|
||||||
|
|
||||||
|
# 从最新到最旧遍历
|
||||||
|
max_scan = max(max_count * 10, 200)
|
||||||
|
for mail_id in reversed(ids):
|
||||||
|
if fetched_count >= max_count:
|
||||||
|
break
|
||||||
|
if scanned_count >= max_scan:
|
||||||
|
break
|
||||||
|
scanned_count += 1
|
||||||
|
stage = f"fetch message {scanned_count}"
|
||||||
|
status, data = mail.fetch(mail_id, "(RFC822 INTERNALDATE)")
|
||||||
|
if status != "OK" or not data or not data[0]:
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
raw_bytes = data[0][1]
|
||||||
|
msg = email.message_from_bytes(raw_bytes)
|
||||||
|
mail_dt = _mail_datetime(msg)
|
||||||
|
# Date header 无法解析时,用 IMAP INTERNALDATE 兜底
|
||||||
|
if mail_dt is None:
|
||||||
|
internal_dt = _parse_internaldate(data)
|
||||||
|
if internal_dt is not None:
|
||||||
|
mail_dt = internal_dt.astimezone().replace(tzinfo=None) if internal_dt.tzinfo else internal_dt
|
||||||
|
date_fallback_count += 1
|
||||||
|
# 时间窗口过滤
|
||||||
|
if has_time_filter and mail_dt is None:
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
if start_dt and mail_dt and mail_dt < start_dt:
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
if end_dt and mail_dt and mail_dt >= end_dt:
|
||||||
|
skipped_count += 1
|
||||||
|
continue
|
||||||
|
stage = f"parse message {scanned_count}"
|
||||||
|
body_text, image_bytes, attachment_paths = _extract_body_and_attachments(msg, save_dir)
|
||||||
|
image_bytes_list.extend(image_bytes)
|
||||||
|
summaries.append(_build_read_summary(msg, body_text, attachment_paths))
|
||||||
|
fetched_count += 1
|
||||||
|
|
||||||
|
debug_lines.append(f"scanned_count={scanned_count}")
|
||||||
|
debug_lines.append(f"fetched_count={fetched_count}")
|
||||||
|
debug_lines.append(f"skipped_count={skipped_count}")
|
||||||
|
if date_fallback_count:
|
||||||
|
debug_lines.append(f"date_fallback_count={date_fallback_count}")
|
||||||
|
debug_lines.append(f"saved_image_count={len(image_bytes_list)}")
|
||||||
|
debug_lines.append(f"elapsed_seconds={time.perf_counter() - started_at:.2f}")
|
||||||
|
|
||||||
|
if not summaries:
|
||||||
|
return (
|
||||||
|
"[OK] Matching emails were searched, but no messages remained after filtering.\n\n"
|
||||||
|
+ _format_debug_block(debug_lines),
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
artifact: dict = {}
|
||||||
|
if image_bytes_list:
|
||||||
|
artifact["images"] = image_bytes_list
|
||||||
|
artifact["image_urls"] = [
|
||||||
|
f"data:{mimetypes.guess_type('x.png')[0] or 'image/png'};base64,{base64.b64encode(img).decode('ascii')}"
|
||||||
|
for img in image_bytes_list
|
||||||
|
]
|
||||||
|
return _format_debug_block(debug_lines) + "\n\n" + ("\n" + ("-" * 60) + "\n\n").join(summaries), artifact
|
||||||
|
except Exception as exc:
|
||||||
|
debug_lines.append(f"failed_stage={stage}")
|
||||||
|
debug_lines.append(f"elapsed_seconds={time.perf_counter() - started_at:.2f}")
|
||||||
|
raise RuntimeError(f"read failed at stage '{stage}': {exc}\n\n{_format_debug_block(debug_lines)}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
class MailToolInput(BaseModel):
|
||||||
|
action: Literal["send", "read"] = Field(..., description="Required action: send or read.")
|
||||||
|
contact: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Recipient email address for send, or sender filter for read.",
|
||||||
|
)
|
||||||
|
start_time: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Optional read start time. Format: YYYY-MM-DD or YYYY-MM-DD HH:MM[:SS]. If only a date is given and end_time is empty, it means that single day.",
|
||||||
|
)
|
||||||
|
end_time: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Optional read end time. Format: YYYY-MM-DD or YYYY-MM-DD HH:MM[:SS].",
|
||||||
|
)
|
||||||
|
max_count: int = Field(
|
||||||
|
default=DEFAULT_READ_MAX_COUNT,
|
||||||
|
description=f"Maximum number of newest matched emails to read. Default is {DEFAULT_READ_MAX_COUNT}.",
|
||||||
|
)
|
||||||
|
subject: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Required subject when action=send.",
|
||||||
|
)
|
||||||
|
content: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Required body content when action=send.",
|
||||||
|
)
|
||||||
|
attachments: Optional[list[str]] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Optional list of local file paths to attach when action=send.",
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("attachments", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _coerce_attachments(cls, v: object) -> list[str] | None:
|
||||||
|
"""LLM 可能将 attachments 传为 JSON 字符串,在此处转为列表。"""
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
if isinstance(v, list):
|
||||||
|
return v
|
||||||
|
if isinstance(v, str):
|
||||||
|
stripped = v.strip()
|
||||||
|
if stripped.startswith("["):
|
||||||
|
try:
|
||||||
|
parsed = _json.loads(stripped)
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
return parsed
|
||||||
|
except (_json.JSONDecodeError, TypeError):
|
||||||
|
pass
|
||||||
|
# 单文件路径字符串 → 转为单元素列表
|
||||||
|
return [stripped]
|
||||||
|
return [str(v)]
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _validate_inputs(self) -> "MailToolInput":
|
||||||
|
if self.action == "send":
|
||||||
|
if not self.contact:
|
||||||
|
raise ValueError("contact is required when action=send.")
|
||||||
|
if not self.subject:
|
||||||
|
raise ValueError("subject is required when action=send.")
|
||||||
|
if not self.content:
|
||||||
|
raise ValueError("content is required when action=send.")
|
||||||
|
if self.action == "read":
|
||||||
|
if self.max_count <= 0:
|
||||||
|
raise ValueError("max_count must be greater than 0 when action=read.")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class MailTool(BaseTool):
|
||||||
|
args_schema: type[BaseModel] = MailToolInput
|
||||||
|
name: str = "mail_tool"
|
||||||
|
description: str = (
|
||||||
|
"Email tool supporting QQ Mail (qq.com) and Netease Mail (163.com / 126.com / yeah.net).\n"
|
||||||
|
"Automatically detects the email provider from EMAIL_ADDRESS in env config.\n"
|
||||||
|
"- action='send': requires contact(recipient email), subject, content. Optionally attachments (local file paths).\n"
|
||||||
|
"- action='read': optional contact(sender filter), optional start_time/end_time, and optional max_count.\n"
|
||||||
|
"- If read only provides start_time as a date like 2026-07-08, it reads that full day.\n"
|
||||||
|
f"- If read does not specify contact or time range, it defaults to unread emails from the last 30 days, limited to the newest {DEFAULT_READ_MAX_COUNT} messages.\n"
|
||||||
|
"- Images are returned as artifact. Attachments are saved locally and their paths are added to the text result."
|
||||||
|
)
|
||||||
|
response_format: Literal["content", "content_and_artifact"] = "content_and_artifact"
|
||||||
|
|
||||||
|
def _run(
|
||||||
|
self,
|
||||||
|
action: str,
|
||||||
|
contact: str | None = None,
|
||||||
|
start_time: str | None = None,
|
||||||
|
end_time: str | None = None,
|
||||||
|
max_count: int = DEFAULT_READ_MAX_COUNT,
|
||||||
|
subject: str | None = None,
|
||||||
|
content: str | None = None,
|
||||||
|
attachments: list[str] | None = None,
|
||||||
|
) -> tuple[str, dict]:
|
||||||
|
stage = "initialize"
|
||||||
|
try:
|
||||||
|
stage = "load mail config"
|
||||||
|
address, auth_code, provider, save_dir = _ensure_mail_config()
|
||||||
|
normalized_action = (action or "").strip().lower()
|
||||||
|
if normalized_action == "send":
|
||||||
|
stage = "send email"
|
||||||
|
result = _send_mail(
|
||||||
|
sender_address=address,
|
||||||
|
auth_code=auth_code,
|
||||||
|
provider=provider,
|
||||||
|
recipient=(contact or "").strip(),
|
||||||
|
subject=(subject or "").strip(),
|
||||||
|
content=content or "",
|
||||||
|
attachments=attachments,
|
||||||
|
)
|
||||||
|
return result, {}
|
||||||
|
|
||||||
|
if normalized_action == "read":
|
||||||
|
stage = "read email"
|
||||||
|
return _read_mail(
|
||||||
|
address=address,
|
||||||
|
auth_code=auth_code,
|
||||||
|
provider=provider,
|
||||||
|
save_dir=save_dir,
|
||||||
|
contact=(contact or "").strip() or None,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
max_count=max_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"[ERROR] Unsupported action: {action}", {}
|
||||||
|
except Exception as exc:
|
||||||
|
return f"[ERROR] mail_tool failed at stage '{stage}': {exc}", {}
|
||||||
|
|
||||||
|
async def _arun(self, **kwargs) -> tuple[str, dict]:
|
||||||
|
return await asyncio.to_thread(self._run, **kwargs)
|
||||||
+48
-21
@@ -2,6 +2,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import re
|
import re
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
@@ -523,32 +524,58 @@ class RAGTool(BaseTool):
|
|||||||
return "清除操作必须提供 filter 条件。", {}
|
return "清除操作必须提供 filter 条件。", {}
|
||||||
|
|
||||||
coll = get_global_collection()
|
coll = get_global_collection()
|
||||||
where = None
|
|
||||||
|
# 处理 filter:若为 JSON 字符串化的列表,尝试解析
|
||||||
if isinstance(filter, str):
|
if isinstance(filter, str):
|
||||||
where = {"source": {"$contains": filter}}
|
try:
|
||||||
elif isinstance(filter, list) and len(filter) > 0:
|
parsed = json.loads(filter)
|
||||||
where = {"source": {"$contains": filter[0]}}
|
if isinstance(parsed, list):
|
||||||
|
filter = parsed
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
if not where:
|
# 统一为列表
|
||||||
return "无法构建有效的过滤条件。", {}
|
if isinstance(filter, str):
|
||||||
|
filter_terms = [filter]
|
||||||
|
elif isinstance(filter, list):
|
||||||
|
filter_terms = [f for f in filter if f]
|
||||||
|
else:
|
||||||
|
return "无效的过滤条件。", {}
|
||||||
|
|
||||||
|
if not filter_terms:
|
||||||
|
return "清除操作必须提供 filter 条件。", {}
|
||||||
|
|
||||||
|
# 获取所有文档,在 Python 侧做子串匹配(避免 ChromaDB $contains 对中文的兼容问题)
|
||||||
try:
|
try:
|
||||||
res = coll.get(where=where)
|
all_data = coll.get()
|
||||||
if res["ids"]:
|
|
||||||
# 清理关联的图片文件
|
|
||||||
for meta in res.get("metadatas", []):
|
|
||||||
img_path = meta.get("image_path", "")
|
|
||||||
if img_path and os.path.isfile(img_path):
|
|
||||||
try:
|
|
||||||
os.remove(img_path)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
coll.delete(ids=res["ids"])
|
|
||||||
return f"已从全局知识库中删除 {len(res['ids'])} 条记录。", {}
|
|
||||||
else:
|
|
||||||
return "未找到匹配的记录。", {}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"清除时出错: {e}", {}
|
return f"获取知识库数据失败: {e}", {}
|
||||||
|
|
||||||
|
if not all_data or not all_data["ids"]:
|
||||||
|
return "知识库中暂无数据。", {}
|
||||||
|
|
||||||
|
ids_to_delete = []
|
||||||
|
for i, meta in enumerate(all_data.get("metadatas", [])):
|
||||||
|
source = meta.get("source", "")
|
||||||
|
# 任一 filter_terms 是 source 的子串即匹配
|
||||||
|
if any(term in source for term in filter_terms):
|
||||||
|
ids_to_delete.append(all_data["ids"][i])
|
||||||
|
|
||||||
|
if not ids_to_delete:
|
||||||
|
return f"未找到匹配 '{filter_terms[0] if len(filter_terms) == 1 else str(filter_terms)}' 的记录。", {}
|
||||||
|
|
||||||
|
# 清理关联的图片文件
|
||||||
|
for i, meta in enumerate(all_data.get("metadatas", [])):
|
||||||
|
if all_data["ids"][i] in ids_to_delete:
|
||||||
|
img_path = meta.get("image_path", "")
|
||||||
|
if img_path and os.path.isfile(img_path):
|
||||||
|
try:
|
||||||
|
os.remove(img_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
coll.delete(ids=ids_to_delete)
|
||||||
|
return f"已从全局知识库中删除 {len(ids_to_delete)} 条记录。", {}
|
||||||
|
|
||||||
# ---------- 列出所有来源 ----------
|
# ---------- 列出所有来源 ----------
|
||||||
def _handle_list_sources(self) -> Tuple[str, dict]:
|
def _handle_list_sources(self) -> Tuple[str, dict]:
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from pydantic import BaseModel, Field, create_model
|
|||||||
class WebSearch(BaseTool):
|
class WebSearch(BaseTool):
|
||||||
|
|
||||||
args_schema : type[BaseModel] = create_model("Input_type", query=(str, Field(description="搜索查询内容")))
|
args_schema : type[BaseModel] = create_model("Input_type", query=(str, Field(description="搜索查询内容")))
|
||||||
name : str = "web_search_class"
|
name : str = "web_search"
|
||||||
description : str = "一个网络搜索工具,可以根据用户的查询返回相关的搜索结果。仅当需要查询近期知识或已有知识无法解答需要联网查询时调用,其余任何情况严禁调用。"
|
description : str = "一个网络搜索工具,可以根据用户的查询返回相关的搜索结果。仅当需要查询近期知识或已有知识无法解答需要联网查询时调用,其余任何情况严禁调用。"
|
||||||
|
|
||||||
def _get_search_wrapper(self):
|
def _get_search_wrapper(self):
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -41,8 +41,10 @@ AUDIO_FILE_EXTENSIONS = frozenset({".wav", ".mp3", ".flac", ".ogg", ".m4a", ".aa
|
|||||||
# 扩展名到内部类型标签的映射
|
# 扩展名到内部类型标签的映射
|
||||||
DOC_EXT_MAP = {
|
DOC_EXT_MAP = {
|
||||||
".txt": "plain_text", ".json": "plain_text", ".md": "plain_text",".cpp": "plain_text",
|
".txt": "plain_text", ".json": "plain_text", ".md": "plain_text",".cpp": "plain_text",
|
||||||
".c": "plain_text", ".py": "plain_text", ".m": "plain_text", ".java": "plain_text",
|
".c": "plain_text", ".py": "plain_text", ".m": "plain_text", ".java": "plain_text",".html": "plain_text",
|
||||||
|
".svg": "plain_text",
|
||||||
".docx": "docx",
|
".docx": "docx",
|
||||||
|
".pptx": "pptx",
|
||||||
".pdf": "pdf",
|
".pdf": "pdf",
|
||||||
".csv": "tabular",
|
".csv": "tabular",
|
||||||
".xlsx": "tabular",
|
".xlsx": "tabular",
|
||||||
@@ -649,6 +651,39 @@ class Documents_process:
|
|||||||
|
|
||||||
return "\n".join(text_parts), image_bytes_list
|
return "\n".join(text_parts), image_bytes_list
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def process_pptx(file_path: str) -> tuple[str, list]:
|
||||||
|
"""将 PPTX 每一页转为 SVG 代码 + 提取内嵌图片。
|
||||||
|
|
||||||
|
使用本地 ppt_utils.py 引擎,将每个幻灯片转换为自包含的 SVG,
|
||||||
|
同时收集所有内嵌图片字节。
|
||||||
|
|
||||||
|
输入: file_path .pptx 路径。
|
||||||
|
输出: (按 <slide>N</slide> 分隔的 SVG 代码文本, 图片字节列表)。
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
from agent.utils.ppt_utils import convert_pptx_to_svg
|
||||||
|
|
||||||
|
pptx_path = Path(file_path)
|
||||||
|
result = convert_pptx_to_svg(pptx_path)
|
||||||
|
|
||||||
|
# 收集所有图片字节
|
||||||
|
image_bytes_list: list[bytes] = []
|
||||||
|
for art in result.slides:
|
||||||
|
for blob in art.media_files.values():
|
||||||
|
image_bytes_list.append(blob)
|
||||||
|
|
||||||
|
# 组装 SVG 文本
|
||||||
|
slide_parts = []
|
||||||
|
for art in result.slides:
|
||||||
|
slide_parts.append(f"<slide>{art.index}</slide>\n{art.svg.strip()}")
|
||||||
|
|
||||||
|
text = "\n\n".join(slide_parts)
|
||||||
|
if not text:
|
||||||
|
text = "(无法解析 PPTX 内容)"
|
||||||
|
|
||||||
|
return text, image_bytes_list
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def process_tabular(file_path: str) -> tuple[str, list]:
|
def process_tabular(file_path: str) -> tuple[str, list]:
|
||||||
"""将 CSV / xlsx 转为 <table> 标记的表格文本。
|
"""将 CSV / xlsx 转为 <table> 标记的表格文本。
|
||||||
@@ -730,6 +765,8 @@ class Documents_process:
|
|||||||
return Documents_process.process_plain_text(file_path)
|
return Documents_process.process_plain_text(file_path)
|
||||||
elif file_type == "docx":
|
elif file_type == "docx":
|
||||||
return Documents_process.process_docx(file_path)
|
return Documents_process.process_docx(file_path)
|
||||||
|
elif file_type == "pptx":
|
||||||
|
return Documents_process.process_pptx(file_path)
|
||||||
elif file_type == "pdf":
|
elif file_type == "pdf":
|
||||||
return Documents_process.process_pdf(file_path)
|
return Documents_process.process_pdf(file_path)
|
||||||
elif file_type == "image":
|
elif file_type == "image":
|
||||||
|
|||||||
@@ -1,302 +0,0 @@
|
|||||||
"""PostgreSQL 元数据查询与只读 SQL 执行(供 Agent 工具层使用)。
|
|
||||||
|
|
||||||
系统定位:
|
|
||||||
为数据库相关 Tool 提供安全封装的 schema introspection 与 SELECT 查询,
|
|
||||||
结果统一 JSON 序列化便于模型消费。
|
|
||||||
|
|
||||||
安全策略:
|
|
||||||
execute_query / validate_query 仅允许 SELECT 与 WITH 语句。
|
|
||||||
|
|
||||||
可扩展性:
|
|
||||||
- 可配置 fetch 行数上限、连接池参数
|
|
||||||
- 注释掉的 rename_table / update_table_comment 可按需启用并加权限控制
|
|
||||||
"""
|
|
||||||
from sqlalchemy import create_engine, inspect, text
|
|
||||||
import json
|
|
||||||
from typing import Optional, List
|
|
||||||
from uuid import UUID
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
class JSONEncoder(json.JSONEncoder):
|
|
||||||
"""扩展 json.JSONEncoder,支持 UUID、datetime 及带 __dict__ 的对象。
|
|
||||||
|
|
||||||
系统定位: PostgreSQL_Manager 各方法 dumps 时的默认编码器。
|
|
||||||
|
|
||||||
可扩展性: 可增加 Decimal、date、Enum 等类型分支。
|
|
||||||
"""
|
|
||||||
|
|
||||||
def default(self, obj):
|
|
||||||
"""将 UUID、datetime 及带 __dict__ 的对象转为 JSON 可编码值。"""
|
|
||||||
if isinstance(obj, UUID):
|
|
||||||
return str(obj)
|
|
||||||
elif isinstance(obj, datetime):
|
|
||||||
return obj.isoformat()
|
|
||||||
elif hasattr(obj, '__dict__'):
|
|
||||||
return str(obj)
|
|
||||||
return super().default(obj)
|
|
||||||
|
|
||||||
def make_json_serializable(obj):
|
|
||||||
"""递归将嵌套结构转为 JSON 可序列化形式。
|
|
||||||
|
|
||||||
输入:
|
|
||||||
obj: dict | list | UUID | datetime | 任意对象。
|
|
||||||
|
|
||||||
输出:
|
|
||||||
仅含 JSON 兼容类型的树结构。
|
|
||||||
|
|
||||||
系统定位:
|
|
||||||
get_table_schema / get_table_comments 返回前清洗 SQLAlchemy 反射结果。
|
|
||||||
|
|
||||||
可扩展性:
|
|
||||||
与 JSONEncoder.default 保持类型处理一致。
|
|
||||||
"""
|
|
||||||
if isinstance(obj, dict):
|
|
||||||
return {k: make_json_serializable(v) for k, v in obj.items()}
|
|
||||||
elif isinstance(obj, (list, tuple)):
|
|
||||||
return [make_json_serializable(item) for item in obj]
|
|
||||||
elif isinstance(obj, UUID):
|
|
||||||
return str(obj)
|
|
||||||
elif isinstance(obj, datetime):
|
|
||||||
return obj.isoformat()
|
|
||||||
elif hasattr(obj, '__dict__'):
|
|
||||||
return str(obj)
|
|
||||||
else:
|
|
||||||
return obj
|
|
||||||
|
|
||||||
class PostgreSQL_Manager:
|
|
||||||
"""PostgreSQL 连接池管理与只读元数据/查询 API。
|
|
||||||
|
|
||||||
系统定位:
|
|
||||||
Agent 数据库工具的后端实现类。
|
|
||||||
|
|
||||||
可扩展性:
|
|
||||||
可注入 engine、增加读写分离、审计日志。
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, connection_params):
|
|
||||||
"""创建 SQLAlchemy 引擎与连接池。
|
|
||||||
|
|
||||||
输入:
|
|
||||||
connection_params: SQLAlchemy URL,如
|
|
||||||
postgresql+psycopg2://user:pass@host:5432/dbname
|
|
||||||
|
|
||||||
输出:
|
|
||||||
无(设置 self.engine)。
|
|
||||||
|
|
||||||
系统定位:
|
|
||||||
工具初始化时构造单例或按请求构造。
|
|
||||||
|
|
||||||
可扩展性:
|
|
||||||
pool_size、pool_recycle 可改为构造参数或环境变量。
|
|
||||||
"""
|
|
||||||
self.engine = create_engine(connection_params, pool_size=5, pool_recycle=7200)
|
|
||||||
|
|
||||||
def get_table_names(self) -> list[str]:
|
|
||||||
"""列出当前库中所有表名。
|
|
||||||
|
|
||||||
输入: 无(使用 self.engine)。
|
|
||||||
|
|
||||||
输出:
|
|
||||||
表名字符串列表;异常时返回空列表并打印错误。
|
|
||||||
|
|
||||||
系统定位:
|
|
||||||
Agent 探索库结构的第一步。
|
|
||||||
|
|
||||||
可扩展性:
|
|
||||||
可过滤 schema、排除系统表。
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# 使用SQLAlchemy的inspect功能获取数据库表名
|
|
||||||
inspector = inspect(self.engine)
|
|
||||||
return inspector.get_table_names()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error occurred while fetching table names: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
def get_table_comments(self, table_name: str = None) -> str:
|
|
||||||
"""获取表 COMMENT 元数据并返回 JSON 字符串。
|
|
||||||
|
|
||||||
输入:
|
|
||||||
table_name: 指定表名;None 表示全部表。
|
|
||||||
|
|
||||||
输出:
|
|
||||||
JSON 字符串;错误时含 error 字段。
|
|
||||||
|
|
||||||
系统定位:
|
|
||||||
供 LLM 理解业务表含义。
|
|
||||||
|
|
||||||
可扩展性:
|
|
||||||
可合并列级 comment。
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
inspector = inspect(self.engine)
|
|
||||||
if table_name is None: # 返回所有表的注释
|
|
||||||
table_names = inspector.get_table_names()
|
|
||||||
comments_dict = {table: inspector.get_table_comment(table).get('text', '') for table in table_names}
|
|
||||||
serializable_dict = make_json_serializable(comments_dict)
|
|
||||||
return json.dumps(serializable_dict, ensure_ascii=False, indent=2, cls=JSONEncoder)
|
|
||||||
else:
|
|
||||||
comment = inspector.get_table_comment(table_name).get('text', '')
|
|
||||||
result = {"table_name": table_name, "comment": comment}
|
|
||||||
serializable_result = make_json_serializable(result)
|
|
||||||
return json.dumps(serializable_result, ensure_ascii=False, indent=2, cls=JSONEncoder)
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = {"error": f"Error occurred while fetching table comments: {str(e)}"}
|
|
||||||
return json.dumps(error_msg, ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
def get_table_schema(self, table_names: Optional[List[str]] = None) -> str:
|
|
||||||
"""反射表结构(列、主键、外键、索引)并返回 JSON。
|
|
||||||
|
|
||||||
输入:
|
|
||||||
table_names: 表名列表;None 表示所有表。
|
|
||||||
|
|
||||||
输出:
|
|
||||||
JSON 字符串,每表含 columns/primary_keys/foreign_keys/indexes。
|
|
||||||
|
|
||||||
系统定位:
|
|
||||||
Text-to-SQL 或数据分析前的 schema 上下文。
|
|
||||||
|
|
||||||
可扩展性:
|
|
||||||
可增加视图、物化视图、枚举类型信息。
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
inspector = inspect(self.engine)
|
|
||||||
table_schema = {}
|
|
||||||
names_to_query = table_names if table_names else inspector.get_table_names()
|
|
||||||
for table_name in names_to_query:
|
|
||||||
columns = inspector.get_columns(table_name)
|
|
||||||
pk_constraints = inspector.get_pk_constraint(table_name)
|
|
||||||
primary_keys = pk_constraints['constrained_columns'] if pk_constraints else []
|
|
||||||
foreign_keys = inspector.get_foreign_keys(table_name)
|
|
||||||
indexes = inspector.get_indexes(table_name)
|
|
||||||
table_schema[table_name] = {
|
|
||||||
'columns': columns,
|
|
||||||
'primary_keys': primary_keys,
|
|
||||||
'foreign_keys': foreign_keys,
|
|
||||||
'indexes': indexes
|
|
||||||
}
|
|
||||||
# 转换为JSON序列化格式
|
|
||||||
serializable_schema = make_json_serializable(table_schema)
|
|
||||||
return json.dumps(serializable_schema, ensure_ascii=False, indent=2, cls=JSONEncoder)
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = {"error": f"Error occurred while fetching table schema: {str(e)}"}
|
|
||||||
return json.dumps(error_msg, ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
def execute_query(self, query: str) -> str:
|
|
||||||
"""执行只读 SQL 并返回最多 100 行结果的 JSON。
|
|
||||||
|
|
||||||
输入:
|
|
||||||
query: 必须以 select 或 with 开头(大小写不敏感)。
|
|
||||||
|
|
||||||
输出:
|
|
||||||
JSON 结果数组或 error/message 对象。
|
|
||||||
|
|
||||||
系统定位:
|
|
||||||
Agent 安全查询数据;禁止 DML/DDL。
|
|
||||||
|
|
||||||
可扩展性:
|
|
||||||
fetch 行数、超时、只读事务隔离级别可配置。
|
|
||||||
"""
|
|
||||||
# 安全检查,防止执行危险的SQL语句,仅允许select和with查询语句
|
|
||||||
if not query.strip().lower().startswith(('select', 'with')):
|
|
||||||
error_msg = {"error": "Only SELECT and WITH queries are allowed."}
|
|
||||||
return json.dumps(error_msg, ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with self.engine.connect() as conn:
|
|
||||||
result = conn.execute(text(query))
|
|
||||||
# 获取列名
|
|
||||||
columns = result.keys()
|
|
||||||
# 获取数据(限制返回数据量防止内存溢出)
|
|
||||||
rows = result.fetchmany(100) # 只获取前100行数据
|
|
||||||
if not rows:
|
|
||||||
return json.dumps({"message": "Query executed successfully, no rows returned."}, ensure_ascii=False, indent=2)
|
|
||||||
else:
|
|
||||||
result_data = []
|
|
||||||
for row in rows:
|
|
||||||
row_dict = dict(zip(columns, row))
|
|
||||||
result_data.append(row_dict)
|
|
||||||
# 转换为JSON序列化格式
|
|
||||||
serializable_data = make_json_serializable(result_data)
|
|
||||||
return json.dumps(serializable_data, ensure_ascii=False, indent=2, cls=JSONEncoder)
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = {"error": f"Error occurred while executing query: {str(e)}"}
|
|
||||||
return json.dumps(error_msg, ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
def validate_query(self, query: str) -> str:
|
|
||||||
"""用 EXPLAIN(PostgreSQL)或试执行验证 SQL 合法性。
|
|
||||||
|
|
||||||
输入:
|
|
||||||
query: 只读 SQL 字符串。
|
|
||||||
|
|
||||||
输出:
|
|
||||||
JSON:成功含 message;失败含 error。
|
|
||||||
|
|
||||||
系统定位:
|
|
||||||
执行前的预检,减少无效查询。
|
|
||||||
|
|
||||||
可扩展性:
|
|
||||||
非 PG 方言可切换验证策略。
|
|
||||||
"""
|
|
||||||
if not query or not query.strip():
|
|
||||||
error_msg = {"error": f"Error occurred while validating query: Query is must not be empty."}
|
|
||||||
return json.dumps(error_msg, ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
# 简单的安全检查,防止执行危险的SQL语句,仅允许select和with查询语句
|
|
||||||
if not query.strip().lower().startswith(('select', 'with')):
|
|
||||||
error_msg = {"error": f"Error occurred while validating query: Only SELECT and WITH queries are allowed."}
|
|
||||||
return json.dumps(error_msg, ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with self.engine.connect() as conn:
|
|
||||||
if self.engine.dialect.name == 'postgresql':
|
|
||||||
# 使用EXPLAIN分析查询计划来验证查询的合法性
|
|
||||||
explain_query = f"EXPLAIN {query}"
|
|
||||||
else:
|
|
||||||
# 对于其他数据库,可以直接执行查询但不获取结果来验证合法性
|
|
||||||
explain_query = query
|
|
||||||
conn.execute(text(explain_query))
|
|
||||||
return json.dumps({"message": "Query is valid."}, ensure_ascii=False, indent=2)
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = {"error": f"Error occurred while validating query: {str(e)}"}
|
|
||||||
return json.dumps(error_msg, ensure_ascii=False, indent=2)
|
|
||||||
if __name__ == "__main__":
|
|
||||||
DB_CONFIG = {
|
|
||||||
"host": "localhost",
|
|
||||||
"port": 5432,
|
|
||||||
"database": "agent_controller",
|
|
||||||
"user": "postgres",
|
|
||||||
"password": "12345678"
|
|
||||||
}
|
|
||||||
connection_params = f"postgresql+psycopg2://{DB_CONFIG['user']}:{DB_CONFIG['password']}@{DB_CONFIG['host']}:{DB_CONFIG['port']}/{DB_CONFIG['database']}?client_encoding=utf8"
|
|
||||||
db_manager = PostgreSQL_Manager(connection_params)
|
|
||||||
|
|
||||||
# 获取并打印所有表名和注释(JSON格式)
|
|
||||||
print("All table comments (JSON):")
|
|
||||||
all_comments_json = db_manager.get_table_comments()
|
|
||||||
print(all_comments_json)
|
|
||||||
|
|
||||||
# 示例:获取特定表的注释(JSON格式)
|
|
||||||
# table_names = db_manager.get_table_names()
|
|
||||||
# if table_names:
|
|
||||||
# print(f"\nSpecific table comment for '{table_names[0]}' (JSON):")
|
|
||||||
# specific_comment_json = db_manager.get_table_comments(table_names[0])
|
|
||||||
# print(specific_comment_json)
|
|
||||||
|
|
||||||
# table_name = "result_artifacts"
|
|
||||||
# new_comment = "用于存放任务运行结果的表"
|
|
||||||
# db_manager.update_table_comment(table_name, new_comment)
|
|
||||||
|
|
||||||
# table_name = "agent_state_history"
|
|
||||||
# new_comment = "用于存放agent状态历史的表"
|
|
||||||
# db_manager.update_table_comment(table_name, new_comment)
|
|
||||||
|
|
||||||
# table_name = "agents"
|
|
||||||
# new_comment = "用于存放agent的注册表"
|
|
||||||
# db_manager.update_table_comment(table_name, new_comment)
|
|
||||||
|
|
||||||
# table_name = "tasks"
|
|
||||||
# new_comment = "用于存放任务的仓库表"
|
|
||||||
# db_manager.update_table_comment(table_name, new_comment)
|
|
||||||
|
|
||||||
|
|
||||||
File diff suppressed because it is too large.
Load diff
Binary file not shown.
Binary file not shown.
+1
-2
@@ -167,11 +167,10 @@
|
|||||||
|
|
||||||
function isAudioOnlyAssistantMessage(msg) {
|
function isAudioOnlyAssistantMessage(msg) {
|
||||||
if (!msg || msg.role !== "assistant") return false;
|
if (!msg || msg.role !== "assistant") return false;
|
||||||
const outputType = msg.meta && typeof msg.meta.output_type === "string" ? msg.meta.output_type : "";
|
|
||||||
const text = getTextFromContent(msg.content);
|
const text = getTextFromContent(msg.content);
|
||||||
const mediaParts = getMediaParts(msg.content);
|
const mediaParts = getMediaParts(msg.content);
|
||||||
const hasOnlyAudioMedia = mediaParts.length > 0 && mediaParts.every((part) => part.type === "audio" && part.url);
|
const hasOnlyAudioMedia = mediaParts.length > 0 && mediaParts.every((part) => part.type === "audio" && part.url);
|
||||||
return (outputType === "audio" || !text) && hasOnlyAudioMedia && !text;
|
return hasOnlyAudioMedia && !text;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clamp(n, min, max) {
|
function clamp(n, min, max) {
|
||||||
|
|||||||
+2
-3
@@ -118,9 +118,8 @@
|
|||||||
</label>
|
</label>
|
||||||
<label class="output-mode-label" for="outputModeSelect">输出模式</label>
|
<label class="output-mode-label" for="outputModeSelect">输出模式</label>
|
||||||
<select id="outputModeSelect" class="output-mode-select" aria-label="Agent 输出模式">
|
<select id="outputModeSelect" class="output-mode-select" aria-label="Agent 输出模式">
|
||||||
<option value="text">纯文字</option>
|
<option value="text">文字模式</option>
|
||||||
<option value="audio">纯语音</option>
|
<option value="voice">语音模式</option>
|
||||||
<option value="both">both</option>
|
|
||||||
</select>
|
</select>
|
||||||
<label class="output-mode-label" for="agentModeSelect">Agent 模式</label>
|
<label class="output-mode-label" for="agentModeSelect">Agent 模式</label>
|
||||||
<select id="agentModeSelect" class="output-mode-select" aria-label="Agent 工作模式">
|
<select id="agentModeSelect" class="output-mode-select" aria-label="Agent 工作模式">
|
||||||
|
|||||||
+8
-2
@@ -389,6 +389,12 @@ def api_delete_session(session_id: str = Form(...)) -> dict[str, Any]:
|
|||||||
"""
|
"""
|
||||||
if not session_id:
|
if not session_id:
|
||||||
raise HTTPException(status_code=400, detail="缺少 session_id")
|
raise HTTPException(status_code=400, detail="缺少 session_id")
|
||||||
|
# 先设置 abort 标志,通知正在执行的 Agent 停止
|
||||||
|
try:
|
||||||
|
from agent.core.runtime import set_abort_flag
|
||||||
|
set_abort_flag(session_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
ui.delete_session(session_id)
|
ui.delete_session(session_id)
|
||||||
# delete_session 已清理 tool_calling 目录,此处仅需清理 todo_store
|
# delete_session 已清理 tool_calling 目录,此处仅需清理 todo_store
|
||||||
try:
|
try:
|
||||||
@@ -499,7 +505,7 @@ def _chat_complete_core(session_id: str, output_type: str = "text", agent_mode:
|
|||||||
|
|
||||||
输入:
|
输入:
|
||||||
session_id: 会话 ID。
|
session_id: 会话 ID。
|
||||||
output_type: text | audio | both。
|
output_type: text | voice。
|
||||||
agent_mode: agent | plan | team。
|
agent_mode: agent | plan | team。
|
||||||
|
|
||||||
输出:
|
输出:
|
||||||
@@ -573,7 +579,7 @@ def api_chat_complete(
|
|||||||
|
|
||||||
输入:
|
输入:
|
||||||
session_id: 表单字段,会话 ID。
|
session_id: 表单字段,会话 ID。
|
||||||
output_type: text | audio | both。
|
output_type: text | voice。
|
||||||
agent_mode: agent | plan | team。
|
agent_mode: agent | plan | team。
|
||||||
|
|
||||||
输出:
|
输出:
|
||||||
|
|||||||
@@ -564,9 +564,9 @@ def _assistant_manifest_entry_to_content(entry: dict) -> str | list:
|
|||||||
if not under or not os.path.isfile(audio_abs_path):
|
if not under or not os.path.isfile(audio_abs_path):
|
||||||
audio_abs_path = None
|
audio_abs_path = None
|
||||||
parts: list[dict] = []
|
parts: list[dict] = []
|
||||||
if output_type in ("text", "both") and text:
|
if output_type in ("text", "voice") and text:
|
||||||
parts.append({"type": "text", "text": text})
|
parts.append({"type": "text", "text": text})
|
||||||
if output_type in ("audio", "both") and audio_abs_path:
|
if output_type == "voice" and audio_abs_path:
|
||||||
parts.append({
|
parts.append({
|
||||||
"type": "audio",
|
"type": "audio",
|
||||||
"audio_path": audio_abs_path,
|
"audio_path": audio_abs_path,
|
||||||
|
|||||||
Reference in new issue
Block a user