实现邮件发送(支持QQ和网易.126.163)PPT制作全流程(SVG路线)

This commit is contained in:
minorQAQ committed 2026-07-10 20:17:08 +08:00
1 parent a5e5aff63f
commit 1111d208c4
60 files changed
+3905 -1388

No files matched your search

+29 -10
View File
@@ -86,14 +86,6 @@ git push minor master
└─ 图结束后 (runtime.py / execute_agent)
└─ 检测到强制终止 → 向前端输出:
"检测到异常循环,工具 `xxx` 已连续调用 5 次..."
1、打开https://erpdemo.fanpusoft.com/main.aspx#
2、点击采购管理
3、点击新增供应商
4、在新增供应商页面进行以下操作:输入供应商名称:“minor”、输入供应商类别:“工程材料供应商”、输入部门:“采购部门B”、输入职务:“CEO”、输入联系人:“赵永顺”、输入生日:“2004/07/09”
5、点击保存、点击确定
---
## 提示词工程文档
@@ -418,9 +410,36 @@ You are a visual grounding model for desktop GUI control.
ssh -o ServerAliveInterval=60 -o ServerAliveCountMax=3 -p 60007 minor123@semi-ai.tech
#### 本地端口映射
ssh -fN -L 8901:127.0.0.1:8901 -L 8902:127.0.0.1:8902 -L 8903:127.0.0.1:8903 minor123@semi-ai.tech -p 60007
ssh -fN -o ServerAliveInterval=60 -o ServerAliveCountMax=3 -L 8901:127.0.0.1:8901 -L 8902:127.0.0.1:8902 -L 8903:127.0.0.1:8903 minor123@semi-ai.tech -p 60007
ssh -fN -o ServerAliveInterval=60 -o ServerAliveCountMax=3 -L 8903:127.0.0.1:8903 minor123@semi-ai.tech -p 60007
# 查看状态码(200表示健康)
curl.exe -o nul -s -w "%{http_code}\n" http://localhost:8901/health
curl.exe -o nul -s -w "%{http_code}\n" http://localhost:8902/health
curl.exe -o nul -s -w "%{http_code}\n" http://localhost:8903/health
curl -o /dev/null -s -w "%{http_code}\n" http://localhost:8901/health
curl -o /dev/null -s -w "%{http_code}\n" http://localhost:8902/health
curl -o /dev/null -s -w "%{http_code}\n" http://localhost:8903/health
(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
View File
@@ -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
View File
@@ -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.
+4 -4
View File
@@ -5,11 +5,11 @@
"max_iterations": 200,
"llm_model_id": "b53afakf",
"tools": [
"send_file",
"directory_listing",
"terminal_execute",
"doc_tool",
"web_search_class"
"date_query",
"web_search",
"human_interaction",
"todo_list"
],
"enabled": true,
"role": "main",
+5
View File
@@ -154,6 +154,9 @@ _TOOL_DEFAULT_AUTO_RULES: dict[str, list[dict]] = {
"skill_router": [
{"parameter": "query", "operator": "exists", "value": ""},
],
"mail_tool": [
{"parameter": "action", "operator": "equals", "value": "read"},
],
}
@@ -321,6 +324,8 @@ def _default_env_config() -> dict:
"LOOP_DETECT_REPEATED_TOOL_WARN": "3",
"LOOP_DETECT_REPEATED_TOOL_END": "5",
"SEND_FILE_SIZE_LIMIT": "10",
"EMAIL_ADDRESS": "",
"EMAIL_AUTH_CODE": "",
}
+6 -4
View File
@@ -1,9 +1,9 @@
{
"WORKING_DIR": "c:\\Users\\86166\\Desktop\\Agent_Learning_minor\\Agent",
"WORKSPACE_DIR": "src\\agent\\workspace",
"TTS_BASE_URL": "127.0.0.0:8902",
"ASR_BASE_URL": "127.0.0.0:8901",
"RAG_BASE_URL": "127.0.0.0:8903",
"TTS_BASE_URL": "http://localhost:8902",
"ASR_BASE_URL": "http://localhost:8901",
"RAG_BASE_URL": "http://localhost:8903",
"TAVILY_API_KEY": "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",
"LLM_CONTEXT_WINDOW": "262144",
"MINI_COMPRESS_RATE": "0.4",
@@ -17,7 +17,9 @@
"LOOP_DETECT_TODOLIST_STALE_ROUNDS": "20",
"LOOP_DETECT_REPEATED_TOOL_WARN": "3",
"LOOP_DETECT_REPEATED_TOOL_END": "5",
"SEND_FILE_SIZE_LIMIT": "10",
"SEND_FILE_SIZE_LIMIT": "30",
"EMAIL_ADDRESS": "3139785954@qq.com",
"EMAIL_AUTH_CODE": "hfjkjqnlhjvqdeef",
"models": [
{
"id": "a44b226e",
+9 -12
View File
@@ -41,7 +41,7 @@ class ChatQwen(ChatOpenAI):
"""Qwen 系列模型的 LangChain 封装,扩展多模态与 TTS 能力。
属性:
output_type: 默认输出模式 text | audio | both。
output_type: 默认输出模式 text | voice。
stream_play: TTS 后是否本地播放。
last_audio_kwargs: 最近一次 TTS 的文件路径与时长等元数据。
@@ -213,7 +213,7 @@ class ChatQwen(ChatOpenAI):
}
]
}
endpoint = f"{env_utils.ASR_BASE_URL.rstrip('/')}/chat/completions"
endpoint = f"{env_utils.ASR_BASE_URL.rstrip('/')}/v1/chat/completions"
resp = requests.post(
endpoint,
headers={"Content-Type": "application/json"},
@@ -370,11 +370,11 @@ class ChatQwen(ChatOpenAI):
输入:
result: ChatResult 含 AIMessage。
output_type: audio | both。
output_type: voice。
audio_output_dir: WAV 保存目录。
输出:
可能清空 audio 模式下 content 的 ChatResult;设置 last_audio_kwargs。
设置 last_audio_kwargs 的 ChatResult。
系统定位:
_generate 在模型返回后的后处理。
@@ -382,7 +382,7 @@ class ChatQwen(ChatOpenAI):
可扩展性:
可上传对象存储而非本地路径。
"""
if output_type not in ("audio", "both") or not audio_output_dir:
if output_type != "voice" or not audio_output_dir:
return result
os.makedirs(audio_output_dir, exist_ok=True)
for idx, gen in enumerate(result.generations):
@@ -391,6 +391,9 @@ class ChatQwen(ChatOpenAI):
msg = gen.message
if not isinstance(msg, AIMessage):
continue
# 跳过含 tool_calls 的中间决策消息,仅对最终回复合成语音
if msg.tool_calls:
continue
text = assistant_text(msg.content)
if not text.strip():
continue
@@ -414,8 +417,6 @@ class ChatQwen(ChatOpenAI):
}
if self.stream_play:
self.play_audio_bytes(audio_bytes)
if output_type == "audio":
msg.content = ""
except Exception as e:
print(f"[TTS ERROR] 合成语音失败: {e}")
# 发生错误时也清空 last_audio_kwargs,避免污染
@@ -459,12 +460,8 @@ class ChatQwen(ChatOpenAI):
result = super()._generate(messages, stop=stop, run_manager=run_manager, **kwargs)
if output_type in ("audio", "both"):
if output_type == "voice":
result = self._apply_tts(result, output_type, audio_output_dir=audio_output_dir)
if output_type == "audio":
for gen in result.generations:
if isinstance(gen, ChatGeneration) and isinstance(gen.message, AIMessage):
gen.message.content = ""
return result
def _create_chat_result(self, response, generation_info=None) -> ChatResult:
+35 -8
View File
@@ -27,12 +27,29 @@ from agent.utils.image_utils import image_bytes_to_openai_image_url_part
from agent.utils.tool_call_utils import normalize_tool_call
def _has_image_content(msg: HumanMessage) -> bool:
"""判断 HumanMessage 是否包含图片内容(截图等 data URL)。
输入:
msg: 待检查的 HumanMessage。
输出:
True 表示 content 中包含 image_url 类型的内容块。
"""
content = msg.content
if isinstance(content, list):
for part in content:
if isinstance(part, dict) and part.get("type") == "image_url":
return True
return False
def _filter_short_term_memory(messages: list, trajectory_rounds: int) -> list:
"""过滤短期记忆:保留所有工具调用,工具返回结果仅保留最近 trajectory_rounds 轮。
"""过滤短期记忆:保留所有工具调用记录和工具文字返回值,图片截图仅保留最近 trajectory_rounds 轮。
输入:
messages: 完整消息列表。
trajectory_rounds: 保留最近 N 轮的工具返回结果;或负数表示不过滤。
trajectory_rounds: 保留最近 N 轮的图片截图(合成 HumanMessage);<=0 表示不过滤任何消息。
输出:
过滤后的消息列表。
@@ -42,9 +59,9 @@ def _filter_short_term_memory(messages: list, trajectory_rounds: int) -> list:
场景下上下文过长。
规则:
- 保留所有非 ToolMessage 消息(SystemMessage、HumanMessage、AIMessage)
- ToolMessage 仅保留最近 trajectory_rounds 轮工具调用对应的返回结果
- 每轮由一个 AIMessage(含 tool_calls)定义,其后的 ToolMessage 属于该轮
- 保留所有 SystemMessage、HumanMessage(非合成图片)、AIMessage、ToolMessage
- 仅 process_tool_artifact 生成的合成图片 HumanMessage 按 trajectory_rounds 裁剪
- 每轮由一个 AIMessage(含 tool_calls)定义
"""
if trajectory_rounds <= 0:
return list(messages)
@@ -59,7 +76,8 @@ def _filter_short_term_memory(messages: list, trajectory_rounds: int) -> list:
if isinstance(msg, AIMessage) and getattr(msg, "tool_calls", None):
rounds_seen += 1
if isinstance(msg, ToolMessage):
# 合成图片消息:仅保留最近 trajectory_rounds 轮
if isinstance(msg, HumanMessage) and msg.additional_kwargs.get("synthetic") and _has_image_content(msg):
if rounds_seen < trajectory_rounds:
kept_indices.add(i)
else:
@@ -274,9 +292,18 @@ def process_tool_artifact(state: AgentState) -> dict:
artifact = last_msg.artifact
img_bytes_list: list[bytes] = []
source_path = ""
if isinstance(artifact, dict) and "image_bytes" in artifact:
if isinstance(artifact, dict):
if "image_bytes" in artifact:
img_bytes_list.append(artifact["image_bytes"])
if "images" in artifact and isinstance(artifact["images"], list):
for item in artifact["images"]:
if isinstance(item, bytes):
img_bytes_list.append(item)
elif isinstance(item, dict) and "image_bytes" in item:
img_bytes_list.append(item["image_bytes"])
source_path = artifact.get("file_path", "") or artifact.get("source_path", "")
elif isinstance(artifact, list):
for item in artifact:
if isinstance(item, dict) and "image_bytes" in item:
@@ -305,7 +332,7 @@ def process_tool_artifact(state: AgentState) -> dict:
)
return {"messages": [human_msg]}
content_parts.append({"type": "text", "text": "上面是工具返回的截图。"})
content_parts.append({"type": "text", "text": "上面是工具返回的截图。" + (f"(来源:{source_path})" if source_path else "")})
if failed_count > 0:
content_parts[-1]["text"] += f"(其中 {failed_count} 张图片处理失败,请忽略)"
+6 -4
View File
@@ -282,7 +282,7 @@ def execute_agent(
runtime: Agent 运行时配置。
chat_history: Web UI 聊天历史(dict 列表);末条须为 user。
session_id: 会话 ID,用于历史、文件目录、manifest 同步。
output_type: text | audio | both,传入图 metadata。
output_type: text | voice,传入图 metadata。
agent_mode: agent(默认)| team(占位提示)。
输出:
@@ -317,6 +317,10 @@ def execute_agent(
# Plan 模式使用专用系统提示词
system_prompt = PLAN_MODE_PROMPT if agent_mode == "plan" else runtime.system_prompt
# 语音模式下追加简洁性提示
if output_type == "voice":
system_prompt = system_prompt + "\n\n【回复风格要求】\n当前处于语音输出模式,你的文字回复必须尽可能简短精炼,一句话能说清楚绝不用两句。直接给出核心信息,省略寒暄和冗余说明。"
# 当 skill_enabled 开启时,在每次用户输入后先做 skill 路由:
# 由 SkillRouter 根据当前 query 匹配相关 skill 指南,注入到系统提示词中
if runtime.skill_enabled:
@@ -451,9 +455,7 @@ def execute_agent(
"turn_id": turn_id,
}
if output_type_from_config == "audio" and assistant_meta.get("audio"):
assistant_content = []
elif output_type_from_config == "both" and assistant_meta.get("audio"):
if output_type_from_config == "voice" and assistant_meta.get("audio"):
assistant_content = text_content
else:
assistant_content = text_content if text_content else "(空回复)"
+23 -25
View File
@@ -1,6 +1,6 @@
[
{
"name": "web_search_class",
"name": "web_search",
"display_name": "WebSearch",
"description": "一个网络搜索工具,可以根据用户的查询返回相关的搜索结果。仅当需要查询近期知识或已有知识无法解答需要联网查询时调用,其余任何情况严禁调用。",
"permission": "direct",
@@ -8,26 +8,18 @@
"enabled": true
},
{
"name": "date_query_class",
"name": "date_query",
"display_name": "DateQuery",
"description": "一个日期查询工具,可以返回当前的日期信息。仅当需要查询当前时间时调用,其余任何情况严禁调用。",
"permission": "direct",
"auto_execute_rules": [],
"enabled": true
},
{
"name": "screen_capture",
"display_name": "ScreenCapture",
"description": "截取当前主屏幕画面。除了返回文字描述外,也会提供原始截图给模型进行视觉分析。注意:这个工具仅用于获取当前屏幕的实时画面,其余任何情况严禁调用。",
"permission": "direct",
"auto_execute_rules": [],
"enabled": true
},
{
"name": "browser_control",
"display_name": "BrowserControl",
"description": "统一控制浏览器:打开/关闭浏览器、刷新/关闭标签页、打开指定 URL。",
"permission": "direct",
"permission": "confirm",
"auto_execute_rules": [
{
"parameter": "action",
@@ -84,7 +76,7 @@
{
"name": "todo_list",
"display_name": "TodoList",
"description": "可选的任务管理工具。当你需要拆分复杂任务为多个步骤并跟踪进度时调用。输入 steps (list[str]) 为每一步的描述,step (int) 为已完成的步骤索引(从0开始)。任务默认状态为 pending,根据 step 自动标记完成。工具返回当前 todo list 文本。仅在需要跟踪多步骤任务进度时调用,简单任务不需要调用。",
"description": "任务管理工具。用于将复杂任务拆分为多个步骤并跟踪执行进度。",
"permission": "direct",
"auto_execute_rules": [],
"enabled": true
@@ -92,7 +84,7 @@
{
"name": "gui_tool",
"display_name": "GUITool",
"description": "GUI automation tool. Visually locates desktop elements from a screenshot, maps coordinates to the real screen, and executes pyautogui actions. Supported actions: click, type, drag_and_drop, scroll, ho",
"description": "GUI 自动化工具。通过截图视觉定位桌面元素,将坐标映射到真实屏幕,并执行 pyautogui 动作。接收一个有序的动作列表,按顺序依次执行。每个动作项支持的操作:click(点击)、type(输入文本,可通过 element_description 指定目标输入框自动聚焦)、drag_and_drop(拖拽)、scroll(滚动)、hotkey(快捷键)、hold_and_press(按住组合键",
"permission": "direct",
"auto_execute_rules": [],
"enabled": true
@@ -100,7 +92,7 @@
{
"name": "call_subagent",
"display_name": "CallSubAgentTool",
"description": "调用已启用的子 Agent 执行指定任务。子 Agent 会独立运行并返回最终结果。",
"description": "调用一个已启用的子 Agent 执行指定任务。子 Agent 会独立运行并返回最终结果。适用于需要专业能力(如代码分析、文档处理等)进行辅助的场景。可用的子 Agent 名称取决于 agent_config.json 中已启用且 role='sub' 的配置。",
"permission": "direct",
"auto_execute_rules": [],
"enabled": true
@@ -108,15 +100,7 @@
{
"name": "doc_tool",
"display_name": "DocTool",
"description": "文档操作工具。支持纯文本(plain_text)、表格(tabular: CSV/XLSX)、Word(docx)、PDF 文档的增删改查。",
"permission": "direct",
"auto_execute_rules": [],
"enabled": true
},
{
"name": "gui_tool(test)",
"display_name": "GUIToolTest",
"description": "GUI 自动化工具(测试版)。通过截图视觉定位桌面元素,将坐标映射到真实屏幕,并执行 pyautogui 动作。接收一个有序的动作列表,按顺序依次执行。每个动作项支持的操作:click(点击)、type(输入文本,可通过 element_description 指定目标输入框自动聚焦)、drag_and_drop(拖拽)、scroll(滚动)、hotkey(快捷键)、hold_and_press(",
"description": "文档操作工具。支持纯文本(plain_text)、表格(tabular: CSV/XLSX)、Word(docx)、PowerPoint(pptx)、PDF 文档的增删改查,以及图片(image)、音频(audio)的读取。",
"permission": "direct",
"auto_execute_rules": [],
"enabled": true
@@ -138,7 +122,7 @@
{
"name": "send_file",
"display_name": "SendFileTool",
"description": "向用户发送文件的工具。根据文件类型自动处理:图片前端直接展示,音频前端播放+ASR转文字,压缩包和各类文件供用户下载(超过大小限制时提示发送到邮箱)。",
"description": "向用户发送文件的工具。根据文件类型自动处理:",
"permission": "direct",
"auto_execute_rules": [],
"enabled": true
@@ -146,9 +130,23 @@
{
"name": "terminal_execute",
"display_name": "TerminalExecute",
"description": "终端命令执行工具。在本地终端(PowerShell)中执行 shell 命令并返回执行结果。支持执行 Python 脚本、安装依赖、运行程序、文件操作等任意终端命令。",
"description": "终端命令执行工具。在本地终端(PowerShell)中执行 shell 命令并返回执行结果。",
"permission": "direct",
"auto_execute_rules": [],
"enabled": true
},
{
"name": "mail_tool",
"display_name": "MailTool",
"description": "QQ email tool. Supports sending and reading email without GUI.",
"permission": "direct",
"auto_execute_rules": [
{
"parameter": "action",
"operator": "equals",
"value": "read"
}
],
"enabled": true
}
]
Binary file not shown.
@@ -1,4 +0,0 @@
{
"version": 3,
"messages": []
}
@@ -1,4 +0,0 @@
{
"version": 3,
"messages": []
}
+17
View File
@@ -127,6 +127,13 @@ def _artifact_image_data_urls(artifact: Any | None) -> list[str]:
return
if isinstance(item.get("image_bytes"), bytes):
image_bytes_list.append(item["image_bytes"])
# 支持 doc_tool 的 "images" 键(多图片列表)
if isinstance(item.get("images"), list):
for img in item["images"]:
if isinstance(img, bytes):
image_bytes_list.append(img)
elif isinstance(img, dict) and isinstance(img.get("image_bytes"), bytes):
image_bytes_list.append(img["image_bytes"])
for key in ("image_url", "url", "data_url"):
value = item.get(key)
if isinstance(value, str) and (value.startswith("data:image/") or value.startswith("http")):
@@ -342,6 +349,16 @@ def _save_artifact_files(artifact: Any, base_name: str) -> tuple[list[str], dict
fname = f"_artifact_{base_name}_0.png"
files.append(fname)
bytes_map[fname] = artifact["image_bytes"]
if "images" in artifact and isinstance(artifact["images"], list):
for i, img in enumerate(artifact["images"]):
if isinstance(img, bytes):
fname = f"_artifact_{base_name}_{i}.png"
files.append(fname)
bytes_map[fname] = img
elif isinstance(img, dict) and "image_bytes" in img:
fname = f"_artifact_{base_name}_{i}.png"
files.append(fname)
bytes_map[fname] = img["image_bytes"]
if artifact.get("file_type") == "file" and "file_bytes" in artifact:
file_name = artifact.get("file_name", "file")
ext = _os.path.splitext(file_name)[1] or ".bin"
Binary file not shown.
+13 -9
View File
@@ -14,12 +14,13 @@ _BASE_PROMPT = """你是一个具备多模态理解能力的桌面操作助手
_WS_TAIL = """\n\n【工作空间配置】
- 工作目录 (WORKING_DIR): {working_dir}
- 项目根目录 (PROJECT_DIR): {working_dir}
- 工作空间 (WORKSPACE_DIR): {workspace_dir}
- Skills 目录 (SKILLS_DIR): {skills_dir}
- 你产生的代码、脚本、图片、文档等文件,默认保存到工作空间目录下。
- 使用 terminal_execute 工具时,如果不指定 working_dir,默认在此工作空间执行命令。
- 使用 doc_tool 创建/修改文件时,文件路径请使用工作空间的绝对路径。"""
- 使用 doc_tool 创建/修改文件时,文件路径请使用工作空间的绝对路径。
"""
def get_main_system_prompt() -> str:
"""动态构建主系统提示词(含工作空间路径)。
@@ -32,9 +33,12 @@ def get_main_system_prompt() -> str:
"""
try:
from agent.utils.env_utils import WORKING_DIR, get_workspace_dir
from pathlib import Path
skills_dir = str(Path(__file__).resolve().parents[1] / "skills")
ws_info = _WS_TAIL.format(
working_dir=WORKING_DIR,
workspace_dir=get_workspace_dir(),
skills_dir=skills_dir,
)
except Exception:
ws_info = ""
@@ -143,12 +147,12 @@ PLAN_FIRST_CALL_PROMPT = """
""".strip()
REFLECTION_PROMPT = """
【请先在你的回复中尽可能简短地输出你的反思,然后再调用工具或给出最终回复】
你需要先用尽可能简短的一句话在文字中思考上一步做了什么?下一步该做什么?然后进行相应的工具调用。格式如下:
1. 先分析上一轮执行结果:上一个调用了什么工具?结果是成功还是失败?
2. 如果上一轮是 GUI 操作(点击、输入等)但未命中目标或未达预期效果,请根据最新的截图重新估算目标位置,调整坐标或参数后重试。
3. 如果上一轮操作成功,说明你根据分析结果进行下一步操作。
4. 然后调用对应的工具执行下一步,或如果任务已完成则直接回复用户(注意只需要回复用户最终结果即可)。
【重要:如果你判断不需要调用任何工具,请直接回复用户,不要输出任何反思内容!】
只有在需要调用工具时,才先进行简短反思(一句话即可),然后调用工具。格式如下:
1. 如果不需要调用工具(任务已完成或无需工具即可回答),直接给出最终回复,不要输出反思。
2. 如果需要调用工具:先用一句话思考上一步结果和下一步计划,然后调用工具。
3. 如果上一轮是 GUI 操作(点击、输入等)但未命中目标或未达预期效果,请根据最新的截图重新估算目标位置,调整坐标或参数后重试。
4. 如果上一轮操作成功,根据分析结果进行下一步操作。
【gui_tool 批量调用规则 - 务必遵守】
如果你计划在同一页面/界面执行多个连续的 GUI 操作(例如:依次点击多个按钮、填写多个表单字段、连续输入多项内容),必须将所有这些操作合并为一次 gui_tool 调用,在 actions 列表中一次性传入所有动作。严禁将同一页面的连续操作拆分多次 gui_tool 调用——这样会导致严重的延迟。仅当需要等页面跳转/加载后才能确定后续操作时,才分步调用。
@@ -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 命名实体 (`&nbsp;` `&mdash;` `&copy;`) | 原始 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 实体 | 无 `&nbsp;` `&mdash;` `&copy;` 等(用原始 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 范围内
+8
View File
@@ -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
}
+41
View File
@@ -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 格式时读取。
+2
View File
@@ -12,6 +12,7 @@ from .doc import DocTool
from .skill_router import SkillRouter
from .send_file import SendFileTool
from .terminal_execute import TerminalExecute
from .email import MailTool
_all_tools = [
WebSearch(),
@@ -28,6 +29,7 @@ _all_tools = [
SkillRouter(),
SendFileTool(),
TerminalExecute(),
MailTool(),
]
# 注册所有可用工具名称(供前端配置面板展示),从 _all_tools 实例推导
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -8,7 +8,7 @@ class DateQuery(BaseTool):
# 定义工具的输入参数结构,这里没有输入参数,所以是一个空的模型
args_schema : type[BaseModel] = create_model("NoneInput")
name : str = "date_query_class"
name : str = "date_query"
description : str = "一个日期查询工具,可以返回当前的日期信息。仅当需要查询当前时间时调用,其余任何情况严禁调用。"
def _run(self) -> str:
+80 -7
View File
@@ -1,8 +1,9 @@
"""文档操作工具:对 plain_text / tabular / docx / pdf 文件进行增删改查。
"""文档操作工具:对 plain_text / tabular / docx / pptx / pdf 文件进行增删改查。
plain_text: 行级粒度(与代码 agent 一致)
tabular: 单元格级粒度(CSV / XLSX)
docx: 段落级粒度(文本段落);表格操作粒度到单元格
pptx: 只读 + 删除(按幻灯片提取文本+内嵌图片+表格)
pdf: 只读 + 删除
"""
@@ -13,7 +14,7 @@ import json
import os
import pathlib
import re
from typing import Any, Literal, Optional
from typing import Literal, Optional
from langchain.tools import BaseTool
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 操作缺少必要参数"
# ---------- 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 ----------
def _read_pdf(file_path: str, max_chars: int | None = None, from_end: bool = False,
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), []
elif doc_type == "docx":
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":
return _read_pdf(file_path, max_chars=max_chars, from_end=from_end, max_images=max_images)
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)
elif doc_type == "docx":
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:
@@ -492,16 +562,17 @@ class DocTool(BaseTool):
args_schema: type[BaseModel] = DocToolInput
name: str = "doc_tool"
description: str = (
"文档操作工具。支持纯文本(plain_text)、表格(tabular: CSV/XLSX)、Word(docx)、PDF 文档的增删改查,"
"文档操作工具。支持纯文本(plain_text)、表格(tabular: CSV/XLSX)、Word(docx)、PowerPoint(pptx)、PDF 文档的增删改查,"
"以及图片(image)、音频(audio)的读取。\n\n"
"【支持的操作】\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"
"- create: 创建新文档。仅支持 plain_text、tabular、docx。\n"
"- create: 创建新文档。仅支持 plain_text、tabular、docx、pptx。\n"
" * plain_text/docx: content 为要写入的文本。\n"
" * tabular(.csv): content 为 CSV 格式文本。\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"
" * plain_text 行级操作: line_number + new_line_text 替换行;\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"
"- delete: 删除文档。支持所有类型。\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"
" pptx: .pptx(read 返回 SVG 代码;create 用 SVG 代码生成;delete 删除)\n"
" pdf: .pdf\n"
" tabular: .csv .xlsx\n"
" image: .png .jpg .jpeg .bmp .gif .webp(仅 read,图片返回到 artifact)\n"
@@ -541,6 +613,7 @@ class DocTool(BaseTool):
import base64
from agent.utils.image_utils import image_bytes_to_openai_image_url_part
artifact["images"] = images
artifact["file_path"] = file_path
# 为前端展示生成 data URL 列表
artifact["image_urls"] = [
f"data:image/png;base64,{base64.b64encode(img).decode('ascii')}"
+665
View File
@@ -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)
+44 -17
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import json
import re
import hashlib
import os
@@ -523,32 +524,58 @@ class RAGTool(BaseTool):
return "清除操作必须提供 filter 条件。", {}
coll = get_global_collection()
where = None
# 处理 filter:若为 JSON 字符串化的列表,尝试解析
if isinstance(filter, str):
where = {"source": {"$contains": filter}}
elif isinstance(filter, list) and len(filter) > 0:
where = {"source": {"$contains": filter[0]}}
if not where:
return "无法构建有效的过滤条件。", {}
try:
res = coll.get(where=where)
if res["ids"]:
parsed = json.loads(filter)
if isinstance(parsed, list):
filter = parsed
except (json.JSONDecodeError, ValueError):
pass
# 统一为列表
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:
all_data = coll.get()
except Exception as 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 meta in res.get("metadatas", []):
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=res["ids"])
return f"已从全局知识库中删除 {len(res['ids'])} 条记录。", {}
else:
return "未找到匹配的记录。", {}
except Exception as e:
return f"清除时出错: {e}", {}
coll.delete(ids=ids_to_delete)
return f"已从全局知识库中删除 {len(ids_to_delete)} 条记录。", {}
# ---------- 列出所有来源 ----------
def _handle_list_sources(self) -> Tuple[str, dict]:
+1 -1
View File
@@ -10,7 +10,7 @@ from pydantic import BaseModel, Field, create_model
class WebSearch(BaseTool):
args_schema : type[BaseModel] = create_model("Input_type", query=(str, Field(description="搜索查询内容")))
name : str = "web_search_class"
name : str = "web_search"
description : str = "一个网络搜索工具,可以根据用户的查询返回相关的搜索结果。仅当需要查询近期知识或已有知识无法解答需要联网查询时调用,其余任何情况严禁调用。"
def _get_search_wrapper(self):
Binary file not shown.
+38 -1
View File
@@ -41,8 +41,10 @@ AUDIO_FILE_EXTENSIONS = frozenset({".wav", ".mp3", ".flac", ".ogg", ".m4a", ".aa
# 扩展名到内部类型标签的映射
DOC_EXT_MAP = {
".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",
".pptx": "pptx",
".pdf": "pdf",
".csv": "tabular",
".xlsx": "tabular",
@@ -649,6 +651,39 @@ class Documents_process:
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
def process_tabular(file_path: str) -> tuple[str, list]:
"""将 CSV / xlsx 转为 <table> 标记的表格文本。
@@ -730,6 +765,8 @@ class Documents_process:
return Documents_process.process_plain_text(file_path)
elif file_type == "docx":
return Documents_process.process_docx(file_path)
elif file_type == "pptx":
return Documents_process.process_pptx(file_path)
elif file_type == "pdf":
return Documents_process.process_pdf(file_path)
elif file_type == "image":
-302
View File
@@ -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
View File
@@ -167,11 +167,10 @@
function isAudioOnlyAssistantMessage(msg) {
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 mediaParts = getMediaParts(msg.content);
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) {
+2 -3
View File
@@ -118,9 +118,8 @@
</label>
<label class="output-mode-label" for="outputModeSelect">输出模式</label>
<select id="outputModeSelect" class="output-mode-select" aria-label="Agent 输出模式">
<option value="text">纯文字</option>
<option value="audio">纯语音</option>
<option value="both">both</option>
<option value="text">文字模式</option>
<option value="voice">语音模式</option>
</select>
<label class="output-mode-label" for="agentModeSelect">Agent 模式</label>
<select id="agentModeSelect" class="output-mode-select" aria-label="Agent 工作模式">
+8 -2
View File
@@ -389,6 +389,12 @@ def api_delete_session(session_id: str = Form(...)) -> dict[str, Any]:
"""
if not 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)
# delete_session 已清理 tool_calling 目录,此处仅需清理 todo_store
try:
@@ -499,7 +505,7 @@ def _chat_complete_core(session_id: str, output_type: str = "text", agent_mode:
输入:
session_id: 会话 ID。
output_type: text | audio | both。
output_type: text | voice。
agent_mode: agent | plan | team。
输出:
@@ -573,7 +579,7 @@ def api_chat_complete(
输入:
session_id: 表单字段,会话 ID。
output_type: text | audio | both。
output_type: text | voice。
agent_mode: agent | plan | team。
输出:
+2 -2
View File
@@ -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):
audio_abs_path = None
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})
if output_type in ("audio", "both") and audio_abs_path:
if output_type == "voice" and audio_abs_path:
parts.append({
"type": "audio",
"audio_path": audio_abs_path,