Initial commit: import Operit protocol plugins from xiaoli-cli
This commit is contained in:
commit
c6600fc47d
52 files changed
+31492
No files matched your search
@@ -0,0 +1,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
node_modules/
|
||||
@@ -0,0 +1,38 @@
|
||||
# xiaoli-cli-operit-plugins
|
||||
|
||||
存放使用 **Operit 协议**的插件脚本,供 [xiaoli-cli](https://gitee.com/shuiyu1123/xiaoli-cli) 的 `operit_bridge` 插件调用。
|
||||
|
||||
## 结构说明
|
||||
|
||||
```
|
||||
xiaoli-cli-operit-plugins/
|
||||
├── scripts/ # Operit 格式的 .ts 插件脚本
|
||||
│ ├── 12306.ts
|
||||
│ ├── google_search.ts
|
||||
│ ├── browser.ts
|
||||
│ └── ... (共 47 个脚本)
|
||||
├── operit_bridge.py # xiaoli-cli 桥接插件(参考实现)
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 脚本来源
|
||||
|
||||
本仓库中的 `scripts/` 目录包含来自 [Operit AI 项目](https://github.com/AAswordman/Operit) 社区的第三方工具脚本。
|
||||
|
||||
- 原项目: https://github.com/AAswordman/Operit
|
||||
- 许可证: LGPL-3.0
|
||||
|
||||
## 使用方法
|
||||
|
||||
在 xiaoli-cli 中配置 `operit_bridge` 插件,将本仓库克隆或下载到 `plugins/operit/` 目录:
|
||||
|
||||
```bash
|
||||
git clone https://gitee.com/shuiyu1123/xiaoli-cli-operit-plugins.git /path/to/xiaoli-cli/plugins/operit-ext
|
||||
```
|
||||
|
||||
然后通过 `operit_bridge scan` 命令扫描加载即可。
|
||||
|
||||
## 许可证
|
||||
|
||||
`scripts/` 目录下的脚本遵守其原始项目的 **LGPL-3.0** 许可证。
|
||||
`operit_bridge.py` 遵循 **MIT** 许可证。
|
||||
@@ -0,0 +1,922 @@
|
||||
"""
|
||||
Operit Bridge — xiaoli-cli 原生 Liugin 插件
|
||||
===========================================
|
||||
让 xiaoli-cli 兼容 Operit AI 的第三方工具格式。
|
||||
|
||||
自动扫描 plugins/operit/ 子目录下所有 .js/.ts 脚本,
|
||||
解析 METADATA 块,注册为 xiaoli-cli 可调用的工具。
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import glob
|
||||
import importlib.util
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Operit METADATA 解析器
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
def _hjson_to_json(raw: str) -> str:
|
||||
"""
|
||||
将 Operit 的 HJSON 风格元数据转为标准 JSON。
|
||||
逐行解析,处理: 无引号键名、单引号/三引号字符串、嵌套双引号、// 注释、尾逗号。
|
||||
"""
|
||||
SQ = chr(39) # '
|
||||
DQ = chr(34) # "
|
||||
result = []
|
||||
i = 0
|
||||
n = len(raw)
|
||||
|
||||
def skip_ws():
|
||||
nonlocal i
|
||||
while i < n and raw[i] in ' \t\r\n':
|
||||
i += 1
|
||||
|
||||
def parse_string_val(quote):
|
||||
"""解析引号字符串,正确处理转义"""
|
||||
nonlocal i
|
||||
i += 1 # skip opening quote
|
||||
chars = []
|
||||
while i < n:
|
||||
ch = raw[i]
|
||||
if ch == '\\':
|
||||
chars.append(ch)
|
||||
i += 1
|
||||
if i < n:
|
||||
chars.append(raw[i])
|
||||
i += 1
|
||||
elif ch == quote:
|
||||
i += 1 # skip closing quote
|
||||
return ''.join(chars)
|
||||
else:
|
||||
chars.append(ch)
|
||||
i += 1
|
||||
return ''.join(chars) # unclosed
|
||||
|
||||
def parse_triple_string():
|
||||
"""解析 '''...''' 三引号字符串"""
|
||||
nonlocal i
|
||||
i += 3 # skip '''
|
||||
chars = []
|
||||
while i < n - 2:
|
||||
if raw[i:i+3] == SQ * 3:
|
||||
i += 3
|
||||
return ''.join(chars)
|
||||
chars.append(raw[i])
|
||||
i += 1
|
||||
return ''.join(chars)
|
||||
|
||||
def parse_unquoted_val():
|
||||
"""解析无引号值,到行尾或逗号/}等"""
|
||||
nonlocal i
|
||||
chars = []
|
||||
depth = 0
|
||||
while i < n:
|
||||
ch = raw[i]
|
||||
if ch == '{' or ch == '[':
|
||||
depth += 1
|
||||
chars.append(ch)
|
||||
i += 1
|
||||
elif ch == '}' or ch == ']':
|
||||
if depth <= 0:
|
||||
break
|
||||
depth -= 1
|
||||
chars.append(ch)
|
||||
i += 1
|
||||
elif ch == ',' and depth == 0:
|
||||
break
|
||||
elif ch == '\n' and depth == 0:
|
||||
break
|
||||
else:
|
||||
chars.append(ch)
|
||||
i += 1
|
||||
return ''.join(chars).strip()
|
||||
|
||||
def escape_for_json(s):
|
||||
"""转义字符串为 JSON 安全格式"""
|
||||
return json.dumps(s)
|
||||
|
||||
# ── 主解析循环 ──
|
||||
skip_ws()
|
||||
if i >= n or raw[i] != '{':
|
||||
return raw # 不是对象
|
||||
result.append('{')
|
||||
i += 1
|
||||
|
||||
expect_comma = False
|
||||
while i < n:
|
||||
skip_ws()
|
||||
if i >= n:
|
||||
break
|
||||
ch = raw[i]
|
||||
|
||||
# 对象结束
|
||||
if ch == '}':
|
||||
result.append('}')
|
||||
i += 1
|
||||
break
|
||||
# 数组结束
|
||||
if ch == ']':
|
||||
result.append(']')
|
||||
i += 1
|
||||
break
|
||||
# 逗号
|
||||
if ch == ',':
|
||||
result.append(',')
|
||||
i += 1
|
||||
expect_comma = False
|
||||
continue
|
||||
|
||||
# 注释
|
||||
if ch == '/' and i + 1 < n and raw[i+1] == '/':
|
||||
while i < n and raw[i] != '\n':
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# 如果需要逗号但遇到新键/值,自动补逗号
|
||||
if expect_comma and (ch == '"' or ch == SQ or ch.isalpha() or ch == '_' or ch == '{' or ch == '['):
|
||||
result.append(',')
|
||||
expect_comma = False
|
||||
|
||||
# 解析 key
|
||||
key = None
|
||||
if ch == '"':
|
||||
key = parse_string_val('"')
|
||||
elif ch == SQ:
|
||||
if i + 2 < n and raw[i+1] == SQ and raw[i+2] == SQ:
|
||||
key = parse_triple_string()
|
||||
else:
|
||||
key = parse_string_val(SQ)
|
||||
elif ch.isalpha() or ch == '_':
|
||||
# 无引号键名
|
||||
start = i
|
||||
while i < n and (raw[i].isalnum() or raw[i] == '_'):
|
||||
i += 1
|
||||
key = raw[start:i]
|
||||
|
||||
if key is None:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
skip_ws()
|
||||
if i >= n or raw[i] != ':':
|
||||
continue
|
||||
i += 1 # skip ':'
|
||||
skip_ws()
|
||||
|
||||
# 输出 key
|
||||
result.append(escape_for_json(key))
|
||||
result.append(':')
|
||||
|
||||
# 解析 value
|
||||
if i >= n:
|
||||
break
|
||||
vch = raw[i]
|
||||
|
||||
if vch == '"':
|
||||
# 双引号字符串
|
||||
val = parse_string_val('"')
|
||||
result.append(escape_for_json(val))
|
||||
elif vch == SQ:
|
||||
if i + 2 < n and raw[i+1] == SQ and raw[i+2] == SQ:
|
||||
val = parse_triple_string()
|
||||
else:
|
||||
val = parse_string_val(SQ)
|
||||
result.append(escape_for_json(val))
|
||||
elif vch == '{' or vch == '[':
|
||||
# 嵌套对象/数组 — 递归处理
|
||||
if vch == '{':
|
||||
depth = 1
|
||||
j = i + 1
|
||||
while j < n and depth > 0:
|
||||
if raw[j] == '{': depth += 1
|
||||
elif raw[j] == '}': depth -= 1
|
||||
j += 1
|
||||
inner = raw[i:j]
|
||||
result.append(_hjson_to_json(inner))
|
||||
i = j
|
||||
else:
|
||||
# 数组 — 找匹配的 ],对内部每个元素递归
|
||||
depth = 1
|
||||
j = i + 1
|
||||
while j < n and depth > 0:
|
||||
if raw[j] == '[': depth += 1
|
||||
elif raw[j] == ']': depth -= 1
|
||||
j += 1
|
||||
arr_inner = raw[i+1:j-1].strip()
|
||||
result.append('[')
|
||||
# 按顶层逗号分割数组元素
|
||||
elems = []
|
||||
elem_start = 0
|
||||
d = 0
|
||||
for k, c in enumerate(arr_inner):
|
||||
if c in '{[': d += 1
|
||||
elif c in '}]': d -= 1
|
||||
elif c == ',' and d == 0:
|
||||
elems.append(arr_inner[elem_start:k])
|
||||
elem_start = k + 1
|
||||
elems.append(arr_inner[elem_start:])
|
||||
for ei, elem in enumerate(elems):
|
||||
elem = elem.strip()
|
||||
if not elem:
|
||||
continue
|
||||
if ei > 0:
|
||||
result.append(',')
|
||||
if elem.startswith('{'):
|
||||
result.append(_hjson_to_json(elem))
|
||||
elif elem.startswith('['):
|
||||
# 嵌套数组
|
||||
result.append(_hjson_to_json('{"_":' + elem + '}')[6:-1])
|
||||
else:
|
||||
# 普通值
|
||||
if elem.startswith('"') or elem.startswith(SQ):
|
||||
q = elem[0]
|
||||
if len(elem) >= 3 and elem[1] == q and elem[2] == q:
|
||||
val = elem[3:-3]
|
||||
else:
|
||||
val = elem[1:-1]
|
||||
result.append(json.dumps(val))
|
||||
elif elem in ('true', 'false', 'null'):
|
||||
result.append(elem)
|
||||
else:
|
||||
try:
|
||||
float(elem)
|
||||
result.append(elem)
|
||||
except ValueError:
|
||||
result.append(json.dumps(elem))
|
||||
result.append(']')
|
||||
i = j
|
||||
elif vch in ('t', 'f', 'n') and raw[i:i+4] in ('true', 'null'):
|
||||
result.append(raw[i:i+4])
|
||||
i += 4
|
||||
elif vch == 'f' and raw[i:i+5] == 'false':
|
||||
result.append('false')
|
||||
i += 5
|
||||
elif vch.isdigit() or vch == '-':
|
||||
start = i
|
||||
while i < n and (raw[i].isdigit() or raw[i] in '.-eE+'):
|
||||
i += 1
|
||||
result.append(raw[start:i])
|
||||
else:
|
||||
# 无引号字符串值
|
||||
val = parse_unquoted_val()
|
||||
if val:
|
||||
result.append(escape_for_json(val))
|
||||
|
||||
expect_comma = True
|
||||
|
||||
return ''.join(result)
|
||||
|
||||
|
||||
def parse_operit_metadata(file_path: str) -> Optional[Dict]:
|
||||
"""
|
||||
从 JS/TS 文件中提取 /* METADATA ... */ 块并解析为 dict。
|
||||
支持 Operit 完整 schema: name, display_name, description, author, category, env, tools, states
|
||||
同时兼容标准 JSON 和 HJSON 风格(无引号键名、单引号、三引号、注释)。
|
||||
"""
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
except (FileNotFoundError, UnicodeDecodeError):
|
||||
return None
|
||||
|
||||
# 匹配 /* METADATA ... */ 块
|
||||
pattern = r'/\*\s*METADATA\s*\n?(.*?)\*/'
|
||||
match = re.search(pattern, content, re.DOTALL)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
raw = match.group(1).strip()
|
||||
|
||||
# 先尝试标准 JSON
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 尝试 HJSON 转换
|
||||
try:
|
||||
cleaned = _hjson_to_json(raw)
|
||||
return json.loads(cleaned)
|
||||
except json.JSONDecodeError:
|
||||
# 最后兜底: 移除注释和尾逗号再试
|
||||
fallback = re.sub(r'//.*$', '', raw, flags=re.MULTILINE)
|
||||
fallback = re.sub(r',\s*([}\]])', r'\1', fallback)
|
||||
try:
|
||||
return json.loads(fallback)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def _localized(field, lang="zh") -> str:
|
||||
"""解析 Operit 多语言字段: 字符串 或 {zh, en, default} 对象"""
|
||||
if isinstance(field, str):
|
||||
return field
|
||||
if isinstance(field, dict):
|
||||
for key in [lang, lang.split("-")[0], "default"]:
|
||||
if key in field:
|
||||
return field[key]
|
||||
return next(iter(field.values()), "")
|
||||
return str(field) if field else ""
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# 格式转换: Operit ↔ MCP
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
_TYPE_MAP = {
|
||||
"string": "string", "number": "number", "integer": "integer",
|
||||
"boolean": "boolean", "object": "object", "array": "array",
|
||||
}
|
||||
|
||||
|
||||
def operit_tool_to_mcp(tool_def: Dict, script_name: str) -> Dict:
|
||||
"""Operit tool 定义 → MCP inputSchema 格式"""
|
||||
properties, required = {}, []
|
||||
for p in tool_def.get("parameters", []):
|
||||
pname = p.get("name", "")
|
||||
properties[pname] = {
|
||||
"type": _TYPE_MAP.get(p.get("type", "string"), "string"),
|
||||
"description": p.get("description", ""),
|
||||
}
|
||||
if p.get("required", False):
|
||||
required.append(pname)
|
||||
|
||||
full_name = f"{script_name}__{tool_def.get('name', 'unknown')}"
|
||||
return {
|
||||
"name": full_name,
|
||||
"description": _localized(tool_def.get("description", "")),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": properties,
|
||||
"required": required,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def meta_to_tools(meta: Dict) -> List[Dict]:
|
||||
"""一个 Operit METADATA → xiaoli-cli 工具列表"""
|
||||
script_name = meta.get("name", "unknown")
|
||||
category = meta.get("category", "")
|
||||
tools = []
|
||||
for td in meta.get("tools", []):
|
||||
mcp = operit_tool_to_mcp(td, script_name)
|
||||
tools.append({
|
||||
"name": mcp["name"],
|
||||
"description": mcp["description"],
|
||||
"keywords": _build_keywords(meta, td),
|
||||
"usage": _build_usage(mcp),
|
||||
"mcp_definition": mcp,
|
||||
"source": "operit",
|
||||
"script_name": script_name,
|
||||
"original_tool_name": td.get("name", ""),
|
||||
})
|
||||
return tools
|
||||
|
||||
|
||||
def _build_keywords(meta: Dict, tool_def: Dict) -> List[str]:
|
||||
kw = ["operit"]
|
||||
for s in [meta.get("name", ""), tool_def.get("name", ""), meta.get("category", "")]:
|
||||
if s and s not in kw:
|
||||
kw.append(s)
|
||||
for text in [_localized(meta.get("description", "")), _localized(tool_def.get("description", ""))]:
|
||||
for w in re.findall(r'[\u4e00-\u9fff]+|[a-zA-Z]{3,}', text):
|
||||
if w not in kw:
|
||||
kw.append(w)
|
||||
return kw
|
||||
|
||||
|
||||
def _build_usage(mcp: Dict) -> str:
|
||||
props = mcp.get("inputSchema", {}).get("properties", {})
|
||||
req = mcp.get("inputSchema", {}).get("required", [])
|
||||
lines = [f"Operit 工具: {mcp['name']}", mcp.get("description", ""), ""]
|
||||
if props:
|
||||
lines.append("参数:")
|
||||
for pn, pd in props.items():
|
||||
r = " (必填)" if pn in req else ""
|
||||
lines.append(f" {pn}: {pd.get('description', pd.get('type', ''))}{r}")
|
||||
lines.append(f'\n调用: {{"action":"use_tool","tool":"{mcp["name"]}","args":"..."}}')
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def xiaoli_to_operit_js(plugin_instance) -> str:
|
||||
"""将 xiaoli-cli 插件导出为 Operit METADATA JS 文件内容"""
|
||||
info = plugin_instance.get_tool_info()
|
||||
mcp = plugin_instance.get_mcp_definition()
|
||||
name = info.get("name", "Unknown")
|
||||
desc = info.get("description", "")
|
||||
schema = mcp.get("inputSchema", {})
|
||||
props = schema.get("properties", {})
|
||||
required = schema.get("required", [])
|
||||
|
||||
parameters = []
|
||||
for pn, pd in props.items():
|
||||
parameters.append({
|
||||
"name": pn,
|
||||
"description": pd.get("description", ""),
|
||||
"type": pd.get("type", "string"),
|
||||
"required": pn in required,
|
||||
})
|
||||
|
||||
metadata = {
|
||||
"name": name,
|
||||
"display_name": {"zh": desc[:50], "en": name},
|
||||
"description": {"zh": desc, "en": desc},
|
||||
"author": ["xiaoli-cli"],
|
||||
"category": "Utility",
|
||||
"tools": [{
|
||||
"name": "execute",
|
||||
"description": {"zh": f"执行 {name}", "en": f"Execute {name}"},
|
||||
"parameters": parameters or [
|
||||
{"name": "args", "description": "操作参数", "type": "string", "required": True}
|
||||
],
|
||||
}],
|
||||
}
|
||||
|
||||
meta_json = json.dumps(metadata, indent=4, ensure_ascii=False)
|
||||
lines = [
|
||||
"/*", "METADATA", meta_json, "*/", "",
|
||||
f"// Auto-generated from xiaoli-cli plugin: {name}",
|
||||
f"// Compatible with Operit AI tool system", "",
|
||||
f"const {name}Bridge = (function () {{",
|
||||
" async function wrap(func, params) {",
|
||||
" try { return await func(params); }",
|
||||
' catch (e) { return { success: false, message: "执行失败: " + e.message }; }',
|
||||
" }",
|
||||
" async function execute(params) {",
|
||||
f" // 由 xiaoli-cli {name} 插件提供",
|
||||
' const args = params.args || JSON.stringify(params);',
|
||||
f' const cmd = "cd /path/to/xiaoli-cli && python3 -c \\"'
|
||||
f"import sys; sys.path.insert(0, '.'); "
|
||||
f"from plugins.{name} import Liugin; p = Liugin(); "
|
||||
f"""print(p.handle('\\" + args + \\"'))\\\\"";""",
|
||||
" try { return { success: true, data: await Tools.System.execute(cmd) }; }",
|
||||
" catch (e) { return { success: false, message: e.message }; }",
|
||||
" }",
|
||||
" return { execute: function(p) { return wrap(execute, p); } };",
|
||||
"})();",
|
||||
f"exports.execute = {name}Bridge.execute;",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Liugin 插件主体
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class Liugin:
|
||||
"""
|
||||
Operit Bridge — 让 xiaoli-cli 兼容 Operit AI 第三方工具格式。
|
||||
|
||||
自动扫描 plugins/operit/ 子目录,解析所有 Operit METADATA 脚本,
|
||||
注册为 xiaoli-cli 可调用的工具。支持双向格式转换和导出。
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.cli = None
|
||||
# 已加载的 Operit 工具: name -> tool_info
|
||||
self._operit_tools: Dict[str, Dict] = {}
|
||||
# 脚本源文件映射
|
||||
self._script_files: Dict[str, str] = {}
|
||||
|
||||
def set_cli(self, cli):
|
||||
self.cli = cli
|
||||
# 挂载后自动扫描 operit 子目录
|
||||
self._auto_scan()
|
||||
|
||||
# ── 自动扫描 plugins/operit/ ──
|
||||
|
||||
def _get_operit_dir(self) -> str:
|
||||
"""获取 plugins/operit/ 绝对路径"""
|
||||
plugin_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
return os.path.join(plugin_dir, "operit")
|
||||
|
||||
def _auto_scan(self):
|
||||
"""启动时自动扫描 operit 子目录"""
|
||||
operit_dir = self._get_operit_dir()
|
||||
if not os.path.isdir(operit_dir):
|
||||
return
|
||||
count = 0
|
||||
for ext in ("*.js", "*.ts"):
|
||||
for fpath in glob.glob(os.path.join(operit_dir, "**", ext), recursive=True):
|
||||
meta = parse_operit_metadata(fpath)
|
||||
if meta:
|
||||
for t in meta_to_tools(meta):
|
||||
t["_source_file"] = fpath
|
||||
self._operit_tools[t["name"]] = t
|
||||
self._script_files[meta.get("name", "")] = fpath
|
||||
count += 1
|
||||
if count:
|
||||
print(f"[operit_bridge] 自动加载了 {count} 个 Operit 脚本, "
|
||||
f"{len(self._operit_tools)} 个工具")
|
||||
|
||||
# ── Liugin 协议接口 ──
|
||||
|
||||
def get_tool_info(self):
|
||||
return {
|
||||
"name": "operit_bridge",
|
||||
"description": "Operit 双向工具桥接 — 自动加载 plugins/operit/ 下的 Operit 脚本, "
|
||||
"支持解析/扫描/导出/格式转换",
|
||||
"keywords": [
|
||||
"operit", "bridge", "桥接", "兼容", "格式转换", "METADATA",
|
||||
"插件互通", "第三方工具", "脚本", "导入", "导出",
|
||||
],
|
||||
"usage": (
|
||||
"operit_bridge <操作> [参数]\n"
|
||||
"操作:\n"
|
||||
" scan [目录] - 扫描 Operit 脚本目录 (默认 plugins/operit/)\n"
|
||||
" list - 列出已加载的 Operit 工具\n"
|
||||
" parse <文件> - 解析单个 Operit METADATA 文件\n"
|
||||
" info <工具名> - 查看工具详情\n"
|
||||
" export <插件名> - 将 xiaoli-cli 插件导出为 Operit 格式\n"
|
||||
" export_all [目录] - 导出所有插件\n"
|
||||
" convert_mcp <JSON> - MCP → Operit\n"
|
||||
" convert_operit <JSON> - Operit → MCP"
|
||||
),
|
||||
}
|
||||
|
||||
def get_mcp_definition(self):
|
||||
return {
|
||||
"name": "operit_bridge",
|
||||
"description": "Operit 双向工具桥接,自动加载 plugins/operit/ 目录下的 Operit 格式脚本",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"operation": {
|
||||
"type": "string",
|
||||
"enum": ["scan", "list", "parse", "info",
|
||||
"export", "export_all", "convert_mcp", "convert_operit"],
|
||||
"description": "操作类型",
|
||||
},
|
||||
"target": {
|
||||
"type": "string",
|
||||
"description": "目标路径/插件名/JSON",
|
||||
},
|
||||
},
|
||||
"required": ["operation"],
|
||||
},
|
||||
}
|
||||
|
||||
def convert_mcp_args(self, arguments):
|
||||
op = arguments.get("operation", "")
|
||||
target = arguments.get("target", "")
|
||||
return f"{op} {target}".strip()
|
||||
|
||||
def handle(self, args: str) -> str:
|
||||
parts = args.strip().split(maxsplit=1)
|
||||
if not parts:
|
||||
return self._help()
|
||||
op = parts[0].lower()
|
||||
target = parts[1].strip() if len(parts) > 1 else ""
|
||||
handler = {
|
||||
"scan": self._h_scan, "list": self._h_list,
|
||||
"parse": self._h_parse, "info": self._h_info,
|
||||
"export": self._h_export, "export_all": self._h_export_all,
|
||||
"convert_mcp": self._h_convert_mcp, "convert_operit": self._h_convert_operit,
|
||||
}.get(op)
|
||||
if not handler:
|
||||
return f"错误: 不支持的操作 '{op}'\n{self._help()}"
|
||||
try:
|
||||
return handler(target)
|
||||
except Exception as e:
|
||||
return f"Operit Bridge 错误: {e}"
|
||||
|
||||
def _help(self):
|
||||
return (
|
||||
"Operit Bridge — 可用操作:\n"
|
||||
" scan [目录] - 扫描 Operit 脚本\n"
|
||||
" list - 列出已加载工具\n"
|
||||
" parse <文件> - 解析单个 METADATA 文件\n"
|
||||
" info <工具名> - 查看工具详情\n"
|
||||
" export <插件名> - 导出 xiaoli 插件为 Operit 格式\n"
|
||||
" export_all [目录] - 批量导出\n"
|
||||
" convert_mcp <JSON> - MCP → Operit\n"
|
||||
" convert_operit <JSON> - Operit → MCP"
|
||||
)
|
||||
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
# 操作实现
|
||||
# ═══════════════════════════════════════════════════════════
|
||||
|
||||
def _h_scan(self, target: str) -> str:
|
||||
"""扫描 Operit 脚本目录"""
|
||||
operit_dir = target or self._get_operit_dir()
|
||||
if not os.path.isdir(operit_dir):
|
||||
return f"错误: 目录不存在 — {operit_dir}\n请将 Operit 脚本放入 plugins/operit/ 目录"
|
||||
|
||||
found, tool_count = 0, 0
|
||||
for ext in ("*.js", "*.ts"):
|
||||
for fpath in glob.glob(os.path.join(operit_dir, "**", ext), recursive=True):
|
||||
meta = parse_operit_metadata(fpath)
|
||||
if meta:
|
||||
tools = meta_to_tools(meta)
|
||||
for t in tools:
|
||||
t["_source_file"] = fpath
|
||||
self._operit_tools[t["name"]] = t
|
||||
self._script_files[meta.get("name", "")] = fpath
|
||||
found += 1
|
||||
tool_count += len(tools)
|
||||
|
||||
lines = [f"✓ 扫描完成: {operit_dir}", f" {found} 个脚本, {tool_count} 个工具", ""]
|
||||
for name, fpath in self._script_files.items():
|
||||
lines.append(f" 📦 {name} — {os.path.basename(fpath)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _h_list(self, _: str) -> str:
|
||||
"""列出已加载的 Operit 工具"""
|
||||
if not self._operit_tools:
|
||||
return ("暂无已加载的 Operit 工具。\n"
|
||||
"请将 .js/.ts 脚本放入 plugins/operit/ 目录,或使用 scan <目录> 手动加载。")
|
||||
lines = [f"已加载 {len(self._operit_tools)} 个 Operit 工具:", ""]
|
||||
for name, t in self._operit_tools.items():
|
||||
src = os.path.basename(t.get("_source_file", "?"))
|
||||
lines.append(f" 🔧 {name} [{src}]")
|
||||
desc = t.get("description", "")
|
||||
if desc:
|
||||
lines.append(f" {desc[:80]}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _h_parse(self, target: str) -> str:
|
||||
"""解析单个 Operit METADATA 文件"""
|
||||
if not target:
|
||||
return "错误: 请提供文件路径"
|
||||
if not os.path.isfile(target):
|
||||
return f"错误: 文件不存在 — {target}"
|
||||
meta = parse_operit_metadata(target)
|
||||
if not meta:
|
||||
return f"错误: 未找到有效 METADATA 块 — {target}"
|
||||
|
||||
tools = meta_to_tools(meta)
|
||||
for t in tools:
|
||||
t["_source_file"] = target
|
||||
self._operit_tools[t["name"]] = t
|
||||
|
||||
lines = [
|
||||
f"✓ 解析成功: {target}",
|
||||
f" 脚本: {meta.get('name', 'N/A')}",
|
||||
f" 描述: {_localized(meta.get('description', ''))}",
|
||||
f" 分类: {meta.get('category', 'N/A')}",
|
||||
]
|
||||
author = meta.get("author", [])
|
||||
if author:
|
||||
lines.append(f" 作者: {', '.join(author) if isinstance(author, list) else author}")
|
||||
lines.append(f" 工具数: {len(tools)}")
|
||||
lines.append("")
|
||||
for t in tools:
|
||||
mcp = t["mcp_definition"]
|
||||
props = mcp["inputSchema"]["properties"]
|
||||
req = mcp["inputSchema"]["required"]
|
||||
lines.append(f" 🔧 {t['name']}")
|
||||
lines.append(f" {t['description']}")
|
||||
if props:
|
||||
ps = []
|
||||
for pn, pd in props.items():
|
||||
r = "*" if pn in req else ""
|
||||
ps.append(f"{pn}({pd.get('type', '?')}){r}")
|
||||
lines.append(f" 参数: {', '.join(ps)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _h_info(self, target: str) -> str:
|
||||
"""查看工具详情"""
|
||||
if not target:
|
||||
return "错误: 请提供工具名"
|
||||
t = self._operit_tools.get(target)
|
||||
if not t:
|
||||
return f"错误: 未找到工具 '{target}'\n可用: {', '.join(self._operit_tools.keys()) or '无'}"
|
||||
lines = [
|
||||
f"工具: {t['name']}",
|
||||
f"描述: {t.get('description', '')}",
|
||||
f"来源: {t.get('_source_file', 'unknown')}",
|
||||
f"脚本: {t.get('script_name', 'N/A')}",
|
||||
f"原始名: {t.get('original_tool_name', 'N/A')}",
|
||||
f"关键词: {', '.join(t.get('keywords', []))}",
|
||||
"", "MCP 定义:",
|
||||
json.dumps(t.get("mcp_definition", {}), indent=2, ensure_ascii=False),
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
def _h_export(self, target: str) -> str:
|
||||
"""将 xiaoli-cli 插件导出为 Operit 格式"""
|
||||
if not target:
|
||||
return "错误: 请提供插件名"
|
||||
instance = self._load_xiaoli_plugin(target)
|
||||
if isinstance(instance, str):
|
||||
return instance # error msg
|
||||
|
||||
js = xiaoli_to_operit_js(instance)
|
||||
out_dir = os.path.join(self._get_operit_dir(), "exported")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
out_path = os.path.join(out_dir, f"{target}.js")
|
||||
with open(out_path, "w", encoding="utf-8") as f:
|
||||
f.write(js)
|
||||
return f"✓ 导出成功!\n 输出: {out_path}\n 可直接放入 Operit AI 使用"
|
||||
|
||||
def _h_export_all(self, target: str) -> str:
|
||||
"""批量导出所有 xiaoli 插件"""
|
||||
plugins_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
out_dir = target or os.path.join(self._get_operit_dir(), "exported")
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
exported, errors = [], []
|
||||
for fname in sorted(os.listdir(plugins_dir)):
|
||||
if not fname.endswith(".py") or fname == "__init__.py":
|
||||
continue
|
||||
name = fname[:-3]
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(name, os.path.join(plugins_dir, fname))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
cls = getattr(mod, 'Liugin', None) or getattr(mod, 'Plugin', None)
|
||||
if cls:
|
||||
inst = cls()
|
||||
with open(os.path.join(out_dir, f"{name}.js"), "w", encoding="utf-8") as f:
|
||||
f.write(xiaoli_to_operit_js(inst))
|
||||
exported.append(name)
|
||||
except Exception as e:
|
||||
errors.append(f"{name}: {e}")
|
||||
lines = [f"✓ 批量导出完成", f" 目录: {out_dir}", f" 成功: {len(exported)}"]
|
||||
if errors:
|
||||
lines.append(f" 失败: {len(errors)}")
|
||||
for err in errors[:5]:
|
||||
lines.append(f" ⚠ {err}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _h_convert_mcp(self, target: str) -> str:
|
||||
"""MCP → Operit METADATA"""
|
||||
if not target:
|
||||
return "错误: 请提供 MCP JSON"
|
||||
mcp = json.loads(target)
|
||||
name = mcp.get("name", "unknown")
|
||||
desc = mcp.get("description", "")
|
||||
props = mcp.get("inputSchema", {}).get("properties", {})
|
||||
required = mcp.get("inputSchema", {}).get("required", [])
|
||||
params = []
|
||||
for pn, pd in props.items():
|
||||
params.append({"name": pn, "description": pd.get("description", ""),
|
||||
"type": pd.get("type", "string"), "required": pn in required})
|
||||
operit = {
|
||||
"name": name,
|
||||
"display_name": {"zh": desc[:50] or name, "en": name},
|
||||
"description": {"zh": desc, "en": desc},
|
||||
"author": ["xiaoli-cli"],
|
||||
"category": "Utility",
|
||||
"tools": [{"name": "execute", "description": {"zh": f"执行 {name}", "en": f"Execute {name}"},
|
||||
"parameters": params}],
|
||||
}
|
||||
return json.dumps(operit, indent=2, ensure_ascii=False)
|
||||
|
||||
def _h_convert_operit(self, target: str) -> str:
|
||||
"""Operit → MCP"""
|
||||
if not target:
|
||||
return "错误: 请提供 Operit METADATA JSON"
|
||||
meta = json.loads(target)
|
||||
results = [operit_tool_to_mcp(t, meta.get("name", "unknown")) for t in meta.get("tools", [])]
|
||||
return json.dumps(results, indent=2, ensure_ascii=False)
|
||||
|
||||
# ── 辅助 ──
|
||||
|
||||
def _load_xiaoli_plugin(self, name: str):
|
||||
"""加载一个 xiaoli-cli 插件实例,返回实例或错误字符串"""
|
||||
plugins_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
fpath = os.path.join(plugins_dir, f"{name}.py")
|
||||
if not os.path.isfile(fpath):
|
||||
# 按 name 搜索
|
||||
for fname in os.listdir(plugins_dir):
|
||||
if fname.endswith(".py") and fname != "__init__.py":
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(fname[:-3],
|
||||
os.path.join(plugins_dir, fname))
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
cls = getattr(mod, 'Liugin', None) or getattr(mod, 'Plugin', None)
|
||||
if cls:
|
||||
inst = cls()
|
||||
if inst.get_tool_info().get("name", "").lower() == name.lower():
|
||||
return inst
|
||||
except Exception:
|
||||
continue
|
||||
return f"错误: 未找到插件 '{name}'"
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(name, fpath)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
cls = getattr(mod, 'Liugin', None) or getattr(mod, 'Plugin', None)
|
||||
if not cls:
|
||||
return f"错误: 插件文件中未找到 Liugin/Plugin 类"
|
||||
return cls()
|
||||
except Exception as e:
|
||||
return f"错误: 加载失败 — {e}"
|
||||
|
||||
# ── 给 plugin_manager 用的工具注册接口 ──
|
||||
|
||||
def get_operit_tools_for_registration(self) -> List[Dict]:
|
||||
"""
|
||||
返回所有已加载的 Operit 工具,格式兼容 plugin_manager.tools 列表。
|
||||
可被 cli_base 在初始化时调用,将 Operit 工具注入工具列表。
|
||||
"""
|
||||
result = []
|
||||
for name, t in self._operit_tools.items():
|
||||
result.append({
|
||||
"name": name,
|
||||
"description": t.get("description", ""),
|
||||
"keywords": t.get("keywords", []),
|
||||
"usage": t.get("usage", ""),
|
||||
"handler": self._make_handler(name),
|
||||
"source": "operit",
|
||||
})
|
||||
return result
|
||||
|
||||
def _make_handler(self, tool_name: str):
|
||||
"""为 Operit 工具创建 handler 闭包"""
|
||||
tool_info = self._operit_tools.get(tool_name)
|
||||
if not tool_info:
|
||||
return lambda args: f"错误: 工具 {tool_name} 未加载"
|
||||
|
||||
def handler(args: str) -> str:
|
||||
# 解析参数: 支持 JSON 和 "key=value" 两种格式
|
||||
params = {}
|
||||
args = args.strip()
|
||||
if args.startswith("{"):
|
||||
try:
|
||||
params = json.loads(args)
|
||||
except json.JSONDecodeError:
|
||||
params = {"args": args}
|
||||
else:
|
||||
# "city=北京 days=3" 格式
|
||||
for part in re.split(r'[;\s]+', args):
|
||||
if '=' in part:
|
||||
k, v = part.split('=', 1)
|
||||
params[k.strip()] = v.strip()
|
||||
elif part:
|
||||
# 单个值填入第一个参数
|
||||
schema_props = tool_info["mcp_definition"]["inputSchema"]["properties"]
|
||||
for pname in schema_props:
|
||||
if pname not in params:
|
||||
params[pname] = part
|
||||
break
|
||||
|
||||
# 如果有 _source_file,尝试用 Node.js 执行
|
||||
source_file = tool_info.get("_source_file", "")
|
||||
original_name = tool_info.get("original_tool_name", "execute")
|
||||
|
||||
if source_file and os.path.isfile(source_file):
|
||||
return self._exec_operit_script(source_file, original_name, params)
|
||||
|
||||
# 否则返回参数解析结果(供 AI 理解)
|
||||
return json.dumps({
|
||||
"status": "parsed",
|
||||
"tool": tool_name,
|
||||
"original_tool": original_name,
|
||||
"source": source_file,
|
||||
"params": params,
|
||||
"note": "如需执行,请确保设备上有 Node.js 运行时",
|
||||
}, ensure_ascii=False)
|
||||
|
||||
return handler
|
||||
|
||||
def _exec_operit_script(self, source_file: str, func_name: str, params: Dict) -> str:
|
||||
"""通过 Node.js 执行 Operit 脚本中的指定函数"""
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
# 生成一个临时执行器脚本
|
||||
runner_code = f"""
|
||||
const path = require('path');
|
||||
const mod = require({json.dumps(source_file)});
|
||||
const func = mod[{json.dumps(func_name)}];
|
||||
if (!func) {{
|
||||
console.log(JSON.stringify({{error: "函数 '{func_name}' 不存在"}}));
|
||||
process.exit(1);
|
||||
}}
|
||||
const params = {json.dumps(params)};
|
||||
func(params).then(result => {{
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}}).catch(err => {{
|
||||
console.log(JSON.stringify({{error: err.message}}));
|
||||
process.exit(1);
|
||||
}});
|
||||
"""
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.js', delete=False,
|
||||
encoding='utf-8') as f:
|
||||
f.write(runner_code)
|
||||
runner_path = f.name
|
||||
|
||||
result = subprocess.run(
|
||||
["node", runner_path],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
os.unlink(runner_path)
|
||||
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
else:
|
||||
return f"执行失败: {result.stderr.strip() or result.stdout.strip()}"
|
||||
except FileNotFoundError:
|
||||
return ("错误: 未找到 Node.js 运行时。\n"
|
||||
"请安装 Node.js 以执行 Operit 脚本,或将脚本移植为 Python 版本。")
|
||||
except subprocess.TimeoutExpired:
|
||||
return "错误: 脚本执行超时 (30s)"
|
||||
except Exception as e:
|
||||
return f"执行错误: {e}"
|
||||
+1015
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,7 @@
|
||||
# LGPL-3.0 License
|
||||
|
||||
`scripts/` 目录下的文件来自 [Operit AI 项目](https://github.com/AAswordman/Operit),遵循 **GNU Lesser General Public License v3.0**。
|
||||
|
||||
完整许可证文本请参见: https://www.gnu.org/licenses/lgpl-3.0.html
|
||||
|
||||
原项目仓库: https://github.com/AAswordman/Operit
|
||||
@@ -0,0 +1,84 @@
|
||||
# Operit 脚本目录
|
||||
|
||||
此目录存放 **Operit AI 格式**的第三方工具脚本(`.js` / `.ts`)。
|
||||
|
||||
`operit_bridge` 插件会自动扫描此目录,解析所有带 `METADATA` 块的脚本,
|
||||
并将它们注册为 xiaoli-cli 可调用的工具。
|
||||
|
||||
## 如何使用
|
||||
|
||||
### 1. 放入 Operit 脚本
|
||||
|
||||
将 Operit 格式的 `.js` 或 `.ts` 文件放入此目录即可。脚本必须包含 `/* METADATA ... */` 块。
|
||||
|
||||
```bash
|
||||
# 从 Operit 社区下载脚本
|
||||
cp ~/Downloads/some_tool.js plugins/operit/
|
||||
|
||||
# 或从 Operit 仓库克隆
|
||||
git clone https://github.com/AAswordman/Operit.git /tmp/operit-repo
|
||||
cp /tmp/operit-repo/examples/*.ts plugins/operit/
|
||||
```
|
||||
|
||||
### 2. 脚本格式要求
|
||||
|
||||
每个脚本头部必须有 METADATA 块:
|
||||
|
||||
```javascript
|
||||
/*
|
||||
METADATA
|
||||
{
|
||||
"name": "ToolName",
|
||||
"display_name": { "zh": "工具名", "en": "Tool Name" },
|
||||
"description": "工具描述",
|
||||
"author": ["作者"],
|
||||
"category": "Utility",
|
||||
"tools": [
|
||||
{
|
||||
"name": "function_name",
|
||||
"description": "功能描述",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "param1",
|
||||
"description": "参数说明",
|
||||
"type": "string",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
*/
|
||||
```
|
||||
|
||||
### 3. 在 xiaoli-cli 中使用
|
||||
|
||||
```
|
||||
operit_bridge list # 查看已加载的 Operit 工具
|
||||
operit_bridge scan plugins/operit # 手动重新扫描
|
||||
operit_bridge info ToolName__function_name # 查看工具详情
|
||||
```
|
||||
|
||||
AI 也可以直接通过 Function Calling 调用这些工具。
|
||||
|
||||
## 支持的 category
|
||||
|
||||
- `Automatic` - 自动化
|
||||
- `Chat` - 对话
|
||||
- `Development` - 开发
|
||||
- `Draw` - 绘图
|
||||
- `File` - 文件
|
||||
- `Life` - 生活
|
||||
- `Map` - 地图
|
||||
- `Media` - 媒体
|
||||
- `Memory` - 记忆
|
||||
- `Network` - 网络
|
||||
- `Search` - 搜索
|
||||
- `System` - 系统
|
||||
- `Utility` - 工具
|
||||
- `Workflow` - 工作流
|
||||
|
||||
## 参考
|
||||
|
||||
- Operit 脚本开发指南: https://github.com/AAswordman/Operit/blob/main/docs/SCRIPT_DEV_GUIDE.md
|
||||
- Operit 示例脚本: https://github.com/AAswordman/Operit/tree/main/examples
|
||||
@@ -0,0 +1,458 @@
|
||||
/* METADATA
|
||||
{
|
||||
"name": "ai_chat",
|
||||
|
||||
"display_name": {
|
||||
"zh": "AI 相互对话",
|
||||
"en": "AI-to-AI Chat"
|
||||
},
|
||||
"description": {
|
||||
"zh": "调用AI模型API实现AI之间智能对话互动。",
|
||||
"en": "Call an AI model API to enable interactive conversations between AIs."
|
||||
},
|
||||
"env": ["AI_API_BASE_URL", "AI_API_KEY", "AI_MODEL_NAME"],
|
||||
"category": "Chat",
|
||||
"tools": [{
|
||||
"name": "chat_completion",
|
||||
"description": {
|
||||
"zh": "发送消息给AI模型并获取回复。支持超时保护(默认30秒)。从根源上禁止分点列表输出。",
|
||||
"en": "Send messages to an AI model and get responses. Includes timeout protection (default: 30s). Enforces non-bulleted output from the source."
|
||||
},
|
||||
"parameters": [
|
||||
{"name": "messages", "description": {"zh": "消息数组或字符串", "en": "Message array or a string"}, "type": "any", "required": true},
|
||||
{"name": "system_prompt", "description": {"zh": "系统提示词(会自动追加禁止分点指令)", "en": "System prompt (the system will automatically append non-bulleted instructions)"}, "type": "string", "required": false},
|
||||
{"name": "temperature", "description": {"zh": "温度参数(0.0-2.0)", "en": "Temperature (0.0-2.0)"}, "type": "number", "required": false, "default": 0.7},
|
||||
{"name": "max_tokens", "description": {"zh": "最大生成长度", "en": "Maximum generation length"}, "type": "number", "required": false},
|
||||
{"name": "timeout", "description": {"zh": "超时时间(毫秒)", "en": "Timeout (milliseconds)"}, "type": "number", "required": false, "default": 30000}
|
||||
]
|
||||
}]
|
||||
} */
|
||||
|
||||
/// <reference path="./types/index.d.ts" />
|
||||
|
||||
const aiModelInteraction = (function () {
|
||||
type ChatRole = 'system' | 'user' | 'assistant' | 'function';
|
||||
|
||||
type ChatMessage = {
|
||||
role: ChatRole;
|
||||
content: string;
|
||||
name?: string;
|
||||
function_call?: {
|
||||
name: string;
|
||||
arguments?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ChatCompletionToolParams = {
|
||||
messages: ChatMessage[] | string;
|
||||
message?: string;
|
||||
system_prompt?: string;
|
||||
temperature?: number;
|
||||
max_tokens?: number;
|
||||
timeout?: number;
|
||||
functions?: any[];
|
||||
};
|
||||
|
||||
type UniversalHttpResponse = {
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers?: Record<string, string>;
|
||||
body: any;
|
||||
};
|
||||
|
||||
async function universalHttpRequest(
|
||||
url: string,
|
||||
method: string = 'POST',
|
||||
headers: Record<string, string> = {},
|
||||
body: any = null,
|
||||
responseType: 'json' | 'text' = 'json',
|
||||
timeout: number = 30000
|
||||
): Promise<UniversalHttpResponse> {
|
||||
const timeoutPromise: Promise<never> = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`请求超时:${timeout}ms`)), timeout)
|
||||
);
|
||||
|
||||
const requestPromise: Promise<UniversalHttpResponse> = (async () => {
|
||||
if (typeof OkHttp === 'undefined') {
|
||||
throw new Error('OkHttp 不可用');
|
||||
}
|
||||
|
||||
const client = OkHttp.newBuilder()
|
||||
.connectTimeout(timeout)
|
||||
.readTimeout(timeout)
|
||||
.writeTimeout(timeout)
|
||||
.build();
|
||||
|
||||
const requestBuilder = client.newRequest()
|
||||
.url(url)
|
||||
.method(method.toUpperCase());
|
||||
|
||||
if (headers && Object.keys(headers).length > 0) {
|
||||
requestBuilder.headers(headers);
|
||||
}
|
||||
|
||||
const upperMethod = method.toUpperCase();
|
||||
if (body !== null && body !== undefined && upperMethod !== 'GET' && upperMethod !== 'HEAD') {
|
||||
if (typeof body === 'string') {
|
||||
requestBuilder.body(body, 'text');
|
||||
} else {
|
||||
requestBuilder.body(body, 'json');
|
||||
}
|
||||
}
|
||||
|
||||
const response: OkHttpResponse = await requestBuilder.build().execute();
|
||||
const resultBody = responseType === 'json' ? response.json() : response.content;
|
||||
return {
|
||||
status: response.statusCode,
|
||||
statusText: response.statusMessage || '',
|
||||
headers: response.headers,
|
||||
body: resultBody,
|
||||
};
|
||||
})();
|
||||
|
||||
return Promise.race([requestPromise, timeoutPromise]);
|
||||
}
|
||||
|
||||
function cleanText(text: unknown): string {
|
||||
if (!text || typeof text !== 'string') return String(text ?? '');
|
||||
|
||||
let cleaned: string = text;
|
||||
|
||||
// 处理转义与实体
|
||||
cleaned = cleaned.replace(/\|["\\]?n/g, '\n');
|
||||
cleaned = cleaned.replace(/\\\\([\\nrt"'&])/g, (m, c) => ({ n: '\n', r: '\r', t: '\t', '"': '"', "'": "'", "&": "&" }[c] || m));
|
||||
cleaned = cleaned.replace(/\\u([0-9A-Fa-f]{4})/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
|
||||
|
||||
// 一次性替换HTML实体
|
||||
const entities = { quot: '"', amp: '&', lt: '<', gt: '>', nbsp: ' ', '#39': "'", apos: "'" };
|
||||
cleaned = cleaned.replace(/&(\w+|#\d+);/g, (m, e) => entities[e] || m);
|
||||
|
||||
// 清理代码块
|
||||
cleaned = cleaned.replace(/```[\w-]*\s*\n([\s\S]*?)```/g, '$1').replace(/```/g, '').replace(/`([^`]+)`/g, '$1');
|
||||
|
||||
// 格式化空白与分点
|
||||
cleaned = cleaned.replace(/[ \t]{2,}/g, ' ');
|
||||
cleaned = cleaned.split('\n').map(l => l.replace(/^[\s\uFEFF\xA0\u3000\u200B-\u200D]+/g, '')).join('\n');
|
||||
cleaned = cleaned.replace(/\n{3,}/g, '\n\n').trim();
|
||||
cleaned = cleaned.replace(/^\s*[-•●]\s+|^\s*\d+\.\s+|^\s*\d+\)\s+|^\s*\([a-zA-Z]\)\s+/gm, '');
|
||||
|
||||
return cleaned || text;
|
||||
}
|
||||
|
||||
function getConfig(varName: string, defaultValue: string | null = null): string {
|
||||
try {
|
||||
const value = getEnv(varName);
|
||||
if (!value || value === `YOUR_${varName}`) {
|
||||
if (defaultValue !== null) return defaultValue;
|
||||
throw new Error(`${varName} 未配置`);
|
||||
}
|
||||
return value.trim();
|
||||
} catch (e) {
|
||||
if (defaultValue !== null) return defaultValue;
|
||||
throw new Error(`${varName} 未配置`);
|
||||
}
|
||||
}
|
||||
|
||||
function getFullConfig() {
|
||||
try {
|
||||
return {
|
||||
apiBaseUrl: getConfig('AI_API_BASE_URL', ''),
|
||||
apiKey: getConfig('AI_API_KEY', ''),
|
||||
modelName: getConfig('AI_MODEL_NAME', ''),
|
||||
timeout: 30000
|
||||
};
|
||||
} catch {
|
||||
return { apiBaseUrl: '', apiKey: '', modelName: '', timeout: 30000 };
|
||||
}
|
||||
}
|
||||
|
||||
function joinUrl(baseUrl: string, path: string): string {
|
||||
const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
|
||||
const normalizedPath = path.startsWith('/') ? path.slice(1) : path;
|
||||
return `${normalizedBase}${normalizedPath}`;
|
||||
}
|
||||
|
||||
async function tryEndpoints(baseUrl: string, payload: Record<string, any>, config: { apiKey: string }, timeout: number): Promise<UniversalHttpResponse> {
|
||||
if (baseUrl.includes('chat/completions')) {
|
||||
return await universalHttpRequest(
|
||||
baseUrl,
|
||||
'POST',
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${config.apiKey}`
|
||||
},
|
||||
payload,
|
||||
'json',
|
||||
timeout
|
||||
);
|
||||
}
|
||||
|
||||
const endpoints = ['v1/chat/completions', 'chat/completions'];
|
||||
let lastError;
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
try {
|
||||
const url = joinUrl(baseUrl, endpoint);
|
||||
const response = await universalHttpRequest(
|
||||
url,
|
||||
'POST',
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${config.apiKey}`
|
||||
},
|
||||
payload,
|
||||
'json',
|
||||
timeout
|
||||
);
|
||||
if (response.status === 200) return response;
|
||||
lastError = new Error(`端点 ${endpoint} 返回 ${response.status}`);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('所有端点尝试失败');
|
||||
}
|
||||
|
||||
async function chat_completion_logic(rawInput: ChatCompletionToolParams | string, timeout: number) {
|
||||
if (rawInput === null || rawInput === undefined) {
|
||||
throw new Error('参数错误: 输入参数不能为空');
|
||||
}
|
||||
|
||||
const internalParams: {
|
||||
messages: ChatMessage[];
|
||||
system_prompt?: string;
|
||||
temperature: number;
|
||||
max_tokens?: number;
|
||||
functions?: any[];
|
||||
} = {
|
||||
messages: [],
|
||||
system_prompt: undefined,
|
||||
temperature: 0.7,
|
||||
max_tokens: undefined,
|
||||
functions: undefined,
|
||||
};
|
||||
|
||||
// 处理输入参数
|
||||
if (typeof rawInput === 'string') {
|
||||
internalParams.messages = [{ role: 'user', content: rawInput }];
|
||||
} else if (typeof rawInput === 'object') {
|
||||
const hasMessageField = 'message' in rawInput;
|
||||
const hasMessagesField = 'messages' in rawInput;
|
||||
|
||||
if (hasMessagesField) {
|
||||
const messagesValue = (rawInput as ChatCompletionToolParams).messages;
|
||||
if (Array.isArray(messagesValue)) {
|
||||
internalParams.messages = messagesValue as ChatMessage[];
|
||||
} else if (typeof messagesValue === 'string') {
|
||||
internalParams.messages = [{ role: 'user', content: messagesValue }];
|
||||
} else {
|
||||
throw new Error(`'messages' 必须是数组或字符串`);
|
||||
}
|
||||
} else if (hasMessageField) {
|
||||
const messageValue = (rawInput as ChatCompletionToolParams).message;
|
||||
if (typeof messageValue !== 'string') {
|
||||
throw new Error(`'message' 必须是字符串`);
|
||||
}
|
||||
internalParams.messages = [{ role: 'user', content: messageValue }];
|
||||
} else {
|
||||
throw new Error(`对象必须包含 'message' 或 'messages' 字段`);
|
||||
}
|
||||
|
||||
internalParams.system_prompt = (rawInput as ChatCompletionToolParams).system_prompt ? String((rawInput as ChatCompletionToolParams).system_prompt) : undefined;
|
||||
internalParams.temperature = (rawInput as ChatCompletionToolParams).temperature !== undefined ? Number((rawInput as ChatCompletionToolParams).temperature) : 0.7;
|
||||
if (isNaN(internalParams.temperature)) internalParams.temperature = 0.7;
|
||||
|
||||
internalParams.max_tokens = (rawInput as ChatCompletionToolParams).max_tokens !== undefined ? Number((rawInput as ChatCompletionToolParams).max_tokens) : undefined;
|
||||
if (internalParams.max_tokens !== undefined && isNaN(internalParams.max_tokens)) {
|
||||
internalParams.max_tokens = undefined;
|
||||
}
|
||||
|
||||
internalParams.functions = Array.isArray((rawInput as ChatCompletionToolParams).functions) ? (rawInput as ChatCompletionToolParams).functions : undefined;
|
||||
} else {
|
||||
throw new Error(`不支持的参数类型 '${typeof rawInput}'`);
|
||||
}
|
||||
|
||||
// 验证messages
|
||||
if (!Array.isArray(internalParams.messages) || internalParams.messages.length === 0) {
|
||||
throw new Error('messages必须是有效数组且不为空');
|
||||
}
|
||||
|
||||
internalParams.messages.forEach((msg, idx) => {
|
||||
if (!msg || typeof msg !== 'object' || !msg.role || typeof msg.content !== 'string') {
|
||||
throw new Error(`消息 #${idx} 格式无效`);
|
||||
}
|
||||
});
|
||||
|
||||
// 获取配置
|
||||
const config = getFullConfig();
|
||||
if (!config.apiBaseUrl || !config.apiKey) {
|
||||
throw new Error('AI_API_BASE_URL 和 AI_API_KEY 必须配置');
|
||||
}
|
||||
|
||||
// 构建消息数组
|
||||
const antiListInstruction = "【重要指令】你必须以连续段落的方式回答,严禁使用任何分点、列表、编号或项目符号格式。";
|
||||
const finalMessages = [
|
||||
{
|
||||
role: 'system',
|
||||
content: internalParams.system_prompt
|
||||
? internalParams.system_prompt + "\n\n" + antiListInstruction
|
||||
: antiListInstruction
|
||||
},
|
||||
...internalParams.messages
|
||||
];
|
||||
|
||||
// 构建请求
|
||||
const payload: Record<string, any> = {
|
||||
model: String(config.modelName || 'gpt-3.5-turbo'),
|
||||
messages: finalMessages,
|
||||
temperature: Number(internalParams.temperature)
|
||||
};
|
||||
|
||||
if (internalParams.max_tokens !== undefined && !isNaN(internalParams.max_tokens)) {
|
||||
payload.max_tokens = Number(internalParams.max_tokens);
|
||||
}
|
||||
if (internalParams.functions) payload.functions = internalParams.functions;
|
||||
|
||||
Object.keys(payload).forEach(key => payload[key] === undefined && delete payload[key]);
|
||||
|
||||
// 发送请求
|
||||
let response;
|
||||
try {
|
||||
response = await tryEndpoints(config.apiBaseUrl, payload, config, timeout);
|
||||
} catch {
|
||||
response = await universalHttpRequest(
|
||||
config.apiBaseUrl,
|
||||
'POST',
|
||||
{
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${config.apiKey}`
|
||||
},
|
||||
payload,
|
||||
'json',
|
||||
timeout
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status !== 200) {
|
||||
let errorMsg = `API请求失败: ${response.status}`;
|
||||
try {
|
||||
const errorData = typeof response.body === 'string' ? JSON.parse(response.body) : response.body;
|
||||
errorMsg += ` - ${errorData.error?.message || JSON.stringify(errorData)}`;
|
||||
} catch {
|
||||
errorMsg += ` - ${response.statusText}`;
|
||||
}
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
const result = typeof response.body === 'string' ? JSON.parse(response.body) : response.body;
|
||||
if (!result.choices?.[0]) {
|
||||
throw new Error('API返回格式异常');
|
||||
}
|
||||
|
||||
const choice = result.choices[0];
|
||||
const reply: any = {
|
||||
message: choice.message,
|
||||
finish_reason: choice.finish_reason,
|
||||
usage: result.usage || null,
|
||||
model: result.model || config.modelName,
|
||||
id: result.id,
|
||||
created: result.created
|
||||
};
|
||||
|
||||
if (choice.message.function_call) {
|
||||
reply.is_function_call = true;
|
||||
reply.function_name = choice.message.function_call.name;
|
||||
reply.function_arguments = JSON.parse(choice.message.function_call.arguments || '{}');
|
||||
}
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
async function chat_completion_impl(params: ChatCompletionToolParams | string) {
|
||||
const normalizedParams: ChatCompletionToolParams = typeof params === 'object'
|
||||
? (params as ChatCompletionToolParams)
|
||||
: { messages: String(params) };
|
||||
|
||||
const timeout = normalizedParams.timeout || 30000;
|
||||
|
||||
const result = await Promise.race([
|
||||
chat_completion_logic(normalizedParams, timeout),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`操作超时:${timeout}ms`)), timeout)
|
||||
)
|
||||
]);
|
||||
|
||||
const rawReply = result.message?.content;
|
||||
const cleanedReply = cleanText(rawReply);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "AI回复获取成功!",
|
||||
data: result,
|
||||
reply: cleanedReply,
|
||||
raw_reply: rawReply,
|
||||
usage: result.usage,
|
||||
anti_list_applied: true
|
||||
};
|
||||
}
|
||||
|
||||
async function wrapToolExecution<T>(func: (params: any) => Promise<T>, params?: any) {
|
||||
try {
|
||||
const result = await func(params || {});
|
||||
complete(result);
|
||||
} catch (error: any) {
|
||||
console.error(`Tool ${func.name} failed unexpectedly`, error);
|
||||
complete({
|
||||
success: false,
|
||||
message: `AI对话失败: ${String(error && error.message ? error.message : error)}`,
|
||||
error_stack: error && error.stack
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function chat_completion(params?: ChatCompletionToolParams | string) {
|
||||
return await wrapToolExecution(chat_completion_impl, params);
|
||||
}
|
||||
|
||||
async function main(params?: { message?: string; temperature?: number; timeout?: number }) {
|
||||
const config = getFullConfig();
|
||||
if (!config.apiBaseUrl || !config.apiKey) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'AI_API_BASE_URL 和 AI_API_KEY 必须配置',
|
||||
config
|
||||
};
|
||||
}
|
||||
|
||||
return await chat_completion_impl({
|
||||
messages: params?.message || 'Hello! Please respond in a continuous paragraph without any list or bullet points.',
|
||||
temperature: params?.temperature ?? 0.2,
|
||||
timeout: params?.timeout ?? 15000,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
chat_completion,
|
||||
single_message: chat_completion,
|
||||
test_connection: async (params?: { timeout?: number }) => wrapToolExecution(
|
||||
async () => await chat_completion_impl({
|
||||
messages: "Hello! Please respond in a continuous paragraph without any list or bullet points.",
|
||||
temperature: 0.1,
|
||||
timeout: params?.timeout ?? 10000
|
||||
}),
|
||||
{}
|
||||
),
|
||||
main: (params?: { message?: string; temperature?: number; timeout?: number }) => wrapToolExecution(main, params),
|
||||
getConfig: getFullConfig,
|
||||
_makeHttpRequest: universalHttpRequest,
|
||||
_cleanText: cleanText
|
||||
};
|
||||
})();
|
||||
|
||||
// 导出工具函数(CommonJS)
|
||||
exports.chat_completion = aiModelInteraction.chat_completion;
|
||||
exports.single_message = aiModelInteraction.single_message;
|
||||
exports.test_connection = aiModelInteraction.test_connection;
|
||||
exports.getConfig = aiModelInteraction.getConfig;
|
||||
exports.main = aiModelInteraction.main;
|
||||
@@ -0,0 +1,633 @@
|
||||
/*
|
||||
METADATA
|
||||
{
|
||||
// 百度地图智能助手
|
||||
name: "Automatic_baiduMap_assistant",
|
||||
display_name: {
|
||||
zh: "百度地图自动化助手"
|
||||
en: "Baidu Map Automation Assistant"
|
||||
}description: { zh: "高级百度地图智能助手,通过UI自动化技术实现地图交互,支持地点搜索、路线规划、周边查询等功能,为AI提供强大的地理位置服务能力。适用于出行规划、信息查询、智能问答等场景。", en: "Advanced Baidu Maps assistant powered by UI automation. Supports place search, navigation/route planning, and nearby discovery, enabling AI-driven location services. Useful for trip planning, information lookup, and Q&A." },
|
||||
|
||||
// Tools in this package
|
||||
category: "Automatic"
|
||||
tools: [
|
||||
{
|
||||
name: "workflow_guide",
|
||||
description: { zh: "百度地图助手工具使用流程指南。要完成复杂任务,请按以下顺序组合使用工具:\n1. **搜索地点**: 使用 `search_location` 查找地点、餐馆、公司等,这是多数操作的起点。\n2. **选择地点**: 如果 `search_location` 返回多个结果,使用 `select_location_from_list` 从列表中选择一个。\n3. **开始导航**: 搜索到唯一地点或从列表中选择地点后,使用 `go_to_location` 来规划到该地的路线。\n4. **周边搜索**: 使用 `search_nearby` 查找当前位置或指定地点附近的设施。\n- **随时导航**: `navigate_to_home` 可随时返回地图主页。", en: "Workflow guide for the Baidu Maps assistant. For complex tasks, combine tools in this order:\n1. **Search**: use `search_location` to find places/restaurants/companies (starting point for most tasks).\n2. **Select**: if `search_location` returns multiple results, use `select_location_from_list` to pick one from the list.\n3. **Navigate**: after a unique result is found or selected, use `go_to_location` to plan a route.\n4. **Nearby**: use `search_nearby` to find facilities near your current location or a specified place.\n- **Go home anytime**: use `navigate_to_home` to return to the map home screen." }
|
||||
parameters: []
|
||||
advice: true
|
||||
},
|
||||
{
|
||||
name: "search_location",
|
||||
description: { zh: "在百度地图中搜索地点、餐馆、公司等信息。", en: "Search places, restaurants, companies, and other POIs in Baidu Maps." },
|
||||
parameters: [
|
||||
{
|
||||
name: "keyword",
|
||||
description: { zh: "要搜索的地点关键词", en: "Keyword of the place to search for." },
|
||||
type: "string",
|
||||
required: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "get_directions",
|
||||
description: { zh: "规划从起点到终点的路线。", en: "Plan a route from a start point to a destination." },
|
||||
parameters: [
|
||||
{
|
||||
name: "start_point",
|
||||
description: { zh: "路线起点,默认为'我的位置'", en: "Route start point (default: 'My location')." },
|
||||
type: "string",
|
||||
required: false
|
||||
},
|
||||
{
|
||||
name: "end_point",
|
||||
description: { zh: "路线终点", en: "Route destination." },
|
||||
type: "string",
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: "transport_mode",
|
||||
description: { zh: "交通方式:driving(驾车), transit(公交), walking(步行), cycling(骑行)", en: "Transport mode: driving, transit, walking, cycling." },
|
||||
type: "string",
|
||||
required: false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "select_location_from_list",
|
||||
description: { zh: "从 'search_location' 返回的搜索结果列表中选择一个地点。", en: "Select a place from the result list returned by `search_location`." },
|
||||
parameters: [
|
||||
{
|
||||
name: "keyword",
|
||||
description: { zh: "要选择的地点名称关键词", en: "Keyword to match the place name to select." },
|
||||
type: "string",
|
||||
required: false
|
||||
},
|
||||
{
|
||||
name: "index",
|
||||
description: { zh: "要选择的地点在列表中的索引位置(从1开始)", en: "Index of the item in the list (1-based)." },
|
||||
type: "number",
|
||||
required: false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "go_to_location",
|
||||
description: { zh: "在地点详情页点击“到这去”,以开启导航或路线规划。可以选择交通方式。", en: "On the place detail page, tap \"Go\" (到这去) to start navigation/route planning. You can optionally select a transport mode." },
|
||||
parameters: [
|
||||
{
|
||||
name: "transport_mode",
|
||||
description: { zh: "交通方式:新能源, 驾车, 打车, 公共交通, 代驾, 骑行, 步行, 拼车, 飞机, 火车, 客车, 摩托车", en: "Transport mode tab label in the app (e.g., 新能源, 驾车, 打车, 公共交通, 代驾, 骑行, 步行, 拼车, 飞机, 火车, 客车, 摩托车)." },
|
||||
type: "string",
|
||||
required: false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "search_nearby",
|
||||
description: { zh: "搜索中心点附近的地点信息。", en: "Search places around a center point." },
|
||||
parameters: [
|
||||
{
|
||||
name: "keyword",
|
||||
description: { zh: "要搜索的周边设施关键词(如:银行, 加油站)", en: "Nearby keyword (e.g., bank, gas station)." },
|
||||
type: "string",
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: "center_point",
|
||||
description: { zh: "搜索的中心点,默认为'我的位置'", en: "Center point for the search (default: 'My location')." },
|
||||
type: "string",
|
||||
required: false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "navigate_to_home",
|
||||
description: { zh: "返回百度地图的主界面。", en: "Return to the Baidu Maps home screen." },
|
||||
parameters: []
|
||||
},
|
||||
{
|
||||
name: "activate_voice_assistant",
|
||||
description: { zh: "在主界面点击语音搜索按钮,启动“小度”语音助手。", en: "Tap the voice search button on the home screen to launch the Xiaodu voice assistant." },
|
||||
parameters: []
|
||||
}
|
||||
]
|
||||
}*/
|
||||
|
||||
interface Array<T> {
|
||||
at(index: number): T | undefined;
|
||||
}
|
||||
|
||||
const BaiduMapAssistant = (function () {
|
||||
|
||||
// 添加 Array.prototype.at 支持
|
||||
if (!Array.prototype.at) {
|
||||
Object.defineProperty(Array.prototype, 'at', {
|
||||
value: function (index: number) {
|
||||
if (index < 0) {
|
||||
index += this.length;
|
||||
}
|
||||
return this[index];
|
||||
},
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
writable: true
|
||||
});
|
||||
}
|
||||
|
||||
// 百度地图应用包名
|
||||
const BAIDU_MAP_PACKAGE = "com.baidu.BaiduMap";
|
||||
const MAIN_ACTIVITY = "com.baidu.baidumaps.MapsActivity";
|
||||
|
||||
// Helper to create response objects
|
||||
function createResponse(success: boolean, message: string, data: object | string = {}) {
|
||||
if (typeof data === 'string') {
|
||||
return { success, message, data };
|
||||
}
|
||||
return { success, message, ...data };
|
||||
}
|
||||
|
||||
// Helper to find a UI element and click it
|
||||
async function findAndClick(finder: () => Promise<any>): Promise<boolean> {
|
||||
const element = await finder();
|
||||
if (element) {
|
||||
await element.click();
|
||||
await Tools.System.sleep(1000);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Helper to find a UI element by text and click it
|
||||
async function findAndClickByText(text: string): Promise<boolean> {
|
||||
return findAndClick(async () => (await UINode.getCurrentPage()).findByText(text));
|
||||
}
|
||||
|
||||
async function isOnMainPage(): Promise<boolean> {
|
||||
const page = await UINode.getCurrentPage();
|
||||
// Check for the presence of key elements that indicate the main map screen.
|
||||
const drivingButton = page.findByText("驾车");
|
||||
const transitButton = page.findByText("公共交通");
|
||||
const taxiButton = page.findByText("打车");
|
||||
const hotelButton = page.findByText("订酒店");
|
||||
|
||||
if (drivingButton && transitButton && taxiButton && hotelButton) {
|
||||
console.log("Detected Baidu Map main page by key elements.");
|
||||
return true;
|
||||
}
|
||||
console.log("Not on main page, key elements not found.");
|
||||
return false;
|
||||
}
|
||||
|
||||
async function ensureMain(packageName: string = BAIDU_MAP_PACKAGE): Promise<boolean> {
|
||||
let pageInfo = await Tools.UI.getPageInfo();
|
||||
|
||||
// 1. If not in the correct app, start it.
|
||||
if (pageInfo.packageName !== packageName) {
|
||||
console.log(`Not in the correct app. Current: ${pageInfo.packageName}. Starting ${packageName}.`);
|
||||
await Tools.System.startApp(packageName);
|
||||
await Tools.System.sleep(5000); // Wait for app to load
|
||||
pageInfo = await Tools.UI.getPageInfo(); // Re-check page info
|
||||
if (pageInfo.packageName !== packageName) {
|
||||
console.error(`Failed to start ${packageName}.`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check if we are already on the main page.
|
||||
if (await isOnMainPage()) {
|
||||
console.log("Already on the main page.");
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3. If not on the main page, try pressing back up to 4 times.
|
||||
console.log("Not on the main page. Attempting to go back.");
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await Tools.UI.pressKey("KEYCODE_BACK");
|
||||
await Tools.System.sleep(1000); // Wait for UI to settle
|
||||
|
||||
pageInfo = await Tools.UI.getPageInfo();
|
||||
// If we have exited the app, stop trying.
|
||||
if (pageInfo.packageName !== packageName) {
|
||||
console.log("Exited the app while trying to go back.");
|
||||
break; // Exit the loop, will proceed to restart
|
||||
}
|
||||
|
||||
if (await isOnMainPage()) {
|
||||
console.log(`Successfully returned to main page on attempt ${i + 1}.`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. If pressing back failed, perform a full app restart.
|
||||
console.log("Pressing back failed or exited app. Performing a full app restart.");
|
||||
await Tools.System.stopApp(packageName);
|
||||
await Tools.System.sleep(1000);
|
||||
await Tools.System.startApp(packageName);
|
||||
await Tools.System.sleep(5000); // Longer wait for cold start
|
||||
|
||||
pageInfo = await Tools.UI.getPageInfo();
|
||||
if (pageInfo.packageName !== packageName) {
|
||||
console.error(`Failed to restart ${packageName}.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (await isOnMainPage()) {
|
||||
console.log("Successfully reached main page after restart.");
|
||||
return true;
|
||||
}
|
||||
|
||||
console.error(`Failed to navigate to the main page of ${packageName} even after restart.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
async function search_location(params: { keyword: string }): Promise<any> {
|
||||
const { keyword } = params;
|
||||
console.log(`Searching for location: ${keyword}`);
|
||||
|
||||
// 确保百度地图已在前台运行
|
||||
if (!await ensureMain()) {
|
||||
return createResponse(false, "无法启动或切换到百度地图");
|
||||
}
|
||||
|
||||
await Tools.System.sleep(2000); // 等待首页完全加载
|
||||
|
||||
// 查找并点击搜索框
|
||||
const page = await UINode.getCurrentPage();
|
||||
const searchBox = page.findById('serachbox_container');
|
||||
if (!searchBox) {
|
||||
return createResponse(false, "在地图主页未找到搜索框 (ID: serachbox_container)");
|
||||
}
|
||||
await searchBox.click();
|
||||
await Tools.System.sleep(300);
|
||||
await Tools.UI.setText(keyword);
|
||||
await Tools.System.sleep(300);
|
||||
|
||||
|
||||
// 点击搜索按钮
|
||||
const searchButton = (await UINode.getCurrentPage()).findByText("搜索");
|
||||
if (searchButton) {
|
||||
await searchButton.click();
|
||||
}
|
||||
|
||||
await Tools.System.sleep(5000); // 等待搜索结果加载
|
||||
|
||||
// 上拉以显示完整的结果列表,这在某些情况下是必要的
|
||||
console.log("执行上拉手势以确保结果列表完全可见...");
|
||||
await Tools.UI.swipe(540, 1800, 540, 900); // 从屏幕底部向上滑动
|
||||
await Tools.System.sleep(1500); // 等待动画完成
|
||||
|
||||
// 检查页面是唯一结果还是列表
|
||||
const pageAfterSearch = await UINode.getCurrentPage();
|
||||
const listContainer = pageAfterSearch.findById("com.baidu.BaiduMap:id/talosListContainer");
|
||||
|
||||
if (listContainer) {
|
||||
// 情况2: 结果列表
|
||||
console.log("检测到列表容器,开始解析结果列表...");
|
||||
const results = await get_map_search_results();
|
||||
|
||||
return createResponse(true, `搜索到 ${results.length} 个相关地点。请使用 'select_location_from_list' 选择一个。`, {
|
||||
keyword: keyword,
|
||||
results: results.map(({ element, ...rest }) => rest), // 移除 element 属性
|
||||
result_count: results.length
|
||||
});
|
||||
}
|
||||
|
||||
const goToButton = pageAfterSearch.findByText("到这去");
|
||||
if (goToButton) {
|
||||
// 情况1: 唯一结果
|
||||
return createResponse(true, `已找到唯一结果 "${keyword}"。您现在可以使用 'go_to_location' 进行导航。`, { keyword });
|
||||
} else {
|
||||
return createResponse(false, `搜索 "${keyword}" 后既未找到结果列表,也未找到唯一结果。`);
|
||||
}
|
||||
}
|
||||
|
||||
async function get_map_search_results(desiredCount: number = 20): Promise<any[]> {
|
||||
const results: any[] = [];
|
||||
const seenTitles = new Set<string>();
|
||||
const maxScrolls = 1;
|
||||
let lastResultCount = -1;
|
||||
|
||||
for (let i = 0; i < maxScrolls; i++) {
|
||||
let page = await UINode.getCurrentPage();
|
||||
const resultsContainer = page.findById('com.baidu.BaiduMap:id/talosListContainer');
|
||||
if (!resultsContainer) {
|
||||
console.log("未找到搜索结果容器 (ID: com.baidu.BaiduMap:id/talosListContainer)");
|
||||
break;
|
||||
}
|
||||
|
||||
const recyclerView = resultsContainer.findByClass("RecyclerView");
|
||||
if (!recyclerView) {
|
||||
console.log("未在结果容器中找到 RecyclerView。");
|
||||
break;
|
||||
}
|
||||
|
||||
// 通过查找所有“到这去”按钮来定位每个列表项
|
||||
const goToButtons = recyclerView.findAllByText("到这去");
|
||||
|
||||
for (const button of goToButtons) {
|
||||
// 找到按钮所属的、作为RecyclerView直接子节点的那个父节点
|
||||
const item = button.closest((node) =>
|
||||
node.parent !== undefined && node.parent.equals(recyclerView)
|
||||
);
|
||||
if (!item) continue;
|
||||
|
||||
const textViews = item.findAllByClass('TextView');
|
||||
const relevantTextViews = textViews.filter(tv => tv.text && tv.text.trim() !== '到这去' && tv.text.trim() !== '');
|
||||
|
||||
if (relevantTextViews.length === 0) continue;
|
||||
|
||||
// 按纵坐标排序,最上面的通常是标题
|
||||
relevantTextViews.sort((a, b) => {
|
||||
try {
|
||||
const [, y1a] = a.bounds!.match(/\[\d+,(\d+)\]/)!;
|
||||
const [, y1b] = b.bounds!.match(/\[\d+,(\d+)\]/)!;
|
||||
return parseInt(y1a) - parseInt(y1b);
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
const title = relevantTextViews[0].text;
|
||||
if (!title || seenTitles.has(title)) {
|
||||
continue;
|
||||
}
|
||||
seenTitles.add(title);
|
||||
|
||||
// 查找包含地址特征的文本作为地址
|
||||
const addressNode = relevantTextViews.find(tv =>
|
||||
tv.text && (tv.text.includes('省') || tv.text.includes('市') || tv.text.includes('区') || tv.text.includes('路') || tv.text.includes('号'))
|
||||
);
|
||||
const address = addressNode ? addressNode.text : "N/A";
|
||||
|
||||
results.push({
|
||||
index: results.length + 1, // 临时索引
|
||||
title: title,
|
||||
address: address,
|
||||
element: item
|
||||
});
|
||||
}
|
||||
|
||||
// 滚动前去重并检查是否需要停止
|
||||
const uniqueResults = Array.from(new Map(results.map(r => [r.title, r])).values());
|
||||
|
||||
if (uniqueResults.length >= desiredCount) {
|
||||
console.log(`已找到 ${uniqueResults.length} 条不重复结果,满足要求。`);
|
||||
break;
|
||||
}
|
||||
if (uniqueResults.length === lastResultCount) {
|
||||
console.log("滚动未产生新结果,停止滚动。");
|
||||
break;
|
||||
}
|
||||
lastResultCount = uniqueResults.length;
|
||||
|
||||
// 向下滚动加载更多
|
||||
await Tools.UI.swipe(540, 1800, 540, 600);
|
||||
await Tools.System.sleep(2000); // 增加等待时间以确保加载
|
||||
}
|
||||
|
||||
// 最终去重并重新编号索引
|
||||
const finalResults = Array.from(new Map(results.map(item => [item.title, item])).values())
|
||||
.map((item, index) => ({ ...item, index: index + 1 }));
|
||||
|
||||
return finalResults.slice(0, desiredCount);
|
||||
}
|
||||
|
||||
// --- Placeholder functions for other tools ---
|
||||
async function select_location_from_list(params: { keyword?: string, index?: number }): Promise<any> {
|
||||
const { keyword, index } = params;
|
||||
|
||||
if (!keyword && !index) {
|
||||
return createResponse(false, "请提供地点关键词或索引。");
|
||||
}
|
||||
console.log(`尝试从列表中选择 - 关键词: "${keyword}", 索引: ${index}`);
|
||||
|
||||
const seenTitles = new Set<string>();
|
||||
let currentItemCount = 0;
|
||||
const maxScrolls = 10;
|
||||
let lastSeenCount = -1;
|
||||
|
||||
for (let i = 0; i < maxScrolls; i++) {
|
||||
const page = await UINode.getCurrentPage();
|
||||
// In Baidu Maps, the results are often inside a RecyclerView
|
||||
const recyclerView = page.findByClass("RecyclerView");
|
||||
if (!recyclerView) {
|
||||
console.log("未找到 RecyclerView, 无法选择项目。");
|
||||
return createResponse(false, "未找到结果列表容器 (RecyclerView)。");
|
||||
}
|
||||
|
||||
const goToButtons = recyclerView.findAllByText("到这去");
|
||||
|
||||
for (const button of goToButtons) {
|
||||
const item = button.closest((node) =>
|
||||
node.parent !== undefined && node.parent.equals(recyclerView)
|
||||
);
|
||||
if (!item) continue;
|
||||
|
||||
const textViews = item.findAllByClass('TextView');
|
||||
const relevantTextViews = textViews.filter(tv => tv.text && tv.text.trim() !== '到这去' && tv.text.trim() !== '');
|
||||
if (relevantTextViews.length === 0) continue;
|
||||
|
||||
relevantTextViews.sort((a, b) => {
|
||||
try {
|
||||
const [, y1a] = a.bounds!.match(/\[\d+,(\d+)\]/)!;
|
||||
const [, y1b] = b.bounds!.match(/\[\d+,(\d+)\]/)!;
|
||||
return parseInt(y1a) - parseInt(y1b);
|
||||
} catch (e) { return 0; }
|
||||
});
|
||||
|
||||
const title = relevantTextViews[0].text;
|
||||
if (!title || seenTitles.has(title)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenTitles.add(title);
|
||||
currentItemCount++;
|
||||
|
||||
let shouldClick = false;
|
||||
if (index) {
|
||||
if (currentItemCount === index) {
|
||||
shouldClick = true;
|
||||
}
|
||||
} else if (keyword) {
|
||||
if (title.includes(keyword)) {
|
||||
shouldClick = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldClick) {
|
||||
await item.click();
|
||||
await Tools.System.sleep(2000);
|
||||
return createResponse(true, `已成功选择地点: "${title}"`);
|
||||
}
|
||||
}
|
||||
|
||||
if (seenTitles.size === lastSeenCount) {
|
||||
console.log("滚动未产生新结果,停止。");
|
||||
break;
|
||||
}
|
||||
lastSeenCount = seenTitles.size;
|
||||
|
||||
if (index && currentItemCount >= index) {
|
||||
// If we're looking for an index and have passed it, no need to scroll more.
|
||||
break;
|
||||
}
|
||||
|
||||
await Tools.UI.swipe(540, 1800, 540, 600); // Swipe up
|
||||
await Tools.System.sleep(2000);
|
||||
}
|
||||
|
||||
return createResponse(false, "在列表中未找到或无法选择指定的地点。", { keyword, index });
|
||||
}
|
||||
|
||||
async function go_to_location(params: { transport_mode?: string }): Promise<any> {
|
||||
const transportMode = params.transport_mode || "驾车"; // 默认为驾车
|
||||
console.log("尝试点击 '到这去' 按钮...");
|
||||
const page = await UINode.getCurrentPage();
|
||||
const goToButton = page.findByText("到这去");
|
||||
if (goToButton) {
|
||||
await goToButton.click();
|
||||
await Tools.System.sleep(3000); // 等待路线规划页面加载
|
||||
|
||||
if (transportMode) {
|
||||
console.log(`尝试选择交通方式: ${transportMode}`);
|
||||
let pageAfterClick = await UINode.getCurrentPage();
|
||||
|
||||
// 尝试直接点击可见的交通方式按钮
|
||||
try {
|
||||
let modeButton = pageAfterClick.findByText(transportMode);
|
||||
if (modeButton) {
|
||||
await modeButton.click();
|
||||
await Tools.System.sleep(1000);
|
||||
return createResponse(true, `已点击“到这去”,并成功切换到“${transportMode}”模式。`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`直接点击失败,按钮可能在屏幕外。尝试滚动查找。`);
|
||||
}
|
||||
|
||||
// 如果未直接找到,尝试展开更多选项
|
||||
console.log(`未直接找到 "${transportMode}",尝试展开更多选项...`);
|
||||
const moreModesButton = pageAfterClick.findById('route_tab_group');
|
||||
if (moreModesButton) {
|
||||
await moreModesButton.click();
|
||||
await Tools.System.sleep(1500); // 等待展开动画
|
||||
pageAfterClick = await UINode.getCurrentPage(); // 刷新页面节点
|
||||
let modeButton = pageAfterClick.findByText(transportMode);
|
||||
if (modeButton) {
|
||||
await modeButton.click();
|
||||
await Tools.System.sleep(1500);
|
||||
return createResponse(true, `已点击“到这去”,并成功切换到“${transportMode}”模式。`);
|
||||
}
|
||||
}
|
||||
|
||||
return createResponse(false, `已点击“到这去”,但无法找到交通方式“${transportMode}”。`);
|
||||
}
|
||||
|
||||
return createResponse(true, "已点击“到这去”,进入路线规划页面。");
|
||||
} else {
|
||||
return createResponse(false, "在当前页面未找到“到这去”按钮。");
|
||||
}
|
||||
}
|
||||
|
||||
async function get_directions(params: { start_point?: string, end_point: string, transport_mode?: string }): Promise<any> {
|
||||
return createResponse(false, "功能 'get_directions' 尚未实现。");
|
||||
}
|
||||
|
||||
async function search_nearby(params: { keyword: string, center_point?: string }): Promise<any> {
|
||||
return createResponse(false, "功能 'search_nearby' 尚未实现。");
|
||||
}
|
||||
|
||||
async function navigate_to_home(params: {}): Promise<any> {
|
||||
console.log("Navigating to home screen.");
|
||||
if (await ensureMain()) {
|
||||
// 在地图应用中,通常按返回键可以回到主图区
|
||||
// 这里可以根据实际情况增加返回逻辑
|
||||
await Tools.UI.pressKey("KEYCODE_BACK");
|
||||
await Tools.System.sleep(500);
|
||||
await Tools.UI.pressKey("KEYCODE_BACK");
|
||||
return createResponse(true, "已尝试返回主界面。");
|
||||
} else {
|
||||
return createResponse(false, "无法导航到主界面,因为无法启动百度地图。");
|
||||
}
|
||||
}
|
||||
|
||||
async function activate_voice_assistant(params: {}): Promise<any> {
|
||||
console.log("正在激活语音助手“小度”...");
|
||||
if (!await ensureMain()) {
|
||||
return createResponse(false, "无法启动或切换到百度地图主页,因此无法激活语音助手。");
|
||||
}
|
||||
await Tools.System.sleep(1000); // 等待UI稳定
|
||||
|
||||
const page = await UINode.getCurrentPage();
|
||||
const voiceButton = page.findById('voice_search');
|
||||
|
||||
if (voiceButton) {
|
||||
await voiceButton.click();
|
||||
await Tools.System.sleep(2000); // 等待语音助手界面弹出
|
||||
return createResponse(true, "已成功激活“小度”语音助手。");
|
||||
} else {
|
||||
return createResponse(false, "在主界面上未找到语音搜索按钮 (ID: voice_search)。");
|
||||
}
|
||||
}
|
||||
|
||||
async function wrapToolExecution(func: (params: any) => Promise<any>, params: any) {
|
||||
try {
|
||||
const result = await func(params);
|
||||
complete({
|
||||
...result,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Tool ${func.name} failed unexpectedly`, error);
|
||||
complete({
|
||||
success: false,
|
||||
message: `工具执行时发生意外错误: ${error.message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function main(params: {}) {
|
||||
console.log("Running main for testing...");
|
||||
// 这是一个用于测试的函数,它会搜索一个默认位置
|
||||
// console.log("--- Test Case 1: Searching for '上海' ---");
|
||||
// await search_location({ keyword: "上海" });
|
||||
// await Tools.System.sleep(2000); // Wait for page to load
|
||||
|
||||
// // console.log("--- Test Case 1: Clicking 'go_to_location' ---");
|
||||
// await go_to_location({ "transport_mode": "飞机" });
|
||||
// await Tools.System.sleep(2000); // Wait for action
|
||||
|
||||
// // Navigate back to home for the next test
|
||||
await navigate_to_home({});
|
||||
await Tools.System.sleep(3000);
|
||||
|
||||
console.log("--- Test Case 2: Searching for '长安大学' to test multiple results ---");
|
||||
await search_location({ keyword: "长安大学" });
|
||||
await Tools.System.sleep(2000);
|
||||
await select_location_from_list({ index: 1 });
|
||||
await Tools.System.sleep(2000);
|
||||
await go_to_location({ transport_mode: "飞机" });
|
||||
await Tools.System.sleep(2000);
|
||||
}
|
||||
|
||||
// 导出所有工具
|
||||
return {
|
||||
search_location: async (params) => await wrapToolExecution(search_location, params),
|
||||
select_location_from_list: async (params) => await wrapToolExecution(select_location_from_list, params),
|
||||
go_to_location: async (params) => await wrapToolExecution(go_to_location, params),
|
||||
get_directions: async (params) => await wrapToolExecution(get_directions, params),
|
||||
search_nearby: async (params) => await wrapToolExecution(search_nearby, params),
|
||||
navigate_to_home: async (params) => await wrapToolExecution(navigate_to_home, params),
|
||||
activate_voice_assistant: async (params) => await wrapToolExecution(activate_voice_assistant, params),
|
||||
main: async (params) => await wrapToolExecution(main, params),
|
||||
};
|
||||
})();
|
||||
|
||||
// 逐个导出,以便在外部调用
|
||||
exports.search_location = BaiduMapAssistant.search_location;
|
||||
exports.select_location_from_list = BaiduMapAssistant.select_location_from_list;
|
||||
exports.go_to_location = BaiduMapAssistant.go_to_location;
|
||||
exports.get_directions = BaiduMapAssistant.get_directions;
|
||||
exports.search_nearby = BaiduMapAssistant.search_nearby;
|
||||
exports.navigate_to_home = BaiduMapAssistant.navigate_to_home;
|
||||
exports.activate_voice_assistant = BaiduMapAssistant.activate_voice_assistant;
|
||||
exports.main = BaiduMapAssistant.main;
|
||||
@@ -0,0 +1,918 @@
|
||||
/*
|
||||
METADATA
|
||||
{
|
||||
// Bilibili 智能助手包
|
||||
name: Automatic_bilibili_assistant
|
||||
|
||||
display_name: {
|
||||
zh: "Bilibili自动化助手"
|
||||
en: "Bilibili Automation Assistant"
|
||||
}description: { zh: "高级B站智能助手,通过UI自动化技术实现B站应用交互,支持视频搜索播放、评论互动、用户关注等功能,为AI赋予B站社交和内容消费能力。适用于自动追番、视频推荐、社交互动等场景。", en: "Advanced Bilibili assistant powered by UI automation. Supports video search/playback, commenting, and following uploaders, enabling AI-driven content consumption and social interaction on Bilibili." }
|
||||
|
||||
// Tools in this package
|
||||
category: "Automatic"
|
||||
tools: [
|
||||
{
|
||||
name: workflow_guide
|
||||
description: { zh: "B站助手工具使用流程指南。要完成复杂任务,请按以下顺序组合使用工具:\n1. **搜索视频**: 使用 `search_video` 查找视频,这是所有操作的起点。\n2. **播放视频**: 从搜索结果中,使用 `play_video` 播放指定视频。\n3. **视频内互动**: 视频播放后,可执行 `get_video_info`, `like_video`, `collect_video`, `follow_uploader`, `send_comment`, `browse_comments` 等操作。\n4. **返回列表**: 要播放另一个视频,必须先用 `return_to_video_list` 返回搜索列表,再调用 `play_video`。\n- **随时导航**: `navigate_to_home` 等导航工具可在多数情况下使用。", en: "Workflow guide for the Bilibili assistant:\n1. **Search videos**: use `search_video` as the entry point for most tasks.\n2. **Play a video**: use `play_video` to open a specific result.\n3. **Interact**: while on the video page you can run `get_video_info`, `like_video`, `collect_video`, `follow_uploader`, `send_comment`, `browse_comments`, etc.\n4. **Back to list**: to play another video, call `return_to_video_list` first, then `play_video`.\n- **Navigation**: tools like `navigate_to_home` can often be used at any time." }
|
||||
parameters: []
|
||||
advice: true
|
||||
},
|
||||
{
|
||||
name: search_video
|
||||
description: { zh: "在B站搜索视频内容", en: "Search videos on Bilibili." }
|
||||
parameters: [
|
||||
{
|
||||
name: keyword
|
||||
description: { zh: "搜索关键词", en: "Search keyword." }
|
||||
type: string
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: filter_type
|
||||
description: { zh: "搜索结果过滤类型:comprehensive(综合)、new(最新)、hot(最多播放)、danmaku(最多弹幕)", en: "Result filter: comprehensive, new, hot (most played), danmaku (most danmaku)." }
|
||||
type: string
|
||||
required: false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: play_video
|
||||
description: { zh: "播放指定的视频,可以通过标题或位置选择", en: "Play a specific video by title keyword or by index." }
|
||||
parameters: [
|
||||
{
|
||||
name: video_title
|
||||
description: { zh: "要播放的视频标题关键词", en: "Keyword to match the video title." }
|
||||
type: string
|
||||
required: false
|
||||
},
|
||||
{
|
||||
name: video_index
|
||||
description: { zh: "要播放的视频在搜索结果中的索引位置(从1开始)", en: "Index of the video in the search results (1-based)." }
|
||||
type: number
|
||||
required: false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: return_to_video_list
|
||||
description: { zh: "从视频播放界面返回到视频列表界面", en: "Return from the video player page back to the video list." }
|
||||
parameters: []
|
||||
},
|
||||
{
|
||||
name: send_comment
|
||||
description: { zh: "在当前视频下发送评论", en: "Post a comment under the current video." }
|
||||
parameters: [
|
||||
{
|
||||
name: comment_text
|
||||
description: { zh: "要发送的评论内容", en: "Comment text to send." }
|
||||
type: string
|
||||
required: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: like_video
|
||||
description: { zh: "给当前视频点赞", en: "Like the current video." }
|
||||
parameters: []
|
||||
},
|
||||
{
|
||||
name: collect_video
|
||||
description: { zh: "收藏当前视频", en: "Save (favorite) the current video." }
|
||||
parameters: [
|
||||
{
|
||||
name: folder_name
|
||||
description: { zh: "收藏夹名称,留空则使用默认收藏夹", en: "Favorite folder name (leave empty to use the default folder)." }
|
||||
type: string
|
||||
required: false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: follow_uploader
|
||||
description: { zh: "关注当前视频的UP主", en: "Follow the uploader (UP) of the current video." }
|
||||
parameters: []
|
||||
},
|
||||
{
|
||||
name: get_video_info
|
||||
description: { zh: "获取当前视频的详细信息,包括标题、UP主、播放量、评论数等", en: "Get detailed info of the current video (title, uploader, views, comments, etc.)." }
|
||||
parameters: []
|
||||
},
|
||||
{
|
||||
name: browse_comments
|
||||
description: { zh: "浏览当前视频的评论,获取热门评论内容", en: "Browse comments of the current video and retrieve popular comments." }
|
||||
parameters: [
|
||||
{
|
||||
name: comment_count
|
||||
description: { zh: "获取的评论数量,默认为5条", en: "Number of comments to fetch (default: 5)." }
|
||||
type: number
|
||||
required: false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: navigate_to_home
|
||||
description: { zh: "导航到B站首页", en: "Navigate to the Bilibili home page." }
|
||||
parameters: []
|
||||
},
|
||||
{
|
||||
name: navigate_to_following
|
||||
description: { zh: "导航到关注页面,查看关注的UP主动态", en: "Navigate to the Following page to view updates from followed uploaders." }
|
||||
parameters: []
|
||||
},
|
||||
{
|
||||
name: navigate_to_history
|
||||
description: { zh: "导航到观看历史页面", en: "Navigate to the watch history page." }
|
||||
parameters: []
|
||||
},
|
||||
{
|
||||
name: toggle_fullscreen
|
||||
description: { zh: "切换视频全屏/非全屏状态", en: "Toggle fullscreen / windowed mode." }
|
||||
parameters: []
|
||||
},
|
||||
{
|
||||
name: adjust_playback_speed
|
||||
description: { zh: "调整视频播放速度", en: "Adjust video playback speed." }
|
||||
parameters: [
|
||||
{
|
||||
name: speed
|
||||
description: { zh: "播放速度:0.5x, 0.75x, 1.0x, 1.25x, 1.5x, 2.0x", en: "Playback speed: 0.5x, 0.75x, 1.0x, 1.25x, 1.5x, 2.0x." }
|
||||
type: string
|
||||
required: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}*/
|
||||
|
||||
interface Array<T> {
|
||||
at(index: number): T | undefined;
|
||||
}
|
||||
|
||||
const BilibiliAssistant = (function () {
|
||||
|
||||
// 添加 Array.prototype.at 支持
|
||||
Array.prototype.at = function (index: number) {
|
||||
if (index < 0) {
|
||||
index = this.length + index;
|
||||
}
|
||||
return this[index];
|
||||
}
|
||||
|
||||
// B站应用包名和主要Activity
|
||||
const BILIBILI_PACKAGE = "tv.danmaku.bili";
|
||||
const MAIN_ACTIVITY = "tv.danmaku.bili.MainActivityV2";
|
||||
const VIDEO_ACTIVITY = "com.bilibili.ship.theseus.detail.UnitedBizDetailsActivity";
|
||||
|
||||
// Helper to create response objects
|
||||
function createResponse(success: boolean, message: string, data: object | string = {}) {
|
||||
if (typeof data === 'string') {
|
||||
return { success: true, message: message, data: data };
|
||||
}
|
||||
return { success, message, ...data };
|
||||
}
|
||||
|
||||
// Helper to find a UI element and click it
|
||||
async function findAndClick(finder: () => Promise<any>): Promise<boolean> {
|
||||
const element = await finder();
|
||||
if (element) {
|
||||
await element.click();
|
||||
await Tools.System.sleep(1000);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Helper to find a UI element by text and click it
|
||||
async function findAndClickByText(text: string): Promise<boolean> {
|
||||
return findAndClick(async () => (await UINode.getCurrentPage()).findByText(text));
|
||||
}
|
||||
|
||||
async function ensureMain(packageName: string = BILIBILI_PACKAGE): Promise<boolean> {
|
||||
let pageInfo = await Tools.UI.getPageInfo();
|
||||
|
||||
// 1. Check if we are already on the main activity.
|
||||
if (pageInfo.packageName === packageName && pageInfo.activityName?.includes(MAIN_ACTIVITY)) {
|
||||
console.log("Already on the main activity.");
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. If not in the correct app, start it.
|
||||
if (pageInfo.packageName !== packageName) {
|
||||
console.log(`Not in the correct app. Current: ${pageInfo.packageName}. Starting ${packageName}.`);
|
||||
await Tools.System.startApp(packageName);
|
||||
await Tools.System.sleep(200); // Wait for app to load
|
||||
}
|
||||
|
||||
// 3. Try pressing back up to 4 times to return to main.
|
||||
console.log("Attempting to go back to main activity.");
|
||||
for (let i = 0; i < 4; i++) {
|
||||
pageInfo = await Tools.UI.getPageInfo();
|
||||
if (pageInfo.packageName === packageName && pageInfo.activityName?.includes(MAIN_ACTIVITY)) {
|
||||
console.log(`Successfully returned to main activity on attempt ${i + 1}.`);
|
||||
return true;
|
||||
}
|
||||
// If we are not in the app anymore, stop trying to go back.
|
||||
if (pageInfo.packageName !== packageName) {
|
||||
console.log("Exited the app while trying to go back.");
|
||||
break;
|
||||
}
|
||||
await Tools.UI.pressKey("KEYCODE_BACK");
|
||||
await Tools.System.sleep(500);
|
||||
}
|
||||
|
||||
// 4. If pressing back failed, perform a full restart.
|
||||
console.log("Pressing back failed or exited app. Performing a full app restart.");
|
||||
await Tools.System.stopApp(packageName);
|
||||
await Tools.System.sleep(1000);
|
||||
await Tools.System.startApp(packageName);
|
||||
await Tools.System.sleep(4000);
|
||||
|
||||
pageInfo = await Tools.UI.getPageInfo();
|
||||
if (pageInfo.packageName === packageName && pageInfo.activityName?.includes(MAIN_ACTIVITY)) {
|
||||
console.log("Successfully reached main activity after restart.");
|
||||
return true;
|
||||
}
|
||||
|
||||
console.error(`Failed to navigate to the main activity of ${packageName}.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
async function navigateToSearch(): Promise<boolean> {
|
||||
console.log("导航到搜索页面");
|
||||
// 确保在主页面
|
||||
if (!await ensureMain()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 点击搜索框容器(正确的搜索区域)
|
||||
const page = await UINode.getCurrentPage();
|
||||
const searchContainer = page.findById('expand_search');
|
||||
if (searchContainer) {
|
||||
await searchContainer.click();
|
||||
await Tools.System.sleep(1000);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// --- Bilibili Automation Logic ---
|
||||
let lastSearchResults: any[] = [];
|
||||
|
||||
/**
|
||||
* Searches for videos on Bilibili.
|
||||
* @param params - The search parameters.
|
||||
* @param {string} params.keyword - The keyword to search for.
|
||||
* @param {string} [params.filter_type] - The filter to apply (e.g., 'new', 'hot', 'danmaku').
|
||||
* @param {number} [params.count=20] - The desired number of video results to return.
|
||||
* @returns {Promise<any>} A promise that resolves with the search results.
|
||||
*/
|
||||
async function search_video(params: { keyword: string; filter_type?: string; count?: number }): Promise<any> {
|
||||
const { keyword, filter_type, count = 20 } = params; // Default count to 20
|
||||
|
||||
console.log(`搜索视频: ${keyword}, 过滤类型: ${filter_type}`);
|
||||
|
||||
// 导航到搜索页面
|
||||
if (!await navigateToSearch()) {
|
||||
return createResponse(false, "无法进入搜索页面", { keyword: keyword });
|
||||
}
|
||||
|
||||
await Tools.System.sleep(1000);
|
||||
|
||||
// 输入搜索关键词
|
||||
const searchInput = (await UINode.getCurrentPage()).findById('search_plate');
|
||||
if (searchInput) {
|
||||
await Tools.UI.setText(keyword);
|
||||
await Tools.System.sleep(500);
|
||||
} else {
|
||||
// 如果没找到输入框,直接输入文本
|
||||
await Tools.UI.setText(keyword);
|
||||
await Tools.System.sleep(500);
|
||||
}
|
||||
|
||||
// 点击搜索按钮
|
||||
const searchButton = (await UINode.getCurrentPage()).findById('action_search');
|
||||
if (searchButton) {
|
||||
await searchButton.click();
|
||||
} else {
|
||||
// 尝试按回车键搜索
|
||||
await Tools.UI.pressKey("KEYCODE_ENTER");
|
||||
}
|
||||
|
||||
await Tools.System.sleep(4000); // 等待搜索结果加载
|
||||
|
||||
// Apply filter if specified
|
||||
if (filter_type) {
|
||||
await applySearchFilter(filter_type);
|
||||
}
|
||||
|
||||
// 获取搜索结果
|
||||
const results = await getSearchResults(count);
|
||||
lastSearchResults = results; // Cache the results
|
||||
|
||||
return createResponse(true, `搜索到 ${results.length} 条有效视频结果`, {
|
||||
keyword: keyword,
|
||||
filter_type: filter_type,
|
||||
results: results.map(({ element, ...rest }) => rest), // Exclude element from response
|
||||
result_count: results.length
|
||||
});
|
||||
}
|
||||
|
||||
async function applySearchFilter(filterType: string): Promise<void> {
|
||||
const page = await UINode.getCurrentPage();
|
||||
const filterButton = page.findByText("综合"); // Default to comprehensive
|
||||
if (filterButton) {
|
||||
await filterButton.click();
|
||||
await Tools.System.sleep(1000);
|
||||
}
|
||||
|
||||
if (filterType === "new") {
|
||||
await findAndClickByText("最新");
|
||||
await Tools.System.sleep(1000);
|
||||
} else if (filterType === "hot") {
|
||||
await findAndClickByText("最多播放");
|
||||
await Tools.System.sleep(1000);
|
||||
} else if (filterType === "danmaku") {
|
||||
await findAndClickByText("最多弹幕");
|
||||
await Tools.System.sleep(1000);
|
||||
}
|
||||
}
|
||||
|
||||
async function getSearchResults(desiredCount: number = 20): Promise<any[]> {
|
||||
let page = await UINode.getCurrentPage();
|
||||
const results: any[] = [];
|
||||
const seenTitles = new Set<string>();
|
||||
|
||||
const resultsContainer = page.findById('tv.danmaku.bili:id/recycler_view');
|
||||
if (!resultsContainer) {
|
||||
console.log("Could not find search results container.");
|
||||
return results;
|
||||
}
|
||||
|
||||
// 不再滚动到顶部,直接从当前位置开始处理,避免触发下拉刷新
|
||||
let lastResultCount = -1;
|
||||
const maxScrolls = 10; // Safety break to prevent infinite loops
|
||||
let scrollCount = 0;
|
||||
|
||||
for (let i = 0; i < maxScrolls; i++) {
|
||||
page = await UINode.getCurrentPage();
|
||||
const currentContainer = page.findById('tv.danmaku.bili:id/recycler_view');
|
||||
if (!currentContainer) break;
|
||||
|
||||
for (const item of currentContainer.children) {
|
||||
const titleNode = item.findById('tv.danmaku.bili:id/title');
|
||||
const title = titleNode ? titleNode.text : null;
|
||||
|
||||
if (!title || seenTitles.has(title)) {
|
||||
continue; // Skip if no title or already seen
|
||||
}
|
||||
|
||||
// Filtering logic
|
||||
const isAd = item.findById('tv.danmaku.bili:id/ad_tint_frame') || item.findById('tv.danmaku.bili:id/ad_tag');
|
||||
const isUser = item.findById('tv.danmaku.bili:id/user_info');
|
||||
const isGame = item.findById('tv.danmaku.bili:id/iv_background');
|
||||
const isLive = item.findByText("直播");
|
||||
|
||||
if (isAd || isUser || isGame || isLive) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenTitles.add(title);
|
||||
|
||||
const uploaderNode = item.findById('tv.danmaku.bili:id/upper') || item.findById('tv.danmaku.bili:id/upuser');
|
||||
const playCountNode = item.findById('tv.danmaku.bili:id/play_num');
|
||||
const durationNode = item.findById('tv.danmaku.bili:id/duration');
|
||||
const postTimeNode = item.findById('tv.danmaku.bili:id/post_time') || item.findById('tv.danmaku.bili:id/danmakus_num');
|
||||
|
||||
results.push({
|
||||
index: results.length + 1,
|
||||
title: title,
|
||||
uploader: uploaderNode ? uploaderNode.text : "N/A",
|
||||
play_count: playCountNode ? playCountNode.text : "N/A",
|
||||
duration: durationNode ? durationNode.text : "N/A",
|
||||
post_time: postTimeNode ? postTimeNode.text : "N/A",
|
||||
element: item
|
||||
});
|
||||
|
||||
if (results.length >= desiredCount) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (results.length >= desiredCount) {
|
||||
console.log(`Reached desired count of ${desiredCount}.`);
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if scrolling is producing new results
|
||||
if (results.length === lastResultCount) {
|
||||
console.log("Scrolling is not yielding new results. Stopping.");
|
||||
break;
|
||||
}
|
||||
lastResultCount = results.length;
|
||||
|
||||
// Scroll down to load more
|
||||
await Tools.UI.swipe(540, 1500, 540, 800); // Swipe up to scroll down
|
||||
await Tools.System.sleep(1500); // Wait for new items to load
|
||||
scrollCount++;
|
||||
}
|
||||
|
||||
console.log(`Scrolling back up ${scrollCount} times.`);
|
||||
for (let i = 0; i < scrollCount; i++) {
|
||||
await Tools.UI.swipe(540, 800, 540, 1550);
|
||||
await Tools.System.sleep(1000);
|
||||
}
|
||||
|
||||
return results.slice(0, desiredCount);
|
||||
}
|
||||
|
||||
async function play_video(params: { video_title?: string, video_index?: number }): Promise<any> {
|
||||
const { video_title, video_index } = params;
|
||||
|
||||
if (!video_title && !video_index) {
|
||||
return createResponse(false, "请提供视频标题或索引。");
|
||||
}
|
||||
|
||||
console.log(`尝试播放视频 - 标题: "${video_title}", 索引: ${video_index}`);
|
||||
|
||||
let page = await UINode.getCurrentPage();
|
||||
const seenTitles = new Set<string>();
|
||||
let currentVideoCount = 0;
|
||||
const maxScrolls = 10;
|
||||
let videoFoundAndClicked = false;
|
||||
let foundVideoTitle: string | undefined = undefined;
|
||||
|
||||
// Ensure we are on search results page by checking for the recycler view
|
||||
const initialContainer = page.findById('tv.danmaku.bili:id/recycler_view');
|
||||
if (!initialContainer) {
|
||||
return createResponse(false, "似乎不在搜索结果页面,无法播放视频。");
|
||||
}
|
||||
|
||||
// 不再滚动到顶部,直接从当前位置开始查找,避免触发下拉刷新
|
||||
let lastSeenCount = -1;
|
||||
let scrollCount = 0; // 记录向下滚动的次数
|
||||
|
||||
for (let i = 0; i < maxScrolls; i++) {
|
||||
page = await UINode.getCurrentPage();
|
||||
const currentContainer = page.findById('tv.danmaku.bili:id/recycler_view');
|
||||
if (!currentContainer) {
|
||||
console.log("Could not find search results container during scroll.");
|
||||
break;
|
||||
}
|
||||
|
||||
const visibleItems = currentContainer.children;
|
||||
|
||||
for (const item of visibleItems) {
|
||||
const titleNode = item.findById('tv.danmaku.bili:id/title');
|
||||
const title = titleNode ? titleNode.text : null;
|
||||
|
||||
if (!title || seenTitles.has(title)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filtering logic
|
||||
const isAd = item.findById('tv.danmaku.bili:id/ad_tint_frame') || item.findById('tv.danmaku.bili:id/ad_tag');
|
||||
const isUser = item.findById('tv.danmaku.bili:id/user_info');
|
||||
const isGame = item.findById('tv.danmaku.bili:id/iv_background');
|
||||
const isLive = item.findByText("直播");
|
||||
|
||||
if (isAd || isUser || isGame || isLive) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// This is a valid video, add to seen and increment count
|
||||
seenTitles.add(title);
|
||||
currentVideoCount++;
|
||||
|
||||
let shouldClick = false;
|
||||
if (video_index) {
|
||||
if (currentVideoCount === video_index) {
|
||||
shouldClick = true;
|
||||
}
|
||||
} else if (video_title) {
|
||||
if (title.includes(video_title)) {
|
||||
shouldClick = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldClick) {
|
||||
// Try clicking the cover image first, as it's a more reliable target
|
||||
const coverImage = item.findById('tv.danmaku.bili:id/cover');
|
||||
if (coverImage) {
|
||||
console.log("Clicking on video cover image.");
|
||||
await coverImage.click();
|
||||
} else {
|
||||
console.log("Cover image not found, clicking on the whole item.");
|
||||
await item.click();
|
||||
}
|
||||
await Tools.System.sleep(4000); // Increased wait time for video to load
|
||||
videoFoundAndClicked = true;
|
||||
foundVideoTitle = title;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (videoFoundAndClicked) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if we should stop scrolling
|
||||
if (seenTitles.size === lastSeenCount) {
|
||||
console.log("Scrolling is not yielding new results. Stopping.");
|
||||
break;
|
||||
}
|
||||
lastSeenCount = seenTitles.size;
|
||||
|
||||
// If we are looking for an index and have already passed it, stop.
|
||||
if (video_index && currentVideoCount >= video_index) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Scroll down to load more
|
||||
await Tools.UI.swipe(540, 1500, 540, 800);
|
||||
await Tools.System.sleep(1500);
|
||||
scrollCount++; // 记录滚动次数
|
||||
}
|
||||
|
||||
// 如果没找到视频,滚动回去
|
||||
if (!videoFoundAndClicked && scrollCount > 0) {
|
||||
console.log(`未找到视频,滚动回去 ${scrollCount} 次`);
|
||||
for (let i = 0; i < scrollCount; i++) {
|
||||
await Tools.UI.swipe(540, 800, 540, 1550);
|
||||
await Tools.System.sleep(1000);
|
||||
}
|
||||
}
|
||||
|
||||
if (videoFoundAndClicked) {
|
||||
return createResponse(true, `成功开始播放视频: "${foundVideoTitle}"`, { video_title, video_index });
|
||||
} else {
|
||||
return createResponse(false, "未找到指定的视频", { video_title, video_index });
|
||||
}
|
||||
}
|
||||
|
||||
async function send_comment(params: { comment_text: string }): Promise<any> {
|
||||
const commentText = params.comment_text || "";
|
||||
|
||||
if (!await switch_to_comment_activity()) {
|
||||
return createResponse(false, "不在视频页面");
|
||||
}
|
||||
|
||||
console.log(`发送评论: ${commentText}`);
|
||||
|
||||
const page = await UINode.getCurrentPage();
|
||||
// 查找评论区域
|
||||
let commentArea = page.findById("input");
|
||||
if (commentArea) {
|
||||
await commentArea.click();
|
||||
await Tools.System.sleep(1000);
|
||||
|
||||
// 输入评论内容
|
||||
await Tools.UI.setText(commentText);
|
||||
await Tools.System.sleep(500);
|
||||
|
||||
// 查找发送按钮
|
||||
const sendButton = page.findByText("发送");
|
||||
if (sendButton) {
|
||||
await sendButton.click();
|
||||
await Tools.System.sleep(1000);
|
||||
|
||||
return createResponse(true, "评论发送成功", { comment_text: commentText });
|
||||
}
|
||||
} else {
|
||||
return createResponse(false, "未找到评论输入框", { comment_text: commentText });
|
||||
}
|
||||
}
|
||||
async function is_in_video_activity(): Promise<boolean> {
|
||||
const pageInfo = await Tools.UI.getPageInfo();
|
||||
return pageInfo.activityName?.includes(VIDEO_ACTIVITY);
|
||||
}
|
||||
|
||||
async function is_on_search_results_page(): Promise<boolean> {
|
||||
// A reliable indicator of the search results page is the presence of filter buttons.
|
||||
const page = await UINode.getCurrentPage();
|
||||
const filterButton = page.findByText("综合") && page.findByText("搜索"); // "Comprehensive" filter
|
||||
return !!filterButton;
|
||||
}
|
||||
|
||||
async function return_to_video_list(params: {}): Promise<any> {
|
||||
console.log("Attempting to return to video list...");
|
||||
if (await is_in_video_activity()) {
|
||||
await Tools.UI.pressKey("KEYCODE_BACK");
|
||||
await Tools.System.sleep(2000); // Wait for transition
|
||||
// Verify we are back on the search results page
|
||||
if (await is_on_search_results_page()) {
|
||||
return createResponse(true, "Successfully returned to video list.");
|
||||
} else {
|
||||
return createResponse(false, "Failed to return to video list, did not land on search results page.");
|
||||
}
|
||||
} else {
|
||||
// If we are already on the search results page, consider it a success.
|
||||
if (await is_on_search_results_page()) {
|
||||
return createResponse(true, "Already on the video list page.");
|
||||
}
|
||||
return createResponse(false, "Not in a video activity, no action taken.");
|
||||
}
|
||||
}
|
||||
|
||||
async function switch_to_comment_activity() {
|
||||
if (!await is_in_video_activity()) {
|
||||
return false;
|
||||
}
|
||||
console.log("切换到评论页面");
|
||||
await findAndClickByText("评论");
|
||||
await Tools.System.sleep(1000);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function like_video(params: {}): Promise<any> {
|
||||
if (!await is_in_video_activity()) {
|
||||
return createResponse(false, "不在视频页面");
|
||||
}
|
||||
console.log("给视频点赞");
|
||||
|
||||
if (await findAndClick(async () => (await UINode.getCurrentPage()).findById("frame_like"))) {
|
||||
return createResponse(true, "点赞成功");
|
||||
} else {
|
||||
return createResponse(false, "未找到点赞按钮");
|
||||
}
|
||||
}
|
||||
|
||||
async function collect_video(params: { folder_name?: string }): Promise<any> {
|
||||
if (!await is_in_video_activity()) {
|
||||
return createResponse(false, "不在视频页面");
|
||||
}
|
||||
const { folder_name: folderName } = params;
|
||||
console.log(`开始收藏视频, 文件夹: ${folderName}`);
|
||||
|
||||
if (await findAndClick(async () => (await UINode.getCurrentPage()).findById("frame_fav"))) {
|
||||
return createResponse(true, "收藏成功", { folder_name: folderName });
|
||||
} else {
|
||||
return createResponse(false, "未找到收藏按钮", { folder_name: folderName });
|
||||
}
|
||||
}
|
||||
|
||||
async function follow_uploader(params: {}): Promise<any> {
|
||||
if (!await is_in_video_activity()) {
|
||||
return createResponse(false, "不在视频页面");
|
||||
}
|
||||
console.log("开始关注UP主");
|
||||
|
||||
if (await findAndClick(async () => (await UINode.getCurrentPage()).findAllById("follow"))) {
|
||||
return createResponse(true, "关注成功");
|
||||
} else {
|
||||
return createResponse(false, "未找到关注按钮或已经关注");
|
||||
}
|
||||
}
|
||||
|
||||
async function get_video_info(params: {}): Promise<any> {
|
||||
if (!await is_in_video_activity()) {
|
||||
return createResponse(false, "不在视频页面");
|
||||
}
|
||||
|
||||
console.log("获取视频信息");
|
||||
|
||||
const page = await UINode.getCurrentPage();
|
||||
const videoInfo = {
|
||||
title: "",
|
||||
uploader: "",
|
||||
play_count: "",
|
||||
comment_count: "",
|
||||
like_count: "",
|
||||
coin_count: "",
|
||||
collect_count: "",
|
||||
forward_count: ""
|
||||
};
|
||||
|
||||
// 获取视频标题
|
||||
const titleElement = page.findById('title');
|
||||
if (titleElement && titleElement.text) {
|
||||
videoInfo.title = titleElement.text;
|
||||
}
|
||||
// 获取视频点赞数量
|
||||
const likeElement = page.findById('frame_like');
|
||||
if (likeElement && likeElement.contentDesc) {
|
||||
videoInfo.like_count = likeElement.contentDesc;
|
||||
}
|
||||
// 获取视频收藏数量
|
||||
const collectElement = page.findById('frame_fav');
|
||||
if (collectElement && collectElement.contentDesc) {
|
||||
videoInfo.collect_count = collectElement.contentDesc;
|
||||
}
|
||||
// 获取视频投币数量
|
||||
const coinElement = page.findById('frame_coin');
|
||||
if (coinElement && coinElement.contentDesc) {
|
||||
videoInfo.coin_count = coinElement.contentDesc;
|
||||
}
|
||||
|
||||
//获取视频转发数量
|
||||
const forwardElement = page.findById('frame_share');
|
||||
if (forwardElement && forwardElement.contentDesc) {
|
||||
videoInfo.forward_count = forwardElement.contentDesc;
|
||||
}
|
||||
|
||||
// 获取UP主名称
|
||||
const uploaderElements = page.findAllById("author_name");
|
||||
if (uploaderElements.length > 0) {
|
||||
videoInfo.uploader = uploaderElements.map(u => u.text).join("/");
|
||||
}
|
||||
|
||||
|
||||
return createResponse(true, "获取视频信息成功", { video_info: videoInfo });
|
||||
}
|
||||
|
||||
async function browse_comments(params: { comment_count?: number }): Promise<any> {
|
||||
const commentCount = params.comment_count || 5;
|
||||
if (!await switch_to_comment_activity()) {
|
||||
return createResponse(false, "不在视频页面");
|
||||
}
|
||||
|
||||
console.log(`浏览评论,获取${commentCount}条`);
|
||||
|
||||
const page = await UINode.getCurrentPage();
|
||||
|
||||
// 滚动到评论区域
|
||||
await Tools.UI.swipe(540, 1500, 540, 800);
|
||||
await Tools.System.sleep(2000);
|
||||
|
||||
// 查找评论列表
|
||||
const commentList = page.allTexts();
|
||||
|
||||
return createResponse(true, "获取评论成功", { "all_texts": commentList });
|
||||
}
|
||||
|
||||
async function navigate_to_home(params: {}): Promise<any> {
|
||||
console.log("导航到首页");
|
||||
|
||||
if (await ensureMain()) {
|
||||
return createResponse(true, "已导航到首页");
|
||||
} else {
|
||||
return createResponse(false, "无法导航到首页");
|
||||
}
|
||||
}
|
||||
|
||||
async function navigate_to_following(params: {}): Promise<any> {
|
||||
console.log("导航到关注页面");
|
||||
|
||||
const page = await UINode.getCurrentPage();
|
||||
|
||||
const followingTab = page.findByText("关注");
|
||||
if (followingTab) {
|
||||
await followingTab.click();
|
||||
await Tools.System.sleep(2000);
|
||||
|
||||
return createResponse(true, "已导航到关注页面");
|
||||
} else {
|
||||
return createResponse(false, "未找到关注标签");
|
||||
}
|
||||
}
|
||||
|
||||
async function navigate_to_history(params: {}): Promise<any> {
|
||||
console.log("导航到历史页面");
|
||||
|
||||
const page = await UINode.getCurrentPage();
|
||||
|
||||
// 查找历史按钮(通常在个人中心或侧边栏)
|
||||
let historyButton = page.findByText("历史");
|
||||
if (!historyButton) {
|
||||
historyButton = page.findByText("观看历史");
|
||||
}
|
||||
|
||||
if (historyButton) {
|
||||
await historyButton.click();
|
||||
await Tools.System.sleep(2000);
|
||||
|
||||
return createResponse(true, "已导航到历史页面");
|
||||
} else {
|
||||
return createResponse(false, "未找到历史按钮");
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle_fullscreen(params: {}): Promise<any> {
|
||||
console.log("切换全屏状态");
|
||||
|
||||
// 点击视频中央来显示控制栏
|
||||
await Tools.UI.tap(540, 960);
|
||||
await Tools.System.sleep(1000);
|
||||
|
||||
const page = await UINode.getCurrentPage();
|
||||
|
||||
// 查找全屏按钮
|
||||
let fullscreenButton = page.findByContentDesc("全屏");
|
||||
if (!fullscreenButton) {
|
||||
fullscreenButton = page.findByText("全屏");
|
||||
}
|
||||
if (!fullscreenButton) {
|
||||
fullscreenButton = page.findById('fullscreen_button');
|
||||
}
|
||||
|
||||
if (fullscreenButton) {
|
||||
await fullscreenButton.click();
|
||||
await Tools.System.sleep(1000);
|
||||
|
||||
return createResponse(true, "全屏状态切换成功");
|
||||
} else {
|
||||
return createResponse(false, "未找到全屏按钮");
|
||||
}
|
||||
}
|
||||
|
||||
async function adjust_playback_speed(params: { speed: string }): Promise<any> {
|
||||
const speed = params.speed || "1.0x";
|
||||
|
||||
console.log(`调整播放速度为: ${speed}`);
|
||||
|
||||
// 点击视频中央来显示控制栏
|
||||
await Tools.UI.tap(540, 960);
|
||||
await Tools.System.sleep(1000);
|
||||
|
||||
const page = await UINode.getCurrentPage();
|
||||
|
||||
// 查找速度设置按钮
|
||||
let speedButton = page.findByText("倍速");
|
||||
if (!speedButton) {
|
||||
speedButton = page.findByText("1.0x");
|
||||
}
|
||||
if (!speedButton) {
|
||||
speedButton = page.findById('speed_button');
|
||||
}
|
||||
|
||||
if (speedButton) {
|
||||
await speedButton.click();
|
||||
await Tools.System.sleep(1000);
|
||||
|
||||
// 选择目标速度
|
||||
const targetSpeedButton = page.findByText(speed);
|
||||
if (targetSpeedButton) {
|
||||
await targetSpeedButton.click();
|
||||
await Tools.System.sleep(1000);
|
||||
|
||||
return createResponse(true, `播放速度已调整为 ${speed}`, { speed: speed });
|
||||
} else {
|
||||
return createResponse(false, `未找到 ${speed} 速度选项`, { speed: speed });
|
||||
}
|
||||
} else {
|
||||
return createResponse(false, "未找到速度设置按钮", { speed: speed });
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("=== B站智能助手测试 ===");
|
||||
console.log(await search_video({ "keyword": "原神" }));
|
||||
await play_video({ "video_index": 4 });
|
||||
console.log(await get_video_info({}));
|
||||
console.log(await browse_comments({}));
|
||||
|
||||
}
|
||||
|
||||
async function wrapToolExecution(func: (params: any) => Promise<any>, params: any) {
|
||||
try {
|
||||
const result = await func(params);
|
||||
complete({
|
||||
...result, // contains success, message, and other data
|
||||
});
|
||||
} catch (error) {
|
||||
// This catch is for unexpected errors in tool logic, not for "soft" failures.
|
||||
console.error(`Tool ${func.name} failed unexpectedly`, error);
|
||||
complete({
|
||||
success: false,
|
||||
message: `工具执行时发生意外错误: ${error.message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
main: main,
|
||||
search_video: async (params) => await wrapToolExecution(search_video, params),
|
||||
play_video: async (params) => await wrapToolExecution(play_video, params),
|
||||
return_to_video_list: async (params) => await wrapToolExecution(return_to_video_list, params),
|
||||
send_comment: async (params) => await wrapToolExecution(send_comment, params),
|
||||
like_video: async (params) => await wrapToolExecution(like_video, params),
|
||||
collect_video: async (params) => await wrapToolExecution(collect_video, params),
|
||||
follow_uploader: async (params) => await wrapToolExecution(follow_uploader, params),
|
||||
get_video_info: async (params) => await wrapToolExecution(get_video_info, params),
|
||||
browse_comments: async (params) => await wrapToolExecution(browse_comments, params),
|
||||
navigate_to_home: async (params) => await wrapToolExecution(navigate_to_home, params),
|
||||
navigate_to_following: async (params) => await wrapToolExecution(navigate_to_following, params),
|
||||
navigate_to_history: async (params) => await wrapToolExecution(navigate_to_history, params),
|
||||
toggle_fullscreen: async (params) => await wrapToolExecution(toggle_fullscreen, params),
|
||||
adjust_playback_speed: async (params) => await wrapToolExecution(adjust_playback_speed, params)
|
||||
};
|
||||
})();
|
||||
|
||||
// 逐个导出
|
||||
exports.search_video = BilibiliAssistant.search_video;
|
||||
exports.play_video = BilibiliAssistant.play_video;
|
||||
exports.return_to_video_list = BilibiliAssistant.return_to_video_list;
|
||||
exports.send_comment = BilibiliAssistant.send_comment;
|
||||
exports.like_video = BilibiliAssistant.like_video;
|
||||
exports.collect_video = BilibiliAssistant.collect_video;
|
||||
exports.follow_uploader = BilibiliAssistant.follow_uploader;
|
||||
exports.get_video_info = BilibiliAssistant.get_video_info;
|
||||
exports.browse_comments = BilibiliAssistant.browse_comments;
|
||||
exports.navigate_to_home = BilibiliAssistant.navigate_to_home;
|
||||
exports.navigate_to_following = BilibiliAssistant.navigate_to_following;
|
||||
exports.navigate_to_history = BilibiliAssistant.navigate_to_history;
|
||||
exports.toggle_fullscreen = BilibiliAssistant.toggle_fullscreen;
|
||||
exports.adjust_playback_speed = BilibiliAssistant.adjust_playback_speed;
|
||||
exports.main = BilibiliAssistant.main;
|
||||
@@ -0,0 +1,358 @@
|
||||
/* METADATA
|
||||
{
|
||||
"name": "Automatic_ui_base",
|
||||
|
||||
"display_name": {
|
||||
"zh": "自动化基础工具",
|
||||
"en": "Automation Base Tools"
|
||||
},
|
||||
"description": { "zh": "提供基本的UI自动化工具,能够按照用户的要求帮助操作设备屏幕(如点击、滑动、输入等)。", "en": "Basic UI automation tools to operate the device screen as requested (tap, swipe, input, etc.)." },
|
||||
"category": "Automatic",
|
||||
"enabledByDefault": true,
|
||||
"tools": [
|
||||
{
|
||||
"name": "usage_advice",
|
||||
"description": { "zh": "UI自动化建议:\\n- 元素定位选项:\\n • 列表:使用index参数(例如,“点击索引为2的列表项”)\\n • 文本:使用bounds或partialMatch进行模糊匹配(例如,“点击包含‘登录’文字的按钮”)\\n- 操作链:组合多个操作以完成复杂任务(例如,“获取页面信息,然后点击元素”)\\n- 错误处理:如果操作失败,分析页面信息找出原因,并尝试其他方法。\\n- **组合调用(推荐)**:强烈建议在一次响应中组合调用2~3个真实存在的工具,例如依次调用tap → get_page_info,或 click_element → sleep → get_page_info,一次性输出完整的操作序列。软件会自动按顺序依次执行这些工具调用。", "en": "UI automation advice:\\n- Element targeting options:\\n • Lists: use the index parameter (e.g., tap list item at index 2).\\n • Text: use bounds or partialMatch for fuzzy matching (e.g., tap a button containing the text 'Login').\\n- Action chains: combine multiple actions to complete complex tasks (e.g., get page info, then click an element).\\n- Error handling: if an action fails, inspect the page info to find the cause and try alternative methods.\\n- **Combined calls (recommended)**: strongly recommend combining 2~3 real tools in a single response, e.g. tap → get_page_info, or click_element → sleep → get_page_info. The system will execute these tool calls sequentially." },
|
||||
"parameters": [],
|
||||
"advice": true
|
||||
},
|
||||
{
|
||||
"name": "app_launch",
|
||||
"description": { "zh": "根据应用包名直接启动应用。如果未找到该包名对应的应用,则返回当前设备的软件安装列表,供你选择其他应用。", "en": "Launch an app by package name. If not found, returns the installed app list for you to choose from." },
|
||||
"parameters": [
|
||||
{ "name": "package_name", "description": { "zh": "应用包名,例如'com.tencent.mm'", "en": "App package name, e.g. 'com.tencent.mm'." }, "type": "string", "required": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "get_page_info",
|
||||
"description": { "zh": "获取当前UI屏幕的信息,包括完整的UI层次结构。", "en": "Get information about the current UI screen, including the full UI hierarchy." },
|
||||
"parameters": [
|
||||
{ "name": "format", "description": { "zh": "格式,可选:'xml'或'json',默认'xml'", "en": "Format: 'xml' or 'json' (default: 'xml')." }, "type": "string", "required": false },
|
||||
{ "name": "detail", "description": { "zh": "详细程度,可选:'minimal'、'summary'或'full',默认'summary'", "en": "Detail level: 'minimal', 'summary', or 'full' (default: 'summary')." }, "type": "string", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "get_page_screenshot_image",
|
||||
"description": { "zh": "获取当前屏幕内容的图片版本(截图),返回保存路径。", "en": "Capture the current screen as an image (screenshot) and return the saved file path." },
|
||||
"parameters": []
|
||||
},
|
||||
{
|
||||
"name": "tap",
|
||||
"description": { "zh": "在特定坐标模拟点击。", "en": "Simulate a tap at the specified coordinates." },
|
||||
"parameters": [
|
||||
{ "name": "x", "description": { "zh": "X坐标", "en": "X coordinate." }, "type": "number", "required": true },
|
||||
{ "name": "y", "description": { "zh": "Y坐标", "en": "Y coordinate." }, "type": "number", "required": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "double_tap",
|
||||
"description": { "zh": "在特定坐标模拟双击(快速连续点击两次)。", "en": "Simulate a double tap at the specified coordinates (two quick taps)." },
|
||||
"parameters": [
|
||||
{ "name": "x", "description": { "zh": "X坐标", "en": "X coordinate." }, "type": "number", "required": true },
|
||||
{ "name": "y", "description": { "zh": "Y坐标", "en": "Y coordinate." }, "type": "number", "required": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "long_press",
|
||||
"description": { "zh": "在特定坐标模拟长按操作。适用于呼出上下文菜单、拖拽前的按住等场景。", "en": "Simulate a long press at the specified coordinates. Useful for context menus or starting a drag." },
|
||||
"parameters": [
|
||||
{ "name": "x", "description": { "zh": "X坐标", "en": "X coordinate." }, "type": "number", "required": true },
|
||||
{ "name": "y", "description": { "zh": "Y坐标", "en": "Y coordinate." }, "type": "number", "required": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "click_element",
|
||||
"description": { "zh": "点击由资源ID或类名标识的元素。必须至少提供一个标识参数。", "en": "Click an element identified by resourceId or className. You must provide at least one identifier." },
|
||||
"parameters": [
|
||||
{ "name": "resourceId", "description": { "zh": "元素资源ID", "en": "Element resourceId." }, "type": "string", "required": false },
|
||||
{ "name": "className", "description": { "zh": "元素类名", "en": "Element class name." }, "type": "string", "required": false },
|
||||
{ "name": "index", "description": { "zh": "要点击的匹配元素,从0开始计数,默认0", "en": "Index of the matched element to click (0-based, default: 0)." }, "type": "number", "required": false },
|
||||
{ "name": "partialMatch", "description": { "zh": "是否启用部分匹配,默认false", "en": "Enable partial match (default: false)." }, "type": "boolean", "required": false },
|
||||
{ "name": "bounds", "description": { "zh": "元素边界,格式为'[left,top][right,bottom]'", "en": "Element bounds in format '[left,top][right,bottom]'." }, "type": "string", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "set_input_text",
|
||||
"description": { "zh": "在输入字段中设置文本。", "en": "Set text in the current input field." },
|
||||
"parameters": [
|
||||
{ "name": "text", "description": { "zh": "要输入的文本", "en": "Text to input." }, "type": "string", "required": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "press_key",
|
||||
"description": { "zh": "模拟按键。", "en": "Simulate a key press." },
|
||||
"parameters": [
|
||||
{ "name": "key_code", "description": { "zh": "键码,例如'KEYCODE_BACK'、'KEYCODE_HOME'等", "en": "Key code, e.g. 'KEYCODE_BACK', 'KEYCODE_HOME'." }, "type": "string", "required": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "swipe",
|
||||
"description": { "zh": "模拟滑动手势。", "en": "Simulate a swipe gesture." },
|
||||
"parameters": [
|
||||
{ "name": "start_x", "description": { "zh": "起始X坐标", "en": "Start X coordinate." }, "type": "number", "required": true },
|
||||
{ "name": "start_y", "description": { "zh": "起始Y坐标", "en": "Start Y coordinate." }, "type": "number", "required": true },
|
||||
{ "name": "end_x", "description": { "zh": "结束X坐标", "en": "End X coordinate." }, "type": "number", "required": true },
|
||||
{ "name": "end_y", "description": { "zh": "结束Y坐标", "en": "End Y coordinate." }, "type": "number", "required": true },
|
||||
{ "name": "duration", "description": { "zh": "持续时间,毫秒,默认300", "en": "Duration in milliseconds (default: 300)." }, "type": "number", "required": false }
|
||||
]
|
||||
}
|
||||
]
|
||||
}*/
|
||||
|
||||
const UIAutomationTools = (function () {
|
||||
|
||||
interface ToolResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
async function get_page_info(params: { format?: 'xml' | 'json', detail?: 'minimal' | 'summary' | 'full' }): Promise<ToolResponse> {
|
||||
const result = (await UINode.getCurrentPage()).toFormattedString!();
|
||||
return { success: true, message: '成功获取页面信息', data: result };
|
||||
}
|
||||
|
||||
async function get_page_screenshot_image(params: {}): Promise<ToolResponse> {
|
||||
try {
|
||||
const screenshotDir = OPERIT_CLEAN_ON_EXIT_DIR;
|
||||
|
||||
// Ensure the directory exists
|
||||
await Tools.Files.mkdir(screenshotDir, true);
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const filePath = `${screenshotDir}/ui_screenshot_${timestamp}.png`;
|
||||
|
||||
console.log(`截取当前UI屏幕并保存到: ${filePath}`);
|
||||
|
||||
const result = await Tools.System.shell(`screencap -p ${filePath}`);
|
||||
|
||||
const imageLink = NativeInterface.registerImageFromPath(filePath);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `截图已保存到 ${filePath}`,
|
||||
data: {
|
||||
file_path: filePath,
|
||||
image_link: imageLink,
|
||||
raw_result: result,
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(`获取屏幕截图失败: ${error.message}`);
|
||||
return {
|
||||
success: false,
|
||||
message: `获取屏幕截图失败: ${error.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function tap(params: { x: number, y: number }): Promise<ToolResponse> {
|
||||
const result = await Tools.UI.tap(params.x, params.y);
|
||||
return { success: true, message: '点击操作成功', data: result };
|
||||
}
|
||||
|
||||
async function double_tap(params: { x: number, y: number }): Promise<ToolResponse> {
|
||||
const first = await Tools.UI.tap(params.x, params.y);
|
||||
await Tools.System.sleep(120);
|
||||
const second = await Tools.UI.tap(params.x, params.y);
|
||||
return {
|
||||
success: true,
|
||||
message: '双击操作成功',
|
||||
data: { first, second },
|
||||
};
|
||||
}
|
||||
|
||||
async function long_press(params: { x: number, y: number }): Promise<ToolResponse> {
|
||||
const result = await Tools.UI.longPress(params.x, params.y);
|
||||
return { success: true, message: '长按操作成功', data: result };
|
||||
}
|
||||
|
||||
async function click_element(params: { resourceId?: string, className?: string, index?: number, partialMatch?: boolean, bounds?: string }): Promise<ToolResponse> {
|
||||
const result = await Tools.UI.clickElement(params);
|
||||
return { success: true, message: '点击元素操作成功', data: result };
|
||||
}
|
||||
|
||||
async function set_input_text(params: { text: string }): Promise<ToolResponse> {
|
||||
const result = await Tools.UI.setText(params.text);
|
||||
return { success: true, message: '输入文本操作成功', data: result };
|
||||
}
|
||||
|
||||
async function press_key(params: { key_code: string }): Promise<ToolResponse> {
|
||||
const result = await Tools.UI.pressKey(params.key_code);
|
||||
return { success: true, message: '按键操作成功', data: result };
|
||||
}
|
||||
|
||||
async function swipe(params: { start_x: number, start_y: number, end_x: number, end_y: number, duration?: number }): Promise<ToolResponse> {
|
||||
const result = await Tools.UI.swipe(params.start_x, params.start_y, params.end_x, params.end_y);
|
||||
return { success: true, message: '滑动操作成功', data: result };
|
||||
}
|
||||
|
||||
async function app_launch(params: { package_name: string }): Promise<ToolResponse> {
|
||||
if (!params.package_name) {
|
||||
return { success: false, message: '必须提供package_name参数' };
|
||||
}
|
||||
|
||||
try {
|
||||
const startResult = await Tools.System.startApp(params.package_name);
|
||||
|
||||
if (startResult && startResult.success) {
|
||||
return {
|
||||
success: true,
|
||||
message: '应用启动成功',
|
||||
data: {
|
||||
operation: startResult,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const appList = await Tools.System.listApps(false);
|
||||
return {
|
||||
success: false,
|
||||
message: '未能启动应用,可能未安装或无法找到启动入口。已返回当前安装的应用列表。',
|
||||
data: {
|
||||
operation: startResult,
|
||||
installed_apps: appList,
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(`app_launch 执行失败: ${error.message}`);
|
||||
try {
|
||||
const appList = await Tools.System.listApps(false);
|
||||
return {
|
||||
success: false,
|
||||
message: `启动应用时发生错误: ${error.message}。已返回当前安装的应用列表。`,
|
||||
data: {
|
||||
installed_apps: appList,
|
||||
},
|
||||
};
|
||||
} catch (listError: any) {
|
||||
console.error(`获取应用列表失败: ${listError.message}`);
|
||||
return {
|
||||
success: false,
|
||||
message: `启动应用失败且无法获取应用列表: ${listError.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function wrapToolExecution<P>(func: (params: P) => Promise<ToolResponse>, params: P) {
|
||||
try {
|
||||
const result = await func(params);
|
||||
complete(result);
|
||||
} catch (error: any) {
|
||||
console.error(`Tool ${func.name} failed unexpectedly`, error);
|
||||
complete({
|
||||
success: false,
|
||||
message: `工具执行时发生意外错误: ${error.message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("=== UI Automation Tools 测试开始 ===\n");
|
||||
const results: any[] = [];
|
||||
|
||||
try {
|
||||
// 1. 测试 get_page_info
|
||||
console.log("1. 测试 get_page_info...");
|
||||
const pageInfoResult = await get_page_info({});
|
||||
results.push({ tool: 'get_page_info', result: pageInfoResult });
|
||||
console.log("✓ get_page_info 测试完成\n");
|
||||
|
||||
// 2. 测试 tap (点击屏幕中心位置)
|
||||
console.log("2. 测试 tap...");
|
||||
const tapResult = await tap({ x: 500, y: 1000 });
|
||||
results.push({ tool: 'tap', result: tapResult });
|
||||
console.log("✓ tap 测试完成\n");
|
||||
await Tools.System.sleep(500);
|
||||
|
||||
// 3. 测试 press_key (按音量上键)
|
||||
console.log("3. 测试 press_key...");
|
||||
const pressKeyResult = await press_key({ key_code: 'KEYCODE_VOLUME_UP' });
|
||||
results.push({ tool: 'press_key', result: pressKeyResult });
|
||||
console.log("✓ press_key 测试完成\n");
|
||||
await Tools.System.sleep(500);
|
||||
|
||||
// 4. 测试 set_input_text
|
||||
console.log("4. 测试 set_input_text...");
|
||||
const setTextResult = await set_input_text({ text: 'UI自动化测试文本' });
|
||||
results.push({ tool: 'set_input_text', result: setTextResult });
|
||||
console.log("✓ set_input_text 测试完成\n");
|
||||
await Tools.System.sleep(500);
|
||||
|
||||
// 5. 测试 swipe (向上滑动)
|
||||
console.log("5. 测试 swipe...");
|
||||
const swipeResult = await swipe({
|
||||
start_x: 500,
|
||||
start_y: 1500,
|
||||
end_x: 500,
|
||||
end_y: 500,
|
||||
duration: 300
|
||||
});
|
||||
results.push({ tool: 'swipe', result: swipeResult });
|
||||
console.log("✓ swipe 测试完成\n");
|
||||
await Tools.System.sleep(500);
|
||||
|
||||
// 6. 测试 click_element (尝试点击一个常见的元素)
|
||||
console.log("6. 测试 click_element...");
|
||||
try {
|
||||
const clickResult = await click_element({
|
||||
className: 'android.widget.Button',
|
||||
index: 0
|
||||
});
|
||||
results.push({ tool: 'click_element', result: clickResult });
|
||||
console.log("✓ click_element 测试完成\n");
|
||||
} catch (error: any) {
|
||||
console.log("⚠ click_element 测试失败(这可能是正常的,如果当前页面没有按钮):", error.message, "\n");
|
||||
results.push({ tool: 'click_element', result: { success: false, message: error.message } });
|
||||
}
|
||||
|
||||
console.log("=== UI Automation Tools 测试完成 ===\n");
|
||||
console.log("测试结果汇总:");
|
||||
results.forEach((r, i) => {
|
||||
const status = r.result.success ? '✓' : '✗';
|
||||
console.log(`${i + 1}. ${status} ${r.tool}: ${r.result.message}`);
|
||||
});
|
||||
|
||||
complete({
|
||||
success: true,
|
||||
message: "所有UI工具测试完成",
|
||||
data: results
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("测试过程中发生错误:", error);
|
||||
complete({
|
||||
success: false,
|
||||
message: `测试失败: ${error.message}`,
|
||||
data: results
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get_page_info: (params: { format?: 'xml' | 'json', detail?: 'minimal' | 'summary' | 'full' }) => wrapToolExecution(get_page_info, params),
|
||||
app_launch: (params: { package_name: string }) => wrapToolExecution(app_launch, params),
|
||||
get_page_screenshot_image: () => wrapToolExecution(get_page_screenshot_image, {}),
|
||||
tap: (params: { x: number, y: number }) => wrapToolExecution(tap, params),
|
||||
double_tap: (params: { x: number, y: number }) => wrapToolExecution(double_tap, params),
|
||||
long_press: (params: { x: number, y: number }) => wrapToolExecution(long_press, params),
|
||||
click_element: (params: { resourceId?: string, className?: string, index?: number, partialMatch?: boolean, bounds?: string }) => wrapToolExecution(click_element, params),
|
||||
set_input_text: (params: { text: string }) => wrapToolExecution(set_input_text, params),
|
||||
press_key: (params: { key_code: string }) => wrapToolExecution(press_key, params),
|
||||
swipe: (params: { start_x: number, start_y: number, end_x: number, end_y: number, duration?: number }) => wrapToolExecution(swipe, params),
|
||||
|
||||
main,
|
||||
};
|
||||
})();
|
||||
|
||||
exports.get_page_info = UIAutomationTools.get_page_info;
|
||||
exports.app_launch = UIAutomationTools.app_launch;
|
||||
exports.get_page_screenshot_image = UIAutomationTools.get_page_screenshot_image;
|
||||
exports.tap = UIAutomationTools.tap;
|
||||
exports.double_tap = UIAutomationTools.double_tap;
|
||||
exports.long_press = UIAutomationTools.long_press;
|
||||
exports.click_element = UIAutomationTools.click_element;
|
||||
exports.set_input_text = UIAutomationTools.set_input_text;
|
||||
exports.press_key = UIAutomationTools.press_key;
|
||||
exports.swipe = UIAutomationTools.swipe;
|
||||
exports.main = UIAutomationTools.main;
|
||||
@@ -0,0 +1,875 @@
|
||||
/* METADATA
|
||||
{
|
||||
name: "Automatic_ui_subagent"
|
||||
|
||||
display_name: {
|
||||
zh: "自动化AutoGLM子代理"
|
||||
en: "Automated AutoGLM Sub-agent"
|
||||
}description: {
|
||||
zh: '''
|
||||
兼容AutoGLM,提供基于独立UI控制器模型(例如 autoglm-phone-9b)的高层UI自动化子代理工具,用于根据自然语言意图自动规划并执行点击/输入/滑动等一系列界面操作。
|
||||
当用户提出需要帮忙完成某个界面操作任务(例如打开应用、搜索内容、在多个页面之间完成一套步骤)时,可以调用本包由子代理自动规划和执行具体步骤。
|
||||
''',
|
||||
en: '''
|
||||
Compatible with AutoGLM. Provides a high-level UI automation sub-agent based on an independent UI-controller model (e.g. autoglm-phone-9b). It can plan and execute a sequence of UI actions (tap/type/swipe) from natural-language intent.
|
||||
When the user asks you to complete a UI task (e.g. open an app, search content, or finish a multi-step workflow across pages), you can call this package and let the sub-agent plan and execute the steps.
|
||||
'''
|
||||
}
|
||||
category: "Automatic"
|
||||
|
||||
tools: []
|
||||
|
||||
states: [
|
||||
{
|
||||
id: "virtual_display"
|
||||
condition: "ui.virtual_display"
|
||||
inheritTools: true
|
||||
tools: [
|
||||
{
|
||||
name: "usage_advice"
|
||||
description: {
|
||||
zh: '''
|
||||
UI子代理使用建议:
|
||||
|
||||
- 屏幕选择规则(重要):不传 agent_id 或传 'default' => 主屏幕;传入且不为 'default' => 对应虚拟屏会话(虚拟屏必须可用,否则会失败)。
|
||||
|
||||
- 会话复用(重要):多次调用尽量复用同一个 agent_id(沿用上一次返回的 data.agentId),保持在同一虚拟屏/同一应用上下文内。
|
||||
- 启动前置(非常重要):当你第一次使用某个 agent_id(新建或更换 agent_id)时,intent 开头必须写“启动XXX应用 ...”,让子代理直接执行 Launch,而不是在桌面自己找。
|
||||
- 对话无状态(重要):每次调用对子代理都是全新对话,intent 需要自带上下文,建议固定模板:
|
||||
当前任务已经完成: ...
|
||||
你需要在此基础上进一步完成: ...
|
||||
可能用到的信息: ...
|
||||
- 意图必须自包含(重要):禁止使用“这五个/继续/同上/刚才说的”等指代。
|
||||
- 多对象任务必须在“可能用到的信息”里给出清单(按界面顺序),并明确当前要处理哪个(例如第1个酒店)。
|
||||
- 若清单未知,本次调用先让子代理从当前页面识别并复述清单,再进行下一步(必要时拆成多次调用)。
|
||||
- 对齐推进(重要):不要一次调用里同时做“收集清单 + 处理清单全部对象”。应拆成:先清单,再按 A→B→C 逐项处理。
|
||||
- 并行优先(重要):互不依赖的子任务(多平台搜索/多入口确认/同一对象的信息提取分工)优先用 run_subagent_parallel_virtual 并行;并行时每个子代理建议不同 agent_id 避免互相干扰.
|
||||
- 并行资源约束(重要):并行分支数必须受“可用独立App数量/可用虚拟屏数量”限制。
|
||||
- 同一个App/同一个包名,不能同时存在于两个虚拟屏/两个 agent_id 中并行操作(会导致应用状态错乱/坏掉)。
|
||||
- 并行分支数不得超过“可同时存在的独立App数量”(例如只有2个独立App可用,就最多2并行)。
|
||||
- 并行调用必须传入 target_app_i=目标应用名(每个 intent_i 对应一个 target_app_i),用于冲突检测;所有启用分支的 target_app_i 必须互不相同.
|
||||
- 第2次/第N次并行(重试或第二轮任务)不得因为“想更快”而擅自提高并行度;应保持同样的并行上限,只重试失败分支,或改为串行.
|
||||
- 失败与完成(重要):半成功/误判完成不算完成;应继续纠错推进。仅在连续 2-3 次失败仍无法推进时才停止,并明确失败原因与可选替代方案.
|
||||
- 例子(详细:1并行 + 2串行)
|
||||
|
||||
例子1(并行:多平台同时找同一酒店的差评要点)
|
||||
目标:同时在 大众点评/美团/携程 搜索“XX酒店”,各自提取“近一年差评Top3要点 + 原文引用 + 日期”,最后主Agent合并.
|
||||
调用流程:
|
||||
1) 主Agent 一次并行:
|
||||
- 调用 run_subagent_parallel_virtual,给每个子代理一个互不干扰的 agent_id(例如 dp_1 / mt_1 / xc_1)。
|
||||
- intent_1(大众点评)示例:
|
||||
当前任务已经完成: 无
|
||||
你需要在此基础上进一步完成: 打开大众点评,搜索“XX酒店”,进入酒店详情,进入评价/差评/低分区,提取近一年差评Top3要点,并把每条要点附带1句原文引用+日期.
|
||||
可能用到的信息: 目标酒店名=XX酒店;输出格式=1)要点 2)引用 3)日期;如果搜到多个同名酒店,必须先确认地址/商圈与目标一致.
|
||||
- intent_2(美团)/ intent_3(携程)同理,各自写清 app、路径、输出格式.
|
||||
2) 主Agent 汇总:读取并行返回 results,合并三个平台的提取结果.
|
||||
3) 只重试失败分支:若 results 里只有美团分支失败,则只对美团再发起一次(不要重跑点评/携程)。
|
||||
- 例如再次调用 run_subagent_virtual(或再次 run_subagent_parallel_virtual 但只填 intent_2),并补充纠错信息:
|
||||
当前任务已经完成: 上次在美团搜索到酒店列表,但未能进入评价页(可能入口在“点评/评价”Tab)。
|
||||
你需要在此基础上进一步完成: 重新打开美团搜索“XX酒店”,进入正确的酒店详情页,找到“评价/点评”入口并进入差评/低分区,按同样格式输出Top3.
|
||||
可能用到的信息: 若页面出现“住客点评/全部评价/差评”多入口,优先选择“全部评价”再筛选“差评/低分”。
|
||||
|
||||
例子2(串行:先清单,再按 A→B→C 逐项处理)
|
||||
目标:在携程酒店列表页,先列出前5家酒店;然后只处理第1家酒店的差评要点;处理完再处理第2家……
|
||||
调用流程:
|
||||
1) 清单阶段(一次 run_subagent_virtual):
|
||||
- 主Agent 调用 run_subagent_virtual(intent=..., agent_id="ctrip_1")
|
||||
- intent 示例:
|
||||
当前任务已经完成: 无
|
||||
你需要在此基础上进一步完成: 打开携程,搜索“杭州 西湖 酒店”,进入列表页;把当前屏幕能看到的酒店按从上到下顺序列出前5个(名称+价格/评分如可见),并停留在列表页不要进入详情.
|
||||
可能用到的信息: 输出必须包含清单序号1-5;若需要滚动才能凑满5个可以滚动一次,但仍要保持顺序.
|
||||
- 该次返回 data.agentId 记为 A(后续复用)
|
||||
2) 单对象阶段:处理“清单第1家”(一次 run_subagent_virtual,复用 agent_id=A)
|
||||
- intent 示例:
|
||||
当前任务已经完成: 已获得酒店清单(1)酒店A 2)酒店B 3)酒店C 4)酒店D 5)酒店E),当前停留在列表页.
|
||||
你需要在此基础上进一步完成: 进入酒店A详情页,找到评价页并筛选差评/低分,提取差评Top3要点(每条含1句原文引用)。完成后返回列表页并确认列表顶部仍是酒店A/B/C顺序.
|
||||
可能用到的信息: 目标对象=清单第1家=酒店A;如果进入后发现标题不是酒店A则立刻返回列表并重新点击正确条目.
|
||||
3) 继续处理第2家/第3家……(每次都复用 agent_id=A,并在 intent 中显式写“已完成/下一步/关键信息”,以及“当前目标=清单第2家=酒店B”)。
|
||||
|
||||
例子3(串行:发消息,强调“页面确认+会话复用”)
|
||||
目标:在微信给“张三”发送“我到楼下了”,并确认发送成功.
|
||||
调用流程:
|
||||
1) 打开并定位会话(一次 run_subagent_virtual):
|
||||
- 主Agent 调用 run_subagent_virtual(intent=..., agent_id="wechat_1")
|
||||
- intent 示例:
|
||||
当前任务已经完成: 无
|
||||
你需要在此基础上进一步完成: 打开微信,进入聊天列表,搜索联系人“张三”,打开与“张三”的聊天页面;必须确认页面顶部标题=张三.
|
||||
可能用到的信息: 如果搜索结果有多个“张三”,需要根据头像/备注/地区等二次确认;不确定时返回并说明.
|
||||
- 该次返回 data.agentId 记为 W(后续复用)
|
||||
2) 发送并二次确认(一次 run_subagent_virtual,复用 agent_id=W):
|
||||
- intent 示例:
|
||||
当前任务已经完成: 已打开与“张三”的聊天页,顶部标题=张三.
|
||||
你需要在此基础上进一步完成: 输入并发送消息“我到楼下了”;发送后在消息列表中确认该消息出现在最新一条且无明显发送失败标记(如红色感叹号).
|
||||
可能用到的信息: 若出现权限弹窗/键盘遮挡,先处理弹窗再继续;若发送失败,尝试重发一次并说明原因.
|
||||
''',
|
||||
en: '''
|
||||
UI sub-agent usage advice:
|
||||
|
||||
- Screen selection rule (important): omitted agent_id or 'default' => main screen; provided and not 'default' => the requested virtual display session (must be available, otherwise the run fails).
|
||||
|
||||
- Session reuse (important): reuse the same agent_id across calls when possible (use the returned data.agentId) to stay in the same virtual display and app context.
|
||||
- Launch first (very important): when you first use an agent_id (new or changed), the intent must start with "Launch <app> ..." so the sub-agent performs a Launch directly instead of searching from the home screen.
|
||||
- Stateless per call (important): each call is a new conversation; include all necessary context. Recommended template: Completed so far / Next objective / Useful info.
|
||||
- Self-contained intent (important): do not use references like "those five / continue / same as above".
|
||||
- For multi-item tasks, list items in "Useful info" (in on-screen order) and specify which one to handle now.
|
||||
- If the list is unknown, first ask the sub-agent to identify and restate the list from the current screen, then proceed.
|
||||
- Step-by-step alignment (important): do not combine "collect list" + "process all items" in one call. Split into: list first, then process A→B→C.
|
||||
- Prefer parallelism (important): for independent subtasks (multi-platform search / multi-entry verification / extraction sub-tasks for the same object), use run_subagent_parallel_virtual. Use different agent_id per branch to avoid interference.
|
||||
- Parallel resource constraints (important): parallel branches are limited by available independent apps / virtual displays.
|
||||
- The same app/package must not be operated in parallel across two virtual displays / agent_id (it can break app state).
|
||||
- Branch count must not exceed available independent apps.
|
||||
- Parallel calls must provide target_app_i for each enabled intent_i; all target_app_i must be different.
|
||||
- Do not increase parallelism in later rounds; keep the same upper bound and only retry failed branches, or switch to serial.
|
||||
- Failure vs done (important): partial success is not done; keep correcting and progressing. Only stop after 2-3 consecutive failures with a clear reason and alternatives.
|
||||
'''
|
||||
}
|
||||
advice: true
|
||||
parameters: []
|
||||
}
|
||||
|
||||
{
|
||||
name: "run_subagent_main"
|
||||
description: {
|
||||
zh: "在主屏幕运行 UI 子代理(强制主屏)。",
|
||||
en: "Run the UI sub-agent on the main screen (forced main screen)."
|
||||
}
|
||||
parameters: [
|
||||
{
|
||||
name: "intent"
|
||||
description: {
|
||||
zh: "任务意图描述",
|
||||
en: "Task intent description"
|
||||
}
|
||||
type: "string"
|
||||
required: true
|
||||
}
|
||||
{
|
||||
name: "target_app"
|
||||
description: {
|
||||
zh: "目标应用名/包名(可选)",
|
||||
en: "Target app name/package (optional)"
|
||||
}
|
||||
type: "string"
|
||||
required: false
|
||||
}
|
||||
{
|
||||
name: "max_steps"
|
||||
description: {
|
||||
zh: "最大执行步数(默认20)",
|
||||
en: "Maximum execution steps (default: 20)"
|
||||
}
|
||||
type: "number"
|
||||
required: false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
{
|
||||
name: "run_subagent_virtual"
|
||||
description: {
|
||||
zh: "在虚拟屏幕会话运行 UI 子代理(强制虚拟屏)。",
|
||||
en: "Run the UI sub-agent on a virtual-display session (forced virtual screen)."
|
||||
}
|
||||
parameters: [
|
||||
{
|
||||
name: "intent"
|
||||
description: {
|
||||
zh: "任务意图描述",
|
||||
en: "Task intent description"
|
||||
}
|
||||
type: "string"
|
||||
required: true
|
||||
}
|
||||
{
|
||||
name: "target_app"
|
||||
description: {
|
||||
zh: "目标应用名/包名(可选)",
|
||||
en: "Target app name/package (optional)"
|
||||
}
|
||||
type: "string"
|
||||
required: false
|
||||
}
|
||||
{
|
||||
name: "max_steps"
|
||||
description: {
|
||||
zh: "最大执行步数(默认20)",
|
||||
en: "Maximum execution steps (default: 20)"
|
||||
}
|
||||
type: "number"
|
||||
required: false
|
||||
}
|
||||
{
|
||||
name: "agent_id"
|
||||
description: {
|
||||
zh: "虚拟屏会话 agent_id(必须为非 'default';可传入复用,或留空复用上次返回的 data.agentId)。",
|
||||
en: "Virtual-screen session agent_id (must be non-'default'; pass to reuse, or omit to reuse returned data.agentId)."
|
||||
}
|
||||
type: "string"
|
||||
required: false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
{
|
||||
name: "run_subagent_parallel_virtual"
|
||||
description: {
|
||||
zh: '''
|
||||
并行运行 1-4 个 UI 子代理(强制虚拟屏)。
|
||||
|
||||
注意:并行调用时,每个子代理对它自身都是全新对话,因此 intent_1..4 需要由主Agent分别写清楚“已完成/下一步/关键信息”。
|
||||
建议并行时每个子代理使用不同的 agent_id(必须显式传入,且不能为 'default'),避免操作同一虚拟屏幕造成冲突。
|
||||
如果并行任务中仅有部分子代理失败,则只对失败的子代理继续发起后续调用(补充纠错信息、提高约束),不要让已成功的子代理重复执行。
|
||||
每个 intent_i 必须自包含;建议不同 agent_id;只重试失败的 intent_i。
|
||||
典型场景:多平台并行搜索;同一对象多入口确认/交叉校验;把“同一对象A”的分工并行做完后再进入B.
|
||||
'''
|
||||
}
|
||||
parameters: [
|
||||
{
|
||||
name: "intent_1"
|
||||
description: {
|
||||
zh: "第1个子代理意图(推荐使用:当前任务已经完成/你需要进一步完成/可能用到的信息 三段式)",
|
||||
en: "Intent for sub-agent #1 (recommended template: Completed so far / Next objective / Useful info)."
|
||||
}
|
||||
type: "string"
|
||||
required: true
|
||||
}
|
||||
{
|
||||
name: "target_app_1"
|
||||
description: {
|
||||
zh: "第1个子代理目标应用名(必填,用于并行冲突检测;各分支必须不同)",
|
||||
en: "Target app name for sub-agent #1 (required for conflict detection; must be different across branches)."
|
||||
}
|
||||
type: "string"
|
||||
required: true
|
||||
}
|
||||
{
|
||||
name: "max_steps_1"
|
||||
description: {
|
||||
zh: "第1个子代理最大步数(默认20)",
|
||||
en: "Max steps for sub-agent #1 (default: 20)."
|
||||
}
|
||||
type: "number"
|
||||
required: false
|
||||
}
|
||||
{
|
||||
name: "agent_id_1"
|
||||
description: {
|
||||
zh: "第1个子代理 agent_id(必填,且不能为 'default';用于指定虚拟屏会话;并行建议不同)",
|
||||
en: "agent_id for sub-agent #1 (required, must not be 'default'; selects a virtual-display session; use different agent_id per branch)."
|
||||
}
|
||||
type: "string"
|
||||
required: true
|
||||
}
|
||||
|
||||
{
|
||||
name: "intent_2"
|
||||
description: {
|
||||
zh: "第2个子代理意图(可选)",
|
||||
en: "Intent for sub-agent #2 (optional)."
|
||||
}
|
||||
type: "string"
|
||||
required: false
|
||||
}
|
||||
{
|
||||
name: "target_app_2"
|
||||
description: {
|
||||
zh: "第2个子代理目标应用名(当 intent_2 存在时必填;各分支必须不同)",
|
||||
en: "Target app name for sub-agent #2 (required when intent_2 is provided; must be different across branches)."
|
||||
}
|
||||
type: "string"
|
||||
required: false
|
||||
}
|
||||
{
|
||||
name: "max_steps_2"
|
||||
description: {
|
||||
zh: "第2个子代理最大步数(默认20)",
|
||||
en: "Max steps for sub-agent #2 (default: 20)."
|
||||
}
|
||||
type: "number"
|
||||
required: false
|
||||
}
|
||||
{
|
||||
name: "agent_id_2"
|
||||
description: {
|
||||
zh: "第2个子代理 agent_id(当 intent_2 存在时必填,且不能为 'default';用于指定虚拟屏会话;并行建议不同)",
|
||||
en: "agent_id for sub-agent #2 (required when intent_2 is provided, must not be 'default'; selects a virtual-display session; use different agent_id per branch)."
|
||||
}
|
||||
type: "string"
|
||||
required: false
|
||||
}
|
||||
|
||||
{
|
||||
name: "intent_3"
|
||||
description: {
|
||||
zh: "第3个子代理意图(可选)",
|
||||
en: "Intent for sub-agent #3 (optional)."
|
||||
}
|
||||
type: "string"
|
||||
required: false
|
||||
}
|
||||
{
|
||||
name: "target_app_3"
|
||||
description: {
|
||||
zh: "第3个子代理目标应用名(当 intent_3 存在时必填;各分支必须不同)",
|
||||
en: "Target app name for sub-agent #3 (required when intent_3 is provided; must be different across branches)."
|
||||
}
|
||||
type: "string"
|
||||
required: false
|
||||
}
|
||||
{
|
||||
name: "max_steps_3"
|
||||
description: {
|
||||
zh: "第3个子代理最大步数(默认20)",
|
||||
en: "Max steps for sub-agent #3 (default: 20)."
|
||||
}
|
||||
type: "number"
|
||||
required: false
|
||||
}
|
||||
{
|
||||
name: "agent_id_3"
|
||||
description: {
|
||||
zh: "第3个子代理 agent_id(当 intent_3 存在时必填,且不能为 'default')",
|
||||
en: "agent_id for sub-agent #3 (required when intent_3 is provided; must not be 'default')."
|
||||
}
|
||||
type: "string"
|
||||
required: false
|
||||
}
|
||||
|
||||
{
|
||||
name: "intent_4"
|
||||
description: {
|
||||
zh: "第4个子代理意图(可选)",
|
||||
en: "Intent for sub-agent #4 (optional)."
|
||||
}
|
||||
type: "string"
|
||||
required: false
|
||||
}
|
||||
{
|
||||
name: "target_app_4"
|
||||
description: {
|
||||
zh: "第4个子代理目标应用名(当 intent_4 存在时必填;各分支必须不同)",
|
||||
en: "Target app name for sub-agent #4 (required when intent_4 is provided; must be different across branches)."
|
||||
}
|
||||
type: "string"
|
||||
required: false
|
||||
}
|
||||
{
|
||||
name: "max_steps_4"
|
||||
description: {
|
||||
zh: "第4个子代理最大步数(默认20)",
|
||||
en: "Max steps for sub-agent #4 (default: 20)."
|
||||
}
|
||||
type: "number"
|
||||
required: false
|
||||
}
|
||||
{
|
||||
name: "agent_id_4"
|
||||
description: {
|
||||
zh: "第4个子代理 agent_id(当 intent_4 存在时必填,且不能为 'default')",
|
||||
en: "agent_id for sub-agent #4 (required when intent_4 is provided; must not be 'default')."
|
||||
}
|
||||
type: "string"
|
||||
required: false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
{
|
||||
name: "close_all_virtual_displays"
|
||||
description: {
|
||||
zh: "关闭所有虚拟屏幕。",
|
||||
en: "Close all virtual displays."
|
||||
}
|
||||
parameters: []
|
||||
}
|
||||
|
||||
{
|
||||
name: "run_subagent_main"
|
||||
description: {
|
||||
zh: "在主屏幕运行 UI 子代理(强制主屏)。",
|
||||
en: "Run the UI sub-agent on the main screen (forced main screen)."
|
||||
}
|
||||
parameters: [
|
||||
{
|
||||
name: "intent"
|
||||
description: {
|
||||
zh: "任务意图描述",
|
||||
en: "Task intent description"
|
||||
}
|
||||
type: "string"
|
||||
required: true
|
||||
}
|
||||
{
|
||||
name: "target_app"
|
||||
description: {
|
||||
zh: "目标应用名/包名(可选)",
|
||||
en: "Target app name/package (optional)"
|
||||
}
|
||||
type: "string"
|
||||
required: false
|
||||
}
|
||||
{
|
||||
name: "max_steps"
|
||||
description: {
|
||||
zh: "最大执行步数(默认20)",
|
||||
en: "Maximum execution steps (default: 20)"
|
||||
}
|
||||
type: "number"
|
||||
required: false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
{
|
||||
id: "main_screen"
|
||||
condition: "!ui.virtual_display"
|
||||
inheritTools: true
|
||||
tools: [
|
||||
{
|
||||
name: "usage_advice"
|
||||
description: {
|
||||
zh: '''
|
||||
UI子代理使用建议(主屏模式):
|
||||
|
||||
- 屏幕选择规则(重要):主屏模式始终在主屏幕执行;agent_id 会被忽略/不适用。
|
||||
|
||||
- 启动前置(非常重要):当你第一次需要操作某个应用时,intent 开头必须写“启动XXX应用 ...”,让子代理直接执行 Launch,而不是在桌面自己找。
|
||||
- 对话无状态(重要):每次调用对子代理都是全新对话,intent 必须自带上下文,建议固定模板:
|
||||
当前任务已经完成: ...
|
||||
你需要在此基础上进一步完成: ...
|
||||
可能用到的信息: ...
|
||||
- 意图必须自包含(重要):禁止使用“这五个/继续/同上/刚才说的”等指代.
|
||||
- 严格串行(重要):主屏模式不支持并行工具;一次只做一个明确子目标,必要时拆成多次调用.
|
||||
- 不支持会话复用(重要):主屏模式不支持 agent_id,会话复用相关策略不适用.
|
||||
- 失败与完成(重要):半成功不算完成;应继续纠错推进.仅在连续 2-3 次失败仍无法推进时才停止,并明确失败原因与可选替代方案.
|
||||
''',
|
||||
en: '''
|
||||
UI sub-agent usage advice (main-screen mode):
|
||||
|
||||
- Screen selection rule (important): main-screen mode always operates on the main screen; agent_id is ignored / not applicable.
|
||||
|
||||
- Launch first (very important): when you need to operate an app for the first time, the intent must start with "Launch <app> ..." so the sub-agent performs Launch directly.
|
||||
- Stateless per call (important): each call is a new conversation; include all context. Recommended template: Completed so far / Next objective / Useful info.
|
||||
- Self-contained intent (important): do not use references like "those five / continue / same as above".
|
||||
- Strictly serial (important): main-screen mode does not support parallel tools; do one clear sub-goal per call.
|
||||
- No session reuse (important): main-screen mode does not support agent_id; session reuse strategies do not apply.
|
||||
- Failure vs done (important): partial success is not done; keep progressing. Only stop after 2-3 consecutive failures with a clear reason and alternatives.
|
||||
'''
|
||||
}
|
||||
advice: true
|
||||
parameters: []
|
||||
}
|
||||
|
||||
{
|
||||
name: "run_subagent_main"
|
||||
description: {
|
||||
zh: '''
|
||||
在主屏幕运行 UI 子代理(强制主屏)。
|
||||
|
||||
注意:主屏模式不支持虚拟屏会话与并行工具。
|
||||
''',
|
||||
en: '''
|
||||
Run the UI sub-agent on the main screen (forced main screen).
|
||||
|
||||
Note: main-screen mode does not support virtual sessions and does not support parallel tools.
|
||||
'''
|
||||
}
|
||||
parameters: [
|
||||
{
|
||||
name: "intent"
|
||||
description: {
|
||||
zh: "任务意图描述,例如:'打开微信并发送一条消息' 或 '在B站搜索某个视频'",
|
||||
en: "Task intent description, e.g. 'Open WeChat and send a message' or 'Search a video on Bilibili'."
|
||||
}
|
||||
type: "string"
|
||||
required: true
|
||||
}
|
||||
{
|
||||
name: "target_app"
|
||||
description: {
|
||||
zh: "目标应用名/包名(建议传入,用于在虚拟屏未创建时先执行一次默认 Launch 预热虚拟屏,避免在主屏误操作;约定:当用户要求分析当前屏幕/页面内容时不要传 target_app,也不要预热虚拟屏)",
|
||||
en: "Target app name/package (recommended). Helps with a default Launch/warm-up and avoids operating on the wrong screen. Convention: when the user asks to analyze the current screen/page, do not pass target_app and do not prewarm the virtual display."
|
||||
}
|
||||
type: "string"
|
||||
required: false
|
||||
}
|
||||
{
|
||||
name: "max_steps"
|
||||
description: {
|
||||
zh: "最大执行步数,默认20,可根据任务复杂度调整。",
|
||||
en: "Maximum execution steps (default: 20). Adjust based on task complexity."
|
||||
}
|
||||
type: "number"
|
||||
required: false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}*/
|
||||
|
||||
const UIAutomationSubAgentTools = (function () {
|
||||
|
||||
const CACHE_KEY = '__operit_ui_subagent_cached_agent_id';
|
||||
function getCachedAgentId(): string | undefined {
|
||||
try {
|
||||
return (globalThis as unknown as Record<string, string | undefined>)[CACHE_KEY];
|
||||
} catch (_e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
function setCachedAgentId(value: unknown) {
|
||||
try {
|
||||
if (value === undefined || value === null || String(value).length === 0) {
|
||||
delete (globalThis as unknown as Record<string, string | undefined>)[CACHE_KEY];
|
||||
} else {
|
||||
(globalThis as unknown as Record<string, string | undefined>)[CACHE_KEY] = String(value);
|
||||
}
|
||||
} catch (_e) {
|
||||
}
|
||||
}
|
||||
|
||||
interface ToolResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
function getPackageState(): string | undefined {
|
||||
try {
|
||||
return getState();
|
||||
} catch (_e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
type InstalledAppEntry = { name: string, pkg?: string, raw: string };
|
||||
|
||||
function errorMessage(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
function getStringArrayFromUnknown(v: unknown): string[] {
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v.map((x) => String(x || '').trim()).filter((s) => s.length > 0);
|
||||
}
|
||||
|
||||
function parseInstalledAppEntry(raw: string): InstalledAppEntry {
|
||||
const s = String(raw || '').trim();
|
||||
if (!s) return { name: '', raw: '' };
|
||||
const open = s.lastIndexOf('(');
|
||||
const close = s.lastIndexOf(')');
|
||||
if (open >= 0 && close === s.length - 1 && open < close) {
|
||||
const name = s.slice(0, open).trim();
|
||||
const pkg = s.slice(open + 1, close).trim();
|
||||
if (name && pkg) return { name, pkg, raw: s };
|
||||
}
|
||||
return { name: s, raw: s };
|
||||
}
|
||||
|
||||
async function getInstalledApps(): Promise<{ entries: InstalledAppEntry[], names: string[] }> {
|
||||
const appList = await Tools.System.listApps(false) as unknown;
|
||||
const rawItems = getStringArrayFromUnknown((appList as { packages?: unknown } | null)?.packages);
|
||||
const entries = rawItems.map(parseInstalledAppEntry).filter((e) => e.name);
|
||||
const nameMap = new Map<string, string>();
|
||||
for (const e of entries) nameMap.set(e.name.toLowerCase(), e.name);
|
||||
const names = Array.from(nameMap.values()).sort((a, b) => a.localeCompare(b));
|
||||
return { entries, names };
|
||||
}
|
||||
|
||||
function matchTarget(targetApp: string, installed: InstalledAppEntry[]): { run: string, key: string } | null {
|
||||
const t = String(targetApp || '').trim();
|
||||
if (!t) return null;
|
||||
const tl = t.toLowerCase();
|
||||
const m =
|
||||
installed.find((a) => (a.pkg || '').toLowerCase() === tl) ||
|
||||
installed.find((a) => a.name.toLowerCase() === tl) ||
|
||||
installed.find((a) => a.raw.toLowerCase() === tl);
|
||||
if (!m) return null;
|
||||
const run = m.pkg || m.name;
|
||||
return { run, key: run.toLowerCase() };
|
||||
}
|
||||
|
||||
async function usage_advice(_params: {}): Promise<ToolResponse> {
|
||||
const state = getPackageState();
|
||||
const isMainScreen = String(state).toLowerCase() === 'main_screen';
|
||||
return {
|
||||
success: true,
|
||||
message: 'UI子代理使用建议',
|
||||
data: {
|
||||
advice: isMainScreen
|
||||
? ("主屏模式:不支持 agent_id,会话复用策略不适用;不支持并行工具,一次只做一个明确子目标(必要时拆多次)。\n" +
|
||||
"屏幕选择规则:不传 agent_id 或传 'default' => 主屏幕;传入且不为 'default' => 虚拟屏(主屏模式下会忽略并强制主屏)。\n" +
|
||||
"启动前置(非常重要):当你第一次需要操作某个应用时,intent 开头必须写“启动XXX应用 ...”,让子代理直接执行 Launch,而不是在桌面自己找。\n" +
|
||||
"对话无状态:每次调用是新对话,intent 写清 已完成/下一步/关键信息。\n" +
|
||||
"自包含:别用‘这五个/继续/同上’;多对象要么列清单+当前目标,要么先让子代理在当前页识别并复述清单。\n" +
|
||||
"失败与完成:半成功不算完成;未达成目标继续推进,连续 2-3 次失败再停并说明原因。")
|
||||
: ("虚拟屏模式:尽量复用 agent_id(沿用 data.agentId)保持同一虚拟屏/同一应用上下文。\n" +
|
||||
"屏幕选择规则:不传 agent_id 或传 'default' => 主屏幕;传入且不为 'default' => 对应虚拟屏会话(虚拟屏必须可用,否则失败)。为避免误操作主屏,虚拟屏模式下首次调用建议显式传入 agent_id。\n" +
|
||||
"启动前置(非常重要):当你第一次使用某个 agent_id(新建或更换 agent_id)时,intent 开头必须写“启动XXX应用 ...”,让子代理直接执行 Launch,而不是在桌面自己找。\n" +
|
||||
"对话无状态:每次调用是新对话,intent 写清 已完成/下一步/关键信息。\n" +
|
||||
"自包含:别用‘这五个/继续/同上’;多对象要么列清单+当前目标,要么先让子代理在当前页识别并复述清单。\n" +
|
||||
"对齐+并行:先清单后逐项(A→B→C);独立子任务/同一对象多入口优先并行(run_subagent_parallel_virtual),只重试失败分支。\n" +
|
||||
"并行资源约束:并行分支数必须受可用独立App/虚拟屏数量限制;同一个App/包名不能同时出现在两个虚拟屏/两个agent_id 中并行操作(会坏)。并行调用必须传 target_app_i=目标应用名,且各分支 target_app_i 不能重复;第2次/第N次并行不得擅自提高并行度,保持上限,只重试失败分支或改串行。\n" +
|
||||
"失败与完成:半成功不算完成;未达成目标继续推进,连续 2-3 次失败再停并说明原因。"
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function run_subagent_internal(params: { intent: string, max_steps?: number, agent_id?: string, target_app?: string }): Promise<ToolResponse> {
|
||||
const { intent, max_steps, agent_id, target_app } = params;
|
||||
const state = getPackageState();
|
||||
const isMainScreen = String(state).toLowerCase() === 'main_screen';
|
||||
|
||||
const explicitAgentId = (agent_id === undefined || agent_id === null) ? '' : String(agent_id).trim();
|
||||
const cachedAgentId = getCachedAgentId();
|
||||
|
||||
let agentIdToUse: string | undefined;
|
||||
if (isMainScreen) {
|
||||
agentIdToUse = 'default';
|
||||
} else {
|
||||
agentIdToUse = explicitAgentId.length > 0 ? explicitAgentId : cachedAgentId;
|
||||
if (agentIdToUse === undefined || agentIdToUse === null || String(agentIdToUse).trim().length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: "虚拟屏模式下未指定 agent_id:为避免误操作主屏幕,请显式传入 agent_id(且不为 'default')来使用虚拟屏会话;或先完成一次成功调用并复用返回的 data.agentId。",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let targetAppForRun = target_app;
|
||||
if (target_app && String(target_app).trim().length > 0) {
|
||||
const installed = await getInstalledApps();
|
||||
const matched = matchTarget(target_app, installed.entries);
|
||||
if (!matched) {
|
||||
return {
|
||||
success: false,
|
||||
message: `目标应用不存在:当前给定的 target_app=“${String(target_app).trim()}” 未在已安装应用中找到。已返回已安装应用名列表。`,
|
||||
data: {
|
||||
target_app: String(target_app).trim(),
|
||||
installed_apps: installed.names,
|
||||
},
|
||||
};
|
||||
}
|
||||
targetAppForRun = matched.run;
|
||||
}
|
||||
|
||||
const result = await Tools.UI.runSubAgent(intent, max_steps, agentIdToUse, targetAppForRun) as unknown;
|
||||
const agentId = (result as { agentId?: unknown } | null)?.agentId;
|
||||
if (agentId && String(agentId).trim().length > 0 && String(agentId).trim().toLowerCase() !== 'default') {
|
||||
setCachedAgentId(agentId);
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: 'UI子代理执行完成',
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
|
||||
async function run_subagent_main(params: { intent: string, max_steps?: number, target_app?: string }): Promise<ToolResponse> {
|
||||
const { intent, max_steps, target_app } = params;
|
||||
return run_subagent_internal({ intent, max_steps, target_app, agent_id: 'default' });
|
||||
}
|
||||
|
||||
async function run_subagent_virtual(params: { intent: string, max_steps?: number, agent_id?: string, target_app?: string }): Promise<ToolResponse> {
|
||||
const { intent, max_steps, agent_id, target_app } = params;
|
||||
|
||||
const explicitAgentId = (agent_id === undefined || agent_id === null) ? '' : String(agent_id).trim();
|
||||
const cachedAgentId = getCachedAgentId();
|
||||
const agentIdToUse = explicitAgentId.length > 0 ? explicitAgentId : cachedAgentId;
|
||||
|
||||
if (agentIdToUse === undefined || agentIdToUse === null || String(agentIdToUse).trim().length === 0 || String(agentIdToUse).trim().toLowerCase() === 'default') {
|
||||
return {
|
||||
success: false,
|
||||
message: "虚拟屏模式必须使用非 'default' 的 agent_id:请显式传入 agent_id(非 'default'),或先完成一次成功调用并复用返回的 data.agentId。",
|
||||
};
|
||||
}
|
||||
|
||||
return run_subagent_internal({ intent, max_steps, target_app, agent_id: String(agentIdToUse) });
|
||||
}
|
||||
|
||||
type Slot = 1 | 2 | 3 | 4;
|
||||
type ParallelParams = {
|
||||
intent_1: string, target_app_1: string, max_steps_1?: number, agent_id_1?: string,
|
||||
intent_2?: string, target_app_2?: string, max_steps_2?: number, agent_id_2?: string,
|
||||
intent_3?: string, target_app_3?: string, max_steps_3?: number, agent_id_3?: string,
|
||||
intent_4?: string, target_app_4?: string, max_steps_4?: number, agent_id_4?: string,
|
||||
};
|
||||
|
||||
async function run_subagent_parallel_internal(params: ParallelParams): Promise<ToolResponse> {
|
||||
const state = getPackageState();
|
||||
const isMainScreen = String(state).toLowerCase() === 'main_screen';
|
||||
if (isMainScreen) {
|
||||
return {
|
||||
success: false,
|
||||
message: "主屏模式不支持 run_subagent_parallel_virtual 并行调用。请改用 run_subagent_main 串行执行。",
|
||||
};
|
||||
}
|
||||
|
||||
const slots: Slot[] = [1, 2, 3, 4];
|
||||
type IntentKey = `intent_${Slot}`;
|
||||
type TargetKey = `target_app_${Slot}`;
|
||||
type MaxStepsKey = `max_steps_${Slot}`;
|
||||
type AgentIdKey = `agent_id_${Slot}`;
|
||||
|
||||
const activeSlots = slots
|
||||
.map((i) => {
|
||||
const intent = params[`intent_${i}` as IntentKey];
|
||||
if (!intent || intent.trim().length === 0) return null;
|
||||
const targetApp = params[`target_app_${i}` as TargetKey];
|
||||
return { index: i, targetApp };
|
||||
})
|
||||
.filter((x): x is { index: Slot, targetApp: string | undefined } => Boolean(x));
|
||||
|
||||
const missingTargets = activeSlots
|
||||
.filter((s) => s.targetApp === undefined || s.targetApp === null || String(s.targetApp).trim().length === 0)
|
||||
.map((s) => s.index);
|
||||
if (missingTargets.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: `并行参数错误:intent_${missingTargets.join(', intent_')} 缺少 target_app_${missingTargets.join(', target_app_')}(目标应用名)。并行时必须为每个启用分支传入目标应用名,用于检测“同一应用不能出现在两个虚拟屏/agent_id”的冲突。`,
|
||||
};
|
||||
}
|
||||
|
||||
const missingAgentIds = activeSlots
|
||||
.filter((s) => {
|
||||
const v = params[`agent_id_${s.index}` as AgentIdKey];
|
||||
const id = v === undefined || v === null ? '' : String(v).trim();
|
||||
return id.length === 0 || id.toLowerCase() === 'default';
|
||||
})
|
||||
.map((s) => s.index);
|
||||
if (missingAgentIds.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: `并行参数错误:虚拟屏并行模式下,每个启用分支必须显式传入非 'default' 的 agent_id。缺少/非法 agent_id 的分支:${missingAgentIds.map((i) => `#${i}`).join(',')}。`,
|
||||
};
|
||||
}
|
||||
|
||||
const installed = await getInstalledApps();
|
||||
const resolvedBySlot = new Map<Slot, { key: string, run: string }>();
|
||||
const missingApps: string[] = [];
|
||||
|
||||
for (const s of activeSlots) {
|
||||
const t = String(s.targetApp || '').trim();
|
||||
const m = matchTarget(t, installed.entries);
|
||||
if (!m) missingApps.push(t);
|
||||
else resolvedBySlot.set(s.index, m);
|
||||
}
|
||||
|
||||
if (missingApps.length) {
|
||||
return {
|
||||
success: false,
|
||||
message: `目标应用不存在:当前给定的 target_app 列表中包含未安装/不存在的应用:${missingApps.map((s) => `“${String(s).trim()}”`).join(',')}。已返回已安装应用名列表。`,
|
||||
data: {
|
||||
missing_apps: missingApps,
|
||||
installed_apps: installed.names,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const used: Record<string, number> = {};
|
||||
for (const s of activeSlots) {
|
||||
const key = resolvedBySlot.get(s.index)?.key || String(s.targetApp).trim().toLowerCase();
|
||||
const prev = used[key];
|
||||
if (prev !== undefined) {
|
||||
return {
|
||||
success: false,
|
||||
message: `并行参数错误:target_app_${prev} 与 target_app_${s.index} 重复(同一目标应用=“${String(s.targetApp).trim()}”)。同一应用不能同时在两个虚拟屏/agent_id 中并行操作。`,
|
||||
};
|
||||
}
|
||||
used[key] = s.index;
|
||||
}
|
||||
|
||||
type ParallelSubAgentResult =
|
||||
| { index: Slot, success: true, result: unknown }
|
||||
| { index: Slot, success: false, error: string };
|
||||
|
||||
const tasks = slots
|
||||
.map((i) => {
|
||||
const intent = params[`intent_${i}` as IntentKey];
|
||||
if (!intent || intent.trim().length === 0) return null;
|
||||
|
||||
const maxSteps = params[`max_steps_${i}` as MaxStepsKey];
|
||||
const agentId = params[`agent_id_${i}` as AgentIdKey];
|
||||
const targetApp = resolvedBySlot.get(i)?.run ?? params[`target_app_${i}` as TargetKey];
|
||||
|
||||
return (async (): Promise<ParallelSubAgentResult> => {
|
||||
try {
|
||||
const result = await Tools.UI.runSubAgent(
|
||||
String(intent),
|
||||
maxSteps === undefined ? undefined : Number(maxSteps),
|
||||
agentId === undefined || agentId === null || String(agentId).trim().length === 0 ? undefined : String(agentId).trim(),
|
||||
targetApp
|
||||
) as unknown;
|
||||
return { index: i, success: true, result };
|
||||
} catch (e: unknown) {
|
||||
return { index: i, success: false, error: errorMessage(e) };
|
||||
}
|
||||
})();
|
||||
})
|
||||
.filter((x): x is Promise<ParallelSubAgentResult> => x !== null);
|
||||
|
||||
const results = await Promise.all(tasks);
|
||||
const okCount = results.filter((r) => r.success).length;
|
||||
return {
|
||||
success: true,
|
||||
message: `并行UI子代理执行完成:成功 ${okCount} 个 / 共 ${results.length} 个`,
|
||||
data: {
|
||||
results,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function run_subagent_parallel_virtual(params: ParallelParams): Promise<ToolResponse> {
|
||||
return run_subagent_parallel_internal(params);
|
||||
}
|
||||
|
||||
async function close_all_virtual_displays(_params: {}): Promise<ToolResponse> {
|
||||
const result = await toolCall('close_all_virtual_displays', {});
|
||||
const ok = (result as { success?: boolean } | null)?.success !== false;
|
||||
const error = (result as { error?: unknown } | null)?.error;
|
||||
return {
|
||||
success: ok,
|
||||
message: ok
|
||||
? '已关闭所有虚拟屏幕。'
|
||||
: `关闭虚拟屏幕失败:${error ? String(error) : 'unknown error'}`,
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
|
||||
async function wrapToolExecution<P>(func: (params: P) => Promise<ToolResponse>, params: P) {
|
||||
try {
|
||||
const result = await func(params);
|
||||
complete(result);
|
||||
} catch (error: unknown) {
|
||||
console.error(`Tool ${func.name} failed unexpectedly`, error);
|
||||
complete({
|
||||
success: false,
|
||||
message: `工具执行时发生意外错误: ${errorMessage(error)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
usage_advice: (params: {}) => wrapToolExecution(usage_advice, params),
|
||||
run_subagent_main: (params: { intent: string, max_steps?: number, target_app?: string }) => wrapToolExecution(run_subagent_main, params),
|
||||
run_subagent_virtual: (params: { intent: string, max_steps?: number, agent_id?: string, target_app?: string }) => wrapToolExecution(run_subagent_virtual, params),
|
||||
close_all_virtual_displays: (params: {}) => wrapToolExecution(close_all_virtual_displays, params),
|
||||
run_subagent_parallel_virtual: (params: {
|
||||
intent_1: string, target_app_1: string, max_steps_1?: number, agent_id_1?: string,
|
||||
intent_2?: string, target_app_2?: string, max_steps_2?: number, agent_id_2?: string,
|
||||
intent_3?: string, target_app_3?: string, max_steps_3?: number, agent_id_3?: string,
|
||||
intent_4?: string, target_app_4?: string, max_steps_4?: number, agent_id_4?: string,
|
||||
}) => wrapToolExecution(run_subagent_parallel_virtual, params),
|
||||
};
|
||||
})();
|
||||
|
||||
exports.usage_advice = UIAutomationSubAgentTools.usage_advice;
|
||||
exports.run_subagent_main = UIAutomationSubAgentTools.run_subagent_main;
|
||||
exports.run_subagent_virtual = UIAutomationSubAgentTools.run_subagent_virtual;
|
||||
exports.close_all_virtual_displays = UIAutomationSubAgentTools.close_all_virtual_displays;
|
||||
exports.run_subagent_parallel_virtual = UIAutomationSubAgentTools.run_subagent_parallel_virtual;
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,190 @@
|
||||
/* METADATA
|
||||
{
|
||||
"name": "beeimg_image_uploader_v2",
|
||||
|
||||
"display_name": {
|
||||
"zh": "Beeimg 图片上传器 V2",
|
||||
"en": "Beeimg Image Uploader V2"
|
||||
},
|
||||
"description": {
|
||||
"zh": "BeeIMG(https://beeimg.com/)工具将本地图片上传到图床并返回图片url,配合图片生成工具实现图生图(图生图务必开启)。",
|
||||
"en": "Upload a local image to BeeIMG (https://beeimg.com/) and return a public image URL. Useful for image-to-image workflows (make sure img2img is enabled)."
|
||||
},
|
||||
"env": [
|
||||
"BEEIMG_API_KEY"
|
||||
],
|
||||
"category": "Media",
|
||||
"tools": [
|
||||
{
|
||||
"name": "upload_image",
|
||||
"description": {
|
||||
"zh": "使用 multipart 上传将本地图片上传到 BeeIMG 图床并返回图片 url。",
|
||||
"en": "Upload a local image to BeeIMG via multipart upload and return the image URL."
|
||||
},
|
||||
"parameters": [
|
||||
{ "name": "file_path", "description": { "zh": "要上传的图片文件绝对路径 (建议使用 /sdcard/ 开头的完整路径)。", "en": "Absolute path of the image file to upload (recommended: full path starting with /sdcard/)." }, "type": "string", "required": true },
|
||||
{ "name": "album_id", "description": { "zh": "相册ID (可选)。", "en": "Album ID (optional)." }, "type": "string", "required": false },
|
||||
{ "name": "privacy", "description": { "zh": "隐私设置,'public' 或 'private' (可选)。", "en": "Privacy setting: 'public' or 'private' (optional)." }, "type": "string", "required": false }
|
||||
]
|
||||
}
|
||||
]
|
||||
}*/
|
||||
|
||||
const beeimgUploader = (function () {
|
||||
// API配置
|
||||
const API_ENDPOINT = "https://beeimg.com/api/upload/file/json/";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function getErrorStack(error: unknown): string | undefined {
|
||||
if (error instanceof Error) return error.stack;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getApiKey(): string {
|
||||
return getEnv("BEEIMG_API_KEY") || "";
|
||||
}
|
||||
|
||||
function guessMimeTypeFromPath(filePath: string): string {
|
||||
const lower = filePath.toLowerCase();
|
||||
if (lower.endsWith(".png")) return "image/png";
|
||||
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
||||
if (lower.endsWith(".webp")) return "image/webp";
|
||||
if (lower.endsWith(".gif")) return "image/gif";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
function safeJsonParseLoose(text: string): unknown {
|
||||
const trimmed = (text || "").trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
return JSON.parse(trimmed) as unknown;
|
||||
} catch (e) {
|
||||
const start = trimmed.indexOf("{");
|
||||
const end = trimmed.lastIndexOf("}");
|
||||
if (start !== -1 && end !== -1 && end > start) {
|
||||
return JSON.parse(trimmed.substring(start, end + 1)) as unknown;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
interface UploadImageParams {
|
||||
file_path: string;
|
||||
album_id?: string;
|
||||
privacy?: string;
|
||||
}
|
||||
|
||||
interface UploadImageResult {
|
||||
url: string;
|
||||
thumbnail_url?: string;
|
||||
page_url?: string;
|
||||
details: string;
|
||||
}
|
||||
|
||||
async function upload_image(params: UploadImageParams): Promise<UploadImageResult> {
|
||||
const file_path = params?.file_path;
|
||||
const album_id = params?.album_id;
|
||||
const privacy = params?.privacy;
|
||||
|
||||
if (!file_path || String(file_path).trim().length === 0) {
|
||||
throw new Error("参数 file_path 不能为空。");
|
||||
}
|
||||
|
||||
// 1. 检查文件是否存在
|
||||
const fileExists = await Tools.Files.exists(file_path);
|
||||
if (!fileExists.exists) {
|
||||
throw new Error(`文件未找到: ${file_path} (请尝试使用 /sdcard/ 开头的绝对路径)`);
|
||||
}
|
||||
|
||||
// 2. 准备参数
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) {
|
||||
throw new Error("BEEIMG_API_KEY 未配置,请在环境变量中设置 BeeIMG 的 API Key。");
|
||||
}
|
||||
|
||||
const form_data: Record<string, string> = {
|
||||
apikey: apiKey
|
||||
};
|
||||
if (album_id) form_data.albumid = String(album_id);
|
||||
if (privacy) form_data.privacy = String(privacy);
|
||||
|
||||
// 3. 发起 multipart 上传
|
||||
console.log("正在执行上传...");
|
||||
|
||||
const resp = await Tools.Net.uploadFile({
|
||||
url: API_ENDPOINT,
|
||||
method: "POST",
|
||||
headers: {
|
||||
// 尽量模拟浏览器 UA,降低被风控概率
|
||||
"User-Agent": "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36"
|
||||
},
|
||||
form_data,
|
||||
files: [
|
||||
{
|
||||
field_name: "file",
|
||||
file_path: file_path,
|
||||
content_type: guessMimeTypeFromPath(file_path)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (resp.statusCode < 200 || resp.statusCode >= 300) {
|
||||
throw new Error(`上传请求失败 (HTTP ${resp.statusCode})。可能是网络问题或 IP 被屏蔽。\n响应: ${resp.content}`);
|
||||
}
|
||||
|
||||
const responseText = (resp.content || "").trim();
|
||||
if (!responseText) {
|
||||
throw new Error("上传失败:服务器返回了空内容。请检查网络连接或尝试关闭 VPN。");
|
||||
}
|
||||
|
||||
let jsonResponse: unknown;
|
||||
try {
|
||||
jsonResponse = safeJsonParseLoose(responseText);
|
||||
} catch (e: unknown) {
|
||||
throw new Error(`API 响应解析失败。服务器返回内容不是 JSON:\n${responseText.substring(0, 200)}...`);
|
||||
}
|
||||
|
||||
const files = isRecord(jsonResponse) && isRecord(jsonResponse["files"]) ? (jsonResponse["files"] as Record<string, unknown>) : null;
|
||||
const ok = !!files && (files["status"] === "Success" || files["code"] === "200" || files["code"] === 200);
|
||||
const url = files ? files["url"] : undefined;
|
||||
|
||||
if (ok && (typeof url === "string" || typeof url === "number") && String(url).trim().length > 0) {
|
||||
return {
|
||||
url: String(url),
|
||||
thumbnail_url: files && files["thumbnail_url"] ? String(files["thumbnail_url"]) : undefined,
|
||||
page_url: files && files["view_url"] ? String(files["view_url"]) : undefined,
|
||||
details: `上传成功! URL: ${String(url)}`
|
||||
};
|
||||
} else {
|
||||
const errMsg = files
|
||||
? (files["status"] || files["message"] || JSON.stringify(files))
|
||||
: "未知错误";
|
||||
throw new Error(`BeeIMG API 报错: ${errMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function wrap<TParams, TResult>(func: (params: TParams) => Promise<TResult>, params: TParams) {
|
||||
try {
|
||||
const result = await func(params);
|
||||
complete({ success: true, message: "图片上传成功", data: result });
|
||||
}
|
||||
catch (error: unknown) {
|
||||
console.error(`Error: ${getErrorMessage(error)}`);
|
||||
complete({ success: false, message: getErrorMessage(error), error_stack: getErrorStack(error) });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
upload_image: (p: UploadImageParams) => wrap(upload_image, p)
|
||||
};
|
||||
})();
|
||||
|
||||
exports.upload_image = beeimgUploader.upload_image;
|
||||
@@ -0,0 +1,768 @@
|
||||
/* METADATA
|
||||
{
|
||||
"name": "bilibili_tools",
|
||||
"display_name": {
|
||||
"zh": "Bilibili 工具集",
|
||||
"en": "Bilibili Toolkit"
|
||||
},
|
||||
"description": { "zh": "提供B站视频信息分析功能,包括获取字幕、弹幕、评论和搜索视频。", "en": "Analyze Bilibili video information, including subtitles, danmaku, comments, and video search." },
|
||||
"enabledByDefault": true,
|
||||
"category": "Media",
|
||||
"tools": [
|
||||
{
|
||||
"name": "get_subtitles",
|
||||
"description": { "zh": "从Bilibili视频中获取字幕。", "en": "Get subtitles from a Bilibili video." },
|
||||
"parameters": [
|
||||
{ "name": "url", "description": { "zh": "Bilibili视频URL,例如:https://www.bilibili.com/video/BV1x341177NN", "en": "Bilibili video URL, e.g. https://www.bilibili.com/video/BV1x341177NN" }, "type": "string", "required": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "get_danmaku",
|
||||
"description": { "zh": "从Bilibili视频中获取弹幕。", "en": "Get danmaku (bullet comments) from a Bilibili video." },
|
||||
"parameters": [
|
||||
{ "name": "url", "description": { "zh": "Bilibili视频URL,例如:https://www.bilibili.com/video/BV1x341177NN", "en": "Bilibili video URL, e.g. https://www.bilibili.com/video/BV1x341177NN" }, "type": "string", "required": true },
|
||||
{ "name": "count", "description": { "zh": "要获取的弹幕数量,默认500,最多1000", "en": "Number of danmaku items to fetch (default: 500, max: 1000)." }, "type": "number", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "get_comments",
|
||||
"description": { "zh": "从Bilibili视频中获取热门评论。", "en": "Get hot comments from a Bilibili video." },
|
||||
"parameters": [
|
||||
{ "name": "url", "description": { "zh": "Bilibili视频URL,例如:https://www.bilibili.com/video/BV1x341177NN", "en": "Bilibili video URL, e.g. https://www.bilibili.com/video/BV1x341177NN" }, "type": "string", "required": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "search_videos",
|
||||
"description": { "zh": "在Bilibili上搜索视频。", "en": "Search videos on Bilibili." },
|
||||
"parameters": [
|
||||
{ "name": "keyword", "description": { "zh": "要搜索的关键词", "en": "Keyword to search for." }, "type": "string", "required": true },
|
||||
{ "name": "page", "description": { "zh": "页码,默认为1", "en": "Page number (default: 1)." }, "type": "number", "required": false },
|
||||
{ "name": "count", "description": { "zh": "返回结果的数量,默认10,最多20", "en": "Number of results to return (default: 10, max: 20)." }, "type": "number", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "get_user_info",
|
||||
"description": { "zh": "获取B站用户的基本信息。", "en": "Get basic information for a Bilibili user." },
|
||||
"parameters": [
|
||||
{ "name": "mid", "description": { "zh": "用户的数字ID (UID)", "en": "User numeric ID (UID)." }, "type": "number", "required": true }
|
||||
]
|
||||
}
|
||||
]
|
||||
}*/
|
||||
|
||||
//参考了https://github.com/vruses/bili-api-interceptor/blob/master/dist/bili-api-interceptor.user.js 特此感谢
|
||||
|
||||
const BilibiliVideoAnalysis = (function () {
|
||||
|
||||
interface ToolResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
interface Subtitle {
|
||||
lan: string;
|
||||
content: string[];
|
||||
}
|
||||
|
||||
interface Comment {
|
||||
user: string;
|
||||
content: string;
|
||||
likes: number;
|
||||
time: string; // 添加评论时间
|
||||
sub_comments: Comment[]; // 添加子评论列表
|
||||
}
|
||||
|
||||
interface SearchResultVideo {
|
||||
title: string;
|
||||
author: string;
|
||||
bvid: string;
|
||||
play: number;
|
||||
danmaku: number;
|
||||
like: number;
|
||||
duration: string;
|
||||
pubdate: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface UserInfo {
|
||||
name: string;
|
||||
sex: string;
|
||||
face: string;
|
||||
sign: string;
|
||||
level: number;
|
||||
birthday: string;
|
||||
follower: number;
|
||||
following: number;
|
||||
mid: number;
|
||||
}
|
||||
|
||||
// Bilibili API endpoints
|
||||
const API_GET_VIEW_INFO = "https://api.bilibili.com/x/web-interface/view";
|
||||
const API_GET_SUBTITLE_LIST = "https://api.bilibili.com/x/player/v2";
|
||||
const API_GET_DANMAKU = "https://api.bilibili.com/x/v1/dm/list.so";
|
||||
const API_GET_COMMENTS = "https://api.bilibili.com/x/v2/reply/wbi/main";
|
||||
const API_SEARCH = "https://api.bilibili.com/x/web-interface/search/all/v2";
|
||||
const API_NAV = "https://api.bilibili.com/x/web-interface/nav";
|
||||
const API_GET_USER_INFO = "https://api.bilibili.com/x/space/wbi/acc/info";
|
||||
const API_GET_RELATION_STAT = "https://api.bilibili.com/x/relation/stat";
|
||||
|
||||
const client = OkHttp.newClient();
|
||||
let SESSDATA: string | undefined;
|
||||
let wbiKeys: { img_key: string; sub_key: string; } | null = null;
|
||||
let buvid3: string | null = null;
|
||||
|
||||
function generateBuvid3(): string {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
||||
const r = Math.random() * 16 | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (SESSDATA === undefined) {
|
||||
// SESSDATA 是B站的Cookie,用于访问一些需要登录的API。
|
||||
// 为了简单起见,这里我们不作强制要求。
|
||||
// 如果遇到请求失败的情况,可以尝试手动在这里设置你的SESSDATA值。
|
||||
// 例如: SESSDATA = "your_sessdata_cookie_value";
|
||||
SESSDATA = "";
|
||||
if (!SESSDATA) {
|
||||
console.log("SESSDATA 未设置,部分需要登录的请求可能会失败。");
|
||||
}
|
||||
}
|
||||
// Pre-fetch WBI keys if not already available.
|
||||
if (!wbiKeys) {
|
||||
wbiKeys = await get_wbi_keys();
|
||||
}
|
||||
}
|
||||
|
||||
function getHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36',
|
||||
'Referer': 'https://www.bilibili.com/'
|
||||
};
|
||||
|
||||
if (!buvid3) {
|
||||
buvid3 = generateBuvid3();
|
||||
}
|
||||
|
||||
const cookies = [`buvid3=${buvid3}`];
|
||||
if (SESSDATA) {
|
||||
cookies.push(`SESSDATA=${SESSDATA}`);
|
||||
}
|
||||
headers['Cookie'] = cookies.join('; ');
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
function unescapeXml(text: string): string {
|
||||
return text.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
const mixinKeyEncTab = [
|
||||
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35,
|
||||
27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13,
|
||||
37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4,
|
||||
22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52
|
||||
];
|
||||
|
||||
function getMixinKey(orig: string): string {
|
||||
return mixinKeyEncTab.map(n => orig[n]).join('').slice(0, 32);
|
||||
}
|
||||
|
||||
function encWbi(params: Record<string, any>, img_key: string, sub_key: string): string {
|
||||
const mixin_key = getMixinKey(img_key + sub_key);
|
||||
const curr_time = Math.round(Date.now() / 1000);
|
||||
const chr_filter = /[!'()*]/g;
|
||||
|
||||
const new_params = { ...params, wts: curr_time };
|
||||
|
||||
const query = Object.keys(new_params).sort().map(key => {
|
||||
const value = String(new_params[key]).replace(chr_filter, "");
|
||||
return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
|
||||
}).join('&');
|
||||
|
||||
const w_rid = CryptoJS.MD5(query + mixin_key).toString();
|
||||
return `${query}&w_rid=${w_rid}`;
|
||||
}
|
||||
|
||||
async function get_wbi_keys(): Promise<{ img_key: string, sub_key: string } | null> {
|
||||
try {
|
||||
const response = await client.get(API_NAV, getHeaders());
|
||||
if (!response.isSuccessful()) {
|
||||
console.error("Failed to get WBI keys, status: " + response.statusCode);
|
||||
return null;
|
||||
}
|
||||
const navData = response.json();
|
||||
|
||||
// The API may return a non-zero code for non-logged-in users, but still provide the WBI keys.
|
||||
if (!navData.data?.wbi_img?.img_url || !navData.data?.wbi_img?.sub_url) {
|
||||
console.error("Failed to get WBI keys: " + (navData.message || "No wbi_img in response"));
|
||||
return null;
|
||||
}
|
||||
|
||||
const imgUrl = navData.data.wbi_img.img_url;
|
||||
const subUrl = navData.data.wbi_img.sub_url;
|
||||
|
||||
const img_key = imgUrl.substring(imgUrl.lastIndexOf('/') + 1, imgUrl.lastIndexOf('.'));
|
||||
const sub_key = subUrl.substring(subUrl.lastIndexOf('/') + 1, subUrl.lastIndexOf('.'));
|
||||
|
||||
return { img_key, sub_key };
|
||||
} catch (e: any) {
|
||||
console.error(`Error fetching WBI keys: ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function extract_bvid(url: string): Promise<string | null> {
|
||||
let match = url.match(/BV[a-zA-Z0-9_]+/);
|
||||
if (match) {
|
||||
return match[0];
|
||||
}
|
||||
|
||||
if (url.includes('b23.tv')) {
|
||||
try {
|
||||
const request = client.newRequest().url(url).method("HEAD").build();
|
||||
const response = await request.execute();
|
||||
const finalUrl = response.raw.url;
|
||||
if (finalUrl) {
|
||||
match = finalUrl.match(/BV[a-zA-Z0-9_]+/);
|
||||
if (match) {
|
||||
return match[0];
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Error resolving short URL: ${e}`);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function get_video_basic_info(bvid: string): Promise<{ aid: number | null, cid: number | null, error: string | null }> {
|
||||
try {
|
||||
const url = `${API_GET_VIEW_INFO}?bvid=${bvid}`;
|
||||
const response = await client.get(url, getHeaders());
|
||||
if (!response.isSuccessful()) {
|
||||
return { aid: null, cid: null, error: `Failed to get video info, status: ${response.statusCode}` };
|
||||
}
|
||||
const data = response.json();
|
||||
if (data.code !== 0) {
|
||||
return { aid: null, cid: null, error: `Failed to get video info: ${data.message}` };
|
||||
}
|
||||
const videoData = data.data;
|
||||
return { aid: videoData.aid, cid: videoData.cid, error: null };
|
||||
} catch (e: any) {
|
||||
return { aid: null, cid: null, error: `Failed to fetch video details: ${e.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
async function get_subtitles_from_api(aid: number, cid: number): Promise<{ subtitles: Subtitle[], error: string | null }> {
|
||||
const subtitles: Subtitle[] = [];
|
||||
try {
|
||||
const url = `${API_GET_SUBTITLE_LIST}?aid=${aid}&cid=${cid}`;
|
||||
const response = await client.get(url, getHeaders());
|
||||
if (!response.isSuccessful()) {
|
||||
return { subtitles: [], error: `Could not fetch subtitles list, status: ${response.statusCode}` };
|
||||
}
|
||||
const subtitleListData = response.json();
|
||||
if (subtitleListData.code === 0 && subtitleListData.data?.subtitle?.subtitles) {
|
||||
for (const sub_meta of subtitleListData.data.subtitle.subtitles) {
|
||||
if (sub_meta.subtitle_url) {
|
||||
try {
|
||||
const subtitle_json_url = `https:${sub_meta.subtitle_url}`;
|
||||
const sub_content_response = await client.get(subtitle_json_url, getHeaders());
|
||||
if (sub_content_response.isSuccessful()) {
|
||||
const sub_content = await sub_content_response.json();
|
||||
const subtitle_body = sub_content.body || [];
|
||||
const content_list = subtitle_body.map((item: any) => item.content || '');
|
||||
subtitles.push({
|
||||
lan: sub_meta.lan,
|
||||
content: content_list
|
||||
});
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error(`Could not fetch or parse subtitle content from ${sub_meta.subtitle_url}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { subtitles, error: null };
|
||||
} catch (e: any) {
|
||||
return { subtitles: [], error: `Could not fetch subtitles: ${e.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
async function get_danmaku_from_api(cid: number, count: number): Promise<{ danmaku: string[], error: string | null }> {
|
||||
const danmaku_list: string[] = [];
|
||||
try {
|
||||
const url = `${API_GET_DANMAKU}?oid=${cid}`;
|
||||
const response = await client.get(url, getHeaders());
|
||||
|
||||
if (!response.isSuccessful()) {
|
||||
return { danmaku: [], error: `Failed to get danmaku, status: ${response.statusCode}` };
|
||||
}
|
||||
|
||||
const compressedData = response.bodyAsBase64();
|
||||
console.log("base64" + compressedData);
|
||||
if (!compressedData) {
|
||||
return { danmaku: [], error: null };
|
||||
}
|
||||
|
||||
const danmaku_content = pako.inflate(compressedData, { to: 'string' });
|
||||
|
||||
const regex = /<d p=".*?">(.*?)<\/d>/g;
|
||||
let match;
|
||||
while ((match = regex.exec(danmaku_content)) !== null) {
|
||||
if (danmaku_list.length >= count) {
|
||||
break;
|
||||
}
|
||||
danmaku_list.push(unescapeXml(match[1]));
|
||||
}
|
||||
|
||||
return { danmaku: danmaku_list, error: null };
|
||||
} catch (e: any) {
|
||||
return { danmaku: [], error: `Failed to get or parse danmaku: ${e.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
async function get_comments_from_api(aid: number): Promise<{ comments: string, count: number, error: string | null }> {
|
||||
const all_comments: Comment[] = [];
|
||||
|
||||
if (!wbiKeys) {
|
||||
return { comments: "", count: 0, error: "获取 WBI keys 失败,无法请求评论 API" };
|
||||
}
|
||||
|
||||
let page_num = 1;
|
||||
let total_pages = 1;
|
||||
|
||||
try {
|
||||
while (page_num <= total_pages) {
|
||||
const params = {
|
||||
type: 1,
|
||||
oid: aid,
|
||||
sort: 2, // sort=2 for hot
|
||||
pn: page_num
|
||||
};
|
||||
|
||||
const signed_query = encWbi(params, wbiKeys.img_key, wbiKeys.sub_key);
|
||||
const url = `${API_GET_COMMENTS}?${signed_query}`;
|
||||
|
||||
const response = await client.get(url, getHeaders());
|
||||
|
||||
if (!response.isSuccessful()) {
|
||||
console.error(`Failed to get comments page ${page_num}, status: ${response.statusCode}`);
|
||||
page_num++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const comments_data = response.json();
|
||||
|
||||
if (comments_data.code !== 0) {
|
||||
console.error(`API error on page ${page_num}: ${comments_data.message}`);
|
||||
page_num++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (page_num === 1 && comments_data.data?.page) {
|
||||
total_pages = Math.ceil(comments_data.data.page.count / comments_data.data.page.size);
|
||||
}
|
||||
|
||||
if (comments_data.data?.replies) {
|
||||
for (const comment of comments_data.data.replies) {
|
||||
const formatted_comment = format_comment(comment);
|
||||
if (formatted_comment) {
|
||||
all_comments.push(formatted_comment);
|
||||
}
|
||||
}
|
||||
}
|
||||
page_num++;
|
||||
}
|
||||
const comment_summary = format_comments_to_string(all_comments);
|
||||
return { comments: comment_summary, count: all_comments.length, error: null };
|
||||
} catch (e: any) {
|
||||
const comment_summary = format_comments_to_string(all_comments);
|
||||
return { comments: comment_summary, count: all_comments.length, error: `Failed to get comments: ${e.message}` }; // 返回已获取的部分数据
|
||||
}
|
||||
}
|
||||
|
||||
function format_comment(comment_item: any): Comment {
|
||||
const sub_comments = (comment_item.replies || []).map(format_comment).filter(Boolean);
|
||||
return {
|
||||
user: comment_item.member.uname,
|
||||
content: comment_item.content.message,
|
||||
likes: comment_item.like || 0,
|
||||
time: new Date(comment_item.ctime * 1000).toLocaleDateString(),
|
||||
sub_comments: sub_comments
|
||||
};
|
||||
}
|
||||
|
||||
function format_comments_to_string(comments: Comment[]): string {
|
||||
const result: string[] = [];
|
||||
function format_single(comment: Comment, indent: string) {
|
||||
result.push(`${indent}- ${comment.user} (👍${comment.likes}) [${comment.time}]: ${comment.content}`);
|
||||
if (comment.sub_comments && comment.sub_comments.length > 0) {
|
||||
for (const sub of comment.sub_comments) {
|
||||
format_single(sub, indent + " ");
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const comment of comments) {
|
||||
format_single(comment, "");
|
||||
}
|
||||
return result.join('\n');
|
||||
}
|
||||
|
||||
|
||||
async function get_subtitles(params: { url: string }): Promise<ToolResponse> {
|
||||
await init();
|
||||
const bvid = await extract_bvid(params.url);
|
||||
if (!bvid) {
|
||||
return { success: false, message: `错误: 无法从 URL 提取 BV 号: ${params.url}` };
|
||||
}
|
||||
|
||||
const { aid, cid, error: infoError } = await get_video_basic_info(bvid);
|
||||
if (infoError) {
|
||||
return { success: false, message: `获取视频信息失败: ${infoError}` };
|
||||
}
|
||||
if (!aid || !cid) {
|
||||
return { success: false, message: '获取视频 aid 或 cid 失败' };
|
||||
}
|
||||
|
||||
const { subtitles, error: subtitlesError } = await get_subtitles_from_api(aid, cid);
|
||||
if (subtitlesError) {
|
||||
return { success: false, message: `获取字幕失败: ${subtitlesError}` };
|
||||
}
|
||||
|
||||
if (subtitles.length === 0) {
|
||||
return { success: true, message: "该视频没有字幕", data: [] };
|
||||
}
|
||||
|
||||
return { success: true, message: `成功获取 ${subtitles.length} 门语言的字幕`, data: subtitles };
|
||||
}
|
||||
|
||||
async function get_danmaku(params: { url: string, count?: number }): Promise<ToolResponse> {
|
||||
await init();
|
||||
const { url, count = 500 } = params;
|
||||
const bvid = await extract_bvid(url);
|
||||
if (!bvid) {
|
||||
return { success: false, message: `错误: 无法从 URL 提取 BV 号: ${url}` };
|
||||
}
|
||||
|
||||
if (count > 1000) {
|
||||
return { success: false, message: "参数 'count' 不能超过 1000" };
|
||||
}
|
||||
|
||||
const { aid, cid, error: infoError } = await get_video_basic_info(bvid);
|
||||
if (infoError) {
|
||||
return { success: false, message: `获取视频信息失败: ${infoError}` };
|
||||
}
|
||||
if (!cid) {
|
||||
return { success: false, message: '获取视频 cid 失败' };
|
||||
}
|
||||
|
||||
const { danmaku, error: danmakuError } = await get_danmaku_from_api(cid, count);
|
||||
if (danmakuError) {
|
||||
return { success: false, message: `获取弹幕失败: ${danmakuError}` };
|
||||
}
|
||||
|
||||
if (danmaku.length === 0) {
|
||||
return { success: true, message: "该视频没有弹幕", data: [] };
|
||||
}
|
||||
|
||||
return { success: true, message: `成功获取 ${danmaku.length} 条弹幕`, data: danmaku };
|
||||
}
|
||||
|
||||
async function get_comments(params: { url: string }): Promise<ToolResponse> {
|
||||
await init();
|
||||
const bvid = await extract_bvid(params.url);
|
||||
if (!bvid) {
|
||||
return { success: false, message: `错误: 无法从 URL 提取 BV 号: ${params.url}` };
|
||||
}
|
||||
|
||||
const { aid, error: infoError } = await get_video_basic_info(bvid);
|
||||
if (infoError) {
|
||||
return { success: false, message: `获取视频信息失败: ${infoError}` };
|
||||
}
|
||||
if (!aid) {
|
||||
return { success: false, message: '获取视频 aid 失败' };
|
||||
}
|
||||
|
||||
const { comments, count, error: commentsError } = await get_comments_from_api(aid);
|
||||
|
||||
if (commentsError && count === 0) {
|
||||
return { success: false, message: `获取评论失败: ${commentsError}` };
|
||||
}
|
||||
|
||||
if (count === 0) {
|
||||
return { success: true, message: "该视频没有热门评论", data: "" };
|
||||
}
|
||||
|
||||
const message = commentsError
|
||||
? `成功获取 ${count} 条热门评论,但过程中发生错误: ${commentsError}`
|
||||
: `成功获取 ${count} 条热门评论`;
|
||||
|
||||
return { success: true, message: message, data: comments };
|
||||
}
|
||||
|
||||
async function search_videos_from_api(keyword: string, page: number): Promise<{ results: any, error: string | null }> {
|
||||
if (!wbiKeys) {
|
||||
return { results: null, error: "获取 WBI keys 失败,无法请求搜索 API" };
|
||||
}
|
||||
|
||||
try {
|
||||
const params = {
|
||||
keyword: keyword,
|
||||
page: page,
|
||||
search_type: "video"
|
||||
};
|
||||
|
||||
const signed_query = encWbi(params, wbiKeys.img_key, wbiKeys.sub_key);
|
||||
const url = `${API_SEARCH}?${signed_query}`;
|
||||
|
||||
const response = await client.get(url, getHeaders());
|
||||
|
||||
if (!response.isSuccessful()) {
|
||||
return { results: null, error: `搜索失败, status: ${response.statusCode}` };
|
||||
}
|
||||
|
||||
const search_data = response.json();
|
||||
if (search_data.code !== 0) {
|
||||
return { results: null, error: `搜索 API 错误: ${search_data.message}` };
|
||||
}
|
||||
|
||||
return { results: search_data.data, error: null };
|
||||
} catch (e: any) {
|
||||
return { results: null, error: `搜索时发生异常: ${e.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
function format_search_results_to_string(data: any, count: number): { formatted: string, result_count: number, total_count: number } {
|
||||
if (!data.result) {
|
||||
return { formatted: "没有找到相关结果。", result_count: 0, total_count: 0 };
|
||||
}
|
||||
|
||||
const videoResults = data.result
|
||||
.find((item: any) => item.result_type === "video")
|
||||
?.data;
|
||||
|
||||
if (!videoResults || videoResults.length === 0) {
|
||||
return { formatted: "没有找到相关视频结果。", result_count: 0, total_count: data.numResults || 0 };
|
||||
}
|
||||
|
||||
const slicedResults = videoResults.slice(0, count);
|
||||
|
||||
const formattedResults = slicedResults
|
||||
.map((video: SearchResultVideo, index: number) => {
|
||||
const cleanTitle = video.title.replace(/<em class="keyword">|<\/em>/g, "");
|
||||
const cleanDescription = video.description.replace(/<em class="keyword">|<\/em>/g, "");
|
||||
return [
|
||||
`${index + 1}. "${cleanTitle}" - ${video.author}`,
|
||||
` BV ID: ${video.bvid}`,
|
||||
` 播放: ${video.play?.toLocaleString()}`,
|
||||
` 弹幕: ${video.danmaku?.toLocaleString()}`,
|
||||
` 点赞: ${video.like?.toLocaleString()}`,
|
||||
` 时长: ${video.duration}`,
|
||||
` 发布于: ${new Date(video.pubdate * 1000).toLocaleDateString()}`,
|
||||
` 简介: ${cleanDescription?.substring(0, 100)}${cleanDescription?.length > 100 ? "..." : ""}`,
|
||||
].join("\n");
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
return { formatted: formattedResults, result_count: slicedResults.length, total_count: data.numResults || 0 };
|
||||
}
|
||||
|
||||
|
||||
async function search_videos(params: { keyword: string, page?: number, count?: number }): Promise<ToolResponse> {
|
||||
await init();
|
||||
const { keyword, page = 1, count = 10 } = params;
|
||||
|
||||
if (count > 20) {
|
||||
return { success: false, message: "参数 'count' 不能超过 20" };
|
||||
}
|
||||
|
||||
const { results, error } = await search_videos_from_api(keyword, page);
|
||||
if (error) {
|
||||
return { success: false, message: `搜索失败: ${error}` };
|
||||
}
|
||||
|
||||
const { formatted, result_count, total_count } = format_search_results_to_string(results, count);
|
||||
|
||||
if (result_count === 0) {
|
||||
return { success: true, message: "没有找到相关视频。", data: "" };
|
||||
}
|
||||
|
||||
const message = `成功为 "${keyword}" 找到 ${total_count} 个相关视频,当前显示第 ${page} 页的 ${result_count} 个结果。`;
|
||||
return { success: true, message, data: formatted };
|
||||
}
|
||||
|
||||
function format_user_info_to_string(user: UserInfo): string {
|
||||
return [
|
||||
`昵称: ${user.name} (UID: ${user.mid})`,
|
||||
`性别: ${user.sex}`,
|
||||
`等级: LV${user.level}`,
|
||||
`生日: ${user.birthday || '未设置'}`,
|
||||
`粉丝数: ${user.follower.toLocaleString()}`,
|
||||
`关注数: ${user.following.toLocaleString()}`,
|
||||
`个人简介: ${user.sign || '这个UP主很懒,什么都没有写...'}`,
|
||||
`头像链接: ${user.face}`
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function get_user_info_from_api(mid: number): Promise<{ user_info: UserInfo | null, error: string | null }> {
|
||||
if (!wbiKeys) {
|
||||
return { user_info: null, error: "获取 WBI keys 失败,无法请求用户信息 API" };
|
||||
}
|
||||
|
||||
try {
|
||||
// Get user account info (WBI signed)
|
||||
const params = { mid };
|
||||
const signed_query = encWbi(params, wbiKeys.img_key, wbiKeys.sub_key);
|
||||
const url = `${API_GET_USER_INFO}?${signed_query}`;
|
||||
const response = await client.get(url, getHeaders());
|
||||
|
||||
if (!response.isSuccessful()) {
|
||||
return { user_info: null, error: `获取用户信息失败, status: ${response.statusCode}` };
|
||||
}
|
||||
|
||||
const userData = response.json();
|
||||
if (userData.code !== 0) {
|
||||
return { user_info: null, error: `用户信息 API 错误: ${userData.message}` };
|
||||
}
|
||||
|
||||
// Get user relation stat (not WBI signed)
|
||||
const statUrl = `${API_GET_RELATION_STAT}?vmid=${mid}`;
|
||||
const statResponse = await client.get(statUrl, getHeaders());
|
||||
let follower = -1, following = -1;
|
||||
if (statResponse.isSuccessful()) {
|
||||
const statData = statResponse.json();
|
||||
if (statData.code === 0) {
|
||||
follower = statData.data.follower;
|
||||
following = statData.data.following;
|
||||
}
|
||||
}
|
||||
|
||||
const userInfo: UserInfo = {
|
||||
mid: userData.data.mid,
|
||||
name: userData.data.name,
|
||||
sex: userData.data.sex,
|
||||
face: userData.data.face,
|
||||
sign: userData.data.sign,
|
||||
level: userData.data.level,
|
||||
birthday: userData.data.birthday,
|
||||
follower: follower,
|
||||
following: following
|
||||
};
|
||||
|
||||
return { user_info: userInfo, error: null };
|
||||
} catch (e: any) {
|
||||
return { user_info: null, error: `获取用户信息时发生异常: ${e.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
async function get_user_info(params: { mid: number }): Promise<ToolResponse> {
|
||||
await init();
|
||||
const { mid } = params;
|
||||
|
||||
if (!mid || typeof mid !== 'number' || mid <= 0) {
|
||||
return { success: false, message: "参数 'mid' 必须是一个正整数" };
|
||||
}
|
||||
|
||||
const { user_info, error } = await get_user_info_from_api(mid);
|
||||
if (error) {
|
||||
return { success: false, message: `获取用户信息失败: ${error}` };
|
||||
}
|
||||
|
||||
if (!user_info) {
|
||||
return { success: false, message: "未能获取到用户信息" };
|
||||
}
|
||||
|
||||
const formatted_info = format_user_info_to_string(user_info);
|
||||
const message = `成功获取 UID:${mid} 的用户信息。`;
|
||||
|
||||
return { success: true, message, data: formatted_info };
|
||||
}
|
||||
|
||||
async function wrapToolExecution<P>(func: (params: P) => Promise<ToolResponse>, params: P) {
|
||||
try {
|
||||
const result = await func(params);
|
||||
complete(result);
|
||||
} catch (error: any) {
|
||||
console.error(`Tool ${func.name} failed unexpectedly`, error);
|
||||
complete({
|
||||
success: false,
|
||||
message: `工具执行时发生意外错误: ${error.message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("--- Bilibili Video Analysis Tool Test ---");
|
||||
const testUrl = "https://www.bilibili.com/video/BV1NRE5zmE3W";
|
||||
const testKeyword = "OpenAI";
|
||||
|
||||
console.log("\n[1/4] Testing search_videos...");
|
||||
const searchResult = await search_videos({ keyword: testKeyword, count: 10 });
|
||||
if (searchResult.success) {
|
||||
console.log(`Success: ${searchResult.message}`);
|
||||
console.log(`---------- Search Results for "${testKeyword}" ----------`);
|
||||
console.log(searchResult.data);
|
||||
console.log("------------------------------------------");
|
||||
} else {
|
||||
console.error(`Failure: ${searchResult.message}`);
|
||||
}
|
||||
|
||||
console.log("\n[2/4] Testing get_subtitles...");
|
||||
const subtitlesResult = await get_subtitles({ url: testUrl });
|
||||
console.log(JSON.stringify(subtitlesResult, null, 2));
|
||||
|
||||
console.log("\n[3/4] Testing get_danmaku with count limit...");
|
||||
const danmakuResult = await get_danmaku({ url: testUrl, count: 20 });
|
||||
console.log(JSON.stringify(danmakuResult, null, 2));
|
||||
|
||||
console.log("\n[4/4] Testing get_comments...");
|
||||
const commentsResult = await get_comments({ url: testUrl });
|
||||
// For compact string format, we don't need JSON.stringify and can print directly
|
||||
if (commentsResult.success) {
|
||||
console.log(`Success: ${commentsResult.message}`);
|
||||
console.log("---------- Comments ----------");
|
||||
console.log(commentsResult.data);
|
||||
console.log("----------------------------");
|
||||
} else {
|
||||
console.error(`Failure: ${commentsResult.message}`);
|
||||
}
|
||||
|
||||
console.log("\n[5/5] Testing get_user_info...");
|
||||
const userInfoResult = await get_user_info({ mid: 2 }); // 2 is B站创始人之一的UID
|
||||
if (userInfoResult.success) {
|
||||
console.log(`Success: ${userInfoResult.message}`);
|
||||
console.log("---------- User Info ----------");
|
||||
console.log(userInfoResult.data);
|
||||
console.log("-----------------------------");
|
||||
} else {
|
||||
console.error(`Failure: ${userInfoResult.message}`);
|
||||
}
|
||||
|
||||
complete({ success: true, message: "Test finished." });
|
||||
}
|
||||
|
||||
return {
|
||||
get_subtitles: (params: { url: string }) => wrapToolExecution(get_subtitles, params),
|
||||
get_danmaku: (params: { url: string, count?: number }) => wrapToolExecution(get_danmaku, params),
|
||||
get_comments: (params: { url: string }) => wrapToolExecution(get_comments, params),
|
||||
search_videos: (params: { keyword: string, page?: number, count?: number }) => wrapToolExecution(search_videos, params),
|
||||
get_user_info: (params: { mid: number }) => wrapToolExecution(get_user_info, params),
|
||||
main,
|
||||
};
|
||||
})();
|
||||
|
||||
exports.get_subtitles = BilibiliVideoAnalysis.get_subtitles;
|
||||
exports.get_danmaku = BilibiliVideoAnalysis.get_danmaku;
|
||||
exports.get_comments = BilibiliVideoAnalysis.get_comments;
|
||||
exports.search_videos = BilibiliVideoAnalysis.search_videos;
|
||||
exports.get_user_info = BilibiliVideoAnalysis.get_user_info;
|
||||
exports.main = BilibiliVideoAnalysis.main;
|
||||
@@ -0,0 +1,509 @@
|
||||
/* METADATA
|
||||
{
|
||||
"name": "browser",
|
||||
"display_name": {
|
||||
"zh": "Browser 自动化操作",
|
||||
"en": "Browser Automation"
|
||||
},
|
||||
"description": {
|
||||
"zh": "严格对齐 Playwright MCP 默认 browser 工具面的浏览器自动化工具集。",
|
||||
"en": "Browser automation tools aligned to the default Playwright MCP browser surface."
|
||||
},
|
||||
"enabledByDefault": true,
|
||||
"category": "Automatic",
|
||||
"tools": [
|
||||
{
|
||||
"name": "click",
|
||||
"description": { "zh": "按快照 ref 点击页面元素,包括同源 iframe 内的元素。", "en": "Click an element by snapshot ref, including refs inside same-origin iframes." },
|
||||
"parameters": [
|
||||
{ "name": "ref", "description": { "zh": "快照中的目标元素引用;和 selector 至少提供一个。", "en": "Target element ref from the snapshot; provide ref or selector." }, "type": "string", "required": false },
|
||||
{ "name": "selector", "description": { "zh": "可选,ref 不可用时的元素选择器。", "en": "Optional selector fallback when ref is not available." }, "type": "string", "required": false },
|
||||
{ "name": "element", "description": { "zh": "可选,人类可读元素描述。", "en": "Optional human-readable element description." }, "type": "string", "required": false },
|
||||
{ "name": "doubleClick", "description": { "zh": "可选,是否双击。", "en": "Optional double click." }, "type": "boolean", "required": false },
|
||||
{ "name": "button", "description": { "zh": "可选,left/right/middle。", "en": "Optional mouse button: left/right/middle." }, "type": "string", "required": false },
|
||||
{ "name": "modifiers", "description": { "zh": "可选,修饰键数组。", "en": "Optional modifier keys array." }, "type": "array", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "close",
|
||||
"description": { "zh": "关闭当前 tab。", "en": "Close the current tab." },
|
||||
"parameters": []
|
||||
},
|
||||
{
|
||||
"name": "console_messages",
|
||||
"description": { "zh": "读取控制台消息。", "en": "Read console messages." },
|
||||
"parameters": [
|
||||
{ "name": "level", "description": { "zh": "可选,日志级别:error/warning/info/debug,默认 info。", "en": "Optional log level: error/warning/info/debug. Defaults to info." }, "type": "string", "required": false },
|
||||
{ "name": "filename", "description": { "zh": "可选,保存输出的文件名。", "en": "Optional output file name." }, "type": "string", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "drag",
|
||||
"description": { "zh": "在两个元素之间拖拽。", "en": "Drag between two elements." },
|
||||
"parameters": [
|
||||
{ "name": "startElement", "description": { "zh": "源元素的人类可读描述。", "en": "Human-readable source element description." }, "type": "string", "required": true },
|
||||
{ "name": "startRef", "description": { "zh": "源元素 ref。", "en": "Source element ref." }, "type": "string", "required": true },
|
||||
{ "name": "endElement", "description": { "zh": "目标元素的人类可读描述。", "en": "Human-readable target element description." }, "type": "string", "required": true },
|
||||
{ "name": "endRef", "description": { "zh": "目标元素 ref。", "en": "Target element ref." }, "type": "string", "required": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "evaluate",
|
||||
"description": { "zh": "在页面或元素上执行 JavaScript 函数。", "en": "Evaluate a JavaScript function on the page or an element." },
|
||||
"parameters": [
|
||||
{ "name": "function", "description": { "zh": "要执行的函数源码。", "en": "Function source to execute." }, "type": "string", "required": true },
|
||||
{ "name": "element", "description": { "zh": "可选,人类可读元素描述。", "en": "Optional human-readable element description." }, "type": "string", "required": false },
|
||||
{ "name": "ref", "description": { "zh": "可选,目标元素 ref。", "en": "Optional target element ref." }, "type": "string", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "upload",
|
||||
"description": { "zh": "向当前文件选择器上传文件。", "en": "Upload files to the current file chooser." },
|
||||
"parameters": [
|
||||
{ "name": "paths", "description": { "zh": "可选,绝对路径数组;不传则取消 file chooser。", "en": "Optional absolute file paths; omit to cancel the file chooser." }, "type": "array", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "fill_form",
|
||||
"description": { "zh": "批量填写表单字段。", "en": "Fill multiple form fields." },
|
||||
"parameters": [
|
||||
{ "name": "fields", "description": { "zh": "字段数组。", "en": "Array of form fields." }, "type": "array", "required": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "handle_dialog",
|
||||
"description": { "zh": "处理当前对话框。", "en": "Handle the current dialog." },
|
||||
"parameters": [
|
||||
{ "name": "accept", "description": { "zh": "是否接受对话框。", "en": "Whether to accept the dialog." }, "type": "boolean", "required": true },
|
||||
{ "name": "promptText", "description": { "zh": "可选,prompt 的输入文本。", "en": "Optional prompt text." }, "type": "string", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "hover",
|
||||
"description": { "zh": "悬停到页面元素上。", "en": "Hover over an element." },
|
||||
"parameters": [
|
||||
{ "name": "ref", "description": { "zh": "目标元素 ref。", "en": "Target element ref." }, "type": "string", "required": true },
|
||||
{ "name": "element", "description": { "zh": "可选,人类可读元素描述。", "en": "Optional human-readable element description." }, "type": "string", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "goto",
|
||||
"description": { "zh": "导航到指定 URL。", "en": "Navigate to a URL." },
|
||||
"parameters": [
|
||||
{ "name": "url", "description": { "zh": "目标 URL。", "en": "Target URL." }, "type": "string", "required": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "back",
|
||||
"description": { "zh": "后退到上一页。", "en": "Go back to the previous page." },
|
||||
"parameters": []
|
||||
},
|
||||
{
|
||||
"name": "network_requests",
|
||||
"description": { "zh": "读取当前页面的网络请求。", "en": "Read network requests for the current page." },
|
||||
"parameters": [
|
||||
{ "name": "includeStatic", "description": { "zh": "可选,是否包含静态资源请求,默认 false。", "en": "Optional include static resource requests. Defaults to false." }, "type": "boolean", "required": false },
|
||||
{ "name": "filename", "description": { "zh": "可选,保存输出的文件名。", "en": "Optional output file name." }, "type": "string", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "press_key",
|
||||
"description": { "zh": "按下键盘按键。", "en": "Press a keyboard key." },
|
||||
"parameters": [
|
||||
{ "name": "key", "description": { "zh": "按键名。", "en": "Key name." }, "type": "string", "required": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "resize",
|
||||
"description": { "zh": "调整浏览器视口大小。", "en": "Resize the browser viewport." },
|
||||
"parameters": [
|
||||
{ "name": "width", "description": { "zh": "宽度。", "en": "Width." }, "type": "number", "required": true },
|
||||
{ "name": "height", "description": { "zh": "高度。", "en": "Height." }, "type": "number", "required": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "run_code",
|
||||
"description": { "zh": "运行 Playwright 风格代码片段。", "en": "Run a Playwright-style code snippet." },
|
||||
"parameters": [
|
||||
{ "name": "code", "description": { "zh": "代码片段。", "en": "Code snippet." }, "type": "string", "required": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "select_option",
|
||||
"description": { "zh": "在下拉框中选择选项。", "en": "Select options in a dropdown." },
|
||||
"parameters": [
|
||||
{ "name": "ref", "description": { "zh": "目标元素 ref。", "en": "Target element ref." }, "type": "string", "required": true },
|
||||
{ "name": "values", "description": { "zh": "要选择的值数组。", "en": "Values to select." }, "type": "array", "required": true },
|
||||
{ "name": "element", "description": { "zh": "可选,人类可读元素描述。", "en": "Optional human-readable element description." }, "type": "string", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "snapshot",
|
||||
"description": { "zh": "获取当前页面结构化快照,包括同源 iframe 内容。", "en": "Get a structured page snapshot, including same-origin iframe content." },
|
||||
"parameters": [
|
||||
{ "name": "filename", "description": { "zh": "可选,保存快照的文件名。", "en": "Optional snapshot output file name." }, "type": "string", "required": false },
|
||||
{ "name": "selector", "description": { "zh": "可选,作为快照根节点的元素选择器。", "en": "Optional root element selector for a partial snapshot." }, "type": "string", "required": false },
|
||||
{ "name": "depth", "description": { "zh": "可选,限制快照树深度。", "en": "Optional snapshot tree depth limit." }, "type": "number", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "type",
|
||||
"description": { "zh": "向可编辑元素输入文本。", "en": "Type text into an editable element." },
|
||||
"parameters": [
|
||||
{ "name": "ref", "description": { "zh": "目标元素 ref。", "en": "Target element ref." }, "type": "string", "required": true },
|
||||
{ "name": "text", "description": { "zh": "输入文本。", "en": "Text to type." }, "type": "string", "required": true },
|
||||
{ "name": "element", "description": { "zh": "可选,人类可读元素描述。", "en": "Optional human-readable element description." }, "type": "string", "required": false },
|
||||
{ "name": "submit", "description": { "zh": "可选,输入后是否提交。", "en": "Optional submit after typing." }, "type": "boolean", "required": false },
|
||||
{ "name": "slowly", "description": { "zh": "可选,是否逐字输入。", "en": "Optional type slowly." }, "type": "boolean", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "wait_for",
|
||||
"description": { "zh": "等待文本出现、消失或等待指定时间。", "en": "Wait for text to appear, disappear, or for a duration." },
|
||||
"parameters": [
|
||||
{ "name": "time", "description": { "zh": "可选,等待秒数。", "en": "Optional number of seconds to wait." }, "type": "number", "required": false },
|
||||
{ "name": "text", "description": { "zh": "可选,等待出现的文本。", "en": "Optional text to wait for." }, "type": "string", "required": false },
|
||||
{ "name": "textGone", "description": { "zh": "可选,等待消失的文本。", "en": "Optional text to wait to disappear." }, "type": "string", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "tabs",
|
||||
"description": { "zh": "列出、创建、切换或关闭 tab。", "en": "List, create, select, or close tabs." },
|
||||
"parameters": [
|
||||
{ "name": "action", "description": { "zh": "操作:list/create/select/close。", "en": "Action: list/create/select/close." }, "type": "string", "required": true },
|
||||
{ "name": "index", "description": { "zh": "可选,0-based tab 索引。", "en": "Optional 0-based tab index." }, "type": "number", "required": false }
|
||||
]
|
||||
},
|
||||
]
|
||||
}*/
|
||||
|
||||
const MAX_INLINE_BROWSER_TEXT_CHARS = 24000;
|
||||
type BrowserTabAction = "list" | "create" | "select" | "close";
|
||||
type BrowserMouseButton = "left" | "right" | "middle";
|
||||
type BrowserModifierKey = "Alt" | "Control" | "ControlOrMeta" | "Meta" | "Shift";
|
||||
type ToolParamValue = string | number | boolean | object;
|
||||
|
||||
interface FilenamePayload {
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
interface SnapshotPayload extends FilenamePayload {
|
||||
selector?: string;
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
interface ClickPayload {
|
||||
ref?: string;
|
||||
selector?: string;
|
||||
element?: string;
|
||||
button?: BrowserMouseButton;
|
||||
modifiers?: BrowserModifierKey[];
|
||||
doubleClick?: boolean;
|
||||
}
|
||||
|
||||
interface ConsoleMessagesPayload extends FilenamePayload {
|
||||
level: string;
|
||||
}
|
||||
|
||||
interface EvaluatePayload {
|
||||
function: string;
|
||||
ref?: string;
|
||||
element?: string;
|
||||
}
|
||||
|
||||
interface UploadPayload {
|
||||
paths?: string[];
|
||||
}
|
||||
|
||||
interface FillFormFieldPayload {
|
||||
name: string;
|
||||
type: string;
|
||||
value: ToolParamValue;
|
||||
ref?: string;
|
||||
selector?: string;
|
||||
}
|
||||
|
||||
interface FillFormPayload {
|
||||
fields: FillFormFieldPayload[];
|
||||
}
|
||||
|
||||
interface HandleDialogPayload {
|
||||
accept: boolean;
|
||||
promptText?: string;
|
||||
}
|
||||
|
||||
interface HoverPayload {
|
||||
ref: string;
|
||||
element?: string;
|
||||
}
|
||||
|
||||
interface DragPayload {
|
||||
startElement: string;
|
||||
startRef: string;
|
||||
endElement: string;
|
||||
endRef: string;
|
||||
}
|
||||
|
||||
interface GotoPayload {
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface NetworkRequestsPayload extends FilenamePayload {
|
||||
includeStatic?: boolean;
|
||||
}
|
||||
|
||||
interface PressKeyPayload {
|
||||
key: string;
|
||||
}
|
||||
|
||||
interface ResizePayload {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface RunCodePayload {
|
||||
code: string;
|
||||
}
|
||||
|
||||
interface SelectOptionPayload {
|
||||
ref: string;
|
||||
values: string[];
|
||||
element?: string;
|
||||
}
|
||||
|
||||
interface TypePayload {
|
||||
ref: string;
|
||||
text: string;
|
||||
element?: string;
|
||||
submit?: boolean;
|
||||
slowly?: boolean;
|
||||
}
|
||||
|
||||
interface WaitForPayload {
|
||||
time?: number;
|
||||
text?: string;
|
||||
textGone?: string;
|
||||
}
|
||||
|
||||
interface TabsPayload {
|
||||
action: BrowserTabAction;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
const TOOL_NAMES = [
|
||||
"click",
|
||||
"close",
|
||||
"close_all",
|
||||
"console_messages",
|
||||
"drag",
|
||||
"evaluate",
|
||||
"upload",
|
||||
"fill_form",
|
||||
"handle_dialog",
|
||||
"hover",
|
||||
"goto",
|
||||
"back",
|
||||
"network_requests",
|
||||
"press_key",
|
||||
"resize",
|
||||
"run_code",
|
||||
"select_option",
|
||||
"snapshot",
|
||||
"type",
|
||||
"wait_for",
|
||||
"tabs"
|
||||
];
|
||||
|
||||
function normalizeOptionalString(value?: string): string | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = value.trim();
|
||||
return normalized ? normalized : undefined;
|
||||
}
|
||||
|
||||
function buildLargeOutputFilename(prefix: string, extension: string): string {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const rand = Math.floor(Math.random() * 1000000);
|
||||
return OPERIT_CLEAN_ON_EXIT_DIR + "/browser_" + prefix + "_" + timestamp + "_" + rand + "." + extension;
|
||||
}
|
||||
|
||||
async function maybePersistLargeBrowserResponse(
|
||||
result: unknown,
|
||||
prefix: string,
|
||||
extension: string = "md"
|
||||
) {
|
||||
if (typeof result !== "string" || result.length <= MAX_INLINE_BROWSER_TEXT_CHARS) {
|
||||
return result;
|
||||
}
|
||||
await Tools.Files.mkdir(OPERIT_CLEAN_ON_EXIT_DIR, true);
|
||||
const filename = buildLargeOutputFilename(prefix, extension);
|
||||
await Tools.Files.write(filename, result, false);
|
||||
const normalizedPath = filename.replace(/\\/g, "/");
|
||||
return "Large browser response saved to:\n- [Browser Output](" + normalizedPath + ")";
|
||||
}
|
||||
|
||||
async function click(params: ClickPayload) {
|
||||
const result = await Tools.Net.browserClick(params);
|
||||
return maybePersistLargeBrowserResponse(result, "click");
|
||||
}
|
||||
|
||||
async function close() {
|
||||
const result = await Tools.Net.browserClose();
|
||||
return maybePersistLargeBrowserResponse(result, "close");
|
||||
}
|
||||
|
||||
async function close_all() {
|
||||
const result = await Tools.Net.browserCloseAll();
|
||||
return maybePersistLargeBrowserResponse(result, "close_all");
|
||||
}
|
||||
|
||||
async function console_messages(params: Partial<ConsoleMessagesPayload> = {}) {
|
||||
const result = await Tools.Net.browserConsoleMessages(params);
|
||||
return maybePersistLargeBrowserResponse(result, "console_messages");
|
||||
}
|
||||
|
||||
async function drag(params: DragPayload) {
|
||||
const result = await Tools.Net.browserDrag(params);
|
||||
return maybePersistLargeBrowserResponse(result, "drag");
|
||||
}
|
||||
|
||||
async function evaluate(params: EvaluatePayload) {
|
||||
const result = await Tools.Net.browserEvaluate(params);
|
||||
return maybePersistLargeBrowserResponse(result, "evaluate");
|
||||
}
|
||||
|
||||
async function upload(params: UploadPayload = {}) {
|
||||
const result = await Tools.Net.browserFileUpload(params);
|
||||
return maybePersistLargeBrowserResponse(result, "upload");
|
||||
}
|
||||
|
||||
function normalizeFormFields(fields: FillFormFieldPayload[]): FillFormFieldPayload[] {
|
||||
if (fields.length === 0) {
|
||||
throw new Error("fields must be a non-empty array");
|
||||
}
|
||||
return fields.map((field, index) => {
|
||||
const normalized: FillFormFieldPayload = {
|
||||
name: field.name.trim(),
|
||||
type: field.type.trim(),
|
||||
value: field.value as ToolParamValue
|
||||
};
|
||||
if (!normalized.name) {
|
||||
throw new Error("fields[" + index + "].name is required");
|
||||
}
|
||||
if (!normalized.type) {
|
||||
throw new Error("fields[" + index + "].type is required");
|
||||
}
|
||||
const ref = normalizeOptionalString(field.ref);
|
||||
const selector = normalizeOptionalString(field.selector);
|
||||
if (!ref && !selector) {
|
||||
throw new Error("fields[" + index + "] requires ref or selector");
|
||||
}
|
||||
if (ref) {
|
||||
normalized.ref = ref;
|
||||
}
|
||||
if (selector) {
|
||||
normalized.selector = selector;
|
||||
}
|
||||
return normalized;
|
||||
});
|
||||
}
|
||||
|
||||
async function fill_form(params: FillFormPayload) {
|
||||
const result = await Tools.Net.browserFillForm({
|
||||
fields: normalizeFormFields(params.fields)
|
||||
});
|
||||
return maybePersistLargeBrowserResponse(result, "fill_form");
|
||||
}
|
||||
|
||||
async function handle_dialog(params: HandleDialogPayload) {
|
||||
const result = await Tools.Net.browserHandleDialog(params);
|
||||
return maybePersistLargeBrowserResponse(result, "handle_dialog");
|
||||
}
|
||||
|
||||
async function hover(params: HoverPayload) {
|
||||
const result = await Tools.Net.browserHover(params);
|
||||
return maybePersistLargeBrowserResponse(result, "hover");
|
||||
}
|
||||
|
||||
async function goto(params: GotoPayload) {
|
||||
const result = await Tools.Net.browserNavigate(params);
|
||||
return maybePersistLargeBrowserResponse(result, "goto");
|
||||
}
|
||||
|
||||
async function back() {
|
||||
const result = await Tools.Net.browserNavigateBack();
|
||||
return maybePersistLargeBrowserResponse(result, "back");
|
||||
}
|
||||
|
||||
async function network_requests(params: NetworkRequestsPayload = {}) {
|
||||
const result = await Tools.Net.browserNetworkRequests(params);
|
||||
return maybePersistLargeBrowserResponse(result, "network_requests");
|
||||
}
|
||||
|
||||
async function press_key(params: PressKeyPayload) {
|
||||
const result = await Tools.Net.browserPressKey(params);
|
||||
return maybePersistLargeBrowserResponse(result, "press_key");
|
||||
}
|
||||
|
||||
async function resize(params: ResizePayload) {
|
||||
const result = await Tools.Net.browserResize(params);
|
||||
return maybePersistLargeBrowserResponse(result, "resize");
|
||||
}
|
||||
|
||||
async function run_code(params: RunCodePayload) {
|
||||
const result = await Tools.Net.browserRunCode(params);
|
||||
return maybePersistLargeBrowserResponse(result, "run_code");
|
||||
}
|
||||
|
||||
async function select_option(params: SelectOptionPayload) {
|
||||
const result = await Tools.Net.browserSelectOption(params);
|
||||
return maybePersistLargeBrowserResponse(result, "select_option");
|
||||
}
|
||||
|
||||
async function snapshot(params: SnapshotPayload = {}) {
|
||||
const result = await Tools.Net.browserSnapshot(params);
|
||||
return maybePersistLargeBrowserResponse(result, "snapshot");
|
||||
}
|
||||
|
||||
async function type(params: TypePayload) {
|
||||
const result = await Tools.Net.browserType(params);
|
||||
return maybePersistLargeBrowserResponse(result, "type");
|
||||
}
|
||||
|
||||
async function wait_for(params: WaitForPayload = {}) {
|
||||
const result = await Tools.Net.browserWaitFor(params);
|
||||
return maybePersistLargeBrowserResponse(result, "wait_for");
|
||||
}
|
||||
|
||||
async function tabs(params: TabsPayload) {
|
||||
const result = await Tools.Net.browserTabs(params);
|
||||
return maybePersistLargeBrowserResponse(result, "tabs");
|
||||
}
|
||||
|
||||
async function browserMain() {
|
||||
return "Browser package ready: " + TOOL_NAMES.join(", ");
|
||||
}
|
||||
|
||||
exports.click = click;
|
||||
exports.close = close;
|
||||
exports.close_all = close_all;
|
||||
exports.console_messages = console_messages;
|
||||
exports.drag = drag;
|
||||
exports.evaluate = evaluate;
|
||||
exports.upload = upload;
|
||||
exports.fill_form = fill_form;
|
||||
exports.handle_dialog = handle_dialog;
|
||||
exports.hover = hover;
|
||||
exports.goto = goto;
|
||||
exports.back = back;
|
||||
exports.network_requests = network_requests;
|
||||
exports.press_key = press_key;
|
||||
exports.resize = resize;
|
||||
exports.run_code = run_code;
|
||||
exports.select_option = select_option;
|
||||
exports.snapshot = snapshot;
|
||||
exports.type = type;
|
||||
exports.wait_for = wait_for;
|
||||
exports.tabs = tabs;
|
||||
exports.main = browserMain;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,607 @@
|
||||
/* METADATA
|
||||
{
|
||||
"name": "crossref",
|
||||
"display_name": {
|
||||
"zh": "Crossref 学术文献查询",
|
||||
"en": "Crossref Academic Literature Search"
|
||||
},
|
||||
"description": {
|
||||
"zh": "Crossref 学术文献查询工具,提供 DOI 查询、关键词搜索、作者搜索等功能,帮助用户查找和获取学术文章元数据。",
|
||||
"en": "Crossref scholarly literature search tools: query by DOI, keyword, author, title, ISSN, and retrieve publication metadata."
|
||||
},
|
||||
"category": "Search",
|
||||
"enabledByDefault": true,
|
||||
"tools": [
|
||||
{
|
||||
"name": "search_by_doi",
|
||||
"description": { "zh": "通过 DOI (Digital Object Identifier) 查询文章的详细信息", "en": "Query article details by DOI (Digital Object Identifier)." },
|
||||
"parameters": [
|
||||
{
|
||||
"name": "doi",
|
||||
"description": { "zh": "文章的 DOI 标识符,例如 '10.1038/nature12373'", "en": "DOI identifier, e.g. '10.1038/nature12373'" },
|
||||
"type": "string",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "search_by_keyword",
|
||||
"description": { "zh": "通过关键词搜索学术文章", "en": "Search scholarly articles by keyword." },
|
||||
"parameters": [
|
||||
{
|
||||
"name": "query",
|
||||
"description": { "zh": "搜索关键词", "en": "Search query keyword(s)" },
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "rows",
|
||||
"description": { "zh": "返回结果数量,默认 10,最大 100", "en": "Number of results to return (default: 10, max: 100)" },
|
||||
"type": "number",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "sort",
|
||||
"description": { "zh": "排序方式,可选值:'relevance'(相关性), 'score'(评分), 'updated'(更新时间), 'deposited'(提交时间), 'indexed'(索引时间), 'published'(发布时间)", "en": "Sort mode. Options: 'relevance', 'score', 'updated', 'deposited', 'indexed', 'published'." },
|
||||
"type": "string",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "order",
|
||||
"description": { "zh": "排序顺序,可选值:'asc'(升序), 'desc'(降序),默认 'desc'", "en": "Sort order: 'asc' or 'desc' (default: 'desc')." },
|
||||
"type": "string",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "search_by_author",
|
||||
"description": { "zh": "通过作者名字搜索文章", "en": "Search articles by author name." },
|
||||
"parameters": [
|
||||
{
|
||||
"name": "author",
|
||||
"description": { "zh": "作者名字", "en": "Author name" },
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "rows",
|
||||
"description": { "zh": "返回结果数量,默认 10,最大 100", "en": "Number of results to return (default: 10, max: 100)" },
|
||||
"type": "number",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "search_by_title",
|
||||
"description": { "zh": "通过文章标题搜索", "en": "Search articles by title." },
|
||||
"parameters": [
|
||||
{
|
||||
"name": "title",
|
||||
"description": { "zh": "文章标题或标题关键词", "en": "Article title or title keyword(s)" },
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "rows",
|
||||
"description": { "zh": "返回结果数量,默认 10,最大 100", "en": "Number of results to return (default: 10, max: 100)" },
|
||||
"type": "number",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "search_by_issn",
|
||||
"description": { "zh": "通过期刊 ISSN 查询该期刊发表的文章", "en": "Search articles published in a journal by ISSN." },
|
||||
"parameters": [
|
||||
{
|
||||
"name": "issn",
|
||||
"description": { "zh": "期刊的 ISSN 标识符,例如 '1476-4687'", "en": "Journal ISSN identifier, e.g. '1476-4687'" },
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "rows",
|
||||
"description": { "zh": "返回结果数量,默认 10,最大 100", "en": "Number of results to return (default: 10, max: 100)" },
|
||||
"type": "number",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}*/
|
||||
const CrossrefSearch = (function () {
|
||||
const BASE_URL = "https://api.crossref.org";
|
||||
const DEFAULT_ROWS = 10;
|
||||
const MAX_ROWS = 100;
|
||||
|
||||
function buildQueryString(params: Record<string, string>): string {
|
||||
return Object.entries(params)
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
||||
.join("&");
|
||||
}
|
||||
|
||||
type SearchByDoiParams = {
|
||||
doi: string;
|
||||
};
|
||||
|
||||
type SearchByKeywordParams = {
|
||||
query: string;
|
||||
rows?: number;
|
||||
sort?: string;
|
||||
order?: string;
|
||||
};
|
||||
|
||||
type SearchByAuthorParams = {
|
||||
author: string;
|
||||
rows?: number;
|
||||
};
|
||||
|
||||
type SearchByTitleParams = {
|
||||
title: string;
|
||||
rows?: number;
|
||||
};
|
||||
|
||||
type SearchByISSNParams = {
|
||||
issn: string;
|
||||
rows?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 格式化作者信息
|
||||
*/
|
||||
function formatAuthors(authors: any[]): string {
|
||||
if (!authors || authors.length === 0) return "N/A";
|
||||
return authors
|
||||
.slice(0, 5) // 只显示前5个作者
|
||||
.map((author: any) => {
|
||||
const given = author.given || "";
|
||||
const family = author.family || "";
|
||||
return `${given} ${family}`.trim();
|
||||
})
|
||||
.filter(name => name.length > 0)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期
|
||||
*/
|
||||
function formatDate(dateParts: number[][]): string {
|
||||
if (!dateParts || dateParts.length === 0 || !dateParts[0]) return "N/A";
|
||||
const parts = dateParts[0];
|
||||
if (parts.length === 1) return `${parts[0]}`;
|
||||
if (parts.length === 2) return `${parts[0]}-${String(parts[1]).padStart(2, '0')}`;
|
||||
if (parts.length === 3) return `${parts[0]}-${String(parts[1]).padStart(2, '0')}-${String(parts[2]).padStart(2, '0')}`;
|
||||
return "N/A";
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化单篇文章信息
|
||||
*/
|
||||
function formatArticle(item: any, index?: number): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (index !== undefined) {
|
||||
lines.push(`\n=== Article ${index + 1} ===`);
|
||||
}
|
||||
|
||||
// 标题
|
||||
const title = item.title && item.title.length > 0 ? item.title[0] : "No Title";
|
||||
lines.push(`Title: ${title}`);
|
||||
|
||||
// DOI
|
||||
if (item.DOI) {
|
||||
lines.push(`DOI: ${item.DOI}`);
|
||||
lines.push(`URL: https://doi.org/${item.DOI}`);
|
||||
}
|
||||
|
||||
// 作者
|
||||
const authors = formatAuthors(item.author);
|
||||
lines.push(`Authors: ${authors}`);
|
||||
|
||||
// 发表日期
|
||||
const publishedDate = formatDate(item.published?.['date-parts'] || item['published-print']?.['date-parts'] || item['published-online']?.['date-parts']);
|
||||
lines.push(`Published: ${publishedDate}`);
|
||||
|
||||
// 期刊/会议
|
||||
if (item['container-title'] && item['container-title'].length > 0) {
|
||||
lines.push(`Journal/Conference: ${item['container-title'][0]}`);
|
||||
}
|
||||
|
||||
// ISSN
|
||||
if (item.ISSN && item.ISSN.length > 0) {
|
||||
lines.push(`ISSN: ${item.ISSN.join(', ')}`);
|
||||
}
|
||||
|
||||
// 出版商
|
||||
if (item.publisher) {
|
||||
lines.push(`Publisher: ${item.publisher}`);
|
||||
}
|
||||
|
||||
// 类型
|
||||
if (item.type) {
|
||||
lines.push(`Type: ${item.type}`);
|
||||
}
|
||||
|
||||
// 引用次数
|
||||
if (item['is-referenced-by-count'] !== undefined) {
|
||||
lines.push(`Citations: ${item['is-referenced-by-count']}`);
|
||||
}
|
||||
|
||||
// 摘要(如果有)
|
||||
if (item.abstract) {
|
||||
// 移除 HTML 标签
|
||||
const abstractText = item.abstract.replace(/<[^>]*>/g, '');
|
||||
lines.push(`Abstract: ${abstractText.substring(0, 500)}${abstractText.length > 500 ? '...' : ''}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 DOI 查询文章
|
||||
*/
|
||||
async function searchByDoi(params: SearchByDoiParams): Promise<any> {
|
||||
const { doi } = params;
|
||||
|
||||
if (!doi || doi.trim() === "") {
|
||||
return {
|
||||
success: false,
|
||||
message: "请提供有效的 DOI"
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `${BASE_URL}/works/${encodeURIComponent(doi)}`;
|
||||
const client = OkHttp.newClient();
|
||||
const response = await client.get(url, {
|
||||
'User-Agent': 'Operit/1.0 (mailto:support@example.com)'
|
||||
});
|
||||
|
||||
if (!response.isSuccessful()) {
|
||||
return {
|
||||
success: false,
|
||||
message: `查询失败: HTTP ${response.statusCode} - ${response.statusMessage}`
|
||||
};
|
||||
}
|
||||
|
||||
const data = response.json();
|
||||
|
||||
if (data.status === "ok" && data.message) {
|
||||
const article = formatArticle(data.message);
|
||||
return {
|
||||
success: true,
|
||||
message: "查询成功",
|
||||
data: article
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: "未找到该 DOI 对应的文章"
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: `查询失败: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过关键词搜索文章
|
||||
*/
|
||||
async function searchByKeyword(params: SearchByKeywordParams): Promise<any> {
|
||||
const { query, rows = DEFAULT_ROWS, sort = "relevance", order = "desc" } = params;
|
||||
|
||||
if (!query || query.trim() === "") {
|
||||
return {
|
||||
success: false,
|
||||
message: "请提供有效的搜索关键词"
|
||||
};
|
||||
}
|
||||
|
||||
const actualRows = Math.min(Math.max(rows, 1), MAX_ROWS);
|
||||
|
||||
try {
|
||||
const queryString = buildQueryString({
|
||||
query: query,
|
||||
rows: String(actualRows),
|
||||
sort: sort,
|
||||
order: order
|
||||
});
|
||||
|
||||
const url = `${BASE_URL}/works?${queryString}`;
|
||||
const client = OkHttp.newClient();
|
||||
const response = await client.get(url, {
|
||||
'User-Agent': 'Operit/1.0 (mailto:support@example.com)'
|
||||
});
|
||||
|
||||
if (!response.isSuccessful()) {
|
||||
return {
|
||||
success: false,
|
||||
message: `搜索失败: HTTP ${response.statusCode} - ${response.statusMessage}`
|
||||
};
|
||||
}
|
||||
|
||||
const data = response.json();
|
||||
|
||||
if (data.status === "ok" && data.message && data.message.items) {
|
||||
const items = data.message.items;
|
||||
const totalResults = data.message['total-results'];
|
||||
|
||||
const results = items.map((item: any, index: number) => formatArticle(item, index));
|
||||
|
||||
const summary = `Found ${totalResults} results (showing ${items.length}):\n${results.join('\n\n')}`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "搜索成功",
|
||||
data: summary,
|
||||
total: totalResults,
|
||||
count: items.length
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: "未找到相关文章"
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: `搜索失败: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过作者搜索文章
|
||||
*/
|
||||
async function searchByAuthor(params: SearchByAuthorParams): Promise<any> {
|
||||
const { author, rows = DEFAULT_ROWS } = params;
|
||||
|
||||
if (!author || author.trim() === "") {
|
||||
return {
|
||||
success: false,
|
||||
message: "请提供有效的作者名字"
|
||||
};
|
||||
}
|
||||
|
||||
const actualRows = Math.min(Math.max(rows, 1), MAX_ROWS);
|
||||
|
||||
try {
|
||||
const queryString = buildQueryString({
|
||||
'query.author': author,
|
||||
rows: String(actualRows)
|
||||
});
|
||||
|
||||
const url = `${BASE_URL}/works?${queryString}`;
|
||||
const client = OkHttp.newClient();
|
||||
const response = await client.get(url, {
|
||||
'User-Agent': 'Operit/1.0 (mailto:support@example.com)'
|
||||
});
|
||||
|
||||
if (!response.isSuccessful()) {
|
||||
return {
|
||||
success: false,
|
||||
message: `搜索失败: HTTP ${response.statusCode} - ${response.statusMessage}`
|
||||
};
|
||||
}
|
||||
|
||||
const data = response.json();
|
||||
|
||||
if (data.status === "ok" && data.message && data.message.items) {
|
||||
const items = data.message.items;
|
||||
const totalResults = data.message['total-results'];
|
||||
|
||||
const results = items.map((item: any, index: number) => formatArticle(item, index));
|
||||
|
||||
const summary = `Found ${totalResults} results for author "${author}" (showing ${items.length}):\n${results.join('\n\n')}`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "搜索成功",
|
||||
data: summary,
|
||||
total: totalResults,
|
||||
count: items.length
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: `未找到该作者 "${author}" 的文章`
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: `搜索失败: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过标题搜索文章
|
||||
*/
|
||||
async function searchByTitle(params: SearchByTitleParams): Promise<any> {
|
||||
const { title, rows = DEFAULT_ROWS } = params;
|
||||
|
||||
if (!title || title.trim() === "") {
|
||||
return {
|
||||
success: false,
|
||||
message: "请提供有效的文章标题"
|
||||
};
|
||||
}
|
||||
|
||||
const actualRows = Math.min(Math.max(rows, 1), MAX_ROWS);
|
||||
|
||||
try {
|
||||
const queryString = buildQueryString({
|
||||
'query.title': title,
|
||||
rows: String(actualRows)
|
||||
});
|
||||
|
||||
const url = `${BASE_URL}/works?${queryString}`;
|
||||
const client = OkHttp.newClient();
|
||||
const response = await client.get(url, {
|
||||
'User-Agent': 'Operit/1.0 (mailto:support@example.com)'
|
||||
});
|
||||
|
||||
|
||||
const data = response.json();
|
||||
|
||||
if (data.status === "ok" && data.message && data.message.items) {
|
||||
const items = data.message.items;
|
||||
const totalResults = data.message['total-results'];
|
||||
|
||||
const results = items.map((item: any, index: number) => formatArticle(item, index));
|
||||
|
||||
const summary = `Found ${totalResults} results matching title "${title}" (showing ${items.length}):\n${results.join('\n\n')}`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "搜索成功",
|
||||
data: summary,
|
||||
total: totalResults,
|
||||
count: items.length
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: `未找到标题包含 "${title}" 的文章`
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: `搜索失败: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 ISSN 查询期刊文章
|
||||
*/
|
||||
async function searchByISSN(params: SearchByISSNParams): Promise<any> {
|
||||
const { issn, rows = DEFAULT_ROWS } = params;
|
||||
|
||||
if (!issn || issn.trim() === "") {
|
||||
return {
|
||||
success: false,
|
||||
message: "请提供有效的 ISSN"
|
||||
};
|
||||
}
|
||||
|
||||
const actualRows = Math.min(Math.max(rows, 1), MAX_ROWS);
|
||||
|
||||
try {
|
||||
const url = `${BASE_URL}/journals/${encodeURIComponent(issn)}/works?rows=${actualRows}`;
|
||||
const client = OkHttp.newClient();
|
||||
const response = await client.get(url, {
|
||||
'User-Agent': 'Operit/1.0 (mailto:support@example.com)'
|
||||
});
|
||||
|
||||
if (!response.isSuccessful()) {
|
||||
return {
|
||||
success: false,
|
||||
message: `查询失败: HTTP ${response.statusCode} - ${response.statusMessage}`
|
||||
};
|
||||
}
|
||||
|
||||
const data = response.json();
|
||||
|
||||
if (data.status === "ok" && data.message && data.message.items) {
|
||||
const items = data.message.items;
|
||||
const totalResults = data.message['total-results'];
|
||||
|
||||
const results = items.map((item: any, index: number) => formatArticle(item, index));
|
||||
|
||||
const summary = `Found ${totalResults} articles from journal ISSN ${issn} (showing ${items.length}):\n${results.join('\n\n')}`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "查询成功",
|
||||
data: summary,
|
||||
total: totalResults,
|
||||
count: items.length
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: `未找到 ISSN "${issn}" 对应期刊的文章`
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: `查询失败: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 包装函数,统一处理错误
|
||||
*/
|
||||
async function wrapToolExecution(func: (params: any) => Promise<any>, params: any) {
|
||||
try {
|
||||
const result = await func(params);
|
||||
complete(result);
|
||||
} catch (error: any) {
|
||||
console.error(`工具执行失败`, error);
|
||||
complete({
|
||||
success: false,
|
||||
message: `工具执行时发生意外错误: ${error.message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试函数
|
||||
*/
|
||||
async function main() {
|
||||
console.log("=== Crossref API 测试 ===\n");
|
||||
|
||||
// 测试 1: 通过 DOI 查询
|
||||
console.log("1. 测试通过 DOI 查询...");
|
||||
const doiResult = await searchByDoi({ doi: "10.1038/nature12373" });
|
||||
console.log(JSON.stringify(doiResult, null, 2));
|
||||
console.log("\n");
|
||||
|
||||
// 测试 2: 通过关键词搜索
|
||||
console.log("2. 测试通过关键词搜索...");
|
||||
const keywordResult = await searchByKeyword({ query: "machine learning", rows: 3 });
|
||||
console.log(JSON.stringify(keywordResult, null, 2));
|
||||
console.log("\n");
|
||||
|
||||
// 测试 3: 通过作者搜索
|
||||
console.log("3. 测试通过作者搜索...");
|
||||
const authorResult = await searchByAuthor({ author: "John Smith", rows: 3 });
|
||||
console.log(JSON.stringify(authorResult, null, 2));
|
||||
console.log("\n");
|
||||
|
||||
// 测试 4: 通过标题搜索
|
||||
console.log("4. 测试通过标题搜索...");
|
||||
const titleResult = await searchByTitle({ title: "neural networks", rows: 3 });
|
||||
console.log(JSON.stringify(titleResult, null, 2));
|
||||
console.log("\n");
|
||||
|
||||
console.log("=== 测试完成 ===");
|
||||
}
|
||||
|
||||
return {
|
||||
search_by_doi: (params: any) => wrapToolExecution(searchByDoi, params),
|
||||
search_by_keyword: (params: any) => wrapToolExecution(searchByKeyword, params),
|
||||
search_by_author: (params: any) => wrapToolExecution(searchByAuthor, params),
|
||||
search_by_title: (params: any) => wrapToolExecution(searchByTitle, params),
|
||||
search_by_issn: (params: any) => wrapToolExecution(searchByISSN, params),
|
||||
main,
|
||||
};
|
||||
})();
|
||||
|
||||
// 导出工具函数
|
||||
exports.search_by_doi = CrossrefSearch.search_by_doi;
|
||||
exports.search_by_keyword = CrossrefSearch.search_by_keyword;
|
||||
exports.search_by_author = CrossrefSearch.search_by_author;
|
||||
exports.search_by_title = CrossrefSearch.search_by_title;
|
||||
exports.search_by_issn = CrossrefSearch.search_by_issn;
|
||||
exports.main = CrossrefSearch.main;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,298 @@
|
||||
/*
|
||||
METADATA
|
||||
{
|
||||
"name": "douyin_download",
|
||||
|
||||
"display_name": {
|
||||
"zh": "抖音下载工具",
|
||||
"en": "Douyin Download Tool"
|
||||
},
|
||||
"description": { "zh": "抖音工具包,提供从分享链接或分享口令中提取并下载无水印视频的功能。", "en": "Douyin toolkit for extracting and downloading watermark-free videos from share links or share codes." },
|
||||
"enabledByDefault": true,
|
||||
"category": "Media",
|
||||
"tools": [
|
||||
{
|
||||
"name": "get_douyin_download_link",
|
||||
"description": { "zh": "解析抖音分享链接或口令,下载无水印视频到本地", "en": "Parse a Douyin share link/code and download a watermark-free video to local storage." },
|
||||
"parameters": [
|
||||
{
|
||||
"name": "input",
|
||||
"description": { "zh": "抖音分享链接或包含链接的分享口令文本", "en": "Douyin share link, or share-code text that contains a link." },
|
||||
"type": "string",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "get_douyin_video_info",
|
||||
"description": { "zh": "解析抖音分享链接或口令,仅获取视频信息和无水印下载链接,不下载视频", "en": "Parse a Douyin share link/code and return video info + watermark-free download URL (without downloading)." },
|
||||
"parameters": [
|
||||
{
|
||||
"name": "input",
|
||||
"description": { "zh": "抖音分享链接或包含链接的分享口令文本", "en": "Douyin share link, or share-code text that contains a link." },
|
||||
"type": "string",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}*/
|
||||
const douyin = (function () {
|
||||
/**
|
||||
* 包装函数调用,提供标准化的成功/错误处理
|
||||
*/
|
||||
async function douyin_wrap<T, U>(
|
||||
func: (params: T) => Promise<U>,
|
||||
params: T,
|
||||
successMessage: string,
|
||||
failMessage: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
console.log(`开始执行函数: ${func.name || '匿名函数'}`);
|
||||
const result = await func(params);
|
||||
complete({ success: true, message: successMessage, data: result });
|
||||
} catch (error: unknown) {
|
||||
console.error(`函数 ${func.name || '匿名函数'} 执行失败! 错误: ${error instanceof Error ? error.message : String(error)}`);
|
||||
complete({ success: false, message: `${failMessage}: ${error instanceof Error ? error.message : String(error)}`, error_stack: error instanceof Error ? error.stack : undefined });
|
||||
}
|
||||
}
|
||||
|
||||
const client = OkHttp.newClient();
|
||||
|
||||
// 抖音相关URL模式
|
||||
const DOUYIN_URL_PATTERNS = [
|
||||
/https?:\/\/v\.douyin\.com\/[^\s]+/,
|
||||
/https?:\/\/www\.douyin\.com\/video\/[0-9]+/,
|
||||
/https?:\/\/www\.douyin\.com\/share\/video\/[0-9]+/
|
||||
];
|
||||
|
||||
/**
|
||||
* 从文本中提取抖音链接
|
||||
*/
|
||||
function extractDouyinUrl(text: string): string | null {
|
||||
for (const pattern of DOUYIN_URL_PATTERNS) {
|
||||
const match = text.match(pattern);
|
||||
if (match) {
|
||||
return match[0];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析抖音分享链接,获取视频信息
|
||||
*/
|
||||
async function resolveDouyinUrl(shareUrl: string): Promise<{ videoId: string, downloadUrl: string, videoTitle: string }> {
|
||||
try {
|
||||
console.log(`正在解析抖音链接: ${shareUrl}`);
|
||||
|
||||
const request = client.newRequest()
|
||||
.url(shareUrl)
|
||||
.method('GET')
|
||||
.headers({
|
||||
"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/121.0.2277.107 Version/17.0 Mobile/15E148 Safari/604.1",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||||
"Connection": "keep-alive"
|
||||
});
|
||||
|
||||
const response = await request.build().execute();
|
||||
|
||||
if (!response.isSuccessful()) {
|
||||
throw new Error(`链接解析失败 (${response.statusCode})`);
|
||||
}
|
||||
|
||||
const html = response.content;
|
||||
|
||||
// 尝试从ROUTER_DATA中提取视频信息
|
||||
const routerDataMatch = html.match(/window\._ROUTER_DATA\s*=\s*({[\s\S]*?})<\/script>/);
|
||||
if (routerDataMatch && routerDataMatch[1]) {
|
||||
try {
|
||||
const routerData = JSON.parse(routerDataMatch[1]);
|
||||
// The actual key might vary, let's find it dynamically
|
||||
const pageDataKey = Object.keys(routerData.loaderData).find(k => k.includes('/page'));
|
||||
if (!pageDataKey) {
|
||||
throw new Error("无法在 _ROUTER_DATA 中找到页面数据");
|
||||
}
|
||||
const videoData = routerData.loaderData[pageDataKey]?.videoInfoRes?.item_list?.[0];
|
||||
|
||||
if (videoData) {
|
||||
const videoId = videoData.aweme_id;
|
||||
const videoTitle = videoData.desc;
|
||||
const watermarkedUrl = videoData.video?.play_addr?.url_list?.[0];
|
||||
|
||||
if (videoId && videoTitle && watermarkedUrl) {
|
||||
console.log(`通过 _ROUTER_DATA 找到视频信息: ID=${videoId}`);
|
||||
const downloadUrl = watermarkedUrl.replace("playwm", "play");
|
||||
return { videoId, downloadUrl, videoTitle };
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.error(`解析 _ROUTER_DATA JSON 失败: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("无法从HTML中解析出 _ROUTER_DATA");
|
||||
|
||||
} catch (error) {
|
||||
console.error(`解析抖音链接时出错: ${error instanceof Error ? error.message : String(error)}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取抖音视频的无水印下载链接并下载视频
|
||||
*/
|
||||
async function get_douyin_download_link(params: { input: string }): Promise<string> {
|
||||
const { input } = params;
|
||||
|
||||
if (!input) {
|
||||
throw new Error("输入内容不能为空");
|
||||
}
|
||||
|
||||
console.log("开始获取并下载抖音无水印视频...");
|
||||
|
||||
try {
|
||||
// 1. 提取和解析URL
|
||||
const douyinUrl = extractDouyinUrl(input);
|
||||
if (!douyinUrl) {
|
||||
throw new Error("未找到有效的抖音链接");
|
||||
}
|
||||
console.log(`找到抖音链接: ${douyinUrl}`);
|
||||
|
||||
// 2. 解析链接并获取所有需要的视频信息
|
||||
console.log("正在解析分享链接...");
|
||||
const { videoId, downloadUrl, videoTitle: rawVideoTitle } = await resolveDouyinUrl(douyinUrl);
|
||||
console.log(`解析到视频ID: ${videoId}`);
|
||||
console.log(`获取到无水印下载链接: ${downloadUrl}`);
|
||||
|
||||
// 获取视频标题(用于文件命名)
|
||||
let videoTitle = (rawVideoTitle || `douyin_${videoId}`).trim();
|
||||
// 替换文件名中的非法字符
|
||||
videoTitle = videoTitle.replace(/[\\/:*?"<>|]/g, '_');
|
||||
|
||||
// 逐级创建目录并下载视频
|
||||
const baseDir = getPluginConfigDir("douyin_download");
|
||||
const destinationDir = `${baseDir}/downloads`;
|
||||
const destinationPath = `${destinationDir}/${videoTitle}_${videoId}.mp4`;
|
||||
|
||||
console.log(`确保目录存在: ${destinationDir}`);
|
||||
// 逐级创建目录以确保路径存在
|
||||
const dirsToCreate = [baseDir, destinationDir];
|
||||
for (const dir of dirsToCreate) {
|
||||
const mkdirResult = await Tools.Files.mkdir(dir);
|
||||
if (!mkdirResult.successful) {
|
||||
// 忽略"目录已存在"的错误,但记录其他可能的错误
|
||||
console.warn(`创建目录 '${dir}' 失败 (可能已存在): ${mkdirResult.details}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`开始下载视频到: ${destinationPath}`);
|
||||
const downloadResult = await Tools.Files.download(downloadUrl, destinationPath);
|
||||
|
||||
if (!downloadResult.successful) {
|
||||
throw new Error(`视频下载失败: ${downloadResult.details}`);
|
||||
}
|
||||
|
||||
const successMessage = `视频"${videoTitle}"成功下载到: ${destinationPath}`;
|
||||
console.log(successMessage);
|
||||
return successMessage;
|
||||
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`处理失败: ${message}`);
|
||||
// 重新抛出错误,以便包装器可以捕获它
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取抖音视频信息和无水印下载链接(不下载视频)
|
||||
*/
|
||||
async function get_douyin_video_info(params: { input: string }): Promise<{ videoId: string; videoTitle: string; downloadUrl: string; originalUrl: string }> {
|
||||
const { input } = params;
|
||||
|
||||
if (!input) {
|
||||
throw new Error("输入内容不能为空");
|
||||
}
|
||||
|
||||
console.log("开始解析抖音视频信息...");
|
||||
|
||||
try {
|
||||
// 1. 提取URL
|
||||
const douyinUrl = extractDouyinUrl(input);
|
||||
if (!douyinUrl) {
|
||||
throw new Error("未找到有效的抖音链接");
|
||||
}
|
||||
console.log(`找到抖音链接: ${douyinUrl}`);
|
||||
|
||||
// 2. 解析链接并获取视频信息
|
||||
console.log("正在解析分享链接...");
|
||||
const { videoId, downloadUrl, videoTitle } = await resolveDouyinUrl(douyinUrl);
|
||||
console.log(`解析到视频ID: ${videoId}`);
|
||||
console.log(`获取到无水印下载链接: ${downloadUrl}`);
|
||||
console.log(`视频标题: ${videoTitle}`);
|
||||
|
||||
const result = {
|
||||
videoId,
|
||||
videoTitle,
|
||||
downloadUrl,
|
||||
originalUrl: douyinUrl
|
||||
};
|
||||
|
||||
console.log("视频信息解析成功");
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`解析失败: ${message}`);
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
//region 测试函数
|
||||
/**
|
||||
* 抖音工具功能测试主函数
|
||||
*/
|
||||
async function main() {
|
||||
console.log("🚀 开始执行抖音工具功能测试...");
|
||||
await test_download_link();
|
||||
const summary = `抖音下载工具测试完成!\n` +
|
||||
`✅ 下载功能已测试`;
|
||||
console.log(`\n${summary}`);
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试下载功能
|
||||
*/
|
||||
async function test_download_link() {
|
||||
const testUrl = "3.00 05/24 Z@M.JI TLW:/ 这里有几首melodic dubstep,看你认识几个 # 电子音乐 # 音乐分享 # 顶级旋律 # 热门音乐🔥百听不厌 # 戴上耳机 https://v.douyin.com/AT8AfEbuP_k/ 复制此链接,打开Dou音搜索,直接观看视频!"; // 使用真实分享文本进行测试
|
||||
console.log(`1. 测试视频下载功能 (使用真实分享文本)`);
|
||||
// 我们预期这个调用会成功提取URL并尝试下载
|
||||
try {
|
||||
const result = await get_douyin_download_link({ input: testUrl });
|
||||
console.log(`✅ 下载功能测试成功, 结果: ${result}`);
|
||||
return result;
|
||||
} catch (e) {
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
console.error(`❌ 下载功能测试失败: ${errorMessage}`);
|
||||
// 在测试中,即使是预期的失败,也应该被视为一个需要注意的问题
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
|
||||
return {
|
||||
get_douyin_download_link: (params: { input: string; }) => douyin_wrap(get_douyin_download_link, params, '抖音视频下载完成', '抖音视频下载失败'),
|
||||
get_douyin_video_info: (params: { input: string; }) => douyin_wrap(get_douyin_video_info, params, '抖音视频信息获取成功', '抖音视频信息获取失败'),
|
||||
main: (params: Record<string, never>) => douyin_wrap(main, params, '抖音工具测试完成', '抖音工具测试失败'),
|
||||
test_download_link: (params: Record<string, never>) => douyin_wrap(test_download_link, params, '下载链接获取测试成功', '下载链接获取测试失败'),
|
||||
};
|
||||
})();
|
||||
|
||||
// 导出所有功能
|
||||
exports.get_douyin_download_link = douyin.get_douyin_download_link;
|
||||
exports.get_douyin_video_info = douyin.get_douyin_video_info;
|
||||
exports.main = douyin.main;
|
||||
exports.test_download_link = douyin.test_download_link;
|
||||
@@ -0,0 +1,247 @@
|
||||
/*
|
||||
METADATA
|
||||
{
|
||||
"name": "duckduckgo",
|
||||
|
||||
"display_name": {
|
||||
"zh": "DuckDuckGo 搜索",
|
||||
"en": "DuckDuckGo Search"
|
||||
},
|
||||
"description": { "zh": "使用DuckDuckGo进行网络搜索和内容抓取。", "en": "Use DuckDuckGo for web search and content extraction." },
|
||||
"category": "Search",
|
||||
"tools": [
|
||||
{
|
||||
"name": "search",
|
||||
"description": { "zh": "执行DuckDuckGo搜索并返回格式化的结果。", "en": "Run a DuckDuckGo search and return formatted results." },
|
||||
"parameters": [
|
||||
{
|
||||
"name": "query",
|
||||
"description": { "zh": "搜索查询字符串", "en": "Search query string." },
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "max_results",
|
||||
"description": { "zh": "返回的最大结果数 (默认: 10)", "en": "Maximum number of results to return (default: 10)." },
|
||||
"type": "string",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "fetch_content",
|
||||
"description": { "zh": "从网页URL抓取和解析内容。", "en": "Fetch and parse content from a webpage URL." },
|
||||
"parameters": [
|
||||
{
|
||||
"name": "url",
|
||||
"description": { "zh": "要抓取内容的网页URL", "en": "Webpage URL to fetch and parse." },
|
||||
"type": "string",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}*/
|
||||
|
||||
const duckduckgo = (function () {
|
||||
const client = OkHttp.newClient();
|
||||
const BASE_URL = "https://html.duckduckgo.com/html/";
|
||||
|
||||
/**
|
||||
* A simple rate limiter to avoid sending too many requests.
|
||||
*/
|
||||
class RateLimiter {
|
||||
private requests: number[] = [];
|
||||
constructor(private requestsPerMinute: number = 30) { }
|
||||
|
||||
async acquire(): Promise<void> {
|
||||
const now = Date.now();
|
||||
// Remove requests older than 1 minute (60000 ms)
|
||||
this.requests = this.requests.filter(reqTime => now - reqTime < 60000);
|
||||
|
||||
if (this.requests.length >= this.requestsPerMinute) {
|
||||
const timeSinceFirstRequest = now - this.requests[0];
|
||||
const waitTime = 60000 - timeSinceFirstRequest;
|
||||
if (waitTime > 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, waitTime));
|
||||
}
|
||||
}
|
||||
|
||||
this.requests.push(Date.now());
|
||||
}
|
||||
}
|
||||
|
||||
const searchRateLimiter = new RateLimiter(30);
|
||||
const fetchRateLimiter = new RateLimiter(20);
|
||||
|
||||
/**
|
||||
* Decodes basic HTML entities.
|
||||
* @param text The text to decode.
|
||||
* @returns Decoded text.
|
||||
*/
|
||||
function decodeHtmlEntities(text: string): string {
|
||||
return text.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Search DuckDuckGo and return formatted results.
|
||||
* @param params Search parameters including query and max_results.
|
||||
* @returns A formatted string of search results.
|
||||
*/
|
||||
async function search(params: { query: string; max_results?: string }): Promise<string> {
|
||||
const { query } = params;
|
||||
let max_results = 10;
|
||||
if (params.max_results) {
|
||||
const parsedMaxResults = parseInt(params.max_results, 10);
|
||||
if (!isNaN(parsedMaxResults)) {
|
||||
max_results = parsedMaxResults;
|
||||
}
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
throw new Error("查询不能为空");
|
||||
}
|
||||
|
||||
await searchRateLimiter.acquire();
|
||||
console.log(`正在从DuckDuckGo搜索: ${query}`);
|
||||
|
||||
const request = client.newRequest()
|
||||
.url(BASE_URL)
|
||||
.method('POST')
|
||||
.body({ q: query, b: '', kl: '' }, 'form')
|
||||
.headers({
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
|
||||
});
|
||||
|
||||
const response = await request.build().execute();
|
||||
if (!response.isSuccessful()) {
|
||||
throw new Error(`HTTP 错误! 状态码: ${response.statusCode}`);
|
||||
}
|
||||
|
||||
const html = response.content;
|
||||
const results: { title: string; link: string; snippet: string; position: number }[] = [];
|
||||
const resultRegex = /<h2 class="result__title">[\s\S]*?<a.*?href="([^"]+)".*?>([\s\S]+?)<\/a>[\s\S]*?<\/h2>[\s\S]*?<a class="result__snippet".*?>([\s\S]+?)<\/a>/g;
|
||||
|
||||
let match;
|
||||
while ((match = resultRegex.exec(html)) !== undefined) {
|
||||
if (results.length >= max_results) {
|
||||
break;
|
||||
}
|
||||
|
||||
let link = match[1];
|
||||
if (link.includes('y.js')) {
|
||||
continue; // Skip ads
|
||||
}
|
||||
|
||||
if (link.startsWith('//duckduckgo.com/l/?uddg=')) {
|
||||
try {
|
||||
link = decodeURIComponent(link.split('uddg=')[1].split('&')[0]);
|
||||
} catch (e) {
|
||||
console.error(`URL 解码失败: ${link}`);
|
||||
}
|
||||
}
|
||||
|
||||
const title = decodeHtmlEntities(match[2].replace(/<[^>]*>/g, '').trim());
|
||||
const snippet = decodeHtmlEntities(match[3].replace(/<[^>]*>/g, '').trim());
|
||||
|
||||
results.push({ title, link, snippet, position: results.length + 1 });
|
||||
}
|
||||
|
||||
console.log(`成功找到 ${results.length} 个结果`);
|
||||
return format_results_for_llm(results);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and parse content from a webpage URL.
|
||||
* @param params Parameters including the URL to fetch.
|
||||
* @returns The cleaned text content of the webpage.
|
||||
*/
|
||||
async function fetch_content(params: { url: string }): Promise<string> {
|
||||
const { url } = params;
|
||||
if (!url) {
|
||||
throw new Error("URL不能为空");
|
||||
}
|
||||
|
||||
await fetchRateLimiter.acquire();
|
||||
console.log(`正在抓取内容: ${url}`);
|
||||
|
||||
try {
|
||||
const request = client.newRequest()
|
||||
.url(url)
|
||||
.method('GET')
|
||||
.headers({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" });
|
||||
|
||||
const response = await request.build().execute();
|
||||
if (!response.isSuccessful()) {
|
||||
throw new Error(`无法访问网页 (${response.statusCode})`);
|
||||
}
|
||||
|
||||
let text = response.content;
|
||||
text = text.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
|
||||
text = text.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
|
||||
text = text.replace(/<nav[^>]*>[\s\S]*?<\/nav>/gi, '');
|
||||
text = text.replace(/<header[^>]*>[\s\S]*?<\/header>/gi, '');
|
||||
text = text.replace(/<footer[^>]*>[\s\S]*?<\/footer>/gi, '');
|
||||
text = text.replace(/<[^>]+>/g, ' ');
|
||||
text = text.replace(/\s+/g, ' ').trim();
|
||||
|
||||
if (text.length > 8000) {
|
||||
text = text.substring(0, 8000) + "... [内容被截断]";
|
||||
}
|
||||
|
||||
console.log(`成功抓取并解析内容 (${text.length} 字符)`);
|
||||
return text;
|
||||
} catch (error) {
|
||||
console.error(`从 ${url} 抓取内容时出错: ${error.message}`);
|
||||
return `错误: 从网页抓取内容时发生意外错误 (${error.message})`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats search results into a readable string for LLMs.
|
||||
* @param results The list of search results.
|
||||
* @returns A formatted string.
|
||||
*/
|
||||
function format_results_for_llm(results: { title: string; link: string; snippet: string; position: number }[]): string {
|
||||
if (!results || results.length === 0) {
|
||||
return "没有为您的搜索查询找到结果。这可能是由于DuckDuckGo的机器人检测或查询没有匹配项。请尝试重新措辞您的搜索或在几分钟后重试。";
|
||||
}
|
||||
|
||||
const output = results.map(r =>
|
||||
`${r.position}. ${r.title}\n URL: ${r.link}\n 摘要: ${r.snippet}`
|
||||
);
|
||||
|
||||
return `找到 ${results.length} 个搜索结果:\n\n${output.join('\n\n')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps function calls for standardized success/error handling.
|
||||
*/
|
||||
async function duckduckgo_wrap<T>(
|
||||
func: (params: any) => Promise<any>,
|
||||
params: any,
|
||||
successMessage: string,
|
||||
failMessage: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
console.log(`开始执行函数: ${func.name || '匿名函数'}`);
|
||||
const result = await func(params);
|
||||
complete({ success: true, message: successMessage, data: result });
|
||||
} catch (error) {
|
||||
console.error(`函数 ${func.name || '匿名函数'} 执行失败! 错误: ${error.message}`);
|
||||
complete({ success: false, message: `${failMessage}: ${error.message}`, error_stack: error.stack });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
search: (params: any) => duckduckgo_wrap(search, params, '搜索完成', '搜索失败'),
|
||||
fetch_content: (params: any) => duckduckgo_wrap(fetch_content, params, '内容抓取完成', '内容抓取失败'),
|
||||
};
|
||||
})();
|
||||
|
||||
exports.search = duckduckgo.search;
|
||||
exports.fetch_content = duckduckgo.fetch_content;
|
||||
@@ -0,0 +1,569 @@
|
||||
/* METADATA
|
||||
{
|
||||
"name": "extended_chat",
|
||||
|
||||
"display_name": {
|
||||
"zh": "增强对话",
|
||||
"en": "Extended Chat"
|
||||
},
|
||||
"description": {
|
||||
"zh": "对话工具包:列出/查找/重命名/删除对话、跨话题读取消息、绑定角色卡对话并发送消息。",
|
||||
"en": "Chat toolkit: list/find/rename/delete chats, read messages across chats, bind character cards and send messages."
|
||||
},
|
||||
"enabledByDefault": true,
|
||||
"category": "Chat",
|
||||
"tools": [
|
||||
{
|
||||
"name": "list_chats",
|
||||
"description": {
|
||||
"zh": "列出并筛选对话(用于获取 chat_id)。",
|
||||
"en": "List and filter chats (to discover chat_id)."
|
||||
},
|
||||
"parameters": [
|
||||
{ "name": "query", "description": { "zh": "可选:标题筛选关键字", "en": "Optional title keyword" }, "type": "string", "required": false },
|
||||
{ "name": "match", "description": { "zh": "可选:contains/exact/regex(默认 contains)", "en": "Optional: contains/exact/regex (default contains)" }, "type": "string", "required": false },
|
||||
{ "name": "limit", "description": { "zh": "可选:最多返回条数(默认 50)", "en": "Optional max results (default 50)" }, "type": "number", "required": false },
|
||||
{ "name": "sort_by", "description": { "zh": "可选:updatedAt/createdAt/messageCount(默认 updatedAt)", "en": "Optional: updatedAt/createdAt/messageCount (default updatedAt)" }, "type": "string", "required": false },
|
||||
{ "name": "sort_order", "description": { "zh": "可选:asc/desc(默认 desc)", "en": "Optional: asc/desc (default desc)" }, "type": "string", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "find_chat",
|
||||
"description": {
|
||||
"zh": "按标题查找一个对话并返回 chat_id。",
|
||||
"en": "Find a single chat by title and return chat_id."
|
||||
},
|
||||
"parameters": [
|
||||
{ "name": "query", "description": { "zh": "标题关键字/正则", "en": "Title keyword/regex" }, "type": "string", "required": true },
|
||||
{ "name": "match", "description": { "zh": "可选:contains/exact/regex(默认 contains)", "en": "Optional: contains/exact/regex (default contains)" }, "type": "string", "required": false },
|
||||
{ "name": "index", "description": { "zh": "可选:当匹配多个时选择第 N 个(默认 0)", "en": "Optional: pick Nth when multiple matches (default 0)" }, "type": "number", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "read_messages",
|
||||
"description": {
|
||||
"zh": "读取指定对话的消息(可按 chat_id 或 chat_title 指定)。",
|
||||
"en": "Read messages from a chat (by chat_id or chat_title)."
|
||||
},
|
||||
"parameters": [
|
||||
{ "name": "chat_id", "description": { "zh": "目标对话 ID(可选)", "en": "Target chat id (optional)" }, "type": "string", "required": false },
|
||||
{ "name": "chat_title", "description": { "zh": "目标对话标题(可选;当 chat_id 为空时使用)", "en": "Target chat title (optional; used when chat_id is empty)" }, "type": "string", "required": false },
|
||||
{ "name": "chat_query", "description": { "zh": "可选:标题筛选关键字(当 chat_id/chat_title 为空时使用)", "en": "Optional title keyword (used when chat_id/chat_title is empty)" }, "type": "string", "required": false },
|
||||
{ "name": "chat_index", "description": { "zh": "可选:当筛选结果有多个时选择第 N 个(默认 0)", "en": "Optional: pick Nth when multiple matches (default 0)" }, "type": "number", "required": false },
|
||||
{ "name": "match", "description": { "zh": "可选:contains/exact/regex(默认 contains)", "en": "Optional: contains/exact/regex (default contains)" }, "type": "string", "required": false },
|
||||
{ "name": "order", "description": { "zh": "可选:asc/desc(默认 desc)", "en": "Optional: asc/desc (default desc)" }, "type": "string", "required": false },
|
||||
{ "name": "limit", "description": { "zh": "可选:返回消息条数(默认 20)", "en": "Optional: max number of messages (default 20)" }, "type": "number", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "rename_chat",
|
||||
"description": {
|
||||
"zh": "重命名指定对话(可按 chat_id 或 chat_title 指定)。",
|
||||
"en": "Rename a chat (by chat_id or chat_title)."
|
||||
},
|
||||
"parameters": [
|
||||
{ "name": "new_title", "description": { "zh": "新的对话标题", "en": "New chat title" }, "type": "string", "required": true },
|
||||
{ "name": "chat_id", "description": { "zh": "目标对话 ID(可选)", "en": "Target chat id (optional)" }, "type": "string", "required": false },
|
||||
{ "name": "chat_title", "description": { "zh": "目标对话标题(可选;当 chat_id 为空时使用)", "en": "Target chat title (optional; used when chat_id is empty)" }, "type": "string", "required": false },
|
||||
{ "name": "chat_query", "description": { "zh": "可选:标题筛选关键字(当 chat_id/chat_title 为空时使用)", "en": "Optional title keyword (used when chat_id/chat_title is empty)" }, "type": "string", "required": false },
|
||||
{ "name": "chat_index", "description": { "zh": "可选:当筛选结果有多个时选择第 N 个(默认 0)", "en": "Optional: pick Nth when multiple matches (default 0)" }, "type": "number", "required": false },
|
||||
{ "name": "match", "description": { "zh": "可选:contains/exact/regex(默认 contains)", "en": "Optional: contains/exact/regex (default contains)" }, "type": "string", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "delete_chat",
|
||||
"description": {
|
||||
"zh": "删除指定对话(可按 chat_id 或 chat_title 指定)。",
|
||||
"en": "Delete a chat (by chat_id or chat_title)."
|
||||
},
|
||||
"parameters": [
|
||||
{ "name": "chat_id", "description": { "zh": "目标对话 ID(可选)", "en": "Target chat id (optional)" }, "type": "string", "required": false },
|
||||
{ "name": "chat_title", "description": { "zh": "目标对话标题(可选;当 chat_id 为空时使用)", "en": "Target chat title (optional; used when chat_id is empty)" }, "type": "string", "required": false },
|
||||
{ "name": "chat_query", "description": { "zh": "可选:标题筛选关键字(当 chat_id/chat_title 为空时使用)", "en": "Optional title keyword (used when chat_id/chat_title is empty)" }, "type": "string", "required": false },
|
||||
{ "name": "chat_index", "description": { "zh": "可选:当筛选结果有多个时选择第 N 个(默认 0)", "en": "Optional: pick Nth when multiple matches (default 0)" }, "type": "number", "required": false },
|
||||
{ "name": "match", "description": { "zh": "可选:contains/exact/regex(默认 contains)", "en": "Optional: contains/exact/regex (default contains)" }, "type": "string", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "chat_with_agent",
|
||||
"description": {
|
||||
"zh": "与对应角色的 agent 对话:传入角色卡名称;chat_id 为空时自动创建新对话并返回新 ID。严格执行一角色一会话,不能多个角色共用同一会话。该工具可用于将任务分担给其他 agent 或与其他角色交流,但通常只有在用户明确表达此意图时使用;多数情况下,能自行完成的任务应优先直接完成。",
|
||||
"en": "Chat with the agent for the specified character card name; if chat_id is empty, create a new chat and return its ID. Enforces one role per chat (no sharing between roles). Use this tool to delegate tasks to other agents or communicate with other roles when the user explicitly intends it; otherwise, prefer completing tasks directly without using this tool."
|
||||
},
|
||||
"parameters": [
|
||||
{ "name": "message", "description": { "zh": "发送给 AI 的内容", "en": "Message to send to AI" }, "type": "string", "required": true },
|
||||
{ "name": "character_card_name", "description": { "zh": "角色卡名称", "en": "Character card name" }, "type": "string", "required": true },
|
||||
{ "name": "chat_id", "description": { "zh": "目标对话 ID(可选;为空时新建)", "en": "Target chat id (optional; create new if empty)" }, "type": "string", "required": false },
|
||||
{ "name": "timeout", "description": { "zh": "可选:等待返回的超时秒数(默认 180)", "en": "Optional timeout seconds to wait for response (default 180)" }, "type": "number", "required": false },
|
||||
{ "name": "persist_turn", "description": { "zh": "可选:是否持久化本轮用户消息和 AI 回复(默认 true)", "en": "Optional: whether to persist this turn's user message and AI reply (default true)" }, "type": "boolean", "required": false },
|
||||
{ "name": "notify_reply", "description": { "zh": "可选:是否覆盖本轮回复通知开关", "en": "Optional: override reply notification for this turn" }, "type": "boolean", "required": false },
|
||||
{ "name": "hide_user_message", "description": { "zh": "可选:是否在 UI 中隐藏用户消息正文并显示占位标记", "en": "Optional: hide the user message body in UI and show a placeholder marker" }, "type": "boolean", "required": false },
|
||||
{ "name": "disable_warning", "description": { "zh": "可选:是否关闭本轮 AI 生成的 warning 标记", "en": "Optional: suppress AI-generated warning markup for this turn" }, "type": "boolean", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "agent_status",
|
||||
"description": {
|
||||
"zh": "查询对话的输入处理状态。",
|
||||
"en": "Check a chat's input processing status."
|
||||
},
|
||||
"parameters": [
|
||||
{ "name": "chat_id", "description": { "zh": "目标对话 ID", "en": "Target chat id" }, "type": "string", "required": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "list_character_cards",
|
||||
"description": {
|
||||
"zh": "列出所有角色卡(用于获取 character_card_id)。",
|
||||
"en": "List all character cards (to discover character_card_id)."
|
||||
},
|
||||
"parameters": []
|
||||
}
|
||||
]
|
||||
}*/
|
||||
|
||||
const HistoryChat = (function () {
|
||||
|
||||
interface ToolResponse<T = unknown> {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: T;
|
||||
}
|
||||
|
||||
type ResolveChatIdParams = {
|
||||
chat_id?: string;
|
||||
chat_title?: string;
|
||||
chat_query?: string;
|
||||
chat_index?: number;
|
||||
match?: string;
|
||||
};
|
||||
|
||||
type ReadMessagesParams = ResolveChatIdParams & {
|
||||
order?: string;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
type RenameChatParams = ResolveChatIdParams & {
|
||||
new_title: string;
|
||||
};
|
||||
|
||||
type DeleteChatParams = ResolveChatIdParams;
|
||||
|
||||
type ListChatsParams = {
|
||||
query?: string;
|
||||
match?: string;
|
||||
limit?: number;
|
||||
sort_by?: string;
|
||||
sort_order?: string;
|
||||
};
|
||||
|
||||
type FindChatParams = {
|
||||
query: string;
|
||||
match?: string;
|
||||
index?: number;
|
||||
};
|
||||
|
||||
type AgentStatusParams = {
|
||||
chat_id: string;
|
||||
};
|
||||
|
||||
type ChatWithAgentParams = {
|
||||
message: string;
|
||||
character_card_name: string;
|
||||
chat_id?: string;
|
||||
timeout?: number;
|
||||
persist_turn?: boolean;
|
||||
notify_reply?: boolean;
|
||||
hide_user_message?: boolean;
|
||||
disable_warning?: boolean;
|
||||
};
|
||||
|
||||
function normalizeMatchMode(match?: string): 'contains' | 'exact' | 'regex' {
|
||||
const m = (match || '').trim().toLowerCase();
|
||||
if (m === 'exact' || m === 'regex' || m === 'contains') return m;
|
||||
return 'contains';
|
||||
}
|
||||
|
||||
async function list_chats_impl(params: ListChatsParams): Promise<ToolResponse> {
|
||||
const query = (params?.query ?? '').toString().trim();
|
||||
const matchMode = normalizeMatchMode(params?.match);
|
||||
const limitRaw = params && params.limit !== undefined ? Number(params.limit) : undefined;
|
||||
const limit = limitRaw !== undefined && !isNaN(limitRaw) ? limitRaw : undefined;
|
||||
const sortBy = params?.sort_by ? params.sort_by.toString().trim() : undefined;
|
||||
const sortOrder = params?.sort_order ? params.sort_order.toString().trim().toLowerCase() : undefined;
|
||||
|
||||
const listParams: ToolParams = {};
|
||||
if (query) listParams.query = query;
|
||||
if (matchMode) listParams.match = matchMode;
|
||||
if (limit !== undefined) listParams.limit = limit;
|
||||
if (sortBy) listParams.sort_by = sortBy;
|
||||
if (sortOrder) listParams.sort_order = sortOrder;
|
||||
|
||||
const listResult = await Tools.Chat.listChats(listParams);
|
||||
const chats = listResult.chats;
|
||||
return {
|
||||
success: true,
|
||||
message: '对话列表获取完成',
|
||||
data: {
|
||||
totalCount: listResult?.totalCount ?? chats.length,
|
||||
currentChatId: listResult?.currentChatId ?? null,
|
||||
matchedCount: listResult?.totalCount ?? chats.length,
|
||||
chats,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function find_chat_impl(params: FindChatParams): Promise<ToolResponse> {
|
||||
const query = (params?.query ?? '').toString().trim();
|
||||
if (!query) {
|
||||
throw new Error('Missing parameter: query');
|
||||
}
|
||||
|
||||
const matchMode = normalizeMatchMode(params?.match);
|
||||
const indexRaw = params && params.index !== undefined ? Number(params.index) : 0;
|
||||
const index = isNaN(indexRaw) ? 0 : indexRaw;
|
||||
const findParams: Parameters<typeof Tools.Chat.findChat>[0] = { query };
|
||||
if (matchMode) findParams.match = matchMode;
|
||||
if (index !== undefined) findParams.index = index;
|
||||
const findResult = await Tools.Chat.findChat(findParams);
|
||||
const picked = findResult?.chat ?? null;
|
||||
if (!picked) {
|
||||
throw new Error(`Chat not found by query: ${query}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '对话查找完成',
|
||||
data: {
|
||||
chat: picked,
|
||||
matchedCount: findResult?.matchedCount ?? 1,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveChatId(params: ResolveChatIdParams): Promise<string> {
|
||||
if (params && typeof params.chat_id === 'string' && params.chat_id.trim()) {
|
||||
return params.chat_id.trim();
|
||||
}
|
||||
|
||||
const title = params && typeof params.chat_title === 'string' ? params.chat_title.trim() : '';
|
||||
const query = params && typeof params.chat_query === 'string' ? params.chat_query.trim() : '';
|
||||
const matchMode = normalizeMatchMode(params?.match);
|
||||
const indexRaw = params && params.chat_index !== undefined ? Number(params.chat_index) : 0;
|
||||
const index = isNaN(indexRaw) ? 0 : indexRaw;
|
||||
|
||||
if (!title && !query) {
|
||||
throw new Error('Missing parameter: chat_id or chat_title or chat_query is required');
|
||||
}
|
||||
|
||||
const needle = title || query;
|
||||
const findParams: Parameters<typeof Tools.Chat.findChat>[0] = { query: needle };
|
||||
findParams.match = title ? 'exact' : matchMode;
|
||||
if (index !== undefined) findParams.index = index;
|
||||
const findResult = await Tools.Chat.findChat(findParams);
|
||||
const picked = findResult?.chat ?? null;
|
||||
if (!picked?.id) {
|
||||
throw new Error(`Chat not found by query: ${needle}`);
|
||||
}
|
||||
return picked.id;
|
||||
}
|
||||
|
||||
async function read_messages_impl(params: ReadMessagesParams): Promise<ToolResponse> {
|
||||
const chatId = await resolveChatId(params || {});
|
||||
|
||||
const orderRaw = params && params.order !== undefined ? String(params.order).trim().toLowerCase() : '';
|
||||
const order = (orderRaw === 'asc' || orderRaw === 'desc') ? orderRaw : 'desc';
|
||||
|
||||
const limitRaw = params && params.limit !== undefined ? Number(params.limit) : 20;
|
||||
const limit = isNaN(limitRaw) ? 20 : limitRaw;
|
||||
|
||||
const result = await Tools.Chat.getMessages(chatId, {
|
||||
order,
|
||||
limit,
|
||||
});
|
||||
|
||||
const rawMessages = result.messages;
|
||||
const text = rawMessages
|
||||
.map((m) => {
|
||||
const role = (m.roleName ?? m.sender ?? '').toString() || 'message';
|
||||
const ts = (m.timestamp !== undefined && m.timestamp !== null) ? String(m.timestamp) : '';
|
||||
const header = ts ? `[${ts}] ${role}` : role;
|
||||
return `${header}:\n${(m.content ?? '').toString()}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '读取对话消息完成',
|
||||
data: {
|
||||
result,
|
||||
text,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function rename_chat_impl(params: RenameChatParams): Promise<ToolResponse> {
|
||||
const newTitle = (params?.new_title ?? '').toString().trim();
|
||||
if (!newTitle) {
|
||||
throw new Error('Missing parameter: new_title');
|
||||
}
|
||||
|
||||
const chatId = await resolveChatId(params || {});
|
||||
const result = await Tools.Chat.updateTitle(chatId, newTitle);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '对话重命名完成',
|
||||
data: {
|
||||
chat_id: chatId,
|
||||
title: newTitle,
|
||||
result,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function delete_chat_impl(params: DeleteChatParams): Promise<ToolResponse> {
|
||||
const chatId = await resolveChatId(params || {});
|
||||
const result = await Tools.Chat.deleteChat(chatId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '对话删除完成',
|
||||
data: {
|
||||
chat_id: chatId,
|
||||
result,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function agent_status_impl(params: AgentStatusParams): Promise<ToolResponse> {
|
||||
const chatId = (params?.chat_id ?? '').toString().trim();
|
||||
if (!chatId) {
|
||||
throw new Error('Missing parameter: chat_id');
|
||||
}
|
||||
const result = await Tools.Chat.agentStatus(chatId);
|
||||
return {
|
||||
success: true,
|
||||
message: '对话状态查询完成',
|
||||
data: {
|
||||
result,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function list_character_cards_impl(): Promise<ToolResponse> {
|
||||
const result = await Tools.Chat.listCharacterCards();
|
||||
const cards = result.cards;
|
||||
return {
|
||||
success: true,
|
||||
message: '角色卡列表获取完成',
|
||||
data: {
|
||||
totalCount: result?.totalCount ?? cards.length,
|
||||
cards,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function chat_with_agent_impl(params: ChatWithAgentParams): Promise<ToolResponse> {
|
||||
const message = (params?.message ?? '').toString();
|
||||
const characterCardNameInput = (params?.character_card_name ?? '').toString().trim();
|
||||
if (!message.trim()) {
|
||||
throw new Error('Missing parameter: message');
|
||||
}
|
||||
if (!characterCardNameInput) {
|
||||
throw new Error('Missing parameter: character_card_name');
|
||||
}
|
||||
|
||||
let characterCardName = characterCardNameInput;
|
||||
let characterCardId = '';
|
||||
try {
|
||||
const cardResult = await Tools.Chat.listCharacterCards();
|
||||
const cards = cardResult.cards;
|
||||
const targetCard = cards.find((card) => card.name === characterCardNameInput);
|
||||
if (!targetCard) {
|
||||
throw new Error(`Character card not found: ${characterCardNameInput}`);
|
||||
}
|
||||
characterCardName = targetCard.name;
|
||||
characterCardId = targetCard.id;
|
||||
} catch {
|
||||
if (!characterCardId) {
|
||||
throw new Error(`Character card not found: ${characterCardNameInput}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await Tools.Chat.startService();
|
||||
} catch {
|
||||
// ignore service start errors to avoid blocking agent message
|
||||
}
|
||||
|
||||
let chatId = (params?.chat_id ?? '').toString().trim();
|
||||
if (!chatId) {
|
||||
const lang = (getLang() || '').toLowerCase();
|
||||
const group = lang === 'zh' ? '子任务' : 'subTask';
|
||||
const creation = await Tools.Chat.createNew(
|
||||
group,
|
||||
false,
|
||||
characterCardId,
|
||||
);
|
||||
chatId = (creation?.chatId ?? '').toString().trim();
|
||||
if (!chatId) {
|
||||
throw new Error('Failed to create new chat');
|
||||
}
|
||||
} else {
|
||||
const findResult = await Tools.Chat.findChat({
|
||||
query: chatId,
|
||||
match: 'exact',
|
||||
index: 0,
|
||||
});
|
||||
const boundName = findResult?.chat?.characterCardName ?? null;
|
||||
if (boundName && boundName !== characterCardName) {
|
||||
throw new Error(`Chat ${chatId} 已绑定角色 ${boundName},不能与 ${characterCardName} 共用会话`);
|
||||
}
|
||||
}
|
||||
|
||||
const timeoutRaw = params?.timeout !== undefined ? Number(params.timeout) : 180;
|
||||
const timeoutSec = isNaN(timeoutRaw) || timeoutRaw <= 0 ? 180 : timeoutRaw;
|
||||
const timeoutMs = timeoutSec * 1000;
|
||||
|
||||
const sendMessageOptions: Parameters<typeof Tools.Chat.sendMessage>[4] = {};
|
||||
if (params?.persist_turn !== undefined) {
|
||||
sendMessageOptions.persist_turn = params.persist_turn;
|
||||
}
|
||||
if (params?.notify_reply !== undefined) {
|
||||
sendMessageOptions.notify_reply = params.notify_reply;
|
||||
}
|
||||
if (params?.hide_user_message !== undefined) {
|
||||
sendMessageOptions.hide_user_message = params.hide_user_message;
|
||||
}
|
||||
if (params?.disable_warning !== undefined) {
|
||||
sendMessageOptions.disable_warning = params.disable_warning;
|
||||
}
|
||||
sendMessageOptions.timeout_ms = timeoutMs;
|
||||
const sendPromise = Tools.Chat.sendMessage(
|
||||
message,
|
||||
chatId,
|
||||
characterCardId,
|
||||
getCallerName() || characterCardName,
|
||||
sendMessageOptions,
|
||||
);
|
||||
|
||||
const timeoutPromise = new Promise<null>((resolve) => {
|
||||
setTimeout(() => resolve(null), timeoutMs);
|
||||
});
|
||||
|
||||
const sendResult = await Promise.race([sendPromise, timeoutPromise]);
|
||||
if (sendResult === null) {
|
||||
return {
|
||||
success: true,
|
||||
message: `已发送给 ${characterCardName},等待响应超时(${timeoutSec}s)`,
|
||||
data: {
|
||||
chat_id: chatId,
|
||||
timeout: true,
|
||||
hint: '可以通过 agent_status 查看该 agent 是否已处理你的问题。',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `发消息给 ${characterCardName}`,
|
||||
data: {
|
||||
chat_id: chatId,
|
||||
result: sendResult,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function wrapToolExecution<P>(func: (params: P) => Promise<ToolResponse>, params: P): Promise<void> {
|
||||
try {
|
||||
const result = await func(params);
|
||||
complete(result);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`Tool ${func.name} failed unexpectedly`, error);
|
||||
complete({
|
||||
success: false,
|
||||
message: `读取对话消息失败: ${message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function wrapToolExecutionNoParams(func: () => Promise<ToolResponse>): Promise<void> {
|
||||
try {
|
||||
const result = await func();
|
||||
complete(result);
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`Tool ${func.name} failed unexpectedly`, error);
|
||||
complete({
|
||||
success: false,
|
||||
message: `读取对话消息失败: ${message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function read_messages(params: ReadMessagesParams): Promise<void> {
|
||||
return await wrapToolExecution(read_messages_impl, params);
|
||||
}
|
||||
|
||||
async function rename_chat(params: RenameChatParams): Promise<void> {
|
||||
return await wrapToolExecution(rename_chat_impl, params);
|
||||
}
|
||||
|
||||
async function delete_chat(params: DeleteChatParams): Promise<void> {
|
||||
return await wrapToolExecution(delete_chat_impl, params);
|
||||
}
|
||||
|
||||
async function list_chats(params: ListChatsParams): Promise<void> {
|
||||
return await wrapToolExecution(list_chats_impl, params);
|
||||
}
|
||||
|
||||
async function find_chat(params: FindChatParams): Promise<void> {
|
||||
return await wrapToolExecution(find_chat_impl, params);
|
||||
}
|
||||
|
||||
async function agent_status(params: AgentStatusParams): Promise<void> {
|
||||
return await wrapToolExecution(agent_status_impl, params);
|
||||
}
|
||||
|
||||
async function list_character_cards(): Promise<void> {
|
||||
return await wrapToolExecutionNoParams(list_character_cards_impl);
|
||||
}
|
||||
|
||||
async function chat_with_agent(params: ChatWithAgentParams): Promise<void> {
|
||||
return await wrapToolExecution(chat_with_agent_impl, params);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
complete({
|
||||
success: true,
|
||||
message: 'extended_chat 工具包已加载',
|
||||
data: {
|
||||
hint: 'Use extended_chat:read_messages / rename_chat / delete_chat.',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
list_chats,
|
||||
find_chat,
|
||||
read_messages,
|
||||
rename_chat,
|
||||
delete_chat,
|
||||
chat_with_agent,
|
||||
agent_status,
|
||||
list_character_cards,
|
||||
main,
|
||||
};
|
||||
})();
|
||||
|
||||
exports.list_chats = HistoryChat.list_chats;
|
||||
exports.find_chat = HistoryChat.find_chat;
|
||||
exports.read_messages = HistoryChat.read_messages;
|
||||
exports.rename_chat = HistoryChat.rename_chat;
|
||||
exports.delete_chat = HistoryChat.delete_chat;
|
||||
exports.chat_with_agent = HistoryChat.chat_with_agent;
|
||||
exports.agent_status = HistoryChat.agent_status;
|
||||
exports.list_character_cards = HistoryChat.list_character_cards;
|
||||
exports.main = HistoryChat.main;
|
||||
@@ -0,0 +1,198 @@
|
||||
/* METADATA
|
||||
{
|
||||
"name": "extended_file_tools",
|
||||
|
||||
"display_name": {
|
||||
"zh": "增强文件工具",
|
||||
"en": "Extended File Tools"
|
||||
},
|
||||
"description": {
|
||||
"zh": "拓展文件工具包:提供 file_exists / move_file / copy_file / file_info / unzip_files / zip_files / open_file / share_file(默认文件工具中已移除这些项)。",
|
||||
"en": "Extended file tools: file_exists / move_file / copy_file / file_info / unzip_files / zip_files / open_file / share_file (removed from default file tools)."
|
||||
},
|
||||
"category": "File",
|
||||
"enabledByDefault": true,
|
||||
"tools": [
|
||||
{
|
||||
"name": "file_exists",
|
||||
"description": { "zh": "检查文件或目录是否存在。", "en": "Check if a file or directory exists." },
|
||||
"parameters": [
|
||||
{ "name": "path", "description": { "zh": "目标路径", "en": "Target path" }, "type": "string", "required": true },
|
||||
{ "name": "environment", "description": { "zh": "可选:android/linux", "en": "Optional: android/linux" }, "type": "string", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "move_file",
|
||||
"description": { "zh": "移动或重命名文件/目录。", "en": "Move or rename a file/directory." },
|
||||
"parameters": [
|
||||
{ "name": "source", "description": { "zh": "源路径", "en": "Source path" }, "type": "string", "required": true },
|
||||
{ "name": "destination", "description": { "zh": "目标路径", "en": "Destination path" }, "type": "string", "required": true },
|
||||
{ "name": "environment", "description": { "zh": "可选:android/linux", "en": "Optional: android/linux" }, "type": "string", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "copy_file",
|
||||
"description": { "zh": "复制文件/目录(支持跨环境复制)。", "en": "Copy a file/directory (supports cross-environment copy)." },
|
||||
"parameters": [
|
||||
{ "name": "source", "description": { "zh": "源路径", "en": "Source path" }, "type": "string", "required": true },
|
||||
{ "name": "destination", "description": { "zh": "目标路径", "en": "Destination path" }, "type": "string", "required": true },
|
||||
{ "name": "recursive", "description": { "zh": "可选:是否递归,默认 false", "en": "Optional: recursive (default: false)" }, "type": "boolean", "required": false },
|
||||
{ "name": "source_environment", "description": { "zh": "可选:源环境 android/linux", "en": "Optional: source environment android/linux" }, "type": "string", "required": false },
|
||||
{ "name": "dest_environment", "description": { "zh": "可选:目标环境 android/linux", "en": "Optional: destination environment android/linux" }, "type": "string", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "file_info",
|
||||
"description": { "zh": "获取文件/目录信息。", "en": "Get file/directory info." },
|
||||
"parameters": [
|
||||
{ "name": "path", "description": { "zh": "目标路径", "en": "Target path" }, "type": "string", "required": true },
|
||||
{ "name": "environment", "description": { "zh": "可选:android/linux", "en": "Optional: android/linux" }, "type": "string", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "zip_files",
|
||||
"description": { "zh": "压缩文件/目录。", "en": "Zip files/directories." },
|
||||
"parameters": [
|
||||
{ "name": "source", "description": { "zh": "源路径", "en": "Source path" }, "type": "string", "required": true },
|
||||
{ "name": "destination", "description": { "zh": "输出 zip 文件路径", "en": "Destination zip path" }, "type": "string", "required": true },
|
||||
{ "name": "environment", "description": { "zh": "可选:android/linux", "en": "Optional: android/linux" }, "type": "string", "required": false },
|
||||
{ "name": "include_root_directory", "description": { "zh": "压缩目录时是否保留源目录名作为顶层目录,默认 true", "en": "When zipping a directory, keep the source directory name as the top-level folder, default true" }, "type": "boolean", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "unzip_files",
|
||||
"description": { "zh": "解压 zip 文件。", "en": "Unzip an archive." },
|
||||
"parameters": [
|
||||
{ "name": "source", "description": { "zh": "zip 文件路径", "en": "Zip file path" }, "type": "string", "required": true },
|
||||
{ "name": "destination", "description": { "zh": "解压目录", "en": "Destination directory" }, "type": "string", "required": true },
|
||||
{ "name": "environment", "description": { "zh": "可选:android/linux", "en": "Optional: android/linux" }, "type": "string", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "open_file",
|
||||
"description": { "zh": "用系统默认应用打开文件。", "en": "Open a file with system default app." },
|
||||
"parameters": [
|
||||
{ "name": "path", "description": { "zh": "文件路径", "en": "File path" }, "type": "string", "required": true },
|
||||
{ "name": "environment", "description": { "zh": "可选:android/linux", "en": "Optional: android/linux" }, "type": "string", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "share_file",
|
||||
"description": { "zh": "分享文件给其他应用。", "en": "Share a file with other apps." },
|
||||
"parameters": [
|
||||
{ "name": "path", "description": { "zh": "文件路径", "en": "File path" }, "type": "string", "required": true },
|
||||
{ "name": "title", "description": { "zh": "可选:分享标题", "en": "Optional: share title" }, "type": "string", "required": false },
|
||||
{ "name": "environment", "description": { "zh": "可选:android/linux", "en": "Optional: android/linux" }, "type": "string", "required": false }
|
||||
]
|
||||
}
|
||||
]
|
||||
}*/
|
||||
|
||||
const ExtendedFileTools = (function () {
|
||||
|
||||
interface ToolResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
type FileEnvironment = 'android' | 'linux';
|
||||
|
||||
async function file_exists(params: { path: string; environment?: FileEnvironment }): Promise<ToolResponse> {
|
||||
const result = await Tools.Files.exists(params.path, params.environment);
|
||||
return { success: !!result && (result.exists ?? true), message: '检查完成', data: result };
|
||||
}
|
||||
|
||||
async function move_file(params: { source: string; destination: string; environment?: FileEnvironment }): Promise<ToolResponse> {
|
||||
const result = await Tools.Files.move(params.source, params.destination, params.environment);
|
||||
return { success: !!result, message: '移动完成', data: result };
|
||||
}
|
||||
|
||||
async function copy_file(params: { source: string; destination: string; recursive?: boolean; source_environment?: FileEnvironment; dest_environment?: FileEnvironment }): Promise<ToolResponse> {
|
||||
const result = await Tools.Files.copy(params.source, params.destination, params.recursive, params.source_environment, params.dest_environment);
|
||||
return { success: !!result, message: '复制完成', data: result };
|
||||
}
|
||||
|
||||
async function file_info(params: { path: string; environment?: FileEnvironment }): Promise<ToolResponse> {
|
||||
const result = await Tools.Files.info(params.path, params.environment);
|
||||
return { success: !!result, message: '获取信息完成', data: result };
|
||||
}
|
||||
|
||||
async function zip_files(params: { source: string; destination: string; environment?: FileEnvironment; include_root_directory?: boolean }): Promise<ToolResponse> {
|
||||
const result = await Tools.Files.zip(
|
||||
params.source,
|
||||
params.destination,
|
||||
params.environment,
|
||||
params.include_root_directory
|
||||
);
|
||||
return { success: !!result, message: '压缩完成', data: result };
|
||||
}
|
||||
|
||||
async function unzip_files(params: { source: string; destination: string; environment?: FileEnvironment }): Promise<ToolResponse> {
|
||||
const result = await Tools.Files.unzip(params.source, params.destination, params.environment);
|
||||
return { success: !!result, message: '解压完成', data: result };
|
||||
}
|
||||
|
||||
async function open_file(params: { path: string; environment?: FileEnvironment }): Promise<ToolResponse> {
|
||||
const result = await Tools.Files.open(params.path, params.environment);
|
||||
return { success: !!result, message: '打开文件完成', data: result };
|
||||
}
|
||||
|
||||
async function share_file(params: { path: string; title?: string; environment?: FileEnvironment }): Promise<ToolResponse> {
|
||||
const result = await Tools.Files.share(params.path, params.title, params.environment);
|
||||
return { success: !!result, message: '分享文件完成', data: result };
|
||||
}
|
||||
|
||||
async function wrapToolExecution<P>(func: (params: P) => Promise<ToolResponse>, params: P) {
|
||||
try {
|
||||
const result = await func(params);
|
||||
complete(result);
|
||||
} catch (error: any) {
|
||||
console.error(`Tool ${func.name} failed unexpectedly`, error);
|
||||
complete({
|
||||
success: false,
|
||||
message: `工具执行时发生意外错误: ${error.message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const results: any[] = [];
|
||||
results.push({ tool: 'file_exists', result: { success: null, message: '未测试' } });
|
||||
results.push({ tool: 'move_file', result: { success: null, message: '未测试(会移动/重命名文件)' } });
|
||||
results.push({ tool: 'copy_file', result: { success: null, message: '未测试(会复制文件)' } });
|
||||
results.push({ tool: 'file_info', result: { success: null, message: '未测试' } });
|
||||
results.push({ tool: 'zip_files', result: { success: null, message: '未测试(会写入zip文件)' } });
|
||||
results.push({ tool: 'unzip_files', result: { success: null, message: '未测试(会写入解压文件)' } });
|
||||
results.push({ tool: 'open_file', result: { success: null, message: '未测试(会拉起系统应用)' } });
|
||||
results.push({ tool: 'share_file', result: { success: null, message: '未测试(会弹出分享面板)' } });
|
||||
|
||||
complete({
|
||||
success: true,
|
||||
message: '拓展文件工具包加载完成(未执行破坏性测试)',
|
||||
data: { results }
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
file_exists: (params: { path: string; environment?: FileEnvironment }) => wrapToolExecution(file_exists, params),
|
||||
move_file: (params: { source: string; destination: string; environment?: FileEnvironment }) => wrapToolExecution(move_file, params),
|
||||
copy_file: (params: { source: string; destination: string; recursive?: boolean; source_environment?: FileEnvironment; dest_environment?: FileEnvironment }) => wrapToolExecution(copy_file, params),
|
||||
file_info: (params: { path: string; environment?: FileEnvironment }) => wrapToolExecution(file_info, params),
|
||||
zip_files: (params: { source: string; destination: string; environment?: FileEnvironment; include_root_directory?: boolean }) => wrapToolExecution(zip_files, params),
|
||||
unzip_files: (params: { source: string; destination: string; environment?: FileEnvironment }) => wrapToolExecution(unzip_files, params),
|
||||
open_file: (params: { path: string; environment?: FileEnvironment }) => wrapToolExecution(open_file, params),
|
||||
share_file: (params: { path: string; title?: string; environment?: FileEnvironment }) => wrapToolExecution(share_file, params),
|
||||
main,
|
||||
};
|
||||
})();
|
||||
|
||||
exports.file_exists = ExtendedFileTools.file_exists;
|
||||
exports.move_file = ExtendedFileTools.move_file;
|
||||
exports.copy_file = ExtendedFileTools.copy_file;
|
||||
exports.file_info = ExtendedFileTools.file_info;
|
||||
exports.zip_files = ExtendedFileTools.zip_files;
|
||||
exports.unzip_files = ExtendedFileTools.unzip_files;
|
||||
exports.open_file = ExtendedFileTools.open_file;
|
||||
exports.share_file = ExtendedFileTools.share_file;
|
||||
exports.main = ExtendedFileTools.main;
|
||||
@@ -0,0 +1,209 @@
|
||||
/* METADATA
|
||||
{
|
||||
"name": "extended_http_tools",
|
||||
|
||||
"display_name": {
|
||||
"zh": "增强 HTTP 工具",
|
||||
"en": "Extended HTTP Tools"
|
||||
},
|
||||
"description": {
|
||||
"zh": "允许文件上传,以及 GET/POST 等网络直接访问操作。",
|
||||
"en": "Allows file uploads and direct network access operations such as GET/POST."
|
||||
},
|
||||
"enabledByDefault": true,
|
||||
"category": "Network",
|
||||
"tools": [
|
||||
{
|
||||
"name": "http_request",
|
||||
"description": { "zh": "发送 HTTP 请求。", "en": "Send an HTTP request." },
|
||||
"parameters": [
|
||||
{ "name": "url", "description": { "zh": "请求 URL", "en": "Request URL" }, "type": "string", "required": true },
|
||||
{ "name": "method", "description": { "zh": "请求方法:GET/POST/PUT/DELETE", "en": "Method: GET/POST/PUT/DELETE" }, "type": "string", "required": true },
|
||||
{ "name": "headers", "description": { "zh": "可选:headers(JSON 对象字符串)", "en": "Optional: headers (JSON object string)" }, "type": "string", "required": false },
|
||||
{ "name": "body", "description": { "zh": "可选:请求体(字符串)", "en": "Optional: body (string)" }, "type": "string", "required": false },
|
||||
{ "name": "body_type", "description": { "zh": "可选:json/form/text/xml", "en": "Optional: json/form/text/xml" }, "type": "string", "required": false },
|
||||
{ "name": "ignore_ssl", "description": { "zh": "可选:是否忽略 HTTPS 证书校验(true/false)", "en": "Optional: ignore HTTPS certificate verification (true/false)" }, "type": "boolean", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "multipart_request",
|
||||
"description": { "zh": "上传文件(multipart)。", "en": "Upload files (multipart)." },
|
||||
"parameters": [
|
||||
{ "name": "url", "description": { "zh": "请求 URL", "en": "Request URL" }, "type": "string", "required": true },
|
||||
{ "name": "method", "description": { "zh": "请求方法:POST/PUT", "en": "Method: POST/PUT" }, "type": "string", "required": true },
|
||||
{ "name": "headers", "description": { "zh": "可选:headers(JSON 对象字符串)", "en": "Optional: headers (JSON object string)" }, "type": "string", "required": false },
|
||||
{ "name": "form_data", "description": { "zh": "可选:form_data(字符串)", "en": "Optional: form_data (string)" }, "type": "string", "required": false },
|
||||
{ "name": "files", "description": { "zh": "可选:files(JSON 数组字符串)", "en": "Optional: files (JSON array string)" }, "type": "string", "required": false },
|
||||
{ "name": "ignore_ssl", "description": { "zh": "可选:是否忽略 HTTPS 证书校验(true/false)", "en": "Optional: ignore HTTPS certificate verification (true/false)" }, "type": "boolean", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "manage_cookies",
|
||||
"description": { "zh": "管理 Cookies。", "en": "Manage cookies." },
|
||||
"parameters": [
|
||||
{ "name": "action", "description": { "zh": "操作:get/set/clear", "en": "Action: get/set/clear" }, "type": "string", "required": true },
|
||||
{ "name": "domain", "description": { "zh": "可选:域名", "en": "Optional: domain" }, "type": "string", "required": false },
|
||||
{ "name": "cookies", "description": { "zh": "可选:cookies(字符串)", "en": "Optional: cookies (string)" }, "type": "string", "required": false }
|
||||
]
|
||||
}
|
||||
]
|
||||
}*/
|
||||
|
||||
const ExtendedHttpTools = (function () {
|
||||
|
||||
const MAX_INLINE_HTTP_RESPONSE_CHARS = 12_000;
|
||||
|
||||
interface ToolResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
async function http_request(params: {
|
||||
url: string;
|
||||
method: string;
|
||||
headers?: string;
|
||||
body?: string;
|
||||
body_type?: string;
|
||||
ignore_ssl?: boolean;
|
||||
}): Promise<ToolResponse> {
|
||||
const toolParams: ToolParams = {
|
||||
url: params.url,
|
||||
method: params.method,
|
||||
};
|
||||
if (params.headers !== undefined) toolParams.headers = params.headers;
|
||||
if (params.body !== undefined) toolParams.body = params.body;
|
||||
if (params.body_type !== undefined) toolParams.body_type = params.body_type;
|
||||
if (params.ignore_ssl !== undefined) toolParams.ignore_ssl = params.ignore_ssl;
|
||||
|
||||
const result = await toolCall({ name: "http_request", params: toolParams });
|
||||
const success = result.statusCode >= 200 && result.statusCode < 400;
|
||||
|
||||
const contentStr = typeof result?.content === "string" ? result.content : "";
|
||||
if (contentStr.length > MAX_INLINE_HTTP_RESPONSE_CHARS) {
|
||||
await Tools.Files.mkdir(OPERIT_CLEAN_ON_EXIT_DIR, true);
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const rand = Math.floor(Math.random() * 1_000_000);
|
||||
|
||||
let ext = "txt";
|
||||
const ct = typeof result?.contentType === "string" ? result.contentType.toLowerCase() : "";
|
||||
if (ct.includes("json")) ext = "json";
|
||||
else if (ct.includes("html")) ext = "html";
|
||||
else if (ct.includes("xml")) ext = "xml";
|
||||
|
||||
const filePath = `${OPERIT_CLEAN_ON_EXIT_DIR}/http_response_${timestamp}_${rand}.${ext}`;
|
||||
await Tools.Files.write(filePath, contentStr, false);
|
||||
|
||||
const resultMeta = {
|
||||
url: result?.url,
|
||||
statusCode: result?.statusCode,
|
||||
statusMessage: result?.statusMessage,
|
||||
headers: result?.headers,
|
||||
contentType: result?.contentType,
|
||||
size: result?.size,
|
||||
content: "(saved_to_file)",
|
||||
};
|
||||
|
||||
return {
|
||||
success,
|
||||
message: `HTTP 请求完成;响应内容过大,已保存到文件:${filePath}。请使用 read_file_part 读取指定行范围,或用 grep_code 在该文件中检索关键字。`,
|
||||
data: {
|
||||
result: resultMeta,
|
||||
content_saved_to: filePath,
|
||||
operit_clean_on_exit_dir: OPERIT_CLEAN_ON_EXIT_DIR,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { success, message: 'HTTP 请求完成', data: result };
|
||||
}
|
||||
|
||||
async function multipart_request(params: {
|
||||
url: string;
|
||||
method: string;
|
||||
headers?: string;
|
||||
form_data?: string;
|
||||
files?: string;
|
||||
ignore_ssl?: boolean;
|
||||
}): Promise<ToolResponse> {
|
||||
const toolParams: ToolParams = {
|
||||
url: params.url,
|
||||
method: params.method,
|
||||
};
|
||||
if (params.headers !== undefined) toolParams.headers = params.headers;
|
||||
if (params.form_data !== undefined) toolParams.form_data = params.form_data;
|
||||
if (params.files !== undefined) toolParams.files = params.files;
|
||||
if (params.ignore_ssl !== undefined) toolParams.ignore_ssl = params.ignore_ssl;
|
||||
|
||||
const result = await toolCall({ name: "multipart_request", params: toolParams });
|
||||
const success = result.statusCode >= 200 && result.statusCode < 400;
|
||||
return { success, message: '文件上传完成', data: result };
|
||||
}
|
||||
|
||||
async function manage_cookies(params: { action: string; domain?: string; cookies?: string }): Promise<ToolResponse> {
|
||||
const toolParams: ToolParams = {
|
||||
action: params.action,
|
||||
};
|
||||
if (params.domain !== undefined) toolParams.domain = params.domain;
|
||||
if (params.cookies !== undefined) toolParams.cookies = params.cookies;
|
||||
|
||||
const result = await toolCall({ name: "manage_cookies", params: toolParams });
|
||||
const success = result.statusCode >= 200 && result.statusCode < 400;
|
||||
return { success, message: 'Cookies 操作完成', data: result };
|
||||
}
|
||||
|
||||
async function wrapToolExecution<P>(func: (params: P) => Promise<ToolResponse>, params: P) {
|
||||
try {
|
||||
const result = await func(params);
|
||||
complete(result);
|
||||
} catch (error: any) {
|
||||
console.error(`Tool ${func.name} failed unexpectedly`, error);
|
||||
complete({
|
||||
success: false,
|
||||
message: `工具执行时发生意外错误: ${error.message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const results: any[] = [];
|
||||
|
||||
// 这些工具可能对外发请求 / 写 cookies,默认不做自动化演示。
|
||||
results.push({ tool: 'http_request', result: { success: null, message: '未测试(会对外发起网络请求)' } });
|
||||
results.push({ tool: 'multipart_request', result: { success: null, message: '未测试(会上传文件/对外发起网络请求)' } });
|
||||
results.push({ tool: 'manage_cookies', result: { success: null, message: '未测试(会读写 cookies)' } });
|
||||
|
||||
complete({
|
||||
success: true,
|
||||
message: "拓展 HTTP 工具包加载完成(未执行网络请求测试)",
|
||||
data: { results }
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
http_request: (params: {
|
||||
url: string;
|
||||
method: string;
|
||||
headers?: string;
|
||||
body?: string;
|
||||
body_type?: string;
|
||||
ignore_ssl?: boolean;
|
||||
}) => wrapToolExecution(http_request, params),
|
||||
multipart_request: (params: {
|
||||
url: string;
|
||||
method: string;
|
||||
headers?: string;
|
||||
form_data?: string;
|
||||
files?: string;
|
||||
ignore_ssl?: boolean;
|
||||
}) => wrapToolExecution(multipart_request, params),
|
||||
manage_cookies: (params: { action: string; domain?: string; cookies?: string }) => wrapToolExecution(manage_cookies, params),
|
||||
main,
|
||||
};
|
||||
})();
|
||||
|
||||
exports.http_request = ExtendedHttpTools.http_request;
|
||||
exports.multipart_request = ExtendedHttpTools.multipart_request;
|
||||
exports.manage_cookies = ExtendedHttpTools.manage_cookies;
|
||||
exports.main = ExtendedHttpTools.main;
|
||||
@@ -0,0 +1,305 @@
|
||||
/* METADATA
|
||||
{
|
||||
"name": "extended_memory_tools",
|
||||
|
||||
"display_name": {
|
||||
"zh": "增强记忆工具",
|
||||
"en": "Extended Memory Tools"
|
||||
},
|
||||
"description": {
|
||||
"zh": "拓展记忆工具包:提供创建/更新/删除/查询/链接记忆,以及更新用户偏好的能力(默认工具中仅保留 query/get/query_links)。",
|
||||
"en": "Extended memory tools: create/update/delete/query/link memories and update user preferences (default tools only keep query/get/query_links)."
|
||||
},
|
||||
"category": "Memory",
|
||||
"enabledByDefault": true,
|
||||
"tools": [
|
||||
{
|
||||
"name": "create_memory",
|
||||
"description": { "zh": "创建新的记忆节点。", "en": "Create a new memory node." },
|
||||
"parameters": [
|
||||
{ "name": "title", "description": { "zh": "记忆标题", "en": "Memory title" }, "type": "string", "required": true },
|
||||
{ "name": "content", "description": { "zh": "记忆内容", "en": "Memory content" }, "type": "string", "required": true },
|
||||
{ "name": "content_type", "description": { "zh": "可选:内容类型,默认 text/plain", "en": "Optional: content type (default: text/plain)" }, "type": "string", "required": false },
|
||||
{ "name": "source", "description": { "zh": "可选:来源,默认 ai_created", "en": "Optional: source (default: ai_created)" }, "type": "string", "required": false },
|
||||
{ "name": "folder_path", "description": { "zh": "可选:文件夹路径,默认空", "en": "Optional: folder path (default: empty)" }, "type": "string", "required": false },
|
||||
{ "name": "tags", "description": { "zh": "可选:标签(逗号分隔字符串)", "en": "Optional: tags (comma-separated string)" }, "type": "string", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "update_memory",
|
||||
"description": { "zh": "按标题更新已有记忆节点。", "en": "Update an existing memory node by title." },
|
||||
"parameters": [
|
||||
{ "name": "old_title", "description": { "zh": "原始标题(用于定位记忆)", "en": "Old title (to locate the memory)" }, "type": "string", "required": true },
|
||||
{ "name": "new_title", "description": { "zh": "可选:新标题(重命名)", "en": "Optional: new title (rename)" }, "type": "string", "required": false },
|
||||
{ "name": "content", "description": { "zh": "可选:新内容", "en": "Optional: new content" }, "type": "string", "required": false },
|
||||
{ "name": "content_type", "description": { "zh": "可选:内容类型", "en": "Optional: content type" }, "type": "string", "required": false },
|
||||
{ "name": "source", "description": { "zh": "可选:来源", "en": "Optional: source" }, "type": "string", "required": false },
|
||||
{ "name": "credibility", "description": { "zh": "可选:可信度 0-1", "en": "Optional: credibility 0-1" }, "type": "number", "required": false },
|
||||
{ "name": "importance", "description": { "zh": "可选:重要性 0-1", "en": "Optional: importance 0-1" }, "type": "number", "required": false },
|
||||
{ "name": "folder_path", "description": { "zh": "可选:文件夹路径", "en": "Optional: folder path" }, "type": "string", "required": false },
|
||||
{ "name": "tags", "description": { "zh": "可选:标签(逗号分隔字符串)", "en": "Optional: tags (comma-separated string)" }, "type": "string", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "delete_memory",
|
||||
"description": { "zh": "按标题删除记忆节点(不可逆)。", "en": "Delete a memory node by title (irreversible)." },
|
||||
"parameters": [
|
||||
{ "name": "title", "description": { "zh": "要删除的记忆标题", "en": "Memory title to delete" }, "type": "string", "required": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "move_memory",
|
||||
"description": { "zh": "批量移动记忆到新文件夹。可按标题列表和/或来源文件夹筛选。", "en": "Move memories to another folder in batch. Filter by titles and/or source folder." },
|
||||
"parameters": [
|
||||
{ "name": "target_folder_path", "description": { "zh": "目标文件夹路径(空字符串表示未分类)", "en": "Target folder path (empty string means uncategorized)" }, "type": "string", "required": true },
|
||||
{ "name": "titles", "description": { "zh": "可选:标题列表(逗号或换行分隔)", "en": "Optional: title list (comma/newline separated)" }, "type": "string", "required": false },
|
||||
{ "name": "source_folder_path", "description": { "zh": "可选:来源文件夹路径(空字符串表示未分类)", "en": "Optional: source folder path (empty string means uncategorized)" }, "type": "string", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "link_memories",
|
||||
"description": { "zh": "创建两条记忆之间的语义链接。", "en": "Create a semantic link between two memories." },
|
||||
"parameters": [
|
||||
{ "name": "source_title", "description": { "zh": "源记忆标题", "en": "Source memory title" }, "type": "string", "required": true },
|
||||
{ "name": "target_title", "description": { "zh": "目标记忆标题", "en": "Target memory title" }, "type": "string", "required": true },
|
||||
{ "name": "link_type", "description": { "zh": "可选:关系类型,默认 related", "en": "Optional: link type (default: related)" }, "type": "string", "required": false },
|
||||
{ "name": "weight", "description": { "zh": "可选:强度 0-1,默认 0.7", "en": "Optional: weight 0-1 (default: 0.7)" }, "type": "number", "required": false },
|
||||
{ "name": "description", "description": { "zh": "可选:关系描述", "en": "Optional: relationship description" }, "type": "string", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "query_memory_links",
|
||||
"description": { "zh": "查询记忆链接(可按 ID/源标题/目标标题/关系类型过滤)。", "en": "Query memory links (filter by id/source/target/type)." },
|
||||
"parameters": [
|
||||
{ "name": "link_id", "description": { "zh": "可选:链接ID", "en": "Optional: link id" }, "type": "number", "required": false },
|
||||
{ "name": "source_title", "description": { "zh": "可选:源记忆标题", "en": "Optional: source memory title" }, "type": "string", "required": false },
|
||||
{ "name": "target_title", "description": { "zh": "可选:目标记忆标题", "en": "Optional: target memory title" }, "type": "string", "required": false },
|
||||
{ "name": "link_type", "description": { "zh": "可选:关系类型", "en": "Optional: link type" }, "type": "string", "required": false },
|
||||
{ "name": "limit", "description": { "zh": "可选:返回上限 1-200,默认20", "en": "Optional: limit 1-200, default 20" }, "type": "number", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "update_memory_link",
|
||||
"description": { "zh": "更新记忆链接(按 link_id 或 source/target/link_type 定位)。", "en": "Update a memory link (by link_id or source/target/link_type)." },
|
||||
"parameters": [
|
||||
{ "name": "link_id", "description": { "zh": "可选:链接ID(优先使用)", "en": "Optional: link ID (preferred)" }, "type": "number", "required": false },
|
||||
{ "name": "source_title", "description": { "zh": "可选:源记忆标题(未提供 link_id 时使用)", "en": "Optional: source title (used when link_id is not provided)" }, "type": "string", "required": false },
|
||||
{ "name": "target_title", "description": { "zh": "可选:目标记忆标题(未提供 link_id 时使用)", "en": "Optional: target title (used when link_id is not provided)" }, "type": "string", "required": false },
|
||||
{ "name": "link_type", "description": { "zh": "可选:当前关系类型(用于唯一定位)", "en": "Optional: current relation type (for unique resolution)" }, "type": "string", "required": false },
|
||||
{ "name": "new_link_type", "description": { "zh": "可选:新的关系类型", "en": "Optional: new relation type" }, "type": "string", "required": false },
|
||||
{ "name": "weight", "description": { "zh": "可选:新的强度 0-1", "en": "Optional: new weight 0-1" }, "type": "number", "required": false },
|
||||
{ "name": "description", "description": { "zh": "可选:新的关系描述", "en": "Optional: new relationship description" }, "type": "string", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "delete_memory_link",
|
||||
"description": { "zh": "删除记忆链接(按 link_id 或 source/target/link_type 定位)。", "en": "Delete a memory link (by link_id or source/target/link_type)." },
|
||||
"parameters": [
|
||||
{ "name": "link_id", "description": { "zh": "可选:链接ID(优先使用)", "en": "Optional: link ID (preferred)" }, "type": "number", "required": false },
|
||||
{ "name": "source_title", "description": { "zh": "可选:源记忆标题(未提供 link_id 时使用)", "en": "Optional: source title (used when link_id is not provided)" }, "type": "string", "required": false },
|
||||
{ "name": "target_title", "description": { "zh": "可选:目标记忆标题(未提供 link_id 时使用)", "en": "Optional: target title (used when link_id is not provided)" }, "type": "string", "required": false },
|
||||
{ "name": "link_type", "description": { "zh": "可选:关系类型(用于唯一定位)", "en": "Optional: relation type (for unique resolution)" }, "type": "string", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "update_user_preferences",
|
||||
"description": { "zh": "更新用户偏好信息(至少提供一个字段)。", "en": "Update user preferences (provide at least one field)." },
|
||||
"parameters": [
|
||||
{ "name": "birth_date", "description": { "zh": "可选:出生日期(Unix 毫秒时间戳)", "en": "Optional: birth date (Unix ms timestamp)" }, "type": "number", "required": false },
|
||||
{ "name": "gender", "description": { "zh": "可选:性别", "en": "Optional: gender" }, "type": "string", "required": false },
|
||||
{ "name": "personality", "description": { "zh": "可选:性格特征", "en": "Optional: personality" }, "type": "string", "required": false },
|
||||
{ "name": "identity", "description": { "zh": "可选:身份/角色", "en": "Optional: identity/role" }, "type": "string", "required": false },
|
||||
{ "name": "occupation", "description": { "zh": "可选:职业", "en": "Optional: occupation" }, "type": "string", "required": false },
|
||||
{ "name": "ai_style", "description": { "zh": "可选:偏好 AI 交互风格", "en": "Optional: preferred AI interaction style" }, "type": "string", "required": false }
|
||||
]
|
||||
}
|
||||
]
|
||||
}*/
|
||||
|
||||
const ExtendedMemoryTools = (function () {
|
||||
|
||||
interface ToolResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
function resolveCallerCardId(): string | undefined {
|
||||
if (typeof getCallerCardId !== 'function') {
|
||||
return undefined;
|
||||
}
|
||||
const callerCardId = String(getCallerCardId() || '').trim();
|
||||
return callerCardId || undefined;
|
||||
}
|
||||
|
||||
async function create_memory(params: { title: string; content: string; content_type?: string; source?: string; folder_path?: string; tags?: string }): Promise<ToolResponse> {
|
||||
const result = await Tools.Memory.create({
|
||||
title: params.title,
|
||||
content: params.content,
|
||||
contentType: params.content_type,
|
||||
source: params.source,
|
||||
folderPath: params.folder_path,
|
||||
tags: params.tags,
|
||||
callerCardId: resolveCallerCardId(),
|
||||
});
|
||||
return { success: typeof result === 'string' && result.length > 0, message: '记忆创建完成', data: result };
|
||||
}
|
||||
|
||||
async function update_memory(params: { old_title: string; new_title?: string; content?: string; content_type?: string; source?: string; credibility?: number; importance?: number; folder_path?: string; tags?: string }): Promise<ToolResponse> {
|
||||
const result = await Tools.Memory.update({
|
||||
oldTitle: params.old_title,
|
||||
newTitle: params.new_title,
|
||||
content: params.content,
|
||||
contentType: params.content_type,
|
||||
source: params.source,
|
||||
credibility: params.credibility,
|
||||
importance: params.importance,
|
||||
folderPath: params.folder_path,
|
||||
tags: params.tags,
|
||||
callerCardId: resolveCallerCardId(),
|
||||
});
|
||||
return { success: typeof result === 'string' && result.length > 0, message: '记忆更新完成', data: result };
|
||||
}
|
||||
|
||||
async function delete_memory(params: { title: string }): Promise<ToolResponse> {
|
||||
const result = await Tools.Memory.deleteMemory({
|
||||
title: params.title,
|
||||
callerCardId: resolveCallerCardId(),
|
||||
});
|
||||
return { success: typeof result === 'string' && result.length > 0, message: '记忆删除完成', data: result };
|
||||
}
|
||||
|
||||
async function move_memory(params: { target_folder_path: string; titles?: string; source_folder_path?: string }): Promise<ToolResponse> {
|
||||
const titles = params.titles
|
||||
? params.titles.split(/[,\n|]/).map(s => s.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
const result = await Tools.Memory.move({
|
||||
targetFolderPath: params.target_folder_path,
|
||||
titles,
|
||||
sourceFolderPath: params.source_folder_path,
|
||||
callerCardId: resolveCallerCardId(),
|
||||
});
|
||||
return { success: typeof result === 'string' && result.length > 0, message: '记忆移动完成', data: result };
|
||||
}
|
||||
|
||||
async function link_memories(params: { source_title: string; target_title: string; link_type?: string; weight?: number; description?: string }): Promise<ToolResponse> {
|
||||
const result = await Tools.Memory.link({
|
||||
sourceTitle: params.source_title,
|
||||
targetTitle: params.target_title,
|
||||
linkType: params.link_type,
|
||||
weight: params.weight,
|
||||
description: params.description,
|
||||
callerCardId: resolveCallerCardId(),
|
||||
});
|
||||
return { success: !!result, message: '记忆链接创建完成', data: result };
|
||||
}
|
||||
|
||||
async function query_memory_links(params: { link_id?: number; source_title?: string; target_title?: string; link_type?: string; limit?: number }): Promise<ToolResponse> {
|
||||
const result = await Tools.Memory.queryLinks({
|
||||
linkId: params.link_id,
|
||||
sourceTitle: params.source_title,
|
||||
targetTitle: params.target_title,
|
||||
linkType: params.link_type,
|
||||
limit: params.limit,
|
||||
callerCardId: resolveCallerCardId(),
|
||||
});
|
||||
return { success: !!result, message: '记忆链接查询完成', data: result };
|
||||
}
|
||||
|
||||
async function update_memory_link(params: { link_id?: number; source_title?: string; target_title?: string; link_type?: string; new_link_type?: string; weight?: number; description?: string }): Promise<ToolResponse> {
|
||||
const result = await Tools.Memory.updateLink({
|
||||
linkId: params.link_id,
|
||||
sourceTitle: params.source_title,
|
||||
targetTitle: params.target_title,
|
||||
linkType: params.link_type,
|
||||
newLinkType: params.new_link_type,
|
||||
weight: params.weight,
|
||||
description: params.description,
|
||||
callerCardId: resolveCallerCardId(),
|
||||
});
|
||||
return { success: !!result, message: '记忆链接更新完成', data: result };
|
||||
}
|
||||
|
||||
async function delete_memory_link(params: { link_id?: number; source_title?: string; target_title?: string; link_type?: string }): Promise<ToolResponse> {
|
||||
const result = await Tools.Memory.deleteLink({
|
||||
linkId: params.link_id,
|
||||
sourceTitle: params.source_title,
|
||||
targetTitle: params.target_title,
|
||||
linkType: params.link_type,
|
||||
callerCardId: resolveCallerCardId(),
|
||||
});
|
||||
return { success: typeof result === 'string' ? result.length > 0 : !!result, message: '记忆链接删除完成', data: result };
|
||||
}
|
||||
|
||||
async function update_user_preferences(params: { birth_date?: number; gender?: string; personality?: string; identity?: string; occupation?: string; ai_style?: string }): Promise<ToolResponse> {
|
||||
const toolParams: ToolParams = {};
|
||||
if (params.birth_date !== undefined) toolParams.birth_date = params.birth_date;
|
||||
if (params.gender !== undefined) toolParams.gender = params.gender;
|
||||
if (params.personality !== undefined) toolParams.personality = params.personality;
|
||||
if (params.identity !== undefined) toolParams.identity = params.identity;
|
||||
if (params.occupation !== undefined) toolParams.occupation = params.occupation;
|
||||
if (params.ai_style !== undefined) toolParams.ai_style = params.ai_style;
|
||||
|
||||
const result = await toolCall({ name: "update_user_preferences", params: toolParams });
|
||||
const success = typeof result === 'string' ? result.length > 0 : !!result;
|
||||
return { success, message: '用户偏好更新完成', data: result };
|
||||
}
|
||||
|
||||
async function wrapToolExecution<P>(func: (params: P) => Promise<ToolResponse>, params: P) {
|
||||
try {
|
||||
const result = await func(params);
|
||||
complete(result);
|
||||
} catch (error: any) {
|
||||
console.error(`Tool ${func.name} failed unexpectedly`, error);
|
||||
complete({
|
||||
success: false,
|
||||
message: `工具执行时发生意外错误: ${error.message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const results: any[] = [];
|
||||
|
||||
// 这些工具都可能修改记忆/偏好,默认不做自动化演示,避免污染用户数据。
|
||||
results.push({ tool: 'create_memory', result: { success: null, message: '未测试(会写入记忆库)' } });
|
||||
results.push({ tool: 'update_memory', result: { success: null, message: '未测试(会修改记忆库)' } });
|
||||
results.push({ tool: 'delete_memory', result: { success: null, message: '未测试(会删除记忆库数据)' } });
|
||||
results.push({ tool: 'move_memory', result: { success: null, message: '未测试(会批量修改记忆文件夹)' } });
|
||||
results.push({ tool: 'link_memories', result: { success: null, message: '未测试(会修改记忆库链接)' } });
|
||||
results.push({ tool: 'query_memory_links', result: { success: null, message: '未测试(只读查询)' } });
|
||||
results.push({ tool: 'update_memory_link', result: { success: null, message: '未测试(会修改记忆库链接)' } });
|
||||
results.push({ tool: 'delete_memory_link', result: { success: null, message: '未测试(会删除记忆库链接)' } });
|
||||
results.push({ tool: 'update_user_preferences', result: { success: null, message: '未测试(会修改用户偏好)' } });
|
||||
|
||||
complete({
|
||||
success: true,
|
||||
message: "拓展记忆工具包加载完成(未执行破坏性测试)",
|
||||
data: { results }
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
create_memory: (params: { title: string; content: string; content_type?: string; source?: string; folder_path?: string; tags?: string }) => wrapToolExecution(create_memory, params),
|
||||
update_memory: (params: { old_title: string; new_title?: string; content?: string; content_type?: string; source?: string; credibility?: number; importance?: number; folder_path?: string; tags?: string }) => wrapToolExecution(update_memory, params),
|
||||
delete_memory: (params: { title: string }) => wrapToolExecution(delete_memory, params),
|
||||
move_memory: (params: { target_folder_path: string; titles?: string; source_folder_path?: string }) => wrapToolExecution(move_memory, params),
|
||||
link_memories: (params: { source_title: string; target_title: string; link_type?: string; weight?: number; description?: string }) => wrapToolExecution(link_memories, params),
|
||||
query_memory_links: (params: { link_id?: number; source_title?: string; target_title?: string; link_type?: string; limit?: number }) => wrapToolExecution(query_memory_links, params),
|
||||
update_memory_link: (params: { link_id?: number; source_title?: string; target_title?: string; link_type?: string; new_link_type?: string; weight?: number; description?: string }) => wrapToolExecution(update_memory_link, params),
|
||||
delete_memory_link: (params: { link_id?: number; source_title?: string; target_title?: string; link_type?: string }) => wrapToolExecution(delete_memory_link, params),
|
||||
update_user_preferences: (params: { birth_date?: number; gender?: string; personality?: string; identity?: string; occupation?: string; ai_style?: string }) => wrapToolExecution(update_user_preferences, params),
|
||||
main,
|
||||
};
|
||||
})();
|
||||
|
||||
exports.create_memory = ExtendedMemoryTools.create_memory;
|
||||
exports.update_memory = ExtendedMemoryTools.update_memory;
|
||||
exports.delete_memory = ExtendedMemoryTools.delete_memory;
|
||||
exports.move_memory = ExtendedMemoryTools.move_memory;
|
||||
exports.link_memories = ExtendedMemoryTools.link_memories;
|
||||
exports.query_memory_links = ExtendedMemoryTools.query_memory_links;
|
||||
exports.update_memory_link = ExtendedMemoryTools.update_memory_link;
|
||||
exports.delete_memory_link = ExtendedMemoryTools.delete_memory_link;
|
||||
exports.update_user_preferences = ExtendedMemoryTools.update_user_preferences;
|
||||
exports.main = ExtendedMemoryTools.main;
|
||||
@@ -0,0 +1,143 @@
|
||||
/* METADATA
|
||||
{
|
||||
"name": "ffmpeg",
|
||||
|
||||
"display_name": {
|
||||
"zh": "FFmpeg 工具集",
|
||||
"en": "FFmpeg Toolkit"
|
||||
},
|
||||
"description": {
|
||||
"zh": "提供FFmpeg工具,用于处理多媒体内容。",
|
||||
"en": "FFmpeg utilities for processing multimedia content."
|
||||
},
|
||||
"enabledByDefault": true,
|
||||
"category": "Media",
|
||||
"tools": [
|
||||
{
|
||||
"name": "ffmpeg_execute",
|
||||
"description": { "zh": "执行自定义FFmpeg命令(仅填写参数,不要包含前缀 ffmpeg)。", "en": "Execute a custom FFmpeg command (arguments only; do not include the leading ffmpeg)." },
|
||||
"parameters": [
|
||||
{ "name": "command", "description": { "zh": "要执行的FFmpeg命令参数(不要包含前缀 ffmpeg)", "en": "FFmpeg command arguments to execute (do not include the leading ffmpeg)" }, "type": "string", "required": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ffmpeg_info",
|
||||
"description": { "zh": "获取FFmpeg系统信息,包括版本、构建配置和支持的编解码器。", "en": "Get FFmpeg system info, including version, build config, and supported codecs." },
|
||||
"parameters": []
|
||||
},
|
||||
{
|
||||
"name": "ffmpeg_convert",
|
||||
"description": { "zh": "使用简化参数转换视频文件。", "en": "Convert a video file using simplified parameters." },
|
||||
"parameters": [
|
||||
{ "name": "input_path", "description": { "zh": "源视频文件路径", "en": "Input video file path" }, "type": "string", "required": true },
|
||||
{ "name": "output_path", "description": { "zh": "目标视频文件路径", "en": "Output video file path" }, "type": "string", "required": true },
|
||||
{ "name": "video_codec", "description": { "zh": "可选,要使用的视频编解码器。推荐使用 'h264'。支持的值: 'h264', 'hevc', 'vp8', 'vp9', 'av1', 'libx265', 'libvpx', 'libaom', 'mpeg4', 'mjpeg', 'prores'。", "en": "Optional. Video codec to use. Prefer 'h264'. Supported values: 'h264', 'hevc', 'vp8', 'vp9', 'av1', 'libx265', 'libvpx', 'libaom', 'mpeg4', 'mjpeg', 'prores'." }, "type": "string", "required": false },
|
||||
{ "name": "audio_codec", "description": { "zh": "可选,要使用的音频编解码器。支持的值: 'aac', 'mp3', 'opus', 'vorbis', 'flac', 'pcm', 'wav', 'ac3', 'eac3'。", "en": "Optional. Audio codec to use. Supported values: 'aac', 'mp3', 'opus', 'vorbis', 'flac', 'pcm', 'wav', 'ac3', 'eac3'." }, "type": "string", "required": false },
|
||||
{ "name": "resolution", "description": { "zh": "可选,输出分辨率,例如 '1280x720'。", "en": "Optional. Output resolution, e.g. '1280x720'." }, "type": "string", "required": false },
|
||||
{ "name": "bitrate", "description": { "zh": "可选,视频比特率,例如 '1000k'。", "en": "Optional. Video bitrate, e.g. '1000k'." }, "type": "string", "required": false }
|
||||
]
|
||||
}
|
||||
]
|
||||
}*/
|
||||
|
||||
const FFmpegTools = (function () {
|
||||
|
||||
type FFmpegVideoCodec = 'h264' | 'hevc' | 'vp8' | 'vp9' | 'av1' | 'libx265' | 'libvpx' | 'libaom' | 'mpeg4' | 'mjpeg' | 'prores';
|
||||
type FFmpegAudioCodec = 'aac' | 'mp3' | 'opus' | 'vorbis' | 'flac' | 'pcm' | 'wav' | 'ac3' | 'eac3';
|
||||
type FFmpegResolution = '1280x720' | '1920x1080' | '3840x2160' | '7680x4320' | `${number}x${number}`;
|
||||
type FFmpegBitrate = '500k' | '1000k' | '2000k' | '4000k' | '8000k' | `${number}k` | `${number}M`;
|
||||
|
||||
interface ToolResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
async function ffmpeg_execute(params: { command: string }): Promise<ToolResponse> {
|
||||
const result = await Tools.FFmpeg.execute(params.command);
|
||||
return {
|
||||
success: result.returnCode === 0,
|
||||
message: result.returnCode === 0 ? "FFmpeg command executed successfully." : `FFmpeg command failed with return code ${result.returnCode}.`,
|
||||
data: result.output
|
||||
};
|
||||
}
|
||||
|
||||
async function ffmpeg_info(): Promise<ToolResponse> {
|
||||
const result = await Tools.FFmpeg.info();
|
||||
return {
|
||||
success: result.returnCode === 0,
|
||||
message: result.returnCode === 0 ? "FFmpeg info retrieved successfully." : "Failed to retrieve FFmpeg info.",
|
||||
data: result.output
|
||||
};
|
||||
}
|
||||
|
||||
async function ffmpeg_convert(params: {
|
||||
input_path: string,
|
||||
output_path: string,
|
||||
video_codec?: FFmpegVideoCodec,
|
||||
audio_codec?: FFmpegAudioCodec,
|
||||
resolution?: FFmpegResolution,
|
||||
bitrate?: FFmpegBitrate
|
||||
}): Promise<ToolResponse> {
|
||||
const result = await Tools.FFmpeg.convert(params.input_path, params.output_path, {
|
||||
video_codec: params.video_codec,
|
||||
audio_codec: params.audio_codec,
|
||||
resolution: params.resolution,
|
||||
bitrate: params.bitrate,
|
||||
});
|
||||
return {
|
||||
success: result.returnCode === 0,
|
||||
message: result.returnCode === 0 ? "FFmpeg conversion completed successfully." : `FFmpeg conversion failed with return code ${result.returnCode}.`,
|
||||
data: result.output
|
||||
};
|
||||
}
|
||||
|
||||
async function wrapToolExecution<P>(func: (params: P) => Promise<ToolResponse>, params: P) {
|
||||
try {
|
||||
const result = await func(params);
|
||||
complete(result);
|
||||
} catch (error: any) {
|
||||
console.error(`Tool ${func.name} failed unexpectedly`, error);
|
||||
complete({
|
||||
success: false,
|
||||
message: `工具执行时发生意外错误: ${error.message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("--- FFmpeg Tools Test ---");
|
||||
|
||||
console.log("\n[1/3] Testing ffmpeg_info...");
|
||||
const infoResult = await ffmpeg_info();
|
||||
console.log(JSON.stringify(infoResult, null, 2));
|
||||
|
||||
console.log("\n[2/3] Testing ffmpeg_execute (example: getting help for a decoder)...");
|
||||
const executeResult = await ffmpeg_execute({ command: '-h decoder=h264' });
|
||||
console.log(JSON.stringify(executeResult, null, 2));
|
||||
|
||||
console.log("\n[3/3] Testing ffmpeg_convert (example)...");
|
||||
console.log("Skipping ffmpeg_convert test as it requires input files.");
|
||||
|
||||
complete({ success: true, message: "Test finished." });
|
||||
}
|
||||
|
||||
return {
|
||||
ffmpeg_execute: (params: { command: string }) => wrapToolExecution(ffmpeg_execute, params),
|
||||
ffmpeg_info: (params: {}) => wrapToolExecution(ffmpeg_info, params),
|
||||
ffmpeg_convert: (params: {
|
||||
input_path: string,
|
||||
output_path: string,
|
||||
video_codec?: FFmpegVideoCodec,
|
||||
audio_codec?: FFmpegAudioCodec,
|
||||
resolution?: FFmpegResolution,
|
||||
bitrate?: FFmpegBitrate
|
||||
}) => wrapToolExecution(ffmpeg_convert, params),
|
||||
main,
|
||||
};
|
||||
})();
|
||||
|
||||
exports.ffmpeg_execute = FFmpegTools.ffmpeg_execute;
|
||||
exports.ffmpeg_info = FFmpegTools.ffmpeg_info;
|
||||
exports.ffmpeg_convert = FFmpegTools.ffmpeg_convert;
|
||||
exports.main = FFmpegTools.main;
|
||||
@@ -0,0 +1,265 @@
|
||||
/* METADATA
|
||||
{
|
||||
"name": "file_converter",
|
||||
|
||||
"display_name": {
|
||||
"zh": "文件转换器",
|
||||
"en": "File Converter"
|
||||
},
|
||||
"description": {
|
||||
"zh": "提供全面的文件格式转换功能。支持常见的音频/视频(如 MP4、MOV、MP3、WAV)、图像(如 JPG、PNG、WEBP)以及文档(如 Markdown、HTML、DOCX、PDF)之间的相互转换。",
|
||||
"en": "Comprehensive file format conversion. Supports converting between common audio/video (MP4, MOV, MP3, WAV), images (JPG, PNG, WEBP), and documents (Markdown, HTML, DOCX, PDF)."
|
||||
},
|
||||
"enabledByDefault": true,
|
||||
"category": "File",
|
||||
"tools": [
|
||||
{
|
||||
"name": "convert_file",
|
||||
"description": {
|
||||
"zh": "调用 FFmpeg、ImageMagick 和 Pandoc 等外部命令行工具,转换文件的格式。支持音视频、图像和文档等多种类型。如果检测到工具未安装,会尝试自动安装。",
|
||||
"en": "Convert files using external CLI tools such as FFmpeg, ImageMagick, and Pandoc. Supports audio/video, images, and documents. If a required tool is missing, it will attempt to install it automatically."
|
||||
},
|
||||
"parameters": [
|
||||
{ "name": "input_path", "description": { "zh": "输入文件的路径。", "en": "Input file path." }, "type": "string", "required": true },
|
||||
{ "name": "output_path", "description": { "zh": "输出文件的路径。扩展名决定了目标格式。", "en": "Output file path. The file extension determines the target format." }, "type": "string", "required": true },
|
||||
{ "name": "options", "description": { "zh": "用于转换工具的可选命令行选项 (例如, 为 ImageMagick 设置 '-quality 80' )。", "en": "Optional CLI options for the conversion tool (e.g. set '-quality 80' for ImageMagick)." }, "type": "string", "required": false }
|
||||
]
|
||||
}
|
||||
]
|
||||
}*/
|
||||
|
||||
const fileConverter = (function () {
|
||||
|
||||
let terminalSessionId: string | null = null;
|
||||
|
||||
async function getTerminalSessionId(): Promise<string> {
|
||||
if (terminalSessionId) {
|
||||
return terminalSessionId;
|
||||
}
|
||||
const session = await Tools.System.terminal.create("file_converter_session");
|
||||
terminalSessionId = session.sessionId;
|
||||
return terminalSessionId;
|
||||
}
|
||||
|
||||
async function executeTerminalCommand(command: string, timeoutMs?: number) {
|
||||
const sessionId = await getTerminalSessionId();
|
||||
return await Tools.System.terminal.exec(sessionId, command);
|
||||
}
|
||||
|
||||
interface ToolResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
interface ConvertFileParams {
|
||||
input_path: string;
|
||||
output_path: string;
|
||||
options?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a command-line tool is installed, and attempts to install it if not found.
|
||||
* @param toolName The name of the command (e.g., "ffmpeg").
|
||||
* @param packageName The name of the package to install (e.g., "ffmpeg").
|
||||
* @returns A promise that resolves to true if the tool is available.
|
||||
* @throws An error if the tool is not found and installation fails.
|
||||
*/
|
||||
async function checkAndInstall(toolName: string, packageName: string): Promise<boolean> {
|
||||
console.log(`Checking for ${toolName}...`);
|
||||
const checkCmd = `command -v ${toolName}`;
|
||||
const checkResult = await executeTerminalCommand(checkCmd);
|
||||
|
||||
if (checkResult.exitCode === 0 && checkResult.output.trim() !== '') {
|
||||
console.log(`${toolName} is already installed at: ${checkResult.output.trim()}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log(`${toolName} not found. Attempting to install package: ${packageName}...`);
|
||||
|
||||
// Assuming an apt-based system (like Debian/Ubuntu).
|
||||
const updateCmd = 'apt-get update';
|
||||
console.log(`Running: ${updateCmd}`);
|
||||
const updateResult = await executeTerminalCommand(updateCmd);
|
||||
if (updateResult.exitCode !== 0) {
|
||||
console.warn(`'apt-get update' failed. This might be okay if caches are fresh, but installation may fail.\nOutput: ${updateResult.output}`);
|
||||
}
|
||||
|
||||
const installCmd = `apt-get install -y ${packageName}`;
|
||||
console.log(`Running: ${installCmd}`);
|
||||
const installResult = await executeTerminalCommand(installCmd);
|
||||
|
||||
if (installResult.exitCode !== 0) {
|
||||
console.error(`Failed to install ${packageName}: ${installResult.output}`);
|
||||
throw new Error(`Failed to install required tool: ${toolName} (package: ${packageName}). Please try installing it manually.`);
|
||||
}
|
||||
|
||||
console.log(`${packageName} installed successfully.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the appropriate conversion tool and command based on file extensions.
|
||||
* @param inputPath Path to the input file.
|
||||
* @param outputPath Path to the output file.
|
||||
* @param options Additional options for the command.
|
||||
* @returns An object with tool information and the command to execute.
|
||||
*/
|
||||
function getConverterInfo(inputPath: string, outputPath: string, options?: string): { command: string; tool: string; pkg: string; } {
|
||||
const getExt = (path: string) => path.split('.').pop()?.toLowerCase() || '';
|
||||
const inputExt = getExt(inputPath);
|
||||
const outputExt = getExt(outputPath);
|
||||
|
||||
const isAudioVideo = (ext: string) => ['mp4', 'mkv', 'avi', 'mov', 'flv', 'webm', 'mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a', 'wma', 'wmv'].includes(ext);
|
||||
const isImage = (ext: string) => ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'tiff', 'ico', 'svg'].includes(ext);
|
||||
const isDocument = (ext: string) => ['md', 'html', 'docx', 'pdf', 'txt', 'epub', 'odt', 'rtf', 'tex', 'rst', 'json', 'csv'].includes(ext);
|
||||
|
||||
if (isAudioVideo(inputExt) || isAudioVideo(outputExt)) {
|
||||
return {
|
||||
tool: 'ffmpeg',
|
||||
pkg: 'ffmpeg',
|
||||
command: `ffmpeg -y -i "${inputPath}" ${options || ''} "${outputPath}"`
|
||||
};
|
||||
}
|
||||
|
||||
if (isImage(inputExt) || isImage(outputExt)) {
|
||||
return {
|
||||
tool: 'convert',
|
||||
pkg: 'imagemagick',
|
||||
command: `convert "${inputPath}" ${options || ''} "${outputPath}"`
|
||||
};
|
||||
}
|
||||
|
||||
if (isDocument(inputExt) || isDocument(outputExt)) {
|
||||
return {
|
||||
tool: 'pandoc',
|
||||
pkg: 'pandoc',
|
||||
command: `pandoc "${inputPath}" -o "${outputPath}" ${options || ''}`
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported or ambiguous file conversion from .${inputExt} to .${outputExt}.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The core logic for the convert_file tool.
|
||||
* @param params Parameters for the conversion.
|
||||
* @returns An object with the result of the conversion.
|
||||
*/
|
||||
async function convert_file(params: ConvertFileParams) {
|
||||
const { input_path, output_path, options } = params;
|
||||
|
||||
const fileExists = await Tools.Files.exists(input_path);
|
||||
if (!fileExists.exists) {
|
||||
throw new Error(`Input file not found: ${input_path}`);
|
||||
}
|
||||
|
||||
const converter = getConverterInfo(input_path, output_path, options);
|
||||
|
||||
await checkAndInstall(converter.tool, converter.pkg);
|
||||
|
||||
console.log(`Executing conversion command: ${converter.command}`);
|
||||
const result = await executeTerminalCommand(converter.command);
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`Conversion failed. Exit code: ${result.exitCode}\nOutput:\n${result.output}`);
|
||||
}
|
||||
|
||||
const outputExists = await Tools.Files.exists(output_path);
|
||||
if (!outputExists.exists) {
|
||||
// Sometimes a tool exits with 0 but fails, writing to stderr.
|
||||
throw new Error(`Conversion process finished, but output file was not created at: ${output_path}\nTerminal Output:\n${result.output}`);
|
||||
}
|
||||
|
||||
return {
|
||||
output_path: output_path,
|
||||
details: `File converted successfully and saved to ${output_path}.`,
|
||||
terminal_output: result.output
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A wrapper function for executing tools to provide standardized success/error handling.
|
||||
*/
|
||||
async function wrap(func: (params: any) => Promise<any>, params: any, successMessage: string, failMessage: string) {
|
||||
try {
|
||||
const result = await func(params);
|
||||
complete({ success: true, message: successMessage, data: result });
|
||||
} catch (error: any) {
|
||||
console.error(`Function ${func.name} failed! Error: ${error.message}`);
|
||||
complete({ success: false, message: `${failMessage}: ${error.message}`, error_stack: error.stack });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A main function for self-testing the capabilities of this tool package.
|
||||
*/
|
||||
async function main() {
|
||||
console.log("--- Starting File Converter Tool Test ---");
|
||||
const testDir = "/sdcard/Download/converter_test";
|
||||
await Tools.Files.mkdir(testDir, true);
|
||||
|
||||
// Test 1: Image conversion (PNG to JPG)
|
||||
try {
|
||||
console.log("\n[1/3] Testing Image Conversion (PNG -> JPG)");
|
||||
// A simple 1x1 red pixel PNG in base64
|
||||
const pngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/wcAAwAB/epv2AAAAABJRU5ErkJggg==";
|
||||
const inputPng = `${testDir}/test.png`;
|
||||
const outputJpg = `${testDir}/test.jpg`;
|
||||
await Tools.Files.writeBinary(inputPng, pngBase64);
|
||||
|
||||
const imageResult = await convert_file({ input_path: inputPng, output_path: outputJpg });
|
||||
console.log("Image conversion success:", imageResult);
|
||||
if (!(await Tools.Files.exists(outputJpg)).exists) {
|
||||
throw new Error("JPG file not created.");
|
||||
}
|
||||
|
||||
// Verify that readBinary can read the converted image as Base64
|
||||
const jpgBinary = await Tools.Files.readBinary(outputJpg);
|
||||
console.log("ReadBinary success: size=", jpgBinary.size, "bytes, base64 length=", jpgBinary.contentBase64.length);
|
||||
} catch (e: any) {
|
||||
console.error("Image conversion test failed:", e.message);
|
||||
}
|
||||
|
||||
// Test 2: Document conversion (MD to HTML)
|
||||
try {
|
||||
console.log("\n[2/3] Testing Document Conversion (MD -> HTML)");
|
||||
const inputMd = `${testDir}/test.md`;
|
||||
const outputHtml = `${testDir}/test.html`;
|
||||
await Tools.Files.write(inputMd, "# Hello World");
|
||||
|
||||
const docResult = await convert_file({ input_path: inputMd, output_path: outputHtml });
|
||||
console.log("Document conversion success:", docResult);
|
||||
const htmlContent = (await Tools.Files.read(outputHtml)).content;
|
||||
if (!htmlContent.includes("<h1")) {
|
||||
throw new Error("HTML content is incorrect.");
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error("Document conversion test failed:", e.message);
|
||||
}
|
||||
|
||||
// Test 3: Unsupported conversion
|
||||
try {
|
||||
console.log("\n[3/3] Testing Unsupported Conversion (zip -> tar)");
|
||||
const inputZip = `${testDir}/test.zip`;
|
||||
await Tools.Files.write(inputZip, "dummy content");
|
||||
await convert_file({ input_path: inputZip, output_path: `${testDir}/test.tar` });
|
||||
console.error("Unsupported conversion test FAILED: It should have thrown an error but didn't.");
|
||||
} catch (e: any) {
|
||||
console.log("Unsupported conversion test PASSED as expected:", e.message);
|
||||
}
|
||||
|
||||
console.log("\n--- File Converter Tool Test Finished ---");
|
||||
await Tools.Files.deleteFile(testDir, true);
|
||||
console.log("Cleaned up test directory.");
|
||||
complete({ success: true, message: "All tests finished." });
|
||||
}
|
||||
|
||||
return {
|
||||
convert_file: (p: ConvertFileParams) => wrap(convert_file, p, 'File conversion successful.', 'File conversion failed.'),
|
||||
main: main,
|
||||
};
|
||||
})();
|
||||
|
||||
exports.convert_file = fileConverter.convert_file;
|
||||
exports.main = fileConverter.main;
|
||||
@@ -0,0 +1,257 @@
|
||||
/* METADATA
|
||||
{
|
||||
"name": "google_search",
|
||||
|
||||
"display_name": {
|
||||
"zh": "Google 搜索",
|
||||
"en": "Google Search"
|
||||
},
|
||||
"description": {
|
||||
"zh": "提供 Google 普通搜索与 Google Scholar 学术搜索能力,支持设置语言与返回条数。",
|
||||
"en": "Google web search and Google Scholar search, with configurable language and result count."
|
||||
},
|
||||
"enabledByDefault": false,
|
||||
"category": "Search",
|
||||
"tools": [
|
||||
{
|
||||
"name": "search_web",
|
||||
"description": { "zh": "执行 Google 普通搜索,返回网页搜索结果。", "en": "Perform a regular Google search and return web results." },
|
||||
"parameters": [
|
||||
{ "name": "query", "description": { "zh": "搜索关键词", "en": "Search query" }, "type": "string", "required": true },
|
||||
{ "name": "max_results", "description": { "zh": "返回结果数量,默认 10,最大 20", "en": "Number of results to return (default: 10, max: 20)" }, "type": "number", "required": false },
|
||||
{ "name": "language", "description": { "zh": "界面语言参数,默认 en", "en": "Interface language (default: en)" }, "type": "string", "required": false },
|
||||
{ "name": "region", "description": { "zh": "地区参数,例如 us、cn。默认 us", "en": "Region parameter, e.g. us, cn (default: us)" }, "type": "string", "required": false },
|
||||
{ "name": "includeLinks", "description": { "zh": "是否在结果中包含可点击的链接列表,默认为false。", "en": "Whether to include a clickable link list in results (default: false)" }, "type": "boolean", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "search_scholar",
|
||||
"description": { "zh": "执行 Google Scholar 学术搜索,返回学术文献结果。", "en": "Perform a Google Scholar search and return academic results." },
|
||||
"parameters": [
|
||||
{ "name": "query", "description": { "zh": "搜索关键词", "en": "Search query" }, "type": "string", "required": true },
|
||||
{ "name": "max_results", "description": { "zh": "返回结果数量,默认 10,最大 20", "en": "Number of results to return (default: 10, max: 20)" }, "type": "number", "required": false },
|
||||
{ "name": "language", "description": { "zh": "界面语言参数,默认 en", "en": "Interface language (default: en)" }, "type": "string", "required": false },
|
||||
{ "name": "includeLinks", "description": { "zh": "是否在结果中包含可点击的链接列表,默认为false。", "en": "Whether to include a clickable link list in results (default: false)" }, "type": "boolean", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "search_scholar_mirror",
|
||||
"description": { "zh": "通过镜像站执行 Google Scholar 学术搜索,以绕过人机验证。", "en": "Use a mirror site to perform Google Scholar searches to bypass CAPTCHA checks." },
|
||||
"parameters": [
|
||||
{ "name": "query", "description": { "zh": "搜索关键词", "en": "Search query" }, "type": "string", "required": true },
|
||||
{ "name": "max_results", "description": { "zh": "返回结果数量,默认 10,最大 20", "en": "Number of results to return (default: 10, max: 20)" }, "type": "number", "required": false },
|
||||
{ "name": "language", "description": { "zh": "界面语言参数,默认 en", "en": "Interface language (default: en)" }, "type": "string", "required": false },
|
||||
{ "name": "includeLinks", "description": { "zh": "是否在结果中包含可点击的链接列表,默认为false。", "en": "Whether to include a clickable link list in results (default: false)" }, "type": "boolean", "required": false }
|
||||
]
|
||||
}
|
||||
]
|
||||
}*/
|
||||
|
||||
const googleSearch = (function () {
|
||||
type SearchParams = {
|
||||
query: string;
|
||||
max_results?: number;
|
||||
language?: string;
|
||||
region?: string;
|
||||
includeLinks?: boolean;
|
||||
};
|
||||
|
||||
type ScholarSearchParams = {
|
||||
query: string;
|
||||
max_results?: number;
|
||||
language?: string;
|
||||
includeLinks?: boolean;
|
||||
};
|
||||
|
||||
type GoogleSearchResult = {
|
||||
title: string;
|
||||
url: string;
|
||||
snippet: string;
|
||||
position: number;
|
||||
};
|
||||
|
||||
const GOOGLE_SEARCH_URL = "https://www.google.com/search";
|
||||
const GOOGLE_SCHOLAR_URL = "https://scholar.google.com/scholar";
|
||||
const GOOGLE_SCHOLAR_MIRRORS = [
|
||||
"https://xs.cntpj.com/scholar",
|
||||
];
|
||||
const MAX_RESULTS = 20;
|
||||
|
||||
function buildUrl(base: string, params: Record<string, string>): string {
|
||||
const queryString = Object.entries(params)
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
||||
.join("&");
|
||||
return `${base}?${queryString}`;
|
||||
}
|
||||
|
||||
function getHostname(rawUrl: string): string {
|
||||
const match = rawUrl.trim().match(/^[a-zA-Z][a-zA-Z\d+\-.]*:\/\/([^/?#]+)/);
|
||||
if (!match || !match[1]) {
|
||||
return rawUrl;
|
||||
}
|
||||
const authority = match[1];
|
||||
const host = authority.includes("@") ? authority.split("@").pop() || authority : authority;
|
||||
return host.replace(/:\d+$/, "");
|
||||
}
|
||||
|
||||
async function fetchHtmlViaWebVisit(url: string): Promise<VisitWebResultData> {
|
||||
const result = await Tools.Net.visit(url);
|
||||
// The result can be a string if the underlying tool returns a simple string.
|
||||
// We'll normalize it to a VisitWebResultData object.
|
||||
if (typeof result === "string") {
|
||||
return {
|
||||
url: url,
|
||||
title: "",
|
||||
content: result,
|
||||
links: [],
|
||||
toString: () => result,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 解析相关逻辑已移除,直接返回 visit 的纯文本结果
|
||||
|
||||
async function performSearch(url: string, includeLinks: boolean = false, sourceName: string) {
|
||||
try {
|
||||
const result = await fetchHtmlViaWebVisit(url);
|
||||
const content = result.content || '';
|
||||
|
||||
let parts: string[] = [];
|
||||
if (result.visitKey) {
|
||||
parts.push(`visit_key: ${result.visitKey}`);
|
||||
}
|
||||
if (includeLinks && result.links && result.links.length > 0) {
|
||||
const linksLines = result.links.map((link, index) => `[${index + 1}] ${link.text}`);
|
||||
parts.push(linksLines.join('\n'));
|
||||
}
|
||||
parts.push(content);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `${sourceName} 搜索成功`,
|
||||
data: parts.join('\n\n')
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: `${sourceName} 搜索失败: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function searchWeb(params: SearchParams) {
|
||||
if (!params.query || params.query.trim() === "") {
|
||||
throw new Error("请提供有效的 query 参数。");
|
||||
}
|
||||
const maxResults = Math.min(Math.max(params.max_results || 10, 1), MAX_RESULTS);
|
||||
const language = params.language || "en";
|
||||
const region = params.region || "us";
|
||||
const url = buildUrl(GOOGLE_SEARCH_URL, {
|
||||
q: params.query,
|
||||
hl: language,
|
||||
gl: region,
|
||||
num: String(maxResults),
|
||||
pws: "0",
|
||||
});
|
||||
|
||||
return performSearch(url, params.includeLinks, "Google");
|
||||
}
|
||||
|
||||
async function searchScholar(params: ScholarSearchParams) {
|
||||
if (!params.query || params.query.trim() === "") {
|
||||
throw new Error("请提供有效的 query 参数。");
|
||||
}
|
||||
const maxResults = Math.min(Math.max(params.max_results || 10, 1), MAX_RESULTS);
|
||||
const language = params.language || "en";
|
||||
const url = buildUrl(GOOGLE_SCHOLAR_URL, {
|
||||
q: params.query,
|
||||
hl: language,
|
||||
as_sdt: "0,5",
|
||||
num: String(maxResults)
|
||||
});
|
||||
|
||||
return performSearch(url, params.includeLinks, "Google Scholar");
|
||||
}
|
||||
|
||||
async function searchScholarMirror(params: ScholarSearchParams) {
|
||||
if (!params.query || params.query.trim() === "") {
|
||||
throw new Error("请提供有效的 query 参数。");
|
||||
}
|
||||
const maxResults = Math.min(Math.max(params.max_results || 10, 1), MAX_RESULTS);
|
||||
const language = params.language || "en";
|
||||
|
||||
const mirrorUrls = GOOGLE_SCHOLAR_MIRRORS.map(mirror => buildUrl(mirror, {
|
||||
q: params.query,
|
||||
hl: language,
|
||||
as_sdt: "0,5",
|
||||
num: String(maxResults)
|
||||
}));
|
||||
|
||||
if (mirrorUrls.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: "没有可用的 Google Scholar 镜像地址。"
|
||||
};
|
||||
}
|
||||
|
||||
let lastError: any = null;
|
||||
|
||||
for (const currentUrl of mirrorUrls) {
|
||||
try {
|
||||
const searchResult = await performSearch(currentUrl, params.includeLinks, `Google Scholar 镜像 (${getHostname(currentUrl)})`);
|
||||
if (searchResult.success && searchResult.data) {
|
||||
// Check for CAPTCHA in the content
|
||||
if (searchResult.data.includes("recaptcha") || searchResult.data.includes("人机身份验证")) {
|
||||
throw new Error("CAPTCHA required");
|
||||
}
|
||||
return searchResult;
|
||||
}
|
||||
// If performSearch itself fails, it will throw and be caught below.
|
||||
} catch (error: any) {
|
||||
lastError = error;
|
||||
console.log(`Attempt with ${currentUrl} failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: `Google Scholar 镜像搜索在尝试所有镜像后失败: ${lastError?.message || 'Unknown error'}`
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("--- Testing Web Search ---");
|
||||
const webResult = await searchWeb({ query: "TypeScript" });
|
||||
console.log(JSON.stringify(webResult, null, 2));
|
||||
|
||||
console.log("\n--- Testing Scholar Search ---");
|
||||
const scholarResult = await searchScholar({ query: "Large Language Models" });
|
||||
console.log(JSON.stringify(scholarResult, null, 2));
|
||||
|
||||
console.log("\n--- Testing Scholar Mirror Search ---");
|
||||
const scholarMirrorResult = await searchScholarMirror({ query: "Quantum Computing" });
|
||||
console.log(JSON.stringify(scholarMirrorResult, null, 2));
|
||||
}
|
||||
|
||||
function wrap(coreFunction: (params: any) => Promise<any>) {
|
||||
return async (params: any) => {
|
||||
// The core function expects the params object directly.
|
||||
return coreFunction(params);
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
search_web: searchWeb,
|
||||
search_scholar: searchScholar,
|
||||
search_scholar_mirror: searchScholarMirror,
|
||||
main,
|
||||
wrap,
|
||||
};
|
||||
})();
|
||||
|
||||
exports.search_web = googleSearch.wrap(googleSearch.search_web);
|
||||
exports.search_scholar = googleSearch.wrap(googleSearch.search_scholar);
|
||||
exports.search_scholar_mirror = googleSearch.wrap(googleSearch.search_scholar_mirror);
|
||||
exports.main = googleSearch.main;
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
/* METADATA
|
||||
{
|
||||
"name": "hex_editor",
|
||||
"display_name": {
|
||||
"zh": "十六进制编辑器",
|
||||
"en": "Hex Editor"
|
||||
},
|
||||
"description": {
|
||||
"zh": "一个面向二进制文件的轻量十六进制查看与修改工具包,支持查看指定偏移的 hex 窗口、按偏移写入字节序列,以及搜索指定 hex 模式。",
|
||||
"en": "A lightweight binary hex inspection and patching toolkit. It can read hex windows at offsets, write byte sequences at offsets, and search for hex patterns."
|
||||
},
|
||||
"enabledByDefault": false,
|
||||
"category": "File",
|
||||
"tools": [
|
||||
{
|
||||
"name": "read_hex_window",
|
||||
"description": {
|
||||
"zh": "读取二进制文件在指定偏移附近的十六进制窗口,并附带 ASCII 预览,便于快速定位和人工核对。",
|
||||
"en": "Read a hex window from a binary file at a specific offset, including an ASCII preview for quick inspection."
|
||||
},
|
||||
"parameters": [
|
||||
{ "name": "file_path", "description": { "zh": "目标文件路径。", "en": "Target file path." }, "type": "string", "required": true },
|
||||
{ "name": "offset", "description": { "zh": "起始偏移,可写十进制或 0x 开头的十六进制。", "en": "Start offset in decimal or 0x-prefixed hexadecimal." }, "type": "string", "required": true },
|
||||
{ "name": "length", "description": { "zh": "读取字节数,默认 128,最大 4096。", "en": "Number of bytes to read. Default 128, max 4096." }, "type": "number", "required": false },
|
||||
{ "name": "environment", "description": { "zh": "文件环境:android 或 linux,默认 android。", "en": "File environment: android or linux. Defaults to android." }, "type": "string", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "write_hex_bytes",
|
||||
"description": {
|
||||
"zh": "把给定的 hex 字节序列直接写入文件指定偏移。该工具会原地修改文件,不会自动生成回退文件。",
|
||||
"en": "Write a hex byte sequence directly into a file at the specified offset. This patches the file in place and does not create rollback files."
|
||||
},
|
||||
"parameters": [
|
||||
{ "name": "file_path", "description": { "zh": "目标文件路径。", "en": "Target file path." }, "type": "string", "required": true },
|
||||
{ "name": "offset", "description": { "zh": "写入起始偏移,可写十进制或 0x 开头的十六进制。", "en": "Write offset in decimal or 0x-prefixed hexadecimal." }, "type": "string", "required": true },
|
||||
{ "name": "hex_bytes", "description": { "zh": "要写入的 hex 字节序列,例如 'DE AD BE EF' 或 'deadbeef'。", "en": "Hex bytes to write, for example 'DE AD BE EF' or 'deadbeef'." }, "type": "string", "required": true },
|
||||
{ "name": "environment", "description": { "zh": "文件环境:android 或 linux,默认 android。", "en": "File environment: android or linux. Defaults to android." }, "type": "string", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "find_hex_pattern",
|
||||
"description": {
|
||||
"zh": "在二进制文件中搜索指定的 hex 模式,返回命中偏移以及每个命中点附近的十六进制窗口。",
|
||||
"en": "Search a binary file for a hex pattern and return matched offsets with nearby hex windows."
|
||||
},
|
||||
"parameters": [
|
||||
{ "name": "file_path", "description": { "zh": "目标文件路径。", "en": "Target file path." }, "type": "string", "required": true },
|
||||
{ "name": "hex_bytes", "description": { "zh": "要搜索的 hex 模式,例如 '50 4B 03 04'。", "en": "Hex pattern to search for, for example '50 4B 03 04'." }, "type": "string", "required": true },
|
||||
{ "name": "max_results", "description": { "zh": "最多返回多少个命中,默认 20,最大 100。", "en": "Maximum number of matches to return. Default 20, max 100." }, "type": "number", "required": false },
|
||||
{ "name": "environment", "description": { "zh": "文件环境:android 或 linux,默认 android。", "en": "File environment: android or linux. Defaults to android." }, "type": "string", "required": false }
|
||||
]
|
||||
}
|
||||
]
|
||||
}*/
|
||||
|
||||
type FileEnvironment = "android" | "linux";
|
||||
|
||||
interface ReadHexWindowParams {
|
||||
file_path: string;
|
||||
offset: string;
|
||||
length?: number;
|
||||
environment?: string;
|
||||
}
|
||||
|
||||
interface WriteHexBytesParams {
|
||||
file_path: string;
|
||||
offset: string;
|
||||
hex_bytes: string;
|
||||
environment?: string;
|
||||
}
|
||||
|
||||
interface FindHexPatternParams {
|
||||
file_path: string;
|
||||
hex_bytes: string;
|
||||
max_results?: number;
|
||||
environment?: string;
|
||||
}
|
||||
|
||||
const hexEditorPackage = (function () {
|
||||
const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
const DEFAULT_WINDOW_LENGTH = 128;
|
||||
const MAX_WINDOW_LENGTH = 4096;
|
||||
const DEFAULT_MAX_RESULTS = 20;
|
||||
const MAX_RESULTS_LIMIT = 100;
|
||||
const CONTEXT_BEFORE = 16;
|
||||
const CONTEXT_TOTAL = 64;
|
||||
const BYTES_PER_LINE = 16;
|
||||
|
||||
function normalizeEnvironment(environment?: string): FileEnvironment {
|
||||
const value = String(environment || "android").trim().toLowerCase();
|
||||
if (value !== "android" && value !== "linux") {
|
||||
throw new Error("environment 只能是 android 或 linux。");
|
||||
}
|
||||
return value as FileEnvironment;
|
||||
}
|
||||
|
||||
function normalizeBase64Input(value: string): string {
|
||||
const cleaned = String(value || "")
|
||||
.replace(/\s+/g, "")
|
||||
.replace(/-/g, "+")
|
||||
.replace(/_/g, "/");
|
||||
if (!cleaned) {
|
||||
return "";
|
||||
}
|
||||
const remainder = cleaned.length % 4;
|
||||
if (remainder === 1) {
|
||||
throw new Error("Invalid base64 string");
|
||||
}
|
||||
return remainder === 0 ? cleaned : cleaned + "=".repeat(4 - remainder);
|
||||
}
|
||||
|
||||
function decodeBase64ToBytes(value: string): number[] {
|
||||
const normalized = normalizeBase64Input(value);
|
||||
const bytes: number[] = [];
|
||||
|
||||
for (let i = 0; i < normalized.length; i += 4) {
|
||||
const c1 = normalized[i];
|
||||
const c2 = normalized[i + 1];
|
||||
const c3 = normalized[i + 2];
|
||||
const c4 = normalized[i + 3];
|
||||
|
||||
const v1 = BASE64_ALPHABET.indexOf(c1);
|
||||
const v2 = BASE64_ALPHABET.indexOf(c2);
|
||||
const v3 = c3 === "=" ? 0 : BASE64_ALPHABET.indexOf(c3);
|
||||
const v4 = c4 === "=" ? 0 : BASE64_ALPHABET.indexOf(c4);
|
||||
|
||||
if (v1 < 0 || v2 < 0 || (c3 !== "=" && v3 < 0) || (c4 !== "=" && v4 < 0)) {
|
||||
throw new Error("Invalid base64 string");
|
||||
}
|
||||
|
||||
const chunk = (v1 << 18) | (v2 << 12) | (v3 << 6) | v4;
|
||||
bytes.push((chunk >> 16) & 0xff);
|
||||
if (c3 !== "=") {
|
||||
bytes.push((chunk >> 8) & 0xff);
|
||||
}
|
||||
if (c4 !== "=") {
|
||||
bytes.push(chunk & 0xff);
|
||||
}
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function encodeBytesToBase64(bytes: number[]): string {
|
||||
let output = "";
|
||||
|
||||
for (let i = 0; i < bytes.length; i += 3) {
|
||||
const b1 = bytes[i] ?? 0;
|
||||
const hasB2 = i + 1 < bytes.length;
|
||||
const hasB3 = i + 2 < bytes.length;
|
||||
const b2 = hasB2 ? (bytes[i + 1] ?? 0) : 0;
|
||||
const b3 = hasB3 ? (bytes[i + 2] ?? 0) : 0;
|
||||
const chunk = (b1 << 16) | (b2 << 8) | b3;
|
||||
|
||||
output += BASE64_ALPHABET[(chunk >> 18) & 0x3f];
|
||||
output += BASE64_ALPHABET[(chunk >> 12) & 0x3f];
|
||||
output += hasB2 ? BASE64_ALPHABET[(chunk >> 6) & 0x3f] : "=";
|
||||
output += hasB3 ? BASE64_ALPHABET[chunk & 0x3f] : "=";
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function parseOffset(value: string): number {
|
||||
const trimmed = String(value || "").trim().toLowerCase();
|
||||
if (!trimmed) {
|
||||
throw new Error("offset 不能为空。");
|
||||
}
|
||||
|
||||
let parsed = NaN;
|
||||
if (/^0x[0-9a-f]+$/.test(trimmed)) {
|
||||
parsed = parseInt(trimmed.slice(2), 16);
|
||||
} else if (/^\d+$/.test(trimmed)) {
|
||||
parsed = parseInt(trimmed, 10);
|
||||
} else {
|
||||
throw new Error("offset 必须是十进制数字或 0x 开头的十六进制。");
|
||||
}
|
||||
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
throw new Error("offset 必须是大于等于 0 的整数。");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function clampPositiveInteger(value: number | undefined, defaultValue: number, maxValue: number, fieldName: string): number {
|
||||
const actual = value == null ? defaultValue : Math.floor(value);
|
||||
if (!Number.isFinite(actual) || actual <= 0) {
|
||||
throw new Error(`${fieldName} 必须是大于 0 的整数。`);
|
||||
}
|
||||
return Math.min(actual, maxValue);
|
||||
}
|
||||
|
||||
function normalizeHexBytesInput(value: string): string {
|
||||
const trimmed = String(value || "").trim();
|
||||
if (!trimmed) {
|
||||
throw new Error("hex_bytes 不能为空。");
|
||||
}
|
||||
const withoutPrefix = trimmed.replace(/^0x/i, "");
|
||||
const normalized = withoutPrefix.replace(/[^0-9a-fA-F]/g, "").toLowerCase();
|
||||
if (!normalized) {
|
||||
throw new Error("hex_bytes 中没有可用的十六进制字符。");
|
||||
}
|
||||
if (normalized.length % 2 !== 0) {
|
||||
throw new Error("hex_bytes 必须包含偶数个十六进制字符。");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function parseHexBytes(value: string): number[] {
|
||||
const normalized = normalizeHexBytesInput(value);
|
||||
const bytes: number[] = [];
|
||||
for (let i = 0; i < normalized.length; i += 2) {
|
||||
bytes.push(parseInt(normalized.slice(i, i + 2), 16));
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function toHex(value: number, width: number): string {
|
||||
let hex = Math.max(0, Number(value) || 0).toString(16).toUpperCase();
|
||||
while (hex.length < width) {
|
||||
hex = `0${hex}`;
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
function bytesToHexString(bytes: number[]): string {
|
||||
return bytes.map((byte) => toHex(byte, 2)).join(" ");
|
||||
}
|
||||
|
||||
function bytesToAscii(bytes: number[]): string {
|
||||
return bytes
|
||||
.map((byte) => {
|
||||
if (byte >= 32 && byte <= 126) {
|
||||
return String.fromCharCode(byte);
|
||||
}
|
||||
return ".";
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function buildHexDump(bytes: number[], startOffset: number): string {
|
||||
if (bytes.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
const offsetWidth = Math.max(8, (startOffset + bytes.length).toString(16).length);
|
||||
|
||||
for (let index = 0; index < bytes.length; index += BYTES_PER_LINE) {
|
||||
const slice = bytes.slice(index, index + BYTES_PER_LINE);
|
||||
const hexCells = slice.map((byte) => toHex(byte, 2));
|
||||
while (hexCells.length < BYTES_PER_LINE) {
|
||||
hexCells.push(" ");
|
||||
}
|
||||
const left = hexCells.slice(0, 8).join(" ");
|
||||
const right = hexCells.slice(8).join(" ");
|
||||
const ascii = bytesToAscii(slice).padEnd(BYTES_PER_LINE, " ");
|
||||
lines.push(`${toHex(startOffset + index, offsetWidth)} ${left} ${right} |${ascii}|`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function ensureFileExists(path: string, environment: FileEnvironment): Promise<void> {
|
||||
const exists = await Tools.Files.exists(path, environment);
|
||||
if (!exists.exists) {
|
||||
throw new Error(`文件不存在: ${path}`);
|
||||
}
|
||||
if (exists.isDirectory) {
|
||||
throw new Error(`目标路径是目录,不是文件: ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function readFileBytes(path: string, environment: FileEnvironment): Promise<{ base64: string; bytes: number[]; size: number }> {
|
||||
await ensureFileExists(path, environment);
|
||||
const result = await Tools.Files.readBinary(path, environment);
|
||||
const base64 = String(result.contentBase64 || "");
|
||||
return {
|
||||
base64,
|
||||
bytes: decodeBase64ToBytes(base64),
|
||||
size: Number(result.size || 0)
|
||||
};
|
||||
}
|
||||
|
||||
async function writeFileBytes(path: string, bytes: number[], environment: FileEnvironment) {
|
||||
return await Tools.Files.writeBinary(path, encodeBytesToBase64(bytes), environment);
|
||||
}
|
||||
|
||||
function collectWindow(bytes: number[], offset: number, length: number): { window: number[]; actualLength: number } {
|
||||
if (offset > bytes.length) {
|
||||
throw new Error(`offset 超出文件范围。文件大小为 ${bytes.length} 字节。`);
|
||||
}
|
||||
const end = Math.min(bytes.length, offset + length);
|
||||
const window = bytes.slice(offset, end);
|
||||
return {
|
||||
window,
|
||||
actualLength: window.length
|
||||
};
|
||||
}
|
||||
|
||||
async function read_hex_window(params: ReadHexWindowParams) {
|
||||
const environment = normalizeEnvironment(params.environment);
|
||||
const offset = parseOffset(params.offset);
|
||||
const length = clampPositiveInteger(params.length, DEFAULT_WINDOW_LENGTH, MAX_WINDOW_LENGTH, "length");
|
||||
const file = await readFileBytes(params.file_path, environment);
|
||||
const { window, actualLength } = collectWindow(file.bytes, offset, length);
|
||||
|
||||
return {
|
||||
file_path: params.file_path,
|
||||
environment,
|
||||
file_size: file.size,
|
||||
offset_decimal: offset,
|
||||
offset_hex: `0x${toHex(offset, 8)}`,
|
||||
requested_length: length,
|
||||
actual_length: actualLength,
|
||||
end_offset_exclusive_decimal: offset + actualLength,
|
||||
end_offset_exclusive_hex: `0x${toHex(offset + actualLength, 8)}`,
|
||||
hex: bytesToHexString(window),
|
||||
ascii_preview: bytesToAscii(window),
|
||||
hex_dump: buildHexDump(window, offset)
|
||||
};
|
||||
}
|
||||
|
||||
async function write_hex_bytes(params: WriteHexBytesParams) {
|
||||
const environment = normalizeEnvironment(params.environment);
|
||||
const offset = parseOffset(params.offset);
|
||||
const patchBytes = parseHexBytes(params.hex_bytes);
|
||||
const file = await readFileBytes(params.file_path, environment);
|
||||
|
||||
if (offset + patchBytes.length > file.bytes.length) {
|
||||
throw new Error(`写入范围超出文件大小。文件大小 ${file.bytes.length} 字节,尝试写入到 ${offset + patchBytes.length}。`);
|
||||
}
|
||||
|
||||
const beforeBytes = file.bytes.slice(offset, offset + patchBytes.length);
|
||||
for (let i = 0; i < patchBytes.length; i += 1) {
|
||||
file.bytes[offset + i] = patchBytes[i];
|
||||
}
|
||||
|
||||
const writeResult = await writeFileBytes(params.file_path, file.bytes, environment);
|
||||
const afterBytes = file.bytes.slice(offset, offset + patchBytes.length);
|
||||
|
||||
return {
|
||||
file_path: params.file_path,
|
||||
environment,
|
||||
offset_decimal: offset,
|
||||
offset_hex: `0x${toHex(offset, 8)}`,
|
||||
bytes_written: patchBytes.length,
|
||||
before_hex: bytesToHexString(beforeBytes),
|
||||
after_hex: bytesToHexString(afterBytes),
|
||||
write_result: writeResult
|
||||
};
|
||||
}
|
||||
|
||||
async function find_hex_pattern(params: FindHexPatternParams) {
|
||||
const environment = normalizeEnvironment(params.environment);
|
||||
const patternBytes = parseHexBytes(params.hex_bytes);
|
||||
const maxResults = clampPositiveInteger(params.max_results, DEFAULT_MAX_RESULTS, MAX_RESULTS_LIMIT, "max_results");
|
||||
const file = await readFileBytes(params.file_path, environment);
|
||||
const matches: Array<{
|
||||
offset_decimal: number;
|
||||
offset_hex: string;
|
||||
matched_hex: string;
|
||||
context_hex_dump: string;
|
||||
}> = [];
|
||||
|
||||
if (patternBytes.length > file.bytes.length) {
|
||||
return {
|
||||
file_path: params.file_path,
|
||||
environment,
|
||||
file_size: file.size,
|
||||
pattern_hex: bytesToHexString(patternBytes),
|
||||
match_count: 0,
|
||||
matches
|
||||
};
|
||||
}
|
||||
|
||||
for (let offset = 0; offset <= file.bytes.length - patternBytes.length; offset += 1) {
|
||||
let matched = true;
|
||||
for (let index = 0; index < patternBytes.length; index += 1) {
|
||||
if (file.bytes[offset + index] !== patternBytes[index]) {
|
||||
matched = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const contextStart = Math.max(0, offset - CONTEXT_BEFORE);
|
||||
const contextEnd = Math.min(file.bytes.length, contextStart + CONTEXT_TOTAL);
|
||||
const contextBytes = file.bytes.slice(contextStart, contextEnd);
|
||||
|
||||
matches.push({
|
||||
offset_decimal: offset,
|
||||
offset_hex: `0x${toHex(offset, 8)}`,
|
||||
matched_hex: bytesToHexString(patternBytes),
|
||||
context_hex_dump: buildHexDump(contextBytes, contextStart)
|
||||
});
|
||||
|
||||
if (matches.length >= maxResults) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
file_path: params.file_path,
|
||||
environment,
|
||||
file_size: file.size,
|
||||
pattern_hex: bytesToHexString(patternBytes),
|
||||
match_count: matches.length,
|
||||
matches
|
||||
};
|
||||
}
|
||||
|
||||
async function wrap(fn: (params: any) => Promise<any>, params: any, successMessage: string, failureMessage: string) {
|
||||
try {
|
||||
const data = await fn(params);
|
||||
complete({
|
||||
success: true,
|
||||
message: successMessage,
|
||||
data
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error(`[hex_editor] ${fn.name} failed:`, error);
|
||||
complete({
|
||||
success: false,
|
||||
message: `${failureMessage}: ${error.message}`,
|
||||
error_stack: error.stack
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
read_hex_window: (params: ReadHexWindowParams) => wrap(read_hex_window, params, "Hex 窗口读取成功。", "Hex 窗口读取失败"),
|
||||
write_hex_bytes: (params: WriteHexBytesParams) => wrap(write_hex_bytes, params, "Hex 字节写入成功。", "Hex 字节写入失败"),
|
||||
find_hex_pattern: (params: FindHexPatternParams) => wrap(find_hex_pattern, params, "Hex 模式搜索成功。", "Hex 模式搜索失败")
|
||||
};
|
||||
})();
|
||||
|
||||
exports.read_hex_window = hexEditorPackage.read_hex_window;
|
||||
exports.write_hex_bytes = hexEditorPackage.write_hex_bytes;
|
||||
exports.find_hex_pattern = hexEditorPackage.find_hex_pattern;
|
||||
@@ -0,0 +1,986 @@
|
||||
/*
|
||||
METADATA
|
||||
{
|
||||
"name": "jmcomic_downloader",
|
||||
|
||||
"display_name": {
|
||||
"zh": "JMComic 下载器",
|
||||
"en": "JMComic Downloader"
|
||||
},
|
||||
"description": { "zh": "提供JMComic漫画下载功能,支持搜索、获取信息和下载,包括对新漫画的图片反爬解码。", "en": "JMComic downloader: search comics, fetch details, and download albums. Includes anti-crawling image decoding for newer comics." },
|
||||
"category": "Media",
|
||||
"tools": [
|
||||
{
|
||||
"name": "main",
|
||||
"description": { "zh": "运行一个内置的测试函数,以验证JMComic工具的基本功能(搜索和获取信息)是否正常工作。", "en": "Run a built-in test to verify basic JMComic functionality (search and info retrieval)." },
|
||||
"parameters": []
|
||||
},
|
||||
{
|
||||
"name": "search_comics",
|
||||
"description": { "zh": "搜索JMComic漫画", "en": "Search JMComic comics." },
|
||||
"parameters": [
|
||||
{
|
||||
"name": "query",
|
||||
"description": { "zh": "搜索关键词", "en": "Search keyword." },
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"description": { "zh": "页码 (默认: 1)", "en": "Page number (default: 1)." },
|
||||
"type": "number",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "order_by",
|
||||
"description": { "zh": "排序方式 (latest, view, picture, like, 默认: view)", "en": "Sort mode: latest/view/picture/like (default: view)." },
|
||||
"type": "string",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "time",
|
||||
"description": { "zh": "时间范围 (today, week, month, all, 默认: all)", "en": "Time range: today/week/month/all (default: all)." },
|
||||
"type": "string",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "get_album_info",
|
||||
"description": { "zh": "获取漫画(本子)的详细信息", "en": "Get detailed information for a comic album." },
|
||||
"parameters": [
|
||||
{
|
||||
"name": "album_id",
|
||||
"description": { "zh": "漫画ID", "en": "Album ID." },
|
||||
"type": "string",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "download_album",
|
||||
"description": { "zh": "下载指定ID的单本漫画,包含图片解码功能。", "en": "Download a single comic album by ID, including image decoding." },
|
||||
"parameters": [
|
||||
{
|
||||
"name": "album_id",
|
||||
"description": { "zh": "要下载的漫画ID", "en": "Album ID to download." },
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "download_dir",
|
||||
"description": { "zh": "下载目录 (可选, 默认: /sdcard/Download/Operit/plugins/jmcomic_downloader/downloads)", "en": "Download directory (optional; default: /sdcard/Download/Operit/plugins/jmcomic_downloader/downloads)." },
|
||||
"type": "string",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "batch_download_albums",
|
||||
"description": { "zh": "批量下载多本漫画,包含图片解码功能。", "en": "Batch download multiple comic albums, including image decoding." },
|
||||
"parameters": [
|
||||
{
|
||||
"name": "album_ids",
|
||||
"description": { "zh": "要下载的漫画ID列表,用逗号分隔", "en": "Comma-separated list of album IDs to download." },
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "download_dir",
|
||||
"description": { "zh": "下载目录 (可选, 默认: /sdcard/Download/Operit/plugins/jmcomic_downloader/downloads)", "en": "Download directory (optional; default: /sdcard/Download/Operit/plugins/jmcomic_downloader/downloads)." },
|
||||
"type": "string",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"enabledByDefault": false
|
||||
}*/
|
||||
|
||||
// region Type Definitions
|
||||
interface Album {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
scrambleId: number;
|
||||
episodeList: { id: string; title: string }[];
|
||||
length: number;
|
||||
}
|
||||
|
||||
interface Photo {
|
||||
id: string;
|
||||
title: string;
|
||||
albumId: string;
|
||||
scrambleId: number;
|
||||
pageArr: string[];
|
||||
length: number;
|
||||
}
|
||||
|
||||
interface SearchResultItem {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
search_params: SearchParams;
|
||||
results: SearchResultItem[];
|
||||
total_results: number;
|
||||
}
|
||||
|
||||
interface SearchParams {
|
||||
query: string;
|
||||
page?: number;
|
||||
order_by?: 'latest' | 'view' | 'picture' | 'like';
|
||||
time?: 'today' | 'week' | 'month' | 'all';
|
||||
}
|
||||
|
||||
interface AlbumInfo {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
chapterCount: number;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
interface DownloadResult {
|
||||
success: boolean;
|
||||
albumId: string;
|
||||
title?: string;
|
||||
downloadedFiles?: {
|
||||
directory: string | undefined;
|
||||
fileCount: number;
|
||||
files: string[];
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface BatchDownloadResult {
|
||||
success: boolean;
|
||||
albumId: string;
|
||||
title?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
const jmcomic = (function () {
|
||||
const DEFAULT_DOWNLOAD_DIR = `${getPluginConfigDir("jmcomic_downloader")}/downloads`;
|
||||
const TEST_DOWNLOAD_DIR = `${getPluginConfigDir("jmcomic_downloader")}/test_downloads`;
|
||||
|
||||
// region Polyfill & Utils
|
||||
const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
|
||||
function normalizeBase64Input(value: string): string {
|
||||
const cleaned = String(value || '')
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/');
|
||||
if (!cleaned) {
|
||||
return '';
|
||||
}
|
||||
const remainder = cleaned.length % 4;
|
||||
if (remainder === 1) {
|
||||
throw new Error('Invalid base64 string');
|
||||
}
|
||||
return remainder === 0 ? cleaned : cleaned + '='.repeat(4 - remainder);
|
||||
}
|
||||
|
||||
function decodeBase64ToBytes(value: string): number[] {
|
||||
const normalized = normalizeBase64Input(value);
|
||||
const bytes: number[] = [];
|
||||
|
||||
for (let i = 0; i < normalized.length; i += 4) {
|
||||
const c1 = normalized[i];
|
||||
const c2 = normalized[i + 1];
|
||||
const c3 = normalized[i + 2];
|
||||
const c4 = normalized[i + 3];
|
||||
|
||||
const v1 = BASE64_ALPHABET.indexOf(c1);
|
||||
const v2 = BASE64_ALPHABET.indexOf(c2);
|
||||
const v3 = c3 === '=' ? 0 : BASE64_ALPHABET.indexOf(c3);
|
||||
const v4 = c4 === '=' ? 0 : BASE64_ALPHABET.indexOf(c4);
|
||||
|
||||
if (v1 < 0 || v2 < 0 || (c3 !== '=' && v3 < 0) || (c4 !== '=' && v4 < 0)) {
|
||||
throw new Error('Invalid base64 string');
|
||||
}
|
||||
|
||||
const chunk = (v1 << 18) | (v2 << 12) | (v3 << 6) | v4;
|
||||
bytes.push((chunk >> 16) & 0xff);
|
||||
if (c3 !== '=') {
|
||||
bytes.push((chunk >> 8) & 0xff);
|
||||
}
|
||||
if (c4 !== '=') {
|
||||
bytes.push(chunk & 0xff);
|
||||
}
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function encodeBytesToBase64(bytes: number[]): string {
|
||||
let output = '';
|
||||
|
||||
for (let i = 0; i < bytes.length; i += 3) {
|
||||
const b1 = bytes[i];
|
||||
const hasB2 = i + 1 < bytes.length;
|
||||
const hasB3 = i + 2 < bytes.length;
|
||||
const b2 = hasB2 ? bytes[i + 1] : 0;
|
||||
const b3 = hasB3 ? bytes[i + 2] : 0;
|
||||
const chunk = (b1 << 16) | (b2 << 8) | b3;
|
||||
|
||||
output += BASE64_ALPHABET[(chunk >> 18) & 0x3f];
|
||||
output += BASE64_ALPHABET[(chunk >> 12) & 0x3f];
|
||||
output += hasB2 ? BASE64_ALPHABET[(chunk >> 6) & 0x3f] : '=';
|
||||
output += hasB3 ? BASE64_ALPHABET[chunk & 0x3f] : '=';
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function decodeBase64Binary(value: string): string {
|
||||
return decodeBase64ToBytes(value)
|
||||
.map((byte) => String.fromCharCode(byte))
|
||||
.join('');
|
||||
}
|
||||
|
||||
function encodeBase64Binary(value: string): string {
|
||||
const bytes: number[] = [];
|
||||
const input = String(value || '');
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
bytes.push(input.charCodeAt(i) & 0xff);
|
||||
}
|
||||
return encodeBytesToBase64(bytes);
|
||||
}
|
||||
|
||||
// Buffer a subset of Buffer functionality for base64 encoding/decoding
|
||||
const Buffer = {
|
||||
from: (str: string, encoding: string = 'utf8') => {
|
||||
if (encoding === 'base64') {
|
||||
return decodeBase64Binary(str);
|
||||
} else if (encoding === 'hex') {
|
||||
let s = '';
|
||||
for (let i = 0; i < str.length; i += 2) {
|
||||
s += String.fromCharCode(parseInt(str.substr(i, 2), 16));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
return str;
|
||||
},
|
||||
toString: (buf: string, encoding: string = 'utf8') => {
|
||||
if (encoding === 'base64') {
|
||||
return encodeBase64Binary(buf);
|
||||
} else if (encoding === 'hex') {
|
||||
let s = '';
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
s += ('0' + buf.charCodeAt(i).toString(16)).slice(-2);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
};
|
||||
|
||||
function joinPath(...segments: string[]): string {
|
||||
return segments.join('/').replace(/\/+/g, '/');
|
||||
}
|
||||
|
||||
function dirname(filePath: string): string {
|
||||
const lastSlashPos = filePath.lastIndexOf('/');
|
||||
if (lastSlashPos === -1) {
|
||||
return ".";
|
||||
}
|
||||
if (lastSlashPos === 0) {
|
||||
return "/";
|
||||
}
|
||||
return filePath.substring(0, lastSlashPos);
|
||||
}
|
||||
|
||||
async function ensureDirExists(dirPath: string): Promise<void> {
|
||||
if (!dirPath || dirPath === '/' || dirPath === '.') {
|
||||
return;
|
||||
}
|
||||
|
||||
const dirExists = await Tools.Files.exists(dirPath);
|
||||
if (dirExists.exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parentDir = dirname(dirPath);
|
||||
await ensureDirExists(parentDir);
|
||||
|
||||
const dirStillNotExists = await Tools.Files.exists(dirPath);
|
||||
if (!dirStillNotExists.exists) {
|
||||
await Tools.Files.mkdir(dirPath);
|
||||
}
|
||||
}
|
||||
|
||||
function basename(filePath: string): string {
|
||||
return filePath.substring(filePath.lastIndexOf('/') + 1);
|
||||
}
|
||||
|
||||
async function runTasksWithConcurrency<T>(tasks: (() => Promise<T>)[], limit: number): Promise<T[]> {
|
||||
const results: (T | Error)[] = new Array(tasks.length);
|
||||
let currentIndex = 0;
|
||||
|
||||
async function runner(): Promise<void> {
|
||||
while (currentIndex < tasks.length) {
|
||||
const taskIndex = currentIndex++;
|
||||
if (taskIndex < tasks.length) {
|
||||
try {
|
||||
results[taskIndex] = await tasks[taskIndex]();
|
||||
} catch (e: any) {
|
||||
console.error(`并发任务 ${taskIndex} 执行失败: ${e.message}`);
|
||||
results[taskIndex] = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const runners: Promise<void>[] = [];
|
||||
const numRunners = Math.min(limit, tasks.length);
|
||||
for (let i = 0; i < numRunners; i++) {
|
||||
runners.push(runner());
|
||||
}
|
||||
|
||||
await Promise.all(runners);
|
||||
return results.filter(r => !(r instanceof Error)) as T[];
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// region Constants and Classes from jmcomic
|
||||
const __version__ = '2.6.4-ts-adapted';
|
||||
|
||||
function shuffleDomains(domains: string[]): string[] {
|
||||
const shuffled = [...domains];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
return shuffled;
|
||||
}
|
||||
|
||||
const JmMagicConstants = {
|
||||
APP_TOKEN_SECRET: '18comicAPP',
|
||||
APP_TOKEN_SECRET_2: '18comicAPPContent',
|
||||
APP_DATA_SECRET: '185Hcomic3PAPP7R',
|
||||
APP_VERSION: '1.8.0',
|
||||
SCRAMBLE_220980: 220980,
|
||||
SCRAMBLE_268850: 268850,
|
||||
SCRAMBLE_421926: 421926
|
||||
};
|
||||
|
||||
const JmModuleConfig = {
|
||||
PROT: 'https://',
|
||||
DOMAIN_API_LIST: shuffleDomains([
|
||||
'www.cdnmhwscc.vip',
|
||||
'www.cdnplaystation6.club',
|
||||
'www.cdnplaystation6.org',
|
||||
'www.cdnuc.vip',
|
||||
'www.cdn-mspjmapiproxy.xyz'
|
||||
]),
|
||||
DOMAIN_IMAGE_LIST: shuffleDomains([
|
||||
'cdn-msp.jmapiproxy1.cc',
|
||||
'cdn-msp.jmapiproxy2.cc',
|
||||
'cdn-msp2.jmapiproxy2.cc',
|
||||
'cdn-msp3.jmapiproxy2.cc'
|
||||
]),
|
||||
APP_HEADERS_TEMPLATE: {
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7',
|
||||
'X-Requested-With': 'com.jiaohua_browser',
|
||||
'user-agent': 'Mozilla/5.0 (Linux; Android 9; V1938CT Build/PQ3A.190705.11211812; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/91.0.4472.114 Safari/537.36',
|
||||
},
|
||||
APP_HEADERS_IMAGE: {
|
||||
'Accept': 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7',
|
||||
'X-Requested-With': 'com.jiaohua_browser',
|
||||
}
|
||||
};
|
||||
|
||||
class JmImageTool {
|
||||
static getNum(scrambleId: number | string, photoId: number | string, imageName: string): number {
|
||||
const scrambleIdNum = parseInt(scrambleId.toString());
|
||||
const photoIdNum = parseInt(photoId.toString());
|
||||
|
||||
if (photoIdNum < scrambleIdNum) {
|
||||
return 0;
|
||||
} else if (photoIdNum < JmMagicConstants.SCRAMBLE_268850) {
|
||||
return 10;
|
||||
} else {
|
||||
const x = photoIdNum < JmMagicConstants.SCRAMBLE_421926 ? 10 : 8;
|
||||
|
||||
const imageNameWithoutExt = this.getFileNameFromUrl(imageName, true);
|
||||
const s = `${photoIdNum}${imageNameWithoutExt}`;
|
||||
|
||||
const hash = CryptoJS.MD5(s).toString();
|
||||
const lastChar = hash.charCodeAt(hash.length - 1);
|
||||
const num = lastChar % x;
|
||||
return (num * 2) + 2;
|
||||
}
|
||||
}
|
||||
|
||||
static getFileNameFromUrl(url: string, withoutExtension: boolean = true): string {
|
||||
const queryIndex = url.indexOf('?');
|
||||
if (queryIndex !== -1) {
|
||||
url = url.substring(0, queryIndex);
|
||||
}
|
||||
const filename = basename(url);
|
||||
if (withoutExtension) {
|
||||
const lastDotIndex = filename.lastIndexOf('.');
|
||||
return lastDotIndex !== -1 ? filename.substring(0, lastDotIndex) : filename;
|
||||
}
|
||||
return filename;
|
||||
}
|
||||
|
||||
static async decodeAndSave(num: number, imageBase64: string, decodedSavePath: string): Promise<void> {
|
||||
if (num === 0) {
|
||||
await Tools.Files.writeBinary(decodedSavePath, imageBase64);
|
||||
return;
|
||||
}
|
||||
|
||||
let srcImage: Jimp.JimpWrapper | undefined = undefined;
|
||||
let resultImage: Jimp.JimpWrapper | undefined = undefined;
|
||||
const stripsToRelease: Jimp.JimpWrapper[] = [];
|
||||
|
||||
try {
|
||||
srcImage = await Jimp.read(imageBase64);
|
||||
const w = await srcImage.getWidth();
|
||||
const h = await srcImage.getHeight();
|
||||
const over = h % num;
|
||||
|
||||
resultImage = await Jimp.create(w, h);
|
||||
|
||||
for (let i = 0; i < num; i++) {
|
||||
let move = Math.floor(h / num);
|
||||
let ySrc = h - (move * (i + 1)) - over;
|
||||
let yDst = move * i;
|
||||
|
||||
if (i === 0) {
|
||||
move += over;
|
||||
} else {
|
||||
yDst += over;
|
||||
}
|
||||
|
||||
if (ySrc < 0 || move <= 0 || (ySrc + move > h)) continue;
|
||||
|
||||
const strip = await srcImage.crop(0, ySrc, w, move);
|
||||
stripsToRelease.push(strip);
|
||||
await resultImage.composite(strip, 0, yDst);
|
||||
}
|
||||
|
||||
const decodedImageBase64 = await resultImage.getBase64(Jimp.MIME_JPEG);
|
||||
|
||||
// 移除 "data:image/jpeg;base64," 前缀
|
||||
const pureBase64 = decodedImageBase64.substring(decodedImageBase64.indexOf(',') + 1);
|
||||
|
||||
await Tools.Files.writeBinary(decodedSavePath, pureBase64);
|
||||
} catch (e: any) {
|
||||
console.error(`图片解码失败,将保存原始图片: ${e.message}`);
|
||||
await Tools.Files.writeBinary(decodedSavePath, imageBase64);
|
||||
} finally {
|
||||
if (srcImage) await srcImage.release();
|
||||
if (resultImage) await resultImage.release();
|
||||
for (const strip of stripsToRelease) {
|
||||
await strip.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class JmCryptoTool {
|
||||
static md5hex(key: string): string {
|
||||
return CryptoJS.MD5(key).toString();
|
||||
}
|
||||
|
||||
static tokenAndTokenparam(ts: number, secret: string = JmMagicConstants.APP_TOKEN_SECRET): [string, string] {
|
||||
const tokenparam = `${ts},${JmMagicConstants.APP_VERSION}`;
|
||||
const token = this.md5hex(`${ts}${secret}`);
|
||||
return [token, tokenparam];
|
||||
}
|
||||
|
||||
static decodeRespData(data: string, ts: number, secret: string = JmMagicConstants.APP_DATA_SECRET): string {
|
||||
try {
|
||||
// Revert to the standard CryptoJS usage pattern.
|
||||
// First, create the key from ts and secret using MD5.
|
||||
const keyHex = this.md5hex(`${ts}${secret}`);
|
||||
const key = CryptoJS.enc.Hex.parse(keyHex);
|
||||
|
||||
// Then, call decrypt with the standard signature.
|
||||
const decrypted = CryptoJS.AES.decrypt(data, key, {
|
||||
mode: CryptoJS.mode.ECB,
|
||||
padding: CryptoJS.pad.Pkcs7
|
||||
});
|
||||
|
||||
const decryptedText = decrypted.toString(CryptoJS.enc.Utf8);
|
||||
|
||||
if (!decryptedText) {
|
||||
throw new Error("AES decryption returned an empty result.");
|
||||
}
|
||||
return decryptedText;
|
||||
} catch (error: any) {
|
||||
console.error(`Underlying decryption error: ${error.message}`);
|
||||
throw new Error(`AES Decryption failed. Original error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class JmApiResp {
|
||||
constructor(private resp: OkHttpResponse, private ts: number) { }
|
||||
|
||||
get isSuccess(): boolean {
|
||||
return this.resp.isSuccessful();
|
||||
}
|
||||
|
||||
get json(): any {
|
||||
try {
|
||||
return JSON.parse(this.resp.content);
|
||||
} catch (error: any) {
|
||||
throw new Error(`JSON解析失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
get isSuccessful(): boolean {
|
||||
return this.isSuccess && this.json.code === 200;
|
||||
}
|
||||
|
||||
get encodedData(): string {
|
||||
return this.json.data;
|
||||
}
|
||||
|
||||
get decodedData(): string {
|
||||
return JmCryptoTool.decodeRespData(this.encodedData, this.ts);
|
||||
}
|
||||
|
||||
get resData(): any {
|
||||
if (!this.isSuccessful) {
|
||||
throw new Error(`API请求失败: code=${this.json.code}`);
|
||||
}
|
||||
const decoded = this.decodedData;
|
||||
try {
|
||||
if (typeof decoded !== 'string' || !decoded) {
|
||||
throw new Error(`Cannot parse non-string or empty value. Type: ${typeof decoded}`);
|
||||
}
|
||||
return JSON.parse(decoded);
|
||||
} catch (error: any) {
|
||||
const preview = String(decoded || 'N/A').substring(0, 80);
|
||||
throw new Error(`Failed to parse decrypted response. Error: ${error.message}. Original data type was ${typeof decoded}.`);
|
||||
}
|
||||
}
|
||||
|
||||
get modelData(): any {
|
||||
return this.resData;
|
||||
}
|
||||
}
|
||||
|
||||
class DirRuleImpl {
|
||||
constructor(public baseDir: string) { }
|
||||
|
||||
decideImageSaveDir(album: Album, photo: Photo): string {
|
||||
return joinPath(this.baseDir, this.sanitize(album.title));
|
||||
}
|
||||
|
||||
decideAlbumRootDir(album: Pick<Album, 'title'>): string {
|
||||
return joinPath(this.baseDir, this.sanitize(album.title));
|
||||
}
|
||||
|
||||
sanitize(name: string): string {
|
||||
return name.replace(/[\\?%*:|"<>]/g, '_');
|
||||
}
|
||||
}
|
||||
|
||||
class JmOptionImpl {
|
||||
public dirRule: DirRuleImpl;
|
||||
constructor(baseDir: string = DEFAULT_DOWNLOAD_DIR) {
|
||||
this.dirRule = new DirRuleImpl(baseDir);
|
||||
}
|
||||
|
||||
static default(baseDir: string = DEFAULT_DOWNLOAD_DIR): JmOptionImpl {
|
||||
return new JmOptionImpl(baseDir);
|
||||
}
|
||||
|
||||
buildJmClient(): JmApiClientImpl {
|
||||
return new JmApiClientImpl();
|
||||
}
|
||||
}
|
||||
|
||||
class JmApiClientImpl {
|
||||
private domainList: string[] = JmModuleConfig.DOMAIN_API_LIST;
|
||||
private retryTimes: number = 3;
|
||||
private client: OkHttpClient = OkHttp.newClient();
|
||||
|
||||
private API_ALBUM = '/album';
|
||||
private API_CHAPTER = '/chapter';
|
||||
private API_SEARCH = '/search';
|
||||
private API_CATEGORIES_FILTER = '/categories/filter';
|
||||
|
||||
async getAlbumDetail(albumId: string): Promise<Album> {
|
||||
const resp = await this.reqApi(`${this.API_ALBUM}?id=${albumId}`);
|
||||
const data = resp.resData;
|
||||
if (!data || !data.name) throw new Error(`本子 ${albumId} 不存在或数据无效`);
|
||||
return this.parseAlbumData(albumId, data);
|
||||
}
|
||||
|
||||
async getPhotoDetail(photoId: string): Promise<Photo> {
|
||||
const resp = await this.reqApi(`${this.API_CHAPTER}?id=${photoId}`);
|
||||
const data = resp.resData;
|
||||
if (!data || !data.name) throw new Error(`章节 ${photoId} 不存在或数据无效`);
|
||||
return this.parsePhotoData(photoId, data);
|
||||
}
|
||||
|
||||
async searchComics(params: SearchParams): Promise<SearchResult> {
|
||||
const { query, page = 1, order_by = 'view', time = 'all' } = params;
|
||||
const orderMap: { [key: string]: string } = { 'latest': 'mr', 'view': 'mv', 'picture': 'mp', 'like': 'tf' };
|
||||
const timeMap: { [key: string]: string } = { 'today': 't', 'week': 'w', 'month': 'm', 'all': 'a' };
|
||||
const apiParams = {
|
||||
search_query: query,
|
||||
page,
|
||||
o: orderMap[order_by.toLowerCase()] || orderMap['view'],
|
||||
t: timeMap[time.toLowerCase()] || timeMap['all']
|
||||
};
|
||||
const resp = await this.reqApi(`${this.API_SEARCH}?${this.toUrlSearchParams(apiParams)}`);
|
||||
const data = resp.resData;
|
||||
const results = (data.content || []).map((item: any): SearchResultItem => ({
|
||||
id: String(item.id || item.album_id),
|
||||
title: item.name || item.title
|
||||
}));
|
||||
return {
|
||||
search_params: params,
|
||||
results: results,
|
||||
total_results: results.length,
|
||||
};
|
||||
}
|
||||
|
||||
async reqApi(url: string, method: 'GET' | 'POST' = 'GET', data?: any): Promise<JmApiResp> {
|
||||
const ts = Math.floor(Date.now() / 1000);
|
||||
for (let i = 0; i < this.domainList.length; i++) {
|
||||
const domain = this.domainList[i];
|
||||
for (let retry = 0; retry < this.retryTimes; retry++) {
|
||||
try {
|
||||
const fullUrl = `${JmModuleConfig.PROT}${domain}${url}`;
|
||||
const [token, tokenparam] = JmCryptoTool.tokenAndTokenparam(ts);
|
||||
const headers = { ...JmModuleConfig.APP_HEADERS_TEMPLATE, token, tokenparam };
|
||||
|
||||
const requestBuilder = this.client.newRequest().url(fullUrl).headers(headers);
|
||||
if (method === 'POST') {
|
||||
requestBuilder.method('POST').jsonBody(data);
|
||||
}
|
||||
|
||||
const resp = await requestBuilder.build().execute();
|
||||
|
||||
if (resp.isSuccessful()) {
|
||||
return new JmApiResp(resp, ts);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.log(`[API] 请求失败: ${error.message} 域名: ${domain}`);
|
||||
if (retry === this.retryTimes - 1 && i === this.domainList.length - 1) {
|
||||
throw new Error(`所有域名和重试都失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error('请求失败');
|
||||
}
|
||||
|
||||
async downloadImage(imageUrl: string, savePath: string, scrambleId: number | string, photoId: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await this.client.newRequest().url(imageUrl).headers(JmModuleConfig.APP_HEADERS_IMAGE).build().execute();
|
||||
|
||||
if (!response.isSuccessful()) {
|
||||
throw new Error(`HTTP error! status: ${response.statusCode}`);
|
||||
}
|
||||
const imageBase64 = response.bodyAsBase64();
|
||||
const dir = dirname(savePath);
|
||||
await ensureDirExists(dir);
|
||||
|
||||
const imageName = JmImageTool.getFileNameFromUrl(imageUrl, false);
|
||||
const num = JmImageTool.getNum(scrambleId, photoId, imageName);
|
||||
await JmImageTool.decodeAndSave(num, imageBase64, savePath);
|
||||
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
console.error(`[图片] 下载失败: ${imageUrl}, 错误: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private toUrlSearchParams(obj: Record<string, any>): string {
|
||||
return Object.keys(obj).map(k => `${encodeURIComponent(k)}=${encodeURIComponent(obj[k])}`).join('&');
|
||||
}
|
||||
|
||||
private parseAlbumData(albumId: string, data: any): Album {
|
||||
const episodeList = data.series && data.series.length > 0 ? data.series : [{ id: albumId, title: data.name }];
|
||||
return {
|
||||
id: albumId,
|
||||
title: data.name || `本子 ${albumId}`,
|
||||
author: (data.author && data.author[0]) || '未知作者',
|
||||
episodeList: episodeList,
|
||||
scrambleId: data.scramble_id || JmMagicConstants.SCRAMBLE_220980,
|
||||
length: episodeList.length,
|
||||
};
|
||||
}
|
||||
|
||||
private parsePhotoData(photoId: string, data: any): Photo {
|
||||
return {
|
||||
id: photoId,
|
||||
title: data.name || `章节 ${photoId}`,
|
||||
pageArr: data.images || [],
|
||||
albumId: data.album_id || photoId,
|
||||
scrambleId: data.scramble_id || JmMagicConstants.SCRAMBLE_220980,
|
||||
length: (data.images || []).length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class JmDownloaderImpl {
|
||||
public option: JmOptionImpl;
|
||||
public client: JmApiClientImpl;
|
||||
|
||||
constructor(option: JmOptionImpl) {
|
||||
this.option = option;
|
||||
this.client = option.buildJmClient();
|
||||
}
|
||||
|
||||
async downloadAlbum(albumId: string): Promise<Album> {
|
||||
const album = await this.client.getAlbumDetail(albumId);
|
||||
await this.downloadByAlbumDetail(album);
|
||||
return album;
|
||||
}
|
||||
|
||||
private async downloadByAlbumDetail(album: Album): Promise<void> {
|
||||
const albumDir = this.option.dirRule.decideAlbumRootDir(album);
|
||||
await ensureDirExists(albumDir);
|
||||
console.log(`[专辑: ${album.title}] 发现 ${album.episodeList.length} 个章节, 开始下载...`);
|
||||
|
||||
const chapterConcurrency = 5;
|
||||
const tasks = album.episodeList.map((episode, i) => async () => {
|
||||
console.log(` [章节 ${i + 1}/${album.episodeList.length}] 开始下载: ${episode.title} (${episode.id})`);
|
||||
try {
|
||||
const photo = await this.client.getPhotoDetail(episode.id);
|
||||
await this.downloadPhotoImages(photo, albumDir, album.id);
|
||||
console.log(` [章节 ${i + 1}/${album.episodeList.length}] 下载完成: ${episode.title}`);
|
||||
} catch (e: any) {
|
||||
console.error(` [章节 ${i + 1}/${album.episodeList.length}] 下载失败: ${episode.title}, 错误: ${e.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
await runTasksWithConcurrency(tasks, chapterConcurrency);
|
||||
}
|
||||
|
||||
private async downloadPhotoImages(photo: Photo, albumDir: string, albumId: string): Promise<void> {
|
||||
if (!photo.pageArr || photo.pageArr.length === 0) return;
|
||||
|
||||
console.log(` [图片集: ${photo.title}] 发现 ${photo.pageArr.length} 张图片, 开始下载...`);
|
||||
const concurrencyLimit = 10;
|
||||
|
||||
const tasks = photo.pageArr.map((imageName, i) => {
|
||||
return async () => {
|
||||
const finalFileName = `${(i + 1).toString().padStart(5, '0')}.jpg`;
|
||||
const filePath = joinPath(albumDir, finalFileName);
|
||||
|
||||
const fileExists = await Tools.Files.exists(filePath);
|
||||
if (fileExists.exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageUrl = this.buildImageUrl(photo, imageName);
|
||||
try {
|
||||
await this.client.downloadImage(imageUrl, filePath, photo.scrambleId, photo.id);
|
||||
} catch (e: any) {
|
||||
console.error(` [图片下载失败] ${finalFileName} from ${photo.title}: ${e.message}`);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
await runTasksWithConcurrency(tasks, concurrencyLimit);
|
||||
console.log(` [图片集: ${photo.title}] 所有图片下载任务已处理。`);
|
||||
}
|
||||
|
||||
private getFileExtension(filename: string): string {
|
||||
const dotIndex = filename.lastIndexOf('.');
|
||||
return dotIndex > 0 ? filename.substring(dotIndex + 1) : 'jpg';
|
||||
}
|
||||
|
||||
private buildImageUrl(photo: Photo, imageName: string): string {
|
||||
const domain = JmModuleConfig.DOMAIN_IMAGE_LIST[Math.floor(Math.random() * JmModuleConfig.DOMAIN_IMAGE_LIST.length)];
|
||||
return `${JmModuleConfig.PROT}${domain}/media/photos/${photo.albumId}/${imageName}`;
|
||||
}
|
||||
}
|
||||
|
||||
class SimpleJMDownloader {
|
||||
private downloader: JmDownloaderImpl;
|
||||
private client: JmApiClientImpl;
|
||||
private option: JmOptionImpl;
|
||||
|
||||
constructor(downloadDir: string = DEFAULT_DOWNLOAD_DIR) {
|
||||
this.option = JmOptionImpl.default(downloadDir);
|
||||
this.downloader = new JmDownloaderImpl(this.option);
|
||||
this.client = this.option.buildJmClient();
|
||||
console.log(`✅ JM下载器初始化成功, 下载目录: ${this.option.dirRule.baseDir}`);
|
||||
}
|
||||
|
||||
async searchComics(params: SearchParams): Promise<SearchResult> {
|
||||
console.log(`🔍 搜索漫画: ${params.query}`);
|
||||
return await this.client.searchComics(params);
|
||||
}
|
||||
|
||||
async getAlbumInfo(albumId: string): Promise<AlbumInfo> {
|
||||
const album = await this.client.getAlbumDetail(albumId);
|
||||
return {
|
||||
id: album.id,
|
||||
title: album.title,
|
||||
author: album.author,
|
||||
chapterCount: album.length,
|
||||
success: true
|
||||
};
|
||||
}
|
||||
|
||||
async downloadAlbum(albumId: string): Promise<DownloadResult> {
|
||||
console.log(`📖 获取本子信息: ${albumId}`);
|
||||
try {
|
||||
const info = await this.getAlbumInfo(albumId);
|
||||
if (!info.success) return { success: false, albumId, error: "获取信息失败" };
|
||||
|
||||
console.log(`📥 开始下载本子: ${info.title}`);
|
||||
await this.downloader.downloadAlbum(albumId);
|
||||
const downloadedFiles = await this._checkDownloadedFiles(info.title);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
albumId: albumId,
|
||||
title: info.title,
|
||||
downloadedFiles
|
||||
};
|
||||
} catch (error: any) {
|
||||
return { success: false, albumId, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async batchDownload(albumIds: string[]): Promise<BatchDownloadResult[]> {
|
||||
const results: BatchDownloadResult[] = [];
|
||||
console.log(`📦 开始批量下载 ${albumIds.length} 个本子`);
|
||||
|
||||
const concurrencyLimit = 3; // 限制并发下载的漫画数量
|
||||
|
||||
const tasks = albumIds.map((albumId, i) => async (): Promise<BatchDownloadResult> => {
|
||||
console.log(`\n[${i + 1}/${albumIds.length}] 开始处理本子: ${albumId}`);
|
||||
const result = await this.downloadAlbum(albumId);
|
||||
if (result.success) {
|
||||
console.log(`✅ [${i + 1}/${albumIds.length}] 下载成功: ${result.title}`);
|
||||
} else {
|
||||
console.log(`❌ [${i + 1}/${albumIds.length}] 下载失败: ${albumId}, ${result.error || 'Unknown error'}`);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
return await runTasksWithConcurrency(tasks, concurrencyLimit);
|
||||
}
|
||||
|
||||
private async _checkDownloadedFiles(title: string): Promise<NonNullable<DownloadResult['downloadedFiles']>> {
|
||||
const albumDir = this.option.dirRule.decideAlbumRootDir({ title });
|
||||
const dirExists = await Tools.Files.exists(albumDir);
|
||||
if (dirExists.exists) {
|
||||
const listResult = await Tools.Files.list(albumDir);
|
||||
const files = listResult.entries.map(e => e.name);
|
||||
return {
|
||||
directory: albumDir,
|
||||
fileCount: files.length,
|
||||
files: files.slice(0, 10)
|
||||
};
|
||||
}
|
||||
return { directory: undefined, fileCount: 0, files: [] };
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Tool Implementations
|
||||
async function main() {
|
||||
console.log("🚀 开始执行JMComic工具功能测试...");
|
||||
const downloader = new SimpleJMDownloader(TEST_DOWNLOAD_DIR);
|
||||
const testQuery = "原神";
|
||||
|
||||
console.log(`1. 测试搜索功能,关键词: "${testQuery}"`);
|
||||
const searchResult = await downloader.searchComics({ query: testQuery });
|
||||
|
||||
if (!searchResult || !searchResult.results || searchResult.results.length === 0) {
|
||||
throw new Error(`搜索测试失败: 未能找到关于 "${testQuery}" 的任何结果。`);
|
||||
}
|
||||
|
||||
console.log(`✅ 搜索成功, 找到 ${searchResult.total_results} 个结果。`);
|
||||
const firstAlbum = searchResult.results[0];
|
||||
console.log(`2. 测试获取作品信息功能, 作品ID: ${firstAlbum.id} (${firstAlbum.title})`);
|
||||
|
||||
const albumInfo = await downloader.getAlbumInfo(firstAlbum.id);
|
||||
|
||||
if (!albumInfo || !albumInfo.success) {
|
||||
throw new Error(`获取作品信息失败, ID: ${firstAlbum.id}`);
|
||||
}
|
||||
|
||||
console.log(`✅ 作品信息获取成功:`);
|
||||
console.log(` - 标题: ${albumInfo.title}`);
|
||||
console.log(` - 作者: ${albumInfo.author}`);
|
||||
console.log(` - 章节数: ${albumInfo.chapterCount}`);
|
||||
|
||||
console.log(`3. 测试下载功能, 作品ID: ${firstAlbum.id} (${firstAlbum.title})`);
|
||||
const downloadResult = await downloader.downloadAlbum(firstAlbum.id);
|
||||
|
||||
if (!downloadResult || !downloadResult.success) {
|
||||
throw new Error(`下载作品失败, ID: ${firstAlbum.id}`);
|
||||
}
|
||||
|
||||
console.log(`✅ 下载成功:`);
|
||||
console.log(` - 保存目录: ${downloadResult.downloadedFiles!.directory}`);
|
||||
console.log(` - 文件数量: ${downloadResult.downloadedFiles!.fileCount}`);
|
||||
|
||||
const summary = `JMComic工具测试完成。成功搜索、获取信息并下载了作品《${albumInfo.title}》。`;
|
||||
console.log(`\n${summary}`);
|
||||
return summary;
|
||||
}
|
||||
|
||||
async function search_comics(params: SearchParams) {
|
||||
const downloader = new SimpleJMDownloader();
|
||||
return await downloader.searchComics(params);
|
||||
}
|
||||
|
||||
async function get_album_info(params: { album_id: string }) {
|
||||
const downloader = new SimpleJMDownloader();
|
||||
return await downloader.getAlbumInfo(params.album_id);
|
||||
}
|
||||
|
||||
async function download_album(params: { album_id: string, download_dir?: string }) {
|
||||
const downloader = new SimpleJMDownloader(params.download_dir);
|
||||
return await downloader.downloadAlbum(params.album_id);
|
||||
}
|
||||
|
||||
async function batch_download_albums(params: { album_ids: string, download_dir?: string }) {
|
||||
const albumIds = params.album_ids.split(',').map(id => id.trim()).filter(id => id);
|
||||
if (albumIds.length === 0) throw new Error("album_ids不能为空");
|
||||
const downloader = new SimpleJMDownloader(params.download_dir);
|
||||
return await downloader.batchDownload(albumIds);
|
||||
}
|
||||
|
||||
async function jmcomic_wrap(func: Function, params: any, successMessage: string, failMessage: string) {
|
||||
try {
|
||||
console.log(`开始执行: ${func.name}`);
|
||||
const result = await func(params);
|
||||
complete({ success: true, message: successMessage, data: result });
|
||||
} catch (error: any) {
|
||||
console.error(`${func.name} 执行失败: ${error.message}`);
|
||||
complete({ success: false, message: `${failMessage}: ${error.message}`, error_stack: error.stack });
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
|
||||
return {
|
||||
main: (p: any) => jmcomic_wrap(main, p, '功能测试完成', '功能测试失败'),
|
||||
search_comics: (p: SearchParams) => jmcomic_wrap(search_comics, p, '搜索完成', '搜索失败'),
|
||||
get_album_info: (p: { album_id: string }) => jmcomic_wrap(get_album_info, p, '信息获取完成', '信息获取失败'),
|
||||
download_album: (p: { album_id: string, download_dir?: string }) => jmcomic_wrap(download_album, p, '下载完成', '下载失败'),
|
||||
batch_download_albums: (p: { album_ids: string, download_dir?: string }) => jmcomic_wrap(batch_download_albums, p, '批量下载完成', '批量下载失败'),
|
||||
};
|
||||
})();
|
||||
|
||||
exports.main = jmcomic.main;
|
||||
exports.search_comics = jmcomic.search_comics;
|
||||
exports.get_album_info = jmcomic.get_album_info;
|
||||
exports.download_album = jmcomic.download_album;
|
||||
exports.batch_download_albums = jmcomic.batch_download_albums;
|
||||
@@ -0,0 +1,691 @@
|
||||
/* METADATA
|
||||
{
|
||||
"name": "minimax_draw",
|
||||
"display_name": {
|
||||
"zh": "MiniMax 绘图",
|
||||
"en": "MiniMax Draw"
|
||||
},
|
||||
"description": {
|
||||
"zh": "使用 MiniMax 官方图像生成接口 (/v1/image_generation) 生成图片,支持文生图和带参考图的生图;结果保存到本地 /sdcard/Download/Operit/plugins/draw/minimax_draw/draws/ 目录,并返回 Markdown 图片提示。",
|
||||
"en": "Generate images with the official MiniMax image generation API (/v1/image_generation). Supports text-to-image and reference-image generation. Saves results to /sdcard/Download/Operit/plugins/draw/minimax_draw/draws/ and returns Markdown image hints."
|
||||
},
|
||||
"category": "Draw",
|
||||
"env": [
|
||||
{
|
||||
"name": "MINIMAX_API_KEY",
|
||||
"description": {
|
||||
"zh": "MiniMax API Key(必填)",
|
||||
"en": "MiniMax API key (required)"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "MINIMAX_API_BASE_URL",
|
||||
"description": {
|
||||
"zh": "MiniMax API Base URL(可选;默认 https://api.minimaxi.com,国际站可改为 https://api.minimax.io)",
|
||||
"en": "MiniMax API base URL (optional; defaults to https://api.minimaxi.com, international users can use https://api.minimax.io)"
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "MINIMAX_IMAGE_MODEL",
|
||||
"description": {
|
||||
"zh": "默认图片模型(可选;未传 model 时使用,默认 image-01)",
|
||||
"en": "Default image model (optional; used when model is omitted, default image-01)"
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "BEEIMG_API_KEY",
|
||||
"description": {
|
||||
"zh": "BeeIMG API Key(可选;仅当 image_paths 传本地参考图路径时需要,用于先上传到公网图床)",
|
||||
"en": "BeeIMG API key (optional; only needed when image_paths is used so local reference images can be uploaded first)"
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"name": "draw_image",
|
||||
"description": {
|
||||
"zh": "根据提示词调用 MiniMax 官方绘图接口生成图片,支持文生图和参考图生图,保存到本地并返回 Markdown 图片提示。",
|
||||
"en": "Generate images with the official MiniMax image API using a prompt. Supports text-to-image and reference-image generation, saves locally, and returns Markdown image hints."
|
||||
},
|
||||
"parameters": [
|
||||
{ "name": "prompt", "description": { "zh": "绘图提示词(中文或英文)", "en": "Image prompt (Chinese or English)" }, "type": "string", "required": true },
|
||||
{ "name": "model", "description": { "zh": "模型名称(可选;不传则使用环境变量 MINIMAX_IMAGE_MODEL,否则默认 image-01)", "en": "Model name (optional; falls back to MINIMAX_IMAGE_MODEL, then image-01)" }, "type": "string", "required": false },
|
||||
{ "name": "aspect_ratio", "description": { "zh": "输出比例,可选 1:1、16:9、4:3、3:2、2:3、3:4、9:16、21:9;21:9 仅 image-01 支持", "en": "Aspect ratio. Supported: 1:1, 16:9, 4:3, 3:2, 2:3, 3:4, 9:16, 21:9. 21:9 is only supported by image-01." }, "type": "string", "required": false },
|
||||
{ "name": "width", "description": { "zh": "输出宽度(可选;仅 image-01 支持;需与 height 一起传,范围 512-2048 且为 8 的倍数)", "en": "Output width (optional; image-01 only; must be provided together with height, range 512-2048 and multiple of 8)" }, "type": "number", "required": false },
|
||||
{ "name": "height", "description": { "zh": "输出高度(可选;仅 image-01 支持;需与 width 一起传,范围 512-2048 且为 8 的倍数)", "en": "Output height (optional; image-01 only; must be provided together with width, range 512-2048 and multiple of 8)" }, "type": "number", "required": false },
|
||||
{ "name": "n", "description": { "zh": "生成图片数量,范围 1-9,默认 1", "en": "Number of images to generate, range 1-9, default 1" }, "type": "number", "required": false },
|
||||
{ "name": "response_format", "description": { "zh": "返回格式,可选 url 或 base64;默认 base64(更适合直接保存到本地)", "en": "Response format: url or base64. Defaults to base64 for direct local saving." }, "type": "string", "required": false },
|
||||
{ "name": "prompt_optimizer", "description": { "zh": "是否启用提示词优化(可选)", "en": "Enable prompt optimizer (optional)" }, "type": "boolean", "required": false },
|
||||
{ "name": "aigc_watermark", "description": { "zh": "是否添加 AIGC 水印(可选)", "en": "Add AIGC watermark (optional)" }, "type": "boolean", "required": false },
|
||||
{ "name": "seed", "description": { "zh": "随机种子(可选)", "en": "Seed (optional)" }, "type": "number", "required": false },
|
||||
{ "name": "image_urls", "description": { "zh": "参考图公网 URL 数组(可选;图生图用)。支持字符串数组、JSON 字符串或逗号分隔字符串", "en": "Reference image public URL list (optional; for reference-image generation). Accepts string array, JSON string, or comma-separated string." }, "type": "array", "required": false },
|
||||
{ "name": "image_paths", "description": { "zh": "参考图本地路径数组(可选;会先上传到图床再提交给 MiniMax)。支持字符串数组、JSON 字符串或逗号分隔字符串", "en": "Reference local image path list (optional; local images are uploaded first before calling MiniMax). Accepts string array, JSON string, or comma-separated string." }, "type": "array", "required": false },
|
||||
{ "name": "file_name", "description": { "zh": "自定义本地文件名(不含扩展名)", "en": "Custom local output file name without extension" }, "type": "string", "required": false },
|
||||
{ "name": "api_base_url", "description": { "zh": "自定义 MiniMax API Base URL(可选)", "en": "Custom MiniMax API base URL (optional)" }, "type": "string", "required": false }
|
||||
]
|
||||
}
|
||||
]
|
||||
}*/
|
||||
|
||||
/// <reference path="./types/index.d.ts" />
|
||||
|
||||
const minimaxDraw = (function () {
|
||||
const HTTP_TIMEOUT_MS = 600000;
|
||||
const client = OkHttp.newBuilder()
|
||||
.connectTimeout(HTTP_TIMEOUT_MS)
|
||||
.readTimeout(HTTP_TIMEOUT_MS)
|
||||
.writeTimeout(HTTP_TIMEOUT_MS)
|
||||
.build();
|
||||
|
||||
const DEFAULT_API_BASE_URL = "https://api.minimaxi.com";
|
||||
const DEFAULT_MODEL = "image-01";
|
||||
const DEFAULT_RESPONSE_FORMAT = "base64";
|
||||
const DEFAULT_IMAGE_COUNT = 1;
|
||||
const DEFAULT_BASE64_EXTENSION = "jpeg";
|
||||
const SUPPORTED_ASPECT_RATIOS = ["1:1", "16:9", "4:3", "3:2", "2:3", "3:4", "9:16", "21:9"];
|
||||
const BEEIMG_UPLOAD_ENDPOINT = "https://beeimg.com/api/upload/file/json/";
|
||||
|
||||
const DRAW_ROOT_DIR = getPluginConfigDir("draw");
|
||||
const STORAGE_DIR = `${DRAW_ROOT_DIR}/minimax_draw`;
|
||||
const DRAWS_DIR = `${STORAGE_DIR}/draws`;
|
||||
|
||||
function isRecord(value) {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getErrorMessage(error) {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function getApiKey() {
|
||||
const apiKey = String(getEnv("MINIMAX_API_KEY") || "").trim();
|
||||
if (!apiKey) {
|
||||
throw new Error("MINIMAX_API_KEY 未配置,请先在环境变量中设置 MiniMax API Key。");
|
||||
}
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
function joinUrl(baseUrl, path) {
|
||||
const normalizedBase = String(baseUrl || "").endsWith("/") ? String(baseUrl || "") : `${baseUrl}/`;
|
||||
const normalizedPath = String(path || "").startsWith("/") ? String(path).slice(1) : String(path || "");
|
||||
return `${normalizedBase}${normalizedPath}`;
|
||||
}
|
||||
|
||||
function getApiBaseUrl(customBaseUrl) {
|
||||
const fromParam = String(customBaseUrl || "").trim();
|
||||
if (fromParam) return fromParam;
|
||||
|
||||
const fromEnv = String(getEnv("MINIMAX_API_BASE_URL") || "").trim();
|
||||
if (fromEnv) return fromEnv;
|
||||
|
||||
return DEFAULT_API_BASE_URL;
|
||||
}
|
||||
|
||||
function getImageEndpoint(baseUrl) {
|
||||
const trimmed = String(baseUrl || "").trim();
|
||||
if (!trimmed) return joinUrl(DEFAULT_API_BASE_URL, "v1/image_generation");
|
||||
if (trimmed.includes("/v1/image_generation")) return trimmed;
|
||||
if (trimmed.endsWith("/v1")) return joinUrl(trimmed, "image_generation");
|
||||
if (trimmed.endsWith("/v1/")) return joinUrl(trimmed, "image_generation");
|
||||
return joinUrl(trimmed, "v1/image_generation");
|
||||
}
|
||||
|
||||
function resolveModel(model) {
|
||||
const fromParam = String(model || "").trim();
|
||||
if (fromParam) return fromParam;
|
||||
|
||||
const fromEnv = String(getEnv("MINIMAX_IMAGE_MODEL") || "").trim();
|
||||
if (fromEnv) return fromEnv;
|
||||
|
||||
return DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
function sanitizeFileName(name, fallbackPrefix) {
|
||||
const safe = String(name || "").replace(/[\\/:*?"<>|]/g, "_").trim();
|
||||
if (!safe) return `${fallbackPrefix}_${Date.now()}`;
|
||||
return safe.substring(0, 80);
|
||||
}
|
||||
|
||||
function buildFileBaseName(prompt, customName, fallbackPrefix) {
|
||||
const fromCustom = String(customName || "").trim();
|
||||
if (fromCustom) {
|
||||
return sanitizeFileName(fromCustom, fallbackPrefix);
|
||||
}
|
||||
const rawPrompt = String(prompt || "").trim();
|
||||
const shortPrompt = rawPrompt.length > 40 ? `${rawPrompt.substring(0, 40)}...` : rawPrompt;
|
||||
return `${sanitizeFileName(shortPrompt || fallbackPrefix, fallbackPrefix)}_${Date.now()}`;
|
||||
}
|
||||
|
||||
async function ensureDirectories() {
|
||||
const dirs = [DRAW_ROOT_DIR, STORAGE_DIR, DRAWS_DIR];
|
||||
for (const dir of dirs) {
|
||||
try {
|
||||
const result = await Tools.Files.mkdir(dir);
|
||||
if (!result.successful) {
|
||||
console.warn(`创建目录失败(可能已存在): ${dir} -> ${result.details}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`创建目录异常: ${dir} -> ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value, fieldName, options?) {
|
||||
const hasDefault = !!options && Object.prototype.hasOwnProperty.call(options, "defaultValue");
|
||||
const defaultValue = hasDefault ? options.defaultValue : undefined;
|
||||
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const numberValue = typeof value === "number" ? value : Number(String(value));
|
||||
if (!Number.isFinite(numberValue) || numberValue <= 0 || !Number.isInteger(numberValue)) {
|
||||
throw new Error(`${fieldName} 必须是正整数。`);
|
||||
}
|
||||
|
||||
if (options && options.min !== undefined && numberValue < options.min) {
|
||||
throw new Error(`${fieldName} 不能小于 ${options.min}。`);
|
||||
}
|
||||
|
||||
if (options && options.max !== undefined && numberValue > options.max) {
|
||||
throw new Error(`${fieldName} 不能大于 ${options.max}。`);
|
||||
}
|
||||
|
||||
return numberValue;
|
||||
}
|
||||
|
||||
function parseInteger(value, fieldName) {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const numberValue = typeof value === "number" ? value : Number(String(value));
|
||||
if (!Number.isFinite(numberValue) || !Number.isInteger(numberValue)) {
|
||||
throw new Error(`${fieldName} 必须是整数。`);
|
||||
}
|
||||
|
||||
return numberValue;
|
||||
}
|
||||
|
||||
function parseBoolean(value, fieldName) {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
if (normalized === "true" || normalized === "1" || normalized === "yes") {
|
||||
return true;
|
||||
}
|
||||
if (normalized === "false" || normalized === "0" || normalized === "no") {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw new Error(`${fieldName} 必须是布尔值。`);
|
||||
}
|
||||
|
||||
function normalizeResponseFormat(value) {
|
||||
const normalized = String(value || DEFAULT_RESPONSE_FORMAT).trim().toLowerCase();
|
||||
if (normalized !== "url" && normalized !== "base64") {
|
||||
throw new Error("response_format 仅支持 url 或 base64。");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeAspectRatio(model, value) {
|
||||
const normalized = String(value || "").trim();
|
||||
if (!normalized) return undefined;
|
||||
|
||||
if (!SUPPORTED_ASPECT_RATIOS.includes(normalized)) {
|
||||
throw new Error(`aspect_ratio 仅支持 ${SUPPORTED_ASPECT_RATIOS.join("、")}。`);
|
||||
}
|
||||
|
||||
if (normalized === "21:9" && String(model || "").trim() !== "image-01") {
|
||||
throw new Error("aspect_ratio=21:9 仅支持 model=image-01。");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeDimensions(model, widthValue, heightValue, aspectRatio) {
|
||||
if (aspectRatio) {
|
||||
return {
|
||||
width: undefined,
|
||||
height: undefined
|
||||
};
|
||||
}
|
||||
|
||||
const width = parsePositiveInteger(widthValue, "width");
|
||||
const height = parsePositiveInteger(heightValue, "height");
|
||||
|
||||
if (width === undefined && height === undefined) {
|
||||
return {
|
||||
width: undefined,
|
||||
height: undefined
|
||||
};
|
||||
}
|
||||
|
||||
if (width === undefined || height === undefined) {
|
||||
throw new Error("width 和 height 需要同时传入。");
|
||||
}
|
||||
|
||||
if (String(model || "").trim() !== "image-01") {
|
||||
throw new Error("width 和 height 仅支持 model=image-01。");
|
||||
}
|
||||
|
||||
if (width < 512 || width > 2048 || width % 8 !== 0) {
|
||||
throw new Error("width 需要在 512-2048 之间,且必须为 8 的倍数。");
|
||||
}
|
||||
|
||||
if (height < 512 || height > 2048 || height % 8 !== 0) {
|
||||
throw new Error("height 需要在 512-2048 之间,且必须为 8 的倍数。");
|
||||
}
|
||||
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
function parseStringList(value, fieldName) {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map(item => String(item || "").trim())
|
||||
.filter(item => item.length > 0);
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed
|
||||
.map(item => String(item || "").trim())
|
||||
.filter(item => item.length > 0);
|
||||
}
|
||||
} catch {
|
||||
// ignore and try comma-separated parsing
|
||||
}
|
||||
|
||||
return trimmed
|
||||
.split(",")
|
||||
.map(item => item.trim())
|
||||
.filter(item => item.length > 0);
|
||||
}
|
||||
|
||||
throw new Error(`${fieldName} 必须是字符串数组、JSON 字符串或逗号分隔字符串。`);
|
||||
}
|
||||
|
||||
function isProbablyUrl(value) {
|
||||
return /^https?:\/\//i.test(String(value || "").trim());
|
||||
}
|
||||
|
||||
function getBeeimgApiKey() {
|
||||
return String(getEnv("BEEIMG_API_KEY") || "").trim();
|
||||
}
|
||||
|
||||
function guessMimeTypeFromPath(filePath) {
|
||||
const lower = String(filePath || "").toLowerCase();
|
||||
if (lower.endsWith(".png")) return "image/png";
|
||||
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
||||
if (lower.endsWith(".webp")) return "image/webp";
|
||||
if (lower.endsWith(".gif")) return "image/gif";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
async function uploadImageToBeeimg(filePath) {
|
||||
const exists = await Tools.Files.exists(filePath);
|
||||
if (!exists.exists) {
|
||||
throw new Error(`参考图文件不存在: ${filePath}`);
|
||||
}
|
||||
|
||||
const apiKey = getBeeimgApiKey();
|
||||
if (!apiKey) {
|
||||
throw new Error("使用 image_paths 需要配置 BEEIMG_API_KEY,用于先把本地参考图上传到公网图床。");
|
||||
}
|
||||
|
||||
const response = await Tools.Net.uploadFile({
|
||||
url: BEEIMG_UPLOAD_ENDPOINT,
|
||||
method: "POST",
|
||||
form_data: {
|
||||
apikey: apiKey
|
||||
},
|
||||
files: [
|
||||
{
|
||||
field_name: "file",
|
||||
file_path: filePath,
|
||||
content_type: guessMimeTypeFromPath(filePath)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw new Error(`BeeIMG 上传失败: HTTP ${response.statusCode} - ${response.content}`);
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(response.content);
|
||||
} catch (error) {
|
||||
throw new Error(`解析 BeeIMG 响应失败: ${getErrorMessage(error)}`);
|
||||
}
|
||||
|
||||
const files = isRecord(parsed) && isRecord(parsed.files) ? parsed.files : null;
|
||||
const fileUrl = files && files.url ? String(files.url).trim() : "";
|
||||
const code = files && files.code !== undefined ? String(files.code) : "";
|
||||
const status = files && files.status ? String(files.status) : "";
|
||||
|
||||
if (!fileUrl || (code !== "200" && code !== "" && status !== "Success")) {
|
||||
throw new Error(`BeeIMG 上传失败: ${response.content}`);
|
||||
}
|
||||
|
||||
return fileUrl;
|
||||
}
|
||||
|
||||
async function resolveSubjectReference(imageUrls, imagePaths) {
|
||||
const resolvedUrls = parseStringList(imageUrls, "image_urls");
|
||||
const resolvedPaths = parseStringList(imagePaths, "image_paths");
|
||||
|
||||
for (const url of resolvedUrls) {
|
||||
if (!isProbablyUrl(url)) {
|
||||
throw new Error(`image_urls 中包含无效链接: ${url}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const filePath of resolvedPaths) {
|
||||
const uploadedUrl = await uploadImageToBeeimg(filePath);
|
||||
resolvedUrls.push(uploadedUrl);
|
||||
}
|
||||
|
||||
if (resolvedUrls.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return resolvedUrls.map(url => ({
|
||||
type: "character",
|
||||
image_file: url
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeBase64(base64) {
|
||||
const raw = String(base64 || "").trim();
|
||||
if (!raw) return raw;
|
||||
|
||||
const prefixIndex = raw.indexOf("base64,");
|
||||
if (raw.startsWith("data:") && prefixIndex >= 0) {
|
||||
return raw.substring(prefixIndex + "base64,".length).trim();
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
function guessExtensionFromUrl(url, fallbackExtension) {
|
||||
const match = String(url || "").match(/\.([a-z0-9]{2,5})(?:[?#].*)?$/i);
|
||||
if (match && match[1]) return match[1].toLowerCase();
|
||||
return fallbackExtension;
|
||||
}
|
||||
|
||||
function parseMiniMaxError(parsed, fallbackMessage) {
|
||||
const baseResp = isRecord(parsed) && isRecord(parsed.base_resp) ? parsed.base_resp : null;
|
||||
if (!baseResp) return;
|
||||
|
||||
const statusCode = baseResp.status_code !== undefined ? Number(baseResp.status_code) : 0;
|
||||
if (Number.isFinite(statusCode) && statusCode !== 0) {
|
||||
const statusMsg = baseResp.status_msg ? String(baseResp.status_msg) : fallbackMessage;
|
||||
throw new Error(`MiniMax 图片接口返回错误: ${statusMsg} (status_code=${statusCode})`);
|
||||
}
|
||||
}
|
||||
|
||||
async function callMiniMaxImageApi(params) {
|
||||
const apiBaseUrl = getApiBaseUrl(params.api_base_url);
|
||||
const endpoint = getImageEndpoint(apiBaseUrl);
|
||||
const effectiveModel = resolveModel(params.model);
|
||||
const responseFormat = normalizeResponseFormat(params.response_format);
|
||||
const aspectRatio = normalizeAspectRatio(effectiveModel, params.aspect_ratio);
|
||||
const dimensions = normalizeDimensions(effectiveModel, params.width, params.height, aspectRatio);
|
||||
const imageCount = parsePositiveInteger(params.n, "n", { defaultValue: DEFAULT_IMAGE_COUNT, min: 1, max: 9 });
|
||||
const promptOptimizer = parseBoolean(params.prompt_optimizer, "prompt_optimizer");
|
||||
const aigcWatermark = parseBoolean(params.aigc_watermark, "aigc_watermark");
|
||||
const seed = parseInteger(params.seed, "seed");
|
||||
const subjectReference = await resolveSubjectReference(params.image_urls, params.image_paths);
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: effectiveModel,
|
||||
prompt: String(params.prompt || "").trim(),
|
||||
response_format: responseFormat,
|
||||
n: imageCount
|
||||
};
|
||||
|
||||
if (aspectRatio) {
|
||||
body.aspect_ratio = aspectRatio;
|
||||
}
|
||||
|
||||
if (dimensions.width !== undefined && dimensions.height !== undefined) {
|
||||
body.width = dimensions.width;
|
||||
body.height = dimensions.height;
|
||||
}
|
||||
|
||||
if (promptOptimizer !== undefined) {
|
||||
body.prompt_optimizer = promptOptimizer;
|
||||
}
|
||||
|
||||
if (aigcWatermark !== undefined) {
|
||||
body.aigc_watermark = aigcWatermark;
|
||||
}
|
||||
|
||||
if (seed !== undefined) {
|
||||
body.seed = seed;
|
||||
}
|
||||
|
||||
if (subjectReference) {
|
||||
body.subject_reference = subjectReference;
|
||||
}
|
||||
|
||||
const request = client
|
||||
.newRequest()
|
||||
.url(endpoint)
|
||||
.method("POST")
|
||||
.headers({
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
"Authorization": `Bearer ${getApiKey()}`
|
||||
})
|
||||
.body(JSON.stringify(body), "json");
|
||||
|
||||
const response = await request.build().execute();
|
||||
if (!response.isSuccessful()) {
|
||||
throw new Error(`MiniMax 图片 API 调用失败: ${response.statusCode} - ${response.content}`);
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(response.content);
|
||||
} catch (error) {
|
||||
throw new Error(`解析 MiniMax 响应失败: ${getErrorMessage(error)}`);
|
||||
}
|
||||
|
||||
parseMiniMaxError(parsed, "MiniMax 接口返回错误");
|
||||
|
||||
const data = isRecord(parsed) && isRecord(parsed.data) ? parsed.data : null;
|
||||
if (!data) {
|
||||
throw new Error("MiniMax 响应中未找到 data 对象。");
|
||||
}
|
||||
|
||||
const metadata = isRecord(data.metadata) ? data.metadata : null;
|
||||
const successCount = metadata && metadata.success_count !== undefined ? Number(metadata.success_count) : undefined;
|
||||
const failedCount = metadata && metadata.failed_count !== undefined ? Number(metadata.failed_count) : undefined;
|
||||
const requestId = isRecord(parsed) && parsed.id ? String(parsed.id) : "";
|
||||
|
||||
if (responseFormat === "base64") {
|
||||
const imageBase64 = Array.isArray(data.image_base64)
|
||||
? data.image_base64.map(item => String(item || "").trim()).filter(item => item.length > 0)
|
||||
: [];
|
||||
|
||||
if (imageBase64.length === 0) {
|
||||
throw new Error("MiniMax 响应中未找到 data.image_base64。");
|
||||
}
|
||||
|
||||
return {
|
||||
request_id: requestId,
|
||||
effective_model: effectiveModel,
|
||||
response_format: responseFormat,
|
||||
image_base64: imageBase64,
|
||||
image_urls: [],
|
||||
success_count: Number.isFinite(successCount) ? successCount : imageBase64.length,
|
||||
failed_count: Number.isFinite(failedCount) ? failedCount : 0,
|
||||
reference_count: subjectReference ? subjectReference.length : 0
|
||||
};
|
||||
}
|
||||
|
||||
const imageUrls = Array.isArray(data.image_urls)
|
||||
? data.image_urls.map(item => String(item || "").trim()).filter(item => item.length > 0)
|
||||
: [];
|
||||
|
||||
if (imageUrls.length === 0) {
|
||||
throw new Error("MiniMax 响应中未找到 data.image_urls。");
|
||||
}
|
||||
|
||||
return {
|
||||
request_id: requestId,
|
||||
effective_model: effectiveModel,
|
||||
response_format: responseFormat,
|
||||
image_base64: [],
|
||||
image_urls: imageUrls,
|
||||
success_count: Number.isFinite(successCount) ? successCount : imageUrls.length,
|
||||
failed_count: Number.isFinite(failedCount) ? failedCount : 0,
|
||||
reference_count: subjectReference ? subjectReference.length : 0
|
||||
};
|
||||
}
|
||||
|
||||
async function draw_image(params) {
|
||||
const prompt = String(params && params.prompt ? params.prompt : "").trim();
|
||||
if (!prompt) {
|
||||
throw new Error("参数 prompt 不能为空。");
|
||||
}
|
||||
|
||||
await ensureDirectories();
|
||||
|
||||
const apiResult = await callMiniMaxImageApi({
|
||||
prompt,
|
||||
model: params.model,
|
||||
aspect_ratio: params.aspect_ratio,
|
||||
width: params.width,
|
||||
height: params.height,
|
||||
n: params.n,
|
||||
response_format: params.response_format,
|
||||
prompt_optimizer: params.prompt_optimizer,
|
||||
aigc_watermark: params.aigc_watermark,
|
||||
seed: params.seed,
|
||||
image_urls: params.image_urls,
|
||||
image_paths: params.image_paths,
|
||||
api_base_url: params.api_base_url
|
||||
});
|
||||
|
||||
const baseName = buildFileBaseName(prompt, params.file_name, "minimax_draw");
|
||||
const files: Array<{
|
||||
file_path: string;
|
||||
file_uri: string;
|
||||
markdown: string;
|
||||
remote_image_url: string | null;
|
||||
}> = [];
|
||||
|
||||
if (apiResult.response_format === "base64") {
|
||||
for (let index = 0; index < apiResult.image_base64.length; index += 1) {
|
||||
const suffix = apiResult.image_base64.length > 1 ? `_${index + 1}` : "";
|
||||
const filePath = `${DRAWS_DIR}/${baseName}${suffix}.${DEFAULT_BASE64_EXTENSION}`;
|
||||
const writeResult = await Tools.Files.writeBinary(filePath, normalizeBase64(apiResult.image_base64[index]));
|
||||
if (!writeResult.successful) {
|
||||
throw new Error(`保存图片失败: ${writeResult.details}`);
|
||||
}
|
||||
const fileUri = `file://${filePath}`;
|
||||
files.push({
|
||||
file_path: filePath,
|
||||
file_uri: fileUri,
|
||||
markdown: ``,
|
||||
remote_image_url: null
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for (let index = 0; index < apiResult.image_urls.length; index += 1) {
|
||||
const imageUrl = apiResult.image_urls[index];
|
||||
const extension = guessExtensionFromUrl(imageUrl, "png");
|
||||
const suffix = apiResult.image_urls.length > 1 ? `_${index + 1}` : "";
|
||||
const filePath = `${DRAWS_DIR}/${baseName}${suffix}.${extension}`;
|
||||
const downloadResult = await Tools.Files.download(imageUrl, filePath);
|
||||
if (!downloadResult.successful) {
|
||||
throw new Error(`下载图片失败: ${downloadResult.details}`);
|
||||
}
|
||||
const fileUri = `file://${filePath}`;
|
||||
files.push({
|
||||
file_path: filePath,
|
||||
file_uri: fileUri,
|
||||
markdown: ``,
|
||||
remote_image_url: imageUrl
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const hintLines: string[] = [];
|
||||
hintLines.push(`图片已生成并保存在本地 ${DRAWS_DIR}。`);
|
||||
hintLines.push(`共生成 ${files.length} 张。`);
|
||||
if (apiResult.reference_count > 0) {
|
||||
hintLines.push(`本次使用了 ${apiResult.reference_count} 张参考图。`);
|
||||
}
|
||||
hintLines.push("");
|
||||
hintLines.push("后续回答如果需要展示图片,请直接输出下面这些 Markdown:");
|
||||
hintLines.push("");
|
||||
for (const fileItem of files) {
|
||||
hintLines.push(fileItem.markdown);
|
||||
}
|
||||
|
||||
return {
|
||||
prompt,
|
||||
model: apiResult.effective_model,
|
||||
request_id: apiResult.request_id || null,
|
||||
response_format: apiResult.response_format,
|
||||
success_count: apiResult.success_count,
|
||||
failed_count: apiResult.failed_count,
|
||||
reference_count: apiResult.reference_count,
|
||||
file_path: files[0].file_path,
|
||||
file_uri: files[0].file_uri,
|
||||
markdown: files[0].markdown,
|
||||
files,
|
||||
hint: hintLines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
async function draw_image_wrapper(params) {
|
||||
try {
|
||||
const result = await draw_image(params || {});
|
||||
complete({
|
||||
success: true,
|
||||
message: "MiniMax 图片生成成功,已保存到本地。",
|
||||
data: result
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("draw_image 执行失败:", error);
|
||||
complete({
|
||||
success: false,
|
||||
message: `MiniMax 图片生成失败: ${getErrorMessage(error)}`,
|
||||
error_stack: error && error.stack ? error.stack : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
draw_image: draw_image_wrapper
|
||||
};
|
||||
})();
|
||||
|
||||
exports.draw_image = minimaxDraw.draw_image;
|
||||
@@ -0,0 +1,744 @@
|
||||
/* METADATA
|
||||
{
|
||||
"name": "nanobanana_draw",
|
||||
|
||||
"display_name": {
|
||||
"zh": "Nanobanana 绘图",
|
||||
"en": "Nanobanana Draw"
|
||||
},
|
||||
"description": {
|
||||
"zh": "使用 Nano Banana API (基于Grsai的api服务/https://grsai.com/) 根据提示词画图,支持文生图和图生图(可传入参考图片URL或本地图片路径;本地图片会先上传到图床以获得公网URL),将图片保存到本地 /sdcard/Download/Operit/plugins/draw/nanobanana_draw/draws/ 目录,并返回 Markdown 图片提示。",
|
||||
"en": "Generate images using the Nano Banana API (via Grsai service / https://grsai.com/). Supports text-to-image and image-to-image (you can provide reference image URLs or local image paths; local images will be uploaded first to get public URLs). Saves images to /sdcard/Download/Operit/plugins/draw/nanobanana_draw/draws/ and returns a Markdown image reference."
|
||||
},
|
||||
"env": [
|
||||
"NANOBANANA_API_KEY",
|
||||
"NANOBANANA_API_BASE_URL",
|
||||
"BEEIMG_API_KEY"
|
||||
],
|
||||
"category": "Draw",
|
||||
"tools": [
|
||||
{
|
||||
"name": "draw_image",
|
||||
"description": {
|
||||
"zh": "根据提示词调用 Nano Banana API 生成图片(支持文生图和图生图),保存到本地并返回 Markdown 图片提示。",
|
||||
"en": "Generate an image via the Nano Banana API using a prompt (supports text-to-image and image-to-image), save locally, and return a Markdown image reference."
|
||||
},
|
||||
"parameters": [
|
||||
{ "name": "prompt", "description": { "zh": "绘图提示词(英文或中文皆可)", "en": "Image prompt (Chinese or English)" }, "type": "string", "required": true },
|
||||
{ "name": "model", "description": { "zh": "Nano Banana 模型名称(优先级最高)。可填 nano-banana-pro(约1800积分)或 nano-banana(约400积分)", "en": "Nano Banana model name (highest priority). e.g. nano-banana-pro (~1800 credits) or nano-banana (~400 credits)." }, "type": "string", "required": false },
|
||||
{ "name": "model_variant", "description": { "zh": "模型档位(二选一,可选):pro(约1800积分)或 nano(约400积分)。未传时默认 pro", "en": "Model tier (optional): pro (~1800 credits) or nano (~400 credits). Defaults to pro." }, "type": "string", "required": false },
|
||||
{ "name": "aspect_ratio", "description": { "zh": "输出图像比例,如 '1:1', '16:9', 'auto' 等,可选", "en": "Output aspect ratio, e.g. '1:1', '16:9', 'auto' (optional)" }, "type": "string", "required": false },
|
||||
{ "name": "image_size", "description": { "zh": "输出图像大小,仅 nano-banana-pro 支持,如 '1K', '2K', '4K',可选", "en": "Output image size (only supported by nano-banana-pro), e.g. '1K', '2K', '4K' (optional)" }, "type": "string", "required": false },
|
||||
{ "name": "image_urls", "description": { "zh": "参考图URL数组(图生图),支持格式:字符串数组['https://...'] 或 JSON字符串'[\"https://...\"]' 或逗号分隔'url1,url2',可选", "en": "Reference image URL list for img2img. Accepts: string array ['https://...'], or JSON string '[\"https://...\"]', or comma-separated 'url1,url2' (optional)." }, "type": "array", "required": false },
|
||||
{ "name": "image_paths", "description": { "zh": "参考图本地路径数组(图生图,会先上传图床再进行生成),支持格式:字符串数组['/sdcard/...'] 或 JSON字符串 或 逗号分隔,可选", "en": "Reference local image path list for img2img (will be uploaded first). Accepts: string array ['/sdcard/...'], or JSON string, or comma-separated list (optional)." }, "type": "array", "required": false },
|
||||
{ "name": "file_name", "description": { "zh": "自定义保存到本地的文件名(不含路径和扩展名)", "en": "Custom output file name (without path or extension)" }, "type": "string", "required": false },
|
||||
{ "name": "poll_interval_ms", "description": { "zh": "轮询间隔(毫秒),默认 5000", "en": "Polling interval (milliseconds), default 5000" }, "type": "number", "required": false },
|
||||
{ "name": "max_wait_time_ms", "description": { "zh": "最长等待时间(毫秒)。默认 10 分钟", "en": "Max wait time (milliseconds). Default 10 minutes." }, "type": "number", "required": false }
|
||||
]
|
||||
}
|
||||
]
|
||||
}*/
|
||||
|
||||
const nanobananaDraw = (function () {
|
||||
const HTTP_TIMEOUT_MS = 600000;
|
||||
const client = OkHttp.newBuilder()
|
||||
.connectTimeout(HTTP_TIMEOUT_MS)
|
||||
.readTimeout(HTTP_TIMEOUT_MS)
|
||||
.writeTimeout(HTTP_TIMEOUT_MS)
|
||||
.build();
|
||||
|
||||
const BEEIMG_UPLOAD_ENDPOINT = "https://beeimg.com/api/upload/file/json/";
|
||||
|
||||
// API配置
|
||||
const DEFAULT_API_BASE_URL = "https://grsai.dakka.com.cn";
|
||||
const DRAW_API_PATH = "v1/draw/nano-banana";
|
||||
const RESULT_API_PATH = "v1/draw/result";
|
||||
const MODEL_PRO = "nano-banana-pro";
|
||||
const MODEL_NANO = "nano-banana";
|
||||
const DEFAULT_MODEL = MODEL_PRO;
|
||||
// Android 实际路径为 /sdcard/Download,对应系统中文名"下载"
|
||||
const DRAW_ROOT_DIR = getPluginConfigDir("draw");
|
||||
const STORAGE_DIR = `${DRAW_ROOT_DIR}/nanobanana_draw`;
|
||||
const DRAWS_DIR = `${STORAGE_DIR}/draws`;
|
||||
|
||||
// 轮询配置
|
||||
const POLL_INTERVAL = 5000; // 每5秒查询一次
|
||||
const MAX_WAIT_TIME = 600000; // 最多等待10分钟
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function getErrorStack(error: unknown): string | undefined {
|
||||
if (error instanceof Error) return error.stack;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveModel(model?: string, modelVariant?: string): string {
|
||||
if (model && model.trim().length > 0) {
|
||||
return model.trim();
|
||||
}
|
||||
|
||||
if (!modelVariant || modelVariant.trim().length === 0) {
|
||||
return DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
const normalizedVariant = modelVariant.trim().toLowerCase();
|
||||
if (normalizedVariant === "pro") {
|
||||
return MODEL_PRO;
|
||||
}
|
||||
if (normalizedVariant === "nano") {
|
||||
return MODEL_NANO;
|
||||
}
|
||||
|
||||
throw new Error("参数 model_variant 仅支持 'pro' 或 'nano'。");
|
||||
}
|
||||
|
||||
function normalizePositiveInt(value: unknown, fallback: number): number {
|
||||
if (value === undefined || value === null) {
|
||||
return fallback;
|
||||
}
|
||||
const n = typeof value === "number" ? value : parseInt(String(value), 10);
|
||||
if (!Number.isFinite(n) || n <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
function getApiKey(): string {
|
||||
const apiKey = getEnv("NANOBANANA_API_KEY");
|
||||
if (!apiKey) {
|
||||
throw new Error("NANOBANANA_API_KEY 未配置,请在环境变量中设置 Nano Banana 的 API Key。");
|
||||
}
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
function getBeeimgApiKey(): string {
|
||||
return getEnv("BEEIMG_API_KEY") || "";
|
||||
}
|
||||
|
||||
function joinUrl(baseUrl: string, path: string): string {
|
||||
const normalizedBase = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
||||
const normalizedPath = path.startsWith("/") ? path.slice(1) : path;
|
||||
return `${normalizedBase}${normalizedPath}`;
|
||||
}
|
||||
|
||||
function getApiBaseUrl(): string {
|
||||
const fromEnv = (getEnv("NANOBANANA_API_BASE_URL") || "").trim();
|
||||
if (fromEnv) return fromEnv;
|
||||
return DEFAULT_API_BASE_URL;
|
||||
}
|
||||
|
||||
function getDrawEndpoint(baseUrl: string): string {
|
||||
const trimmed = baseUrl.trim();
|
||||
if (!trimmed) return joinUrl(DEFAULT_API_BASE_URL, DRAW_API_PATH);
|
||||
if (trimmed.includes("/v1/draw/nano-banana")) return trimmed;
|
||||
if (trimmed.endsWith("/v1")) return joinUrl(trimmed, "draw/nano-banana");
|
||||
if (trimmed.endsWith("/v1/")) return joinUrl(trimmed, "draw/nano-banana");
|
||||
return joinUrl(trimmed, DRAW_API_PATH);
|
||||
}
|
||||
|
||||
function getResultEndpoint(baseUrl: string): string {
|
||||
const trimmed = baseUrl.trim();
|
||||
if (!trimmed) return joinUrl(DEFAULT_API_BASE_URL, RESULT_API_PATH);
|
||||
if (trimmed.includes("/v1/draw/result")) return trimmed;
|
||||
if (trimmed.includes("/v1/draw/nano-banana")) {
|
||||
return trimmed.replace("/v1/draw/nano-banana", "/v1/draw/result");
|
||||
}
|
||||
if (trimmed.endsWith("/v1")) return joinUrl(trimmed, "draw/result");
|
||||
if (trimmed.endsWith("/v1/")) return joinUrl(trimmed, "draw/result");
|
||||
return joinUrl(trimmed, RESULT_API_PATH);
|
||||
}
|
||||
|
||||
function guessMimeTypeFromPath(filePath: string): string {
|
||||
const lower = filePath.toLowerCase();
|
||||
if (lower.endsWith(".png")) return "image/png";
|
||||
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
||||
if (lower.endsWith(".webp")) return "image/webp";
|
||||
if (lower.endsWith(".gif")) return "image/gif";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
function safeJsonParseLoose(text: string): unknown {
|
||||
const trimmed = (text || "").trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
return JSON.parse(trimmed) as unknown;
|
||||
} catch (e) {
|
||||
const start = trimmed.indexOf("{");
|
||||
const end = trimmed.lastIndexOf("}");
|
||||
if (start !== -1 && end !== -1 && end > start) {
|
||||
return JSON.parse(trimmed.substring(start, end + 1)) as unknown;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function isApiSuccessResponse(parsed: Record<string, unknown>): boolean {
|
||||
const code = parsed["code"];
|
||||
return code === undefined || code === null || code === 0 || code === "0";
|
||||
}
|
||||
|
||||
function extractTaskPayload(parsed: unknown): Record<string, unknown> | null {
|
||||
if (!isRecord(parsed)) {
|
||||
return null;
|
||||
}
|
||||
if (isRecord(parsed["data"])) {
|
||||
return parsed["data"] as Record<string, unknown>;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function extractMessage(parsed: Record<string, unknown>, fallback: string): string {
|
||||
const payload = extractTaskPayload(parsed);
|
||||
const candidates: unknown[] = [
|
||||
parsed["msg"],
|
||||
parsed["message"],
|
||||
payload ? payload["failure_reason"] : undefined,
|
||||
payload ? payload["error"] : undefined,
|
||||
payload ? payload["msg"] : undefined,
|
||||
payload ? payload["message"] : undefined
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if ((typeof candidate === "string" || typeof candidate === "number") && String(candidate).trim().length > 0) {
|
||||
return String(candidate).trim();
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeStatus(status: unknown): string {
|
||||
return typeof status === "string" ? status.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
function normalizeProgress(progress: unknown): number {
|
||||
const normalized = typeof progress === "number" ? progress : Number(String(progress));
|
||||
return Number.isFinite(normalized) ? normalized : 0;
|
||||
}
|
||||
|
||||
function isSuccessStatus(status: string): boolean {
|
||||
return status === "succeeded" || status === "success" || status === "completed" || status === "done";
|
||||
}
|
||||
|
||||
function isFailureStatus(status: string): boolean {
|
||||
return status === "failed" || status === "error" || status === "canceled" || status === "cancelled";
|
||||
}
|
||||
|
||||
function extractImageUrlFromPayload(payload: Record<string, unknown>): string {
|
||||
const directCandidates: unknown[] = [
|
||||
payload["url"],
|
||||
payload["image_url"],
|
||||
payload["imageUrl"],
|
||||
payload["file_url"],
|
||||
payload["fileUrl"],
|
||||
payload["result_url"]
|
||||
];
|
||||
|
||||
for (const candidate of directCandidates) {
|
||||
if ((typeof candidate === "string" || typeof candidate === "number") && String(candidate).trim().length > 0) {
|
||||
return String(candidate).trim();
|
||||
}
|
||||
}
|
||||
|
||||
const results = payload["results"];
|
||||
if (!Array.isArray(results) || results.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const first = results[0];
|
||||
if (isRecord(first)) {
|
||||
const nestedCandidates: unknown[] = [
|
||||
first["url"],
|
||||
first["image_url"],
|
||||
first["imageUrl"],
|
||||
first["file_url"],
|
||||
first["fileUrl"],
|
||||
first["result_url"]
|
||||
];
|
||||
for (const candidate of nestedCandidates) {
|
||||
if ((typeof candidate === "string" || typeof candidate === "number") && String(candidate).trim().length > 0) {
|
||||
return String(candidate).trim();
|
||||
}
|
||||
}
|
||||
} else if ((typeof first === "string" || typeof first === "number") && String(first).trim().length > 0) {
|
||||
return String(first).trim();
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
async function uploadImageToBeeimg(filePath: string): Promise<string> {
|
||||
const exists = await Tools.Files.exists(filePath);
|
||||
if (!exists.exists) {
|
||||
throw new Error(`参考图文件不存在: ${filePath}`);
|
||||
}
|
||||
|
||||
const apiKey = getBeeimgApiKey();
|
||||
if (!apiKey) {
|
||||
throw new Error("使用 image_paths 需要配置 BEEIMG_API_KEY(用于把本地图片上传到图床以获得公网URL)。");
|
||||
}
|
||||
|
||||
const resp = await Tools.Net.uploadFile({
|
||||
url: BEEIMG_UPLOAD_ENDPOINT,
|
||||
method: "POST",
|
||||
form_data: {
|
||||
apikey: apiKey
|
||||
},
|
||||
files: [
|
||||
{
|
||||
field_name: "file",
|
||||
file_path: filePath,
|
||||
content_type: guessMimeTypeFromPath(filePath)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (resp.statusCode < 200 || resp.statusCode >= 300) {
|
||||
throw new Error(`BeeIMG 上传失败: HTTP ${resp.statusCode} - ${resp.content}`);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = safeJsonParseLoose(resp.content);
|
||||
} catch (e: unknown) {
|
||||
throw new Error(`BeeIMG 上传响应解析失败: ${getErrorMessage(e)}`);
|
||||
}
|
||||
|
||||
const files = isRecord(parsed) && isRecord(parsed["files"]) ? (parsed["files"] as Record<string, unknown>) : null;
|
||||
const ok = !!files && (files["status"] === "Success" || files["code"] === "200" || files["code"] === 200);
|
||||
const url = files ? files["url"] : undefined;
|
||||
if (!ok || (typeof url !== "string" && typeof url !== "number") || String(url).trim().length === 0) {
|
||||
throw new Error(`BeeIMG 上传失败: ${resp.content}`);
|
||||
}
|
||||
return String(url);
|
||||
}
|
||||
|
||||
function sanitizeFileName(name: string): string {
|
||||
const safe = name.replace(/[\\/:*?"<>|]/g, "_").trim();
|
||||
if (!safe) {
|
||||
return `nano_draw_${Date.now()}`;
|
||||
}
|
||||
return safe.substring(0, 80);
|
||||
}
|
||||
|
||||
function buildFileName(prompt: string, customName?: string | null): string {
|
||||
if (customName && customName.trim().length > 0) {
|
||||
return sanitizeFileName(customName);
|
||||
}
|
||||
const shortPrompt = prompt.length > 40 ? `${prompt.substring(0, 40)}...` : prompt;
|
||||
const base = sanitizeFileName(shortPrompt || "image");
|
||||
const timestamp = Date.now();
|
||||
return `${base}_${timestamp}`;
|
||||
}
|
||||
|
||||
async function ensureDirectories(): Promise<void> {
|
||||
const dirs = [DRAW_ROOT_DIR, STORAGE_DIR, DRAWS_DIR];
|
||||
for (const dir of dirs) {
|
||||
try {
|
||||
const result = await Tools.Files.mkdir(dir);
|
||||
if (!result.successful) {
|
||||
console.warn(`创建目录失败(可能已存在): ${dir} -> ${result.details}`);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.warn(`创建目录异常: ${dir} -> ${getErrorMessage(e)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function callNanobananaApi(params: {
|
||||
prompt: string;
|
||||
model: string;
|
||||
aspect_ratio?: string;
|
||||
image_size?: string;
|
||||
image_urls?: string[];
|
||||
poll_interval_ms?: number;
|
||||
max_wait_time_ms?: number;
|
||||
}): Promise<string> {
|
||||
const apiKey = getApiKey();
|
||||
const endpoint = getDrawEndpoint(getApiBaseUrl());
|
||||
|
||||
// 构建请求体 - 使用异步模式(webHook: "-1")
|
||||
const body: Record<string, unknown> = {
|
||||
model: params.model,
|
||||
prompt: params.prompt,
|
||||
webHook: "-1", // 关键:立即返回任务ID
|
||||
shutProgress: false
|
||||
};
|
||||
|
||||
// 添加可选参数
|
||||
if (params.aspect_ratio && params.aspect_ratio.trim().length > 0) {
|
||||
body.aspectRatio = params.aspect_ratio.trim();
|
||||
}
|
||||
|
||||
if (params.image_size && params.image_size.trim().length > 0) {
|
||||
body.imageSize = params.image_size.trim();
|
||||
}
|
||||
|
||||
// 图生图:添加参考图URL数组
|
||||
// 支持格式:['https://example.com/1.jpg', 'https://example.com/2.jpg']
|
||||
// 或 JSON字符串:"[\"https://...\"]"
|
||||
// 或 逗号分隔:"url1,url2"
|
||||
if (params.image_urls && Array.isArray(params.image_urls) && params.image_urls.length > 0) {
|
||||
body.urls = params.image_urls.filter(url => url && url.trim().length > 0);
|
||||
}
|
||||
|
||||
const headers = {
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
"Authorization": `Bearer ${apiKey}`
|
||||
};
|
||||
|
||||
const request = client
|
||||
.newRequest()
|
||||
.url(endpoint)
|
||||
.method("POST")
|
||||
.headers(headers)
|
||||
.body(JSON.stringify(body), "json");
|
||||
|
||||
console.log("步骤1/2: 提交绘图任务...");
|
||||
const response = await request.build().execute();
|
||||
|
||||
if (!response.isSuccessful()) {
|
||||
throw new Error(`Nano Banana API 调用失败: ${response.statusCode} - ${response.content}`);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(response.content) as unknown;
|
||||
} catch (e: unknown) {
|
||||
throw new Error(`解析 Nano Banana 响应失败: ${getErrorMessage(e)}`);
|
||||
}
|
||||
|
||||
if (!isRecord(parsed)) {
|
||||
throw new Error("API响应不是合法对象,请检查参数是否正确。响应: " + response.content);
|
||||
}
|
||||
if (!isApiSuccessResponse(parsed)) {
|
||||
throw new Error(`Nano Banana API 返回错误: ${extractMessage(parsed, response.content)}`);
|
||||
}
|
||||
|
||||
const payload = extractTaskPayload(parsed);
|
||||
if (!payload || typeof payload["id"] !== "string") {
|
||||
throw new Error("API响应中未找到任务ID,请检查参数是否正确。响应: " + JSON.stringify(parsed));
|
||||
}
|
||||
|
||||
const taskId: string = payload["id"] as string;
|
||||
console.log(`任务提交成功! ID: ${taskId}`);
|
||||
const pollIntervalMs = params.poll_interval_ms ?? POLL_INTERVAL;
|
||||
const maxWaitTimeMs = params.max_wait_time_ms ?? MAX_WAIT_TIME;
|
||||
console.log(`步骤2/2: 等待任务完成(轮询中,每${pollIntervalMs / 1000}秒查询一次,最长等待${Math.ceil(maxWaitTimeMs / 60000)}分钟)...`);
|
||||
|
||||
return taskId;
|
||||
}
|
||||
|
||||
async function pollForResult(taskId: string, options: { poll_interval_ms?: number; max_wait_time_ms?: number } = {}): Promise<string> {
|
||||
const apiKey = getApiKey();
|
||||
const endpoint = getResultEndpoint(getApiBaseUrl());
|
||||
const pollIntervalMs = normalizePositiveInt(options.poll_interval_ms, POLL_INTERVAL);
|
||||
const maxWaitTimeMs = normalizePositiveInt(options.max_wait_time_ms, MAX_WAIT_TIME);
|
||||
const startTime = Date.now();
|
||||
let attempt = 0;
|
||||
|
||||
const doSleep = async (ms: number): Promise<void> => {
|
||||
await Tools.System.sleep(ms);
|
||||
};
|
||||
|
||||
while (Date.now() - startTime < maxWaitTimeMs) {
|
||||
attempt++;
|
||||
console.log(`第${attempt}次查询任务状态...`);
|
||||
|
||||
try {
|
||||
const request = client
|
||||
.newRequest()
|
||||
.url(endpoint)
|
||||
.method("POST")
|
||||
.headers({
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
"Authorization": `Bearer ${apiKey}`
|
||||
})
|
||||
.body(JSON.stringify({ id: taskId }), "json");
|
||||
|
||||
const response = await request.build().execute();
|
||||
|
||||
if (!response.isSuccessful()) {
|
||||
console.warn(`⚠️ 查询请求未成功 (HTTP ${response.statusCode}): ${response.content},将重试...`);
|
||||
await doSleep(pollIntervalMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(response.content) as unknown;
|
||||
} catch (_error: unknown) {
|
||||
console.warn("⚠️ 解析结果响应失败,将重试");
|
||||
await doSleep(pollIntervalMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isRecord(parsed)) {
|
||||
console.warn(`查询响应异常: ${JSON.stringify(parsed)}`);
|
||||
await doSleep(pollIntervalMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsed["code"] === -22 || parsed["code"] === "-22") {
|
||||
console.log("任务排队/处理中... (等待服务器生成)");
|
||||
await doSleep(pollIntervalMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isApiSuccessResponse(parsed)) {
|
||||
console.warn(`⚠️ API 返回异常状态,将重试: ${JSON.stringify(parsed)}`);
|
||||
await doSleep(pollIntervalMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = extractTaskPayload(parsed);
|
||||
if (!data) {
|
||||
console.warn(`查询响应异常 (无有效数据): ${JSON.stringify(parsed)}`);
|
||||
await doSleep(pollIntervalMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
const progress = normalizeProgress(data["progress"]);
|
||||
const status = normalizeStatus(data["status"]);
|
||||
const imageUrl = extractImageUrlFromPayload(data);
|
||||
|
||||
console.log(`当前进度: ${progress}% | 状态: ${status || "unknown"}`);
|
||||
|
||||
if (isSuccessStatus(status) || (progress >= 100 && imageUrl.length > 0)) {
|
||||
console.log("✅ 任务完成!");
|
||||
if (imageUrl.length === 0) {
|
||||
throw new Error("任务完成但响应中未找到图片URL: " + JSON.stringify(data));
|
||||
}
|
||||
return imageUrl;
|
||||
}
|
||||
if (isFailureStatus(status)) {
|
||||
throw new Error(`任务执行失败: ${JSON.stringify(data)}`);
|
||||
}
|
||||
if ((status === "running" || status === "processing") && progress > 0) {
|
||||
console.log(`生成中... 进度: ${progress}%`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.log(`⚠️ 第${attempt}次查询发生不可预知的异常: ${getErrorMessage(error)},程序将自动进行下一次尝试...`);
|
||||
}
|
||||
|
||||
await doSleep(pollIntervalMs);
|
||||
}
|
||||
|
||||
throw new Error(`任务超时: 等待超过${Math.ceil(maxWaitTimeMs / 60000)}分钟仍未完成`);
|
||||
}
|
||||
|
||||
function guessExtensionFromUrl(url: string): string {
|
||||
const match = url.match(/\.(png|jpg|jpeg|webp|gif)(?:\?|#|$)/i);
|
||||
if (match && match[1]) {
|
||||
return match[1].toLowerCase();
|
||||
}
|
||||
return "png";
|
||||
}
|
||||
|
||||
interface DrawImageParams {
|
||||
prompt: string;
|
||||
model?: string;
|
||||
model_variant?: string;
|
||||
aspect_ratio?: string;
|
||||
image_size?: string;
|
||||
image_urls?: string[] | string;
|
||||
image_paths?: string[] | string;
|
||||
file_name?: string;
|
||||
poll_interval_ms?: number | string;
|
||||
max_wait_time_ms?: number | string;
|
||||
}
|
||||
|
||||
interface DrawImageResult {
|
||||
file_path: string;
|
||||
file_uri: string;
|
||||
markdown: string;
|
||||
prompt: string;
|
||||
model: string;
|
||||
aspect_ratio?: string;
|
||||
image_size?: string;
|
||||
image_urls?: string[] | string;
|
||||
image_paths?: string[] | string;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
async function draw_image(params: DrawImageParams): Promise<DrawImageResult> {
|
||||
if (!params || !params.prompt || params.prompt.trim().length === 0) {
|
||||
throw new Error("参数 prompt 不能为空。");
|
||||
}
|
||||
|
||||
const prompt = params.prompt.trim();
|
||||
const resolvedModel = resolveModel(params.model, params.model_variant);
|
||||
|
||||
if (params.image_size && params.image_size.trim().length > 0 && resolvedModel !== MODEL_PRO) {
|
||||
throw new Error("参数 image_size 仅支持 pro 模型(model_variant='pro' 或 model='nano-banana-pro')。");
|
||||
}
|
||||
|
||||
const pollIntervalMs = normalizePositiveInt(params.poll_interval_ms, POLL_INTERVAL);
|
||||
const normalizedImageSize = params.image_size ? params.image_size.trim().toUpperCase() : "";
|
||||
const defaultMaxWaitTimeMs = normalizedImageSize === "4K" ? 600000 : MAX_WAIT_TIME;
|
||||
const maxWaitTimeMs = normalizePositiveInt(params.max_wait_time_ms, defaultMaxWaitTimeMs);
|
||||
|
||||
// 添加辅助函数来解析URL数组
|
||||
function parseImageUrls(image_urls: string[] | string): string[] {
|
||||
// 如果已经是数组,直接过滤空值返回
|
||||
if (Array.isArray(image_urls)) {
|
||||
return image_urls.filter(url => url && url.trim().length > 0);
|
||||
}
|
||||
|
||||
// 如果是字符串,尝试解析
|
||||
if (typeof image_urls === "string") {
|
||||
// 方法一:尝试JSON解析
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(image_urls) as unknown;
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.filter((url) => typeof url === "string" && url.trim().length > 0);
|
||||
}
|
||||
} catch (e) {
|
||||
// 解析失败继续方法二
|
||||
}
|
||||
|
||||
// 方法二:按逗号分割(支持 "url1,url2" 格式)
|
||||
const splitUrls = image_urls.split(",")
|
||||
.map(url => url.trim())
|
||||
.filter(url => url.length > 0);
|
||||
if (splitUrls.length > 0) {
|
||||
return splitUrls;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function parseImagePaths(image_paths: string[] | string): string[] {
|
||||
if (Array.isArray(image_paths)) {
|
||||
return image_paths.filter(p => p && String(p).trim().length > 0).map(p => String(p).trim());
|
||||
}
|
||||
if (typeof image_paths === "string") {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(image_paths) as unknown;
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.filter((p) => p && String(p).trim().length > 0).map((p) => String(p).trim());
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const splitPaths = image_paths.split(",")
|
||||
.map(p => p.trim())
|
||||
.filter(p => p.length > 0);
|
||||
if (splitPaths.length > 0) {
|
||||
return splitPaths;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// 替换原有的验证逻辑
|
||||
let imageUrlsArray: string[] = [];
|
||||
if (params.image_urls) {
|
||||
imageUrlsArray = parseImageUrls(params.image_urls);
|
||||
if (imageUrlsArray.length === 0) {
|
||||
throw new Error("参数 image_urls 必须是有效的URL数组。");
|
||||
}
|
||||
}
|
||||
|
||||
let imagePathsArray: string[] = [];
|
||||
if (params.image_paths) {
|
||||
imagePathsArray = parseImagePaths(params.image_paths);
|
||||
if (imagePathsArray.length === 0) {
|
||||
throw new Error("参数 image_paths 必须是有效的本地路径数组。");
|
||||
}
|
||||
}
|
||||
|
||||
if (imagePathsArray.length > 0) {
|
||||
console.log(`检测到 ${imagePathsArray.length} 张本地参考图,开始上传以获得公网URL...`);
|
||||
for (const p of imagePathsArray) {
|
||||
const url = await uploadImageToBeeimg(p);
|
||||
imageUrlsArray.push(url);
|
||||
}
|
||||
console.log("本地参考图上传完成。");
|
||||
}
|
||||
|
||||
await ensureDirectories();
|
||||
|
||||
// 步骤1: 提交任务并获取任务ID
|
||||
const taskId = await callNanobananaApi({
|
||||
prompt,
|
||||
model: resolvedModel,
|
||||
aspect_ratio: params.aspect_ratio,
|
||||
image_size: params.image_size,
|
||||
image_urls: imageUrlsArray,
|
||||
poll_interval_ms: pollIntervalMs,
|
||||
max_wait_time_ms: maxWaitTimeMs
|
||||
});
|
||||
|
||||
// 步骤2: 轮询等待任务完成
|
||||
const imageUrl = await pollForResult(taskId, { poll_interval_ms: pollIntervalMs, max_wait_time_ms: maxWaitTimeMs });
|
||||
|
||||
const ext = guessExtensionFromUrl(imageUrl);
|
||||
const baseName = buildFileName(prompt, params.file_name ?? null);
|
||||
const filePath = `${DRAWS_DIR}/${baseName}.${ext}`;
|
||||
|
||||
const downloadResult = await Tools.Files.download(imageUrl, filePath);
|
||||
if (!downloadResult.successful) {
|
||||
throw new Error(`下载图片失败: ${downloadResult.details}`);
|
||||
}
|
||||
|
||||
const fileUri = `file://${filePath}`;
|
||||
const markdown = ``;
|
||||
|
||||
const hintLines: string[] = [];
|
||||
hintLines.push(`图片已生成并保存在本地 ${DRAWS_DIR}。`);
|
||||
hintLines.push(`本地路径: ${filePath}`);
|
||||
hintLines.push("");
|
||||
hintLines.push("在后续回答中,请直接输出下面这一行 Markdown 来展示这张图片:");
|
||||
hintLines.push("");
|
||||
hintLines.push(markdown);
|
||||
|
||||
return {
|
||||
file_path: filePath,
|
||||
file_uri: fileUri,
|
||||
markdown,
|
||||
prompt,
|
||||
model: resolvedModel,
|
||||
aspect_ratio: params.aspect_ratio,
|
||||
image_size: params.image_size,
|
||||
image_urls: params.image_urls,
|
||||
image_paths: params.image_paths,
|
||||
hint: hintLines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
async function draw_image_wrapper(params: DrawImageParams) {
|
||||
try {
|
||||
const result = await draw_image(params);
|
||||
complete({
|
||||
success: true,
|
||||
message: `图片生成成功,已保存到 ${DRAWS_DIR},并返回 Markdown 图片提示。`,
|
||||
data: result
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error("draw_image 执行失败:", error);
|
||||
complete({
|
||||
success: false,
|
||||
message: `图片生成失败: ${getErrorMessage(error)}`,
|
||||
error_stack: getErrorStack(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
draw_image: draw_image_wrapper
|
||||
};
|
||||
})();
|
||||
|
||||
exports.draw_image = nanobananaDraw.draw_image;
|
||||
@@ -0,0 +1,874 @@
|
||||
/*
|
||||
METADATA
|
||||
{
|
||||
"name": "network_test",
|
||||
|
||||
"display_name": {
|
||||
"zh": "网络测试",
|
||||
"en": "Network Test"
|
||||
},
|
||||
"category": "Network",
|
||||
"description": { "zh": "网络测试工具集合,提供基于OkHttp3的网络请求功能,包括GET、POST、PUT、DELETE请求方法,以及请求超时设置、重定向控制和拦截器管理。支持多种数据格式,便于测试API接口和网络连接性能。", "en": "Network testing tools based on OkHttp3. Provides GET/POST/PUT/DELETE requests, timeout settings, redirect control, and interceptor management. Supports multiple data formats for API testing and connectivity diagnostics." },
|
||||
"tools": [
|
||||
{
|
||||
"name": "http_get",
|
||||
"description": { "zh": "发送HTTP GET请求", "en": "Send an HTTP GET request." },
|
||||
"parameters": [
|
||||
{
|
||||
"name": "url",
|
||||
"description": { "zh": "请求URL", "en": "Request URL." },
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "headers",
|
||||
"description": { "zh": "请求头", "en": "Request headers." },
|
||||
"type": "object",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "http_post",
|
||||
"description": { "zh": "发送HTTP POST请求", "en": "Send an HTTP POST request." },
|
||||
"parameters": [
|
||||
{
|
||||
"name": "url",
|
||||
"description": { "zh": "请求URL", "en": "Request URL." },
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "body",
|
||||
"description": { "zh": "请求体", "en": "Request body." },
|
||||
"type": "object",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "headers",
|
||||
"description": { "zh": "请求头", "en": "Request headers." },
|
||||
"type": "object",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "body_type",
|
||||
"description": { "zh": "请求体类型,支持'text'、'json'、'form'、'multipart'", "en": "Body type: 'text', 'json', 'form', or 'multipart'." },
|
||||
"type": "string",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "http_put",
|
||||
"description": { "zh": "发送HTTP PUT请求", "en": "Send an HTTP PUT request." },
|
||||
"parameters": [
|
||||
{
|
||||
"name": "url",
|
||||
"description": { "zh": "请求URL", "en": "Request URL." },
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "body",
|
||||
"description": { "zh": "请求体", "en": "Request body." },
|
||||
"type": "object",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "headers",
|
||||
"description": { "zh": "请求头", "en": "Request headers." },
|
||||
"type": "object",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "body_type",
|
||||
"description": { "zh": "请求体类型,支持'text'、'json'、'form'、'multipart'", "en": "Body type: 'text', 'json', 'form', or 'multipart'." },
|
||||
"type": "string",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "http_delete",
|
||||
"description": { "zh": "发送HTTP DELETE请求", "en": "Send an HTTP DELETE request." },
|
||||
"parameters": [
|
||||
{
|
||||
"name": "url",
|
||||
"description": { "zh": "请求URL", "en": "Request URL." },
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "headers",
|
||||
"description": { "zh": "请求头", "en": "Request headers." },
|
||||
"type": "object",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "config_client",
|
||||
"description": { "zh": "配置HTTP客户端", "en": "Configure the HTTP client." },
|
||||
"parameters": [
|
||||
{
|
||||
"name": "connect_timeout",
|
||||
"description": { "zh": "连接超时时间(毫秒)", "en": "Connection timeout (milliseconds)." },
|
||||
"type": "number",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "read_timeout",
|
||||
"description": { "zh": "读取超时时间(毫秒)", "en": "Read timeout (milliseconds)." },
|
||||
"type": "number",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "write_timeout",
|
||||
"description": { "zh": "写入超时时间(毫秒)", "en": "Write timeout (milliseconds)." },
|
||||
"type": "number",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "follow_redirects",
|
||||
"description": { "zh": "是否跟随重定向", "en": "Whether to follow redirects." },
|
||||
"type": "boolean",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "retry_on_failure",
|
||||
"description": { "zh": "是否在连接失败时重试", "en": "Whether to retry on connection failure." },
|
||||
"type": "boolean",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ping_test",
|
||||
"description": { "zh": "测试与指定URL的网络连接", "en": "Test network connectivity to a specified URL." },
|
||||
"parameters": [
|
||||
{
|
||||
"name": "url",
|
||||
"description": { "zh": "要测试的URL", "en": "URL to test." },
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "count",
|
||||
"description": { "zh": "测试次数", "en": "Number of test attempts." },
|
||||
"type": "number",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "test_all",
|
||||
"description": { "zh": "运行所有网络测试", "en": "Run all network tests." },
|
||||
"parameters": []
|
||||
}
|
||||
]
|
||||
}*/
|
||||
|
||||
const networkTest = (function () {
|
||||
// 默认客户端
|
||||
let defaultClient = OkHttp.newClient();
|
||||
|
||||
// 客户端配置
|
||||
let clientConfig: OkHttpConfig = {
|
||||
timeouts: {
|
||||
connect: 10000,
|
||||
read: 10000,
|
||||
write: 10000
|
||||
},
|
||||
followRedirects: true,
|
||||
retryOnConnectionFailure: true,
|
||||
interceptors: []
|
||||
};
|
||||
|
||||
/**
|
||||
* 配置HTTP客户端
|
||||
* @param params - 配置参数
|
||||
*/
|
||||
async function config_client(params: {
|
||||
connect_timeout?: number,
|
||||
read_timeout?: number,
|
||||
write_timeout?: number,
|
||||
follow_redirects?: boolean,
|
||||
retry_on_failure?: boolean
|
||||
}): Promise<any> {
|
||||
try {
|
||||
// 更新配置
|
||||
if (params.connect_timeout !== undefined) {
|
||||
clientConfig.timeouts.connect = params.connect_timeout;
|
||||
}
|
||||
if (params.read_timeout !== undefined) {
|
||||
clientConfig.timeouts.read = params.read_timeout;
|
||||
}
|
||||
if (params.write_timeout !== undefined) {
|
||||
clientConfig.timeouts.write = params.write_timeout;
|
||||
}
|
||||
if (params.follow_redirects !== undefined) {
|
||||
clientConfig.followRedirects = params.follow_redirects;
|
||||
}
|
||||
if (params.retry_on_failure !== undefined) {
|
||||
clientConfig.retryOnConnectionFailure = params.retry_on_failure;
|
||||
}
|
||||
|
||||
// 创建新客户端
|
||||
const builder = OkHttp.newBuilder()
|
||||
.connectTimeout(clientConfig.timeouts.connect)
|
||||
.readTimeout(clientConfig.timeouts.read)
|
||||
.writeTimeout(clientConfig.timeouts.write)
|
||||
.followRedirects(clientConfig.followRedirects)
|
||||
.retryOnConnectionFailure(clientConfig.retryOnConnectionFailure);
|
||||
|
||||
// 添加拦截器
|
||||
if (clientConfig.interceptors.length > 0) {
|
||||
for (const interceptor of clientConfig.interceptors) {
|
||||
builder.addInterceptor(interceptor);
|
||||
}
|
||||
}
|
||||
|
||||
// 构建客户端
|
||||
defaultClient = builder.build();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "HTTP客户端配置已更新",
|
||||
config: {
|
||||
...clientConfig
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`配置HTTP客户端失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送HTTP GET请求
|
||||
* @param params - 请求参数
|
||||
*/
|
||||
async function http_get(params: { url: string, headers?: Record<string, string> }): Promise<any> {
|
||||
try {
|
||||
if (!params.url) {
|
||||
throw new Error("URL不能为空");
|
||||
}
|
||||
|
||||
console.log(`发送GET请求: ${params.url}`);
|
||||
console.log(`请求头: ${JSON.stringify(params.headers || {})}`);
|
||||
|
||||
// 准备请求
|
||||
const request = defaultClient.newRequest()
|
||||
.url(params.url)
|
||||
.method('GET');
|
||||
|
||||
// 添加请求头
|
||||
if (params.headers) {
|
||||
request.headers(params.headers);
|
||||
}
|
||||
|
||||
// 执行请求
|
||||
const response = await request.build().execute();
|
||||
|
||||
// 返回结果
|
||||
const result = await formatResponse(response);
|
||||
|
||||
// 额外输出重要的响应信息
|
||||
console.log(`\n🟢 GET请求完成: ${params.url}`);
|
||||
console.log(`🟢 状态码: ${result.status_code} ${result.status_message}`);
|
||||
if (result.json) {
|
||||
console.log(`🟢 返回数据类型: ${Array.isArray(result.json) ? '数组' : (typeof result.json === 'object' ? '对象' : typeof result.json)}`);
|
||||
}
|
||||
console.log(`🟢 响应大小: ${result.content_length} 字节\n`);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw new Error(`GET请求失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送HTTP POST请求
|
||||
* @param params - 请求参数
|
||||
*/
|
||||
async function http_post(params: {
|
||||
url: string,
|
||||
body: any,
|
||||
headers?: Record<string, string>,
|
||||
body_type?: 'text' | 'json' | 'form' | 'multipart'
|
||||
}): Promise<any> {
|
||||
try {
|
||||
if (!params.url) {
|
||||
throw new Error("URL不能为空");
|
||||
}
|
||||
|
||||
if (params.body === undefined) {
|
||||
throw new Error("请求体不能为空");
|
||||
}
|
||||
|
||||
console.log(`发送POST请求: ${params.url}`);
|
||||
|
||||
// 准备请求
|
||||
const request = defaultClient.newRequest()
|
||||
.url(params.url)
|
||||
.method('POST');
|
||||
|
||||
// 添加请求头
|
||||
if (params.headers) {
|
||||
request.headers(params.headers);
|
||||
}
|
||||
|
||||
// 添加请求体
|
||||
const bodyType = params.body_type || 'json';
|
||||
request.body(params.body, bodyType);
|
||||
|
||||
// 执行请求
|
||||
const response = await request.build().execute();
|
||||
|
||||
// 返回结果
|
||||
return await formatResponse(response);
|
||||
} catch (error) {
|
||||
throw new Error(`POST请求失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送HTTP PUT请求
|
||||
* @param params - 请求参数
|
||||
*/
|
||||
async function http_put(params: {
|
||||
url: string,
|
||||
body: any,
|
||||
headers?: Record<string, string>,
|
||||
body_type?: 'text' | 'json' | 'form' | 'multipart'
|
||||
}): Promise<any> {
|
||||
try {
|
||||
if (!params.url) {
|
||||
throw new Error("URL不能为空");
|
||||
}
|
||||
|
||||
if (params.body === undefined) {
|
||||
throw new Error("请求体不能为空");
|
||||
}
|
||||
|
||||
console.log(`发送PUT请求: ${params.url}`);
|
||||
|
||||
// 准备请求
|
||||
const request = defaultClient.newRequest()
|
||||
.url(params.url)
|
||||
.method('PUT');
|
||||
|
||||
// 添加请求头
|
||||
if (params.headers) {
|
||||
request.headers(params.headers);
|
||||
}
|
||||
|
||||
// 添加请求体
|
||||
const bodyType = params.body_type || 'json';
|
||||
request.body(params.body, bodyType);
|
||||
|
||||
// 执行请求
|
||||
const response = await request.build().execute();
|
||||
|
||||
// 返回结果
|
||||
return await formatResponse(response);
|
||||
} catch (error) {
|
||||
throw new Error(`PUT请求失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送HTTP DELETE请求
|
||||
* @param params - 请求参数
|
||||
*/
|
||||
async function http_delete(params: { url: string, headers?: Record<string, string> }): Promise<any> {
|
||||
try {
|
||||
if (!params.url) {
|
||||
throw new Error("URL不能为空");
|
||||
}
|
||||
|
||||
console.log(`发送DELETE请求: ${params.url}`);
|
||||
|
||||
// 准备请求
|
||||
const request = defaultClient.newRequest()
|
||||
.url(params.url)
|
||||
.method('DELETE');
|
||||
|
||||
// 添加请求头
|
||||
if (params.headers) {
|
||||
request.headers(params.headers);
|
||||
}
|
||||
|
||||
// 执行请求
|
||||
const response = await request.build().execute();
|
||||
|
||||
// 返回结果
|
||||
return await formatResponse(response);
|
||||
} catch (error) {
|
||||
throw new Error(`DELETE请求失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化响应结果
|
||||
* @param response - OkHttp响应
|
||||
*/
|
||||
async function formatResponse(response: OkHttpResponse): Promise<any> {
|
||||
try {
|
||||
let responseBody = '';
|
||||
let jsonData: any = undefined;
|
||||
|
||||
// 获取响应文本
|
||||
responseBody = response.content;
|
||||
console.log(`\n===== 响应内容开始 =====`);
|
||||
console.log(`状态码: ${response.statusCode} ${response.statusMessage}`);
|
||||
console.log(`内容类型: ${response.contentType || '未知'}`);
|
||||
|
||||
// 如果响应体不太长,完整显示
|
||||
if (responseBody && responseBody.length < 1000) {
|
||||
console.log(`响应体:\n${responseBody}`);
|
||||
} else if (responseBody) {
|
||||
console.log(`响应体(截断):\n${responseBody.substring(0, 500)}...`);
|
||||
console.log(`[完整长度: ${responseBody.length} 字符]`);
|
||||
} else {
|
||||
console.log(`响应体为空`);
|
||||
}
|
||||
|
||||
// 尝试解析JSON
|
||||
if (response.contentType && response.contentType.includes('application/json')) {
|
||||
try {
|
||||
jsonData = response.json();
|
||||
if (jsonData) {
|
||||
if (Array.isArray(jsonData)) {
|
||||
console.log(`- 数组数据, 长度: ${(jsonData as any[]).length}`);
|
||||
if ((jsonData as any[]).length > 0) {
|
||||
console.log(`- 第一项样本: ${JSON.stringify(jsonData[0]).substring(0, 100)}${JSON.stringify(jsonData[0]).length > 100 ? '...' : ''}`);
|
||||
}
|
||||
} else if (typeof jsonData === 'object') {
|
||||
const keys = Object.keys(jsonData);
|
||||
console.log(`- 对象数据, 字段数: ${keys.length}`);
|
||||
console.log(`- 字段列表: ${keys.slice(0, 5).join(', ')}${keys.length > 5 ? '...' : ''}`);
|
||||
// 输出一个示例值
|
||||
if (keys.length > 0) {
|
||||
const sampleKey = keys[0];
|
||||
const sampleValue = JSON.stringify(jsonData[sampleKey]);
|
||||
console.log(`- 示例 "${sampleKey}": ${sampleValue.substring(0, 60)}${sampleValue.length > 60 ? '...' : ''}`);
|
||||
}
|
||||
|
||||
// 输出完整的JSON格式化数据
|
||||
console.log(`\nJSON完整数据:`);
|
||||
console.log(JSON.stringify(jsonData, undefined, 2));
|
||||
} else {
|
||||
console.log(`- 基本类型数据: ${JSON.stringify(jsonData).substring(0, 100)}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("响应内容不是有效的JSON格式");
|
||||
}
|
||||
}
|
||||
console.log(`===== 响应内容结束 =====\n`);
|
||||
|
||||
return {
|
||||
success: response.isSuccessful(),
|
||||
status_code: response.statusCode,
|
||||
status_message: response.statusMessage,
|
||||
headers: response.headers,
|
||||
content_type: response.contentType,
|
||||
content_length: response.size,
|
||||
body: responseBody,
|
||||
json: jsonData,
|
||||
time_info: {
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`格式化响应出错: ${error.message}`);
|
||||
return {
|
||||
success: false,
|
||||
error: `格式化响应出错: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试与指定URL的网络连接
|
||||
* @param params - 测试参数
|
||||
*/
|
||||
async function ping_test(params: { url: string, count?: number }): Promise<any> {
|
||||
try {
|
||||
if (!params.url) {
|
||||
throw new Error("URL不能为空");
|
||||
}
|
||||
|
||||
const count = params.count || 3;
|
||||
const results: Array<{
|
||||
attempt: number;
|
||||
success: boolean;
|
||||
time_ms: number;
|
||||
status?: number;
|
||||
error?: string;
|
||||
}> = [];
|
||||
let totalTime = 0;
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
console.log(`开始Ping测试: ${params.url}, 次数: ${count}`);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
// 发送简单的HEAD请求来测试连接
|
||||
const request = defaultClient.newRequest()
|
||||
.url(params.url)
|
||||
.method('HEAD')
|
||||
.build();
|
||||
|
||||
const response = await request.execute();
|
||||
const endTime = Date.now();
|
||||
const elapsed = endTime - startTime;
|
||||
|
||||
totalTime += elapsed;
|
||||
successCount++;
|
||||
|
||||
results.push({
|
||||
attempt: i + 1,
|
||||
success: true,
|
||||
time_ms: elapsed,
|
||||
status: response.statusCode
|
||||
});
|
||||
|
||||
console.log(`Ping #${i + 1}: ${elapsed}ms, 状态: ${response.statusCode}`);
|
||||
|
||||
// 等待一小段时间后再发送下一个请求
|
||||
if (i < count - 1) {
|
||||
await sleep(500);
|
||||
}
|
||||
} catch (error) {
|
||||
const endTime = Date.now();
|
||||
const elapsed = endTime - startTime;
|
||||
failCount++;
|
||||
|
||||
results.push({
|
||||
attempt: i + 1,
|
||||
success: false,
|
||||
time_ms: elapsed,
|
||||
error: error.message
|
||||
});
|
||||
|
||||
console.log(`Ping #${i + 1}: 失败, 错误: ${error.message}`);
|
||||
|
||||
// 等待一小段时间后再发送下一个请求
|
||||
if (i < count - 1) {
|
||||
await sleep(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const avgTime = successCount > 0 ? totalTime / successCount : 0;
|
||||
|
||||
return {
|
||||
url: params.url,
|
||||
success: successCount > 0,
|
||||
summary: {
|
||||
total_count: count,
|
||||
success_count: successCount,
|
||||
fail_count: failCount,
|
||||
success_rate: `${(successCount / count * 100).toFixed(1)}%`,
|
||||
average_time: `${avgTime.toFixed(2)}ms`
|
||||
},
|
||||
detail_results: results
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Ping测试失败: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待指定的毫秒数
|
||||
* @param ms 等待的毫秒数
|
||||
*/
|
||||
async function sleep(ms: number | string): Promise<void> {
|
||||
const sleepTime = Number(ms);
|
||||
if (isNaN(sleepTime)) {
|
||||
throw new Error("无效的等待时间");
|
||||
}
|
||||
return new Promise(resolve => setTimeout(resolve, sleepTime));
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试所有网络功能
|
||||
*/
|
||||
async function test_all(): Promise<any> {
|
||||
try {
|
||||
console.log("开始网络功能测试...");
|
||||
|
||||
const results: {
|
||||
config?: any;
|
||||
ping?: any;
|
||||
get?: any;
|
||||
post?: any;
|
||||
put?: any;
|
||||
delete?: any;
|
||||
} = {};
|
||||
|
||||
// 1. 测试客户端配置
|
||||
console.log("测试客户端配置...");
|
||||
try {
|
||||
const configResult = await config_client({
|
||||
connect_timeout: 8000,
|
||||
read_timeout: 8000,
|
||||
write_timeout: 8000,
|
||||
follow_redirects: true,
|
||||
retry_on_failure: true
|
||||
});
|
||||
results.config = configResult;
|
||||
console.log("✓ 客户端配置成功");
|
||||
} catch (error) {
|
||||
results.config = { error: `客户端配置失败: ${error.message}` };
|
||||
console.log("✗ 客户端配置失败");
|
||||
}
|
||||
|
||||
// 2. 测试连接
|
||||
console.log("测试网络连接...");
|
||||
try {
|
||||
const pingResult = await ping_test({
|
||||
url: "https://httpbin.org",
|
||||
count: 2
|
||||
});
|
||||
results.ping = pingResult;
|
||||
console.log("✓ Ping测试成功");
|
||||
} catch (error) {
|
||||
results.ping = { error: `Ping测试失败: ${error.message}` };
|
||||
console.log("✗ Ping测试失败");
|
||||
}
|
||||
|
||||
// 3. 测试GET请求
|
||||
console.log("测试GET请求...");
|
||||
try {
|
||||
const getResult = await http_get({
|
||||
url: "https://httpbin.org/get",
|
||||
headers: {
|
||||
"User-Agent": "OkHttp-Network-Tester/1.0"
|
||||
}
|
||||
});
|
||||
results.get = getResult;
|
||||
console.log("✓ GET请求成功");
|
||||
|
||||
// 显示更多关于GET响应的信息
|
||||
console.log("\n====== GET请求结果摘要 ======");
|
||||
console.log(`状态: ${getResult.status_code} ${getResult.status_message}`);
|
||||
|
||||
// 如果有JSON数据,显示一些关键信息
|
||||
if (getResult.json) {
|
||||
console.log("GET响应数据预览:");
|
||||
|
||||
// httpbin.org/get 通常会返回请求信息的镜像
|
||||
if (getResult.json.headers) {
|
||||
console.log("- 发送的请求头:");
|
||||
Object.keys(getResult.json.headers).forEach(key => {
|
||||
console.log(` ${key}: ${getResult.json.headers[key]}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (getResult.json.url) {
|
||||
console.log(`- 请求URL: ${getResult.json.url}`);
|
||||
}
|
||||
|
||||
if (getResult.json.origin) {
|
||||
console.log(`- 来源IP: ${getResult.json.origin}`);
|
||||
}
|
||||
}
|
||||
console.log("===============================\n");
|
||||
} catch (error) {
|
||||
results.get = { error: `GET请求失败: ${error.message}` };
|
||||
console.log("✗ GET请求失败");
|
||||
}
|
||||
|
||||
// 4. 测试POST请求
|
||||
console.log("测试POST请求...");
|
||||
try {
|
||||
const postResult = await http_post({
|
||||
url: "https://httpbin.org/post",
|
||||
body: {
|
||||
name: "OkHttp网络测试",
|
||||
timestamp: new Date().toISOString()
|
||||
},
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "OkHttp-Network-Tester/1.0"
|
||||
}
|
||||
});
|
||||
results.post = postResult;
|
||||
console.log("✓ POST请求成功");
|
||||
} catch (error) {
|
||||
results.post = { error: `POST请求失败: ${error.message}` };
|
||||
console.log("✗ POST请求失败");
|
||||
}
|
||||
|
||||
// 5. 测试PUT请求
|
||||
console.log("测试PUT请求...");
|
||||
try {
|
||||
const putResult = await http_put({
|
||||
url: "https://httpbin.org/put",
|
||||
body: {
|
||||
name: "OkHttp网络测试",
|
||||
timestamp: new Date().toISOString(),
|
||||
action: "update"
|
||||
},
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "OkHttp-Network-Tester/1.0"
|
||||
}
|
||||
});
|
||||
results.put = putResult;
|
||||
console.log("✓ PUT请求成功");
|
||||
} catch (error) {
|
||||
results.put = { error: `PUT请求失败: ${error.message}` };
|
||||
console.log("✗ PUT请求失败");
|
||||
}
|
||||
|
||||
// 6. 测试DELETE请求
|
||||
console.log("测试DELETE请求...");
|
||||
try {
|
||||
const deleteResult = await http_delete({
|
||||
url: "https://httpbin.org/delete",
|
||||
headers: {
|
||||
"User-Agent": "OkHttp-Network-Tester/1.0"
|
||||
}
|
||||
});
|
||||
results.delete = deleteResult;
|
||||
console.log("✓ DELETE请求成功");
|
||||
} catch (error) {
|
||||
results.delete = { error: `DELETE请求失败: ${error.message}` };
|
||||
console.log("✗ DELETE请求失败");
|
||||
}
|
||||
|
||||
// 返回所有测试结果
|
||||
return {
|
||||
message: "网络功能测试完成",
|
||||
test_results: results,
|
||||
timestamp: new Date().toISOString(),
|
||||
summary: "测试了各种网络功能,包括配置、Ping测试和HTTP请求。请查看各功能的测试结果。"
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `测试过程中发生错误: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 包装函数 - 统一处理所有network_test函数的返回结果
|
||||
* @param func 原始函数
|
||||
* @param params 函数参数
|
||||
* @param successMessage 成功消息
|
||||
* @param failMessage 失败消息
|
||||
* @param additionalInfo 附加信息(可选)
|
||||
*/
|
||||
async function network_wrap<T>(
|
||||
func: (params: any) => Promise<any>,
|
||||
params: any,
|
||||
successMessage: string,
|
||||
failMessage: string,
|
||||
additionalInfo: string = ""
|
||||
): Promise<void> {
|
||||
try {
|
||||
console.log(`开始执行函数: ${func.name || '匿名函数'}`);
|
||||
console.log(`参数:`, JSON.stringify(params, undefined, 2));
|
||||
|
||||
// 执行原始函数
|
||||
const result = await func(params);
|
||||
|
||||
console.log(`函数 ${func.name || '匿名函数'} 执行结果:`, JSON.stringify(result, undefined, 2));
|
||||
|
||||
// 如果原始函数已经调用了complete,就不需要再次调用
|
||||
if (result === undefined) return;
|
||||
|
||||
// 根据结果类型处理
|
||||
if (typeof result === "boolean") {
|
||||
// 布尔类型结果
|
||||
complete({
|
||||
success: result,
|
||||
message: result ? successMessage : failMessage,
|
||||
additionalInfo: additionalInfo
|
||||
});
|
||||
} else {
|
||||
// 数据类型结果
|
||||
complete({
|
||||
success: true,
|
||||
message: successMessage,
|
||||
additionalInfo: additionalInfo,
|
||||
data: result
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// 详细记录错误信息
|
||||
console.error(`函数 ${func.name || '匿名函数'} 执行失败!`);
|
||||
console.error(`错误信息: ${error.message}`);
|
||||
console.error(`错误堆栈: ${error.stack}`);
|
||||
|
||||
// 处理错误
|
||||
complete({
|
||||
success: false,
|
||||
message: `${failMessage}: ${error.message}`,
|
||||
additionalInfo: additionalInfo,
|
||||
error_stack: error.stack
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
config_client: async (params) => await network_wrap(
|
||||
config_client,
|
||||
params,
|
||||
"配置HTTP客户端成功",
|
||||
"配置HTTP客户端失败"
|
||||
),
|
||||
http_get: async (params) => await network_wrap(
|
||||
http_get,
|
||||
params,
|
||||
"GET请求成功",
|
||||
"GET请求失败"
|
||||
),
|
||||
http_post: async (params) => await network_wrap(
|
||||
http_post,
|
||||
params,
|
||||
"POST请求成功",
|
||||
"POST请求失败"
|
||||
),
|
||||
http_put: async (params) => await network_wrap(
|
||||
http_put,
|
||||
params,
|
||||
"PUT请求成功",
|
||||
"PUT请求失败"
|
||||
),
|
||||
http_delete: async (params) => await network_wrap(
|
||||
http_delete,
|
||||
params,
|
||||
"DELETE请求成功",
|
||||
"DELETE请求失败"
|
||||
),
|
||||
ping_test: async (params) => await network_wrap(
|
||||
ping_test,
|
||||
params,
|
||||
"网络连接测试成功",
|
||||
"网络连接测试失败"
|
||||
),
|
||||
test_all: async () => await network_wrap(
|
||||
test_all,
|
||||
{},
|
||||
"网络功能测试完成",
|
||||
"网络功能测试失败"
|
||||
)
|
||||
};
|
||||
})();
|
||||
|
||||
// 导出所有函数
|
||||
exports.config_client = networkTest.config_client;
|
||||
exports.http_get = networkTest.http_get;
|
||||
exports.http_post = networkTest.http_post;
|
||||
exports.http_put = networkTest.http_put;
|
||||
exports.http_delete = networkTest.http_delete;
|
||||
exports.ping_test = networkTest.ping_test;
|
||||
exports.test_all = networkTest.test_all;
|
||||
exports.main = networkTest.test_all;
|
||||
@@ -0,0 +1,338 @@
|
||||
/* METADATA
|
||||
{
|
||||
"name": "openai_draw",
|
||||
|
||||
"display_name": {
|
||||
"zh": "OpenAI 绘图",
|
||||
"en": "OpenAI Draw"
|
||||
},
|
||||
"description": {
|
||||
"zh": "使用 OpenAI 格式的图像生成 API (/v1/images/generations) 根据提示词画图,将图片保存到本地 /sdcard/Download/Operit/plugins/draw/openai_draw/draws/ 目录,并返回 Markdown 图片提示。",
|
||||
"en": "Generate images via an OpenAI-compatible image generation API (/v1/images/generations) from a prompt, save to /sdcard/Download/Operit/plugins/draw/openai_draw/draws/, and return a Markdown image reference."
|
||||
},
|
||||
"category": "Draw",
|
||||
"env": [
|
||||
{
|
||||
"name": "OPENAI_API_KEY",
|
||||
"description": {
|
||||
"zh": "OpenAI API Key(必填)",
|
||||
"en": "OpenAI API key (required)"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "OPENAI_API_BASE_URL",
|
||||
"description": {
|
||||
"zh": "OpenAI API Base URL(可选,不填则默认 https://api.openai.com )",
|
||||
"en": "OpenAI API base URL (optional; defaults to https://api.openai.com)"
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "OPENAI_IMAGE_MODEL",
|
||||
"description": {
|
||||
"zh": "默认绘图模型(可选;当 draw_image 未传 model 时使用)",
|
||||
"en": "Default image model (optional; used when draw_image doesn't pass model)"
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"name": "draw_image",
|
||||
"description": {
|
||||
"zh": "根据提示词调用 OpenAI 格式图像生成接口生成图片,保存到本地并返回 Markdown 图片提示。",
|
||||
"en": "Generate an image via an OpenAI-compatible image generation endpoint using a prompt, save it locally, and return a Markdown image reference."
|
||||
},
|
||||
"parameters": [
|
||||
{ "name": "prompt", "description": { "zh": "绘图提示词(英文或中文皆可)", "en": "Prompt for image generation (Chinese or English)" }, "type": "string", "required": true },
|
||||
{ "name": "model", "description": { "zh": "模型名称(可选;不传则使用环境变量 OPENAI_IMAGE_MODEL,再不行使用默认值)", "en": "Model name (optional; falls back to env OPENAI_IMAGE_MODEL, then default)" }, "type": "string", "required": false },
|
||||
{ "name": "size", "description": { "zh": "图片尺寸,例如 '1024x1024',可选", "en": "Image size, e.g. '1024x1024' (optional)" }, "type": "string", "required": false },
|
||||
{ "name": "file_name", "description": { "zh": "自定义保存到本地的文件名(不含路径和扩展名)", "en": "Custom output file name (without path or extension)" }, "type": "string", "required": false },
|
||||
{ "name": "api_base_url", "description": { "zh": "OpenAI API Base URL(不传则取环境变量 OPENAI_API_BASE_URL 或默认 https://api.openai.com )", "en": "OpenAI API base URL (optional; falls back to env OPENAI_API_BASE_URL or https://api.openai.com)" }, "type": "string", "required": false }
|
||||
]
|
||||
}
|
||||
]
|
||||
}*/
|
||||
|
||||
/// <reference path="./types/index.d.ts" />
|
||||
|
||||
const openaiDraw = (function () {
|
||||
const HTTP_TIMEOUT_MS = 600000;
|
||||
const client = OkHttp.newBuilder()
|
||||
.connectTimeout(HTTP_TIMEOUT_MS)
|
||||
.readTimeout(HTTP_TIMEOUT_MS)
|
||||
.writeTimeout(HTTP_TIMEOUT_MS)
|
||||
.build();
|
||||
const DEFAULT_API_BASE_URL = "https://api.openai.com";
|
||||
const DEFAULT_MODEL = "gpt-image-1";
|
||||
|
||||
const DRAW_ROOT_DIR = getPluginConfigDir("draw");
|
||||
const STORAGE_DIR = `${DRAW_ROOT_DIR}/openai_draw`;
|
||||
const DRAWS_DIR = `${STORAGE_DIR}/draws`;
|
||||
|
||||
function getApiKey(): string {
|
||||
const apiKey = getEnv("OPENAI_API_KEY");
|
||||
if (!apiKey) {
|
||||
throw new Error("OPENAI_API_KEY 未配置,请在环境变量中设置 OpenAI 的 API Key。");
|
||||
}
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
function joinUrl(baseUrl: string, path: string): string {
|
||||
const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
|
||||
const normalizedPath = path.startsWith('/') ? path.slice(1) : path;
|
||||
return `${normalizedBase}${normalizedPath}`;
|
||||
}
|
||||
|
||||
function getApiBaseUrl(customBaseUrl?: string | null): string {
|
||||
const fromParam = (customBaseUrl || "").trim();
|
||||
if (fromParam) return fromParam;
|
||||
|
||||
const fromEnv = (getEnv("OPENAI_API_BASE_URL") || "").trim();
|
||||
if (fromEnv) return fromEnv;
|
||||
|
||||
return DEFAULT_API_BASE_URL;
|
||||
}
|
||||
|
||||
function getImageEndpoint(baseUrl: string): string {
|
||||
const trimmed = baseUrl.trim();
|
||||
if (!trimmed) return joinUrl(DEFAULT_API_BASE_URL, "v1/images/generations");
|
||||
|
||||
if (trimmed.includes("/v1/images/generations")) return trimmed;
|
||||
|
||||
// If user passes .../v1, we should append images/generations
|
||||
if (trimmed.endsWith("/v1")) return joinUrl(trimmed, "images/generations");
|
||||
if (trimmed.endsWith("/v1/")) return joinUrl(trimmed, "images/generations");
|
||||
|
||||
// Otherwise append v1/images/generations
|
||||
return joinUrl(trimmed, "v1/images/generations");
|
||||
}
|
||||
|
||||
function sanitizeFileName(name: string): string {
|
||||
const safe = name.replace(/[\\/:*?"<>|]/g, "_").trim();
|
||||
if (!safe) {
|
||||
return `openai_draw_${Date.now()}`;
|
||||
}
|
||||
return safe.substring(0, 80);
|
||||
}
|
||||
|
||||
function buildFileName(prompt: string, customName?: string | null): string {
|
||||
if (customName && customName.trim().length > 0) {
|
||||
return sanitizeFileName(customName);
|
||||
}
|
||||
const shortPrompt = prompt.length > 40 ? `${prompt.substring(0, 40)}...` : prompt;
|
||||
const base = sanitizeFileName(shortPrompt || "image");
|
||||
const timestamp = Date.now();
|
||||
return `${base}_${timestamp}`;
|
||||
}
|
||||
|
||||
async function ensureDirectories() {
|
||||
const dirs = [DRAW_ROOT_DIR, STORAGE_DIR, DRAWS_DIR];
|
||||
for (const dir of dirs) {
|
||||
try {
|
||||
const result = await Tools.Files.mkdir(dir);
|
||||
if (!result.successful) {
|
||||
console.warn(`创建目录失败(可能已存在): ${dir} -> ${result.details}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn(`创建目录异常: ${dir} -> ${e?.message || e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBase64(base64: string): string {
|
||||
const raw = String(base64 || "").trim();
|
||||
if (!raw) return raw;
|
||||
|
||||
// Some providers may return: data:image/png;base64,xxxx
|
||||
const prefixIndex = raw.indexOf("base64,");
|
||||
if (raw.startsWith("data:") && prefixIndex >= 0) {
|
||||
return raw.substring(prefixIndex + "base64,".length).trim();
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
type OpenAIImageApiResult = {
|
||||
b64_json: string;
|
||||
revised_prompt?: string;
|
||||
effective_model: string;
|
||||
};
|
||||
|
||||
async function callOpenAIImageApi(params: {
|
||||
prompt: string;
|
||||
model?: string;
|
||||
size?: string;
|
||||
api_base_url?: string;
|
||||
}): Promise<OpenAIImageApiResult> {
|
||||
const apiKey = getApiKey();
|
||||
const apiBaseUrl = getApiBaseUrl(params.api_base_url);
|
||||
const endpoint = getImageEndpoint(apiBaseUrl);
|
||||
|
||||
const modelFromParam = (params.model || "").trim();
|
||||
const modelFromEnv = (getEnv("OPENAI_IMAGE_MODEL") || "").trim();
|
||||
const effectiveModel = modelFromParam || modelFromEnv || DEFAULT_MODEL;
|
||||
|
||||
const body: any = {
|
||||
model: effectiveModel,
|
||||
prompt: params.prompt,
|
||||
response_format: "b64_json"
|
||||
};
|
||||
|
||||
if (params.size && params.size.trim().length > 0) {
|
||||
body.size = params.size.trim();
|
||||
}
|
||||
|
||||
const headers = {
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
"Authorization": `Bearer ${apiKey}`
|
||||
};
|
||||
|
||||
const request = client
|
||||
.newRequest()
|
||||
.url(endpoint)
|
||||
.method("POST")
|
||||
.headers(headers)
|
||||
.body(JSON.stringify(body), "json");
|
||||
|
||||
const response = await request.build().execute();
|
||||
|
||||
if (!response.isSuccessful()) {
|
||||
throw new Error(`OpenAI 图片 API 调用失败: ${response.statusCode} - ${response.content}`);
|
||||
}
|
||||
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = JSON.parse(response.content);
|
||||
} catch (e: any) {
|
||||
throw new Error(`解析 OpenAI 响应失败: ${e?.message || e}`);
|
||||
}
|
||||
|
||||
const item = parsed?.data?.[0];
|
||||
if (!item) {
|
||||
throw new Error("OpenAI 响应中未找到 data[0],请检查接口返回格式。");
|
||||
}
|
||||
|
||||
if (item.b64_json && String(item.b64_json).trim().length > 0) {
|
||||
return {
|
||||
b64_json: String(item.b64_json),
|
||||
revised_prompt: item.revised_prompt ? String(item.revised_prompt) : undefined,
|
||||
effective_model: effectiveModel
|
||||
};
|
||||
}
|
||||
|
||||
if (item.url && String(item.url).trim().length > 0) {
|
||||
// Some OpenAI-compatible APIs may return url only. We download it, then read as base64.
|
||||
const tmpName = `openai_tmp_${Date.now()}`;
|
||||
const tmpPath = `${DRAWS_DIR}/${tmpName}.png`;
|
||||
const downloadResult = await Tools.Files.download(String(item.url), tmpPath);
|
||||
if (!downloadResult.successful) {
|
||||
throw new Error(`下载图片失败: ${downloadResult.details}`);
|
||||
}
|
||||
|
||||
const readBinary = await Tools.Files.readBinary(tmpPath);
|
||||
const contentBase64 = (readBinary as any)?.contentBase64;
|
||||
if (!contentBase64 || String(contentBase64).trim().length === 0) {
|
||||
throw new Error("读取下载图片失败: contentBase64 为空");
|
||||
}
|
||||
|
||||
try {
|
||||
await Tools.Files.deleteFile(tmpPath);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return {
|
||||
b64_json: String(contentBase64),
|
||||
revised_prompt: item.revised_prompt ? String(item.revised_prompt) : undefined,
|
||||
effective_model: effectiveModel
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error("OpenAI 响应中未找到 b64_json 或 url,请检查模型/参数以及接口兼容性。");
|
||||
}
|
||||
|
||||
async function draw_image(params: {
|
||||
prompt: string;
|
||||
model?: string;
|
||||
size?: string;
|
||||
file_name?: string;
|
||||
api_base_url?: string;
|
||||
}) {
|
||||
if (!params || !params.prompt || params.prompt.trim().length === 0) {
|
||||
throw new Error("参数 prompt 不能为空。");
|
||||
}
|
||||
|
||||
const prompt = params.prompt.trim();
|
||||
|
||||
await ensureDirectories();
|
||||
|
||||
const apiResult = await callOpenAIImageApi({
|
||||
prompt,
|
||||
model: params.model,
|
||||
size: params.size,
|
||||
api_base_url: params.api_base_url
|
||||
});
|
||||
|
||||
const baseName = buildFileName(prompt, params.file_name);
|
||||
const filePath = `${DRAWS_DIR}/${baseName}.png`;
|
||||
|
||||
const writeResult = await Tools.Files.writeBinary(filePath, normalizeBase64(apiResult.b64_json));
|
||||
if (!writeResult.successful) {
|
||||
throw new Error(`保存图片失败: ${writeResult.details}`);
|
||||
}
|
||||
|
||||
const fileUri = `file://${filePath}`;
|
||||
const markdown = ``;
|
||||
|
||||
const hintLines: string[] = [];
|
||||
hintLines.push(`图片已生成并保存在本地 ${DRAWS_DIR}。`);
|
||||
hintLines.push(`本地路径: ${filePath}`);
|
||||
hintLines.push("");
|
||||
hintLines.push("在后续回答中,请直接输出下面这一行 Markdown 来展示这张图片:");
|
||||
hintLines.push("");
|
||||
hintLines.push(markdown);
|
||||
|
||||
return {
|
||||
file_path: filePath,
|
||||
file_uri: fileUri,
|
||||
markdown,
|
||||
prompt,
|
||||
revised_prompt: apiResult.revised_prompt || null,
|
||||
model: apiResult.effective_model,
|
||||
hint: hintLines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
async function draw_image_wrapper(params: {
|
||||
prompt: string;
|
||||
model?: string;
|
||||
size?: string;
|
||||
file_name?: string;
|
||||
api_base_url?: string;
|
||||
}) {
|
||||
try {
|
||||
const result = await draw_image(params);
|
||||
complete({
|
||||
success: true,
|
||||
message: `图片生成成功,已保存到 ${DRAWS_DIR},并返回 Markdown 图片提示。`,
|
||||
data: result
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("draw_image 执行失败:", error);
|
||||
complete({
|
||||
success: false,
|
||||
message: `图片生成失败: ${error?.message || error}`,
|
||||
error_stack: error?.stack
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
draw_image: draw_image_wrapper
|
||||
};
|
||||
})();
|
||||
|
||||
exports.draw_image = openaiDraw.draw_image;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,755 @@
|
||||
/* METADATA
|
||||
{
|
||||
"name": "pdf_vision_parser",
|
||||
"display_name": {
|
||||
"zh": "PDF 逐页识图解析",
|
||||
"en": "PDF Vision Parser"
|
||||
},
|
||||
"description": {
|
||||
"zh": "将 PDF 逐页渲染为图片,并调用用户当前配置的识图模型进行解析,最后按页拼接结果。",
|
||||
"en": "Render a PDF page by page into images, send each image to the user's configured vision model, and merge the results."
|
||||
},
|
||||
"enabledByDefault": false,
|
||||
"category": "File",
|
||||
"tools": [
|
||||
{
|
||||
"name": "parse_pdf_with_vision",
|
||||
"description": {
|
||||
"zh": "将 PDF 逐页拆成图片,逐页调用 IMAGE_RECOGNITION 功能模型识图,并返回分页索引与输出文件路径。",
|
||||
"en": "Split a PDF into page images, analyze each page with the IMAGE_RECOGNITION function model, and return per-page indexes plus output file paths."
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "pdf_path",
|
||||
"description": {
|
||||
"zh": "PDF 文件路径。",
|
||||
"en": "Path to the PDF file."
|
||||
},
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "page_prompt",
|
||||
"description": {
|
||||
"zh": "每页识图提示词;不传则使用默认的尽量转文字提示词。",
|
||||
"en": "Vision prompt for each page; defaults to a text-extraction-oriented prompt."
|
||||
},
|
||||
"type": "string",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "start_page",
|
||||
"description": {
|
||||
"zh": "起始页码,1-based,默认 1。",
|
||||
"en": "Start page number, 1-based, default 1."
|
||||
},
|
||||
"type": "number",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "end_page",
|
||||
"description": {
|
||||
"zh": "结束页码,1-based,默认文档最后一页。",
|
||||
"en": "End page number, 1-based, default is the last page."
|
||||
},
|
||||
"type": "number",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}*/
|
||||
|
||||
const PdfVisionParser = (function () {
|
||||
const DEFAULT_PAGE_PROMPT =
|
||||
"尽量完整提取本页可见文字与结构,保留标题、列表、表格顺序,不做总结,不补充页外信息;公式和图表转成可读文本描述";
|
||||
const TOOL_NAME = "parse_pdf_with_vision";
|
||||
const TOOL_FUNCTION_TYPE = "IMAGE_RECOGNITION";
|
||||
const RENDER_SCALE = 2;
|
||||
const PAGE_CONCURRENCY = 4;
|
||||
const IMAGE_FORMAT = "png";
|
||||
const MERGED_OUTPUT_FILE_NAME = "parsed_output.txt";
|
||||
|
||||
const File = Java.type("java.io.File");
|
||||
const FileOutputStream = Java.type("java.io.FileOutputStream");
|
||||
const OutputStreamWriter = Java.type("java.io.OutputStreamWriter");
|
||||
const BufferedWriter = Java.type("java.io.BufferedWriter");
|
||||
const ParcelFileDescriptor = Java.type("android.os.ParcelFileDescriptor");
|
||||
const PdfRenderer = Java.type("android.graphics.pdf.PdfRenderer");
|
||||
const PdfRendererPage = Java.type("android.graphics.pdf.PdfRenderer$Page");
|
||||
const Bitmap = Java.type("android.graphics.Bitmap");
|
||||
const BitmapConfig = Java.type("android.graphics.Bitmap$Config");
|
||||
const BitmapCompressFormat = Java.type("android.graphics.Bitmap$CompressFormat");
|
||||
const Color = Java.type("android.graphics.Color");
|
||||
const StandardCharsets = Java.type("java.nio.charset.StandardCharsets");
|
||||
const EnhancedAIService = Java.type("com.ai.assistance.operit.api.chat.EnhancedAIService");
|
||||
|
||||
interface ParsePdfWithVisionParams {
|
||||
pdf_path: string;
|
||||
page_prompt?: string;
|
||||
start_page?: number;
|
||||
end_page?: number;
|
||||
}
|
||||
|
||||
interface FailedPageInfo {
|
||||
page_number: number;
|
||||
stage: "precheck" | "render" | "vision";
|
||||
error: string;
|
||||
}
|
||||
|
||||
interface PageResult {
|
||||
page_number: number;
|
||||
image_path: string;
|
||||
image_link: string;
|
||||
output_path: string;
|
||||
}
|
||||
|
||||
interface InternalPageResult extends PageResult {
|
||||
analysis: string;
|
||||
}
|
||||
|
||||
interface PageProcessingFailure {
|
||||
page_number: number;
|
||||
stage: FailedPageInfo["stage"];
|
||||
error: string;
|
||||
partial_results: InternalPageResult[];
|
||||
}
|
||||
|
||||
interface VisionModelInfo {
|
||||
function_type: "IMAGE_RECOGNITION";
|
||||
config_id: string;
|
||||
config_name: string;
|
||||
selected_model: string;
|
||||
}
|
||||
|
||||
interface ParsePdfWithVisionData {
|
||||
pdf_path: string;
|
||||
image_dir: string;
|
||||
output_path: string;
|
||||
total_page_count: number;
|
||||
processed_page_count: number;
|
||||
pages: PageResult[];
|
||||
vision_model: VisionModelInfo;
|
||||
failed_page?: FailedPageInfo;
|
||||
}
|
||||
|
||||
interface ToolResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: ParsePdfWithVisionData;
|
||||
}
|
||||
|
||||
function isBlank(value: unknown): boolean {
|
||||
return String(value ?? "").trim().length === 0;
|
||||
}
|
||||
|
||||
function toTrimmedString(value: unknown): string {
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
|
||||
function parsePositiveInteger(
|
||||
value: number | undefined,
|
||||
fieldName: string,
|
||||
options?: { allowUndefined?: boolean }
|
||||
): number | undefined {
|
||||
if (value === undefined || value === null) {
|
||||
if (options?.allowUndefined) {
|
||||
return undefined;
|
||||
}
|
||||
throw new Error(`${fieldName} 不能为空。`);
|
||||
}
|
||||
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`${fieldName} 必须是大于 0 的整数。`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function buildMergedText(pages: InternalPageResult[]): string {
|
||||
return pages
|
||||
.map(page => `===== Page ${page.page_number} =====\n${page.analysis}`)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
function buildVisionModelInfo(binding: FunctionModelConfigResultData): VisionModelInfo {
|
||||
return {
|
||||
function_type: TOOL_FUNCTION_TYPE,
|
||||
config_id: String(binding.configId || ""),
|
||||
config_name: String(binding.configName || ""),
|
||||
selected_model: String(binding.selectedModel || "")
|
||||
};
|
||||
}
|
||||
|
||||
function buildData(
|
||||
pdfPath: string,
|
||||
imageDir: string,
|
||||
outputPath: string,
|
||||
totalPageCount: number,
|
||||
pages: InternalPageResult[],
|
||||
visionModel: VisionModelInfo,
|
||||
failedPage?: FailedPageInfo
|
||||
): ParsePdfWithVisionData {
|
||||
const data: ParsePdfWithVisionData = {
|
||||
pdf_path: pdfPath,
|
||||
image_dir: imageDir,
|
||||
output_path: outputPath,
|
||||
total_page_count: totalPageCount,
|
||||
processed_page_count: pages.length,
|
||||
pages: pages.map(page => ({
|
||||
page_number: page.page_number,
|
||||
image_path: page.image_path,
|
||||
image_link: page.image_link,
|
||||
output_path: page.output_path
|
||||
})),
|
||||
vision_model: visionModel
|
||||
};
|
||||
|
||||
if (failedPage) {
|
||||
data.failed_page = failedPage;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function buildPrecheckFailure(message: string): ToolResponse {
|
||||
return {
|
||||
success: false,
|
||||
message,
|
||||
data: {
|
||||
pdf_path: "",
|
||||
image_dir: "",
|
||||
output_path: "",
|
||||
total_page_count: 0,
|
||||
processed_page_count: 0,
|
||||
pages: [],
|
||||
vision_model: {
|
||||
function_type: TOOL_FUNCTION_TYPE,
|
||||
config_id: "",
|
||||
config_name: "",
|
||||
selected_model: ""
|
||||
},
|
||||
failed_page: {
|
||||
page_number: 0,
|
||||
stage: "precheck",
|
||||
error: message
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function safeClose(resource: any, label: string): void {
|
||||
if (!resource) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resource.close();
|
||||
} catch (error) {
|
||||
console.warn(`[${TOOL_NAME}] 关闭 ${label} 失败: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function safeRecycle(bitmap: any): void {
|
||||
if (!bitmap) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
bitmap.recycle();
|
||||
} catch (error) {
|
||||
console.warn(`[${TOOL_NAME}] 回收 bitmap 失败: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDirectoryExists(dir: any): void {
|
||||
if (dir.exists()) {
|
||||
if (!dir.isDirectory()) {
|
||||
throw new Error(`目标路径不是目录: ${String(dir.getAbsolutePath())}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const created = dir.mkdirs();
|
||||
if (!created) {
|
||||
throw new Error(`无法创建目录: ${String(dir.getAbsolutePath())}`);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAppContext(): JavaBridgeInstance {
|
||||
if (typeof Java.getApplicationContext === "function") {
|
||||
return Java.getApplicationContext();
|
||||
}
|
||||
if (typeof Java.getContext === "function") {
|
||||
return Java.getContext();
|
||||
}
|
||||
throw new Error("无法获取应用 Context。");
|
||||
}
|
||||
|
||||
function createOutputDirectory(): any {
|
||||
const parserRootDir = new File(OPERIT_CLEAN_ON_EXIT_DIR, "pdf_vision_parser");
|
||||
ensureDirectoryExists(parserRootDir);
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const runDir = new File(parserRootDir, timestamp);
|
||||
ensureDirectoryExists(runDir);
|
||||
return runDir;
|
||||
}
|
||||
|
||||
function writeTextFile(targetFile: any, content: string): string {
|
||||
let outputStream: any = null;
|
||||
let writer: any = null;
|
||||
let bufferedWriter: any = null;
|
||||
try {
|
||||
outputStream = new FileOutputStream(targetFile);
|
||||
writer = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8);
|
||||
bufferedWriter = new BufferedWriter(writer);
|
||||
bufferedWriter.write(String(content ?? ""));
|
||||
bufferedWriter.flush();
|
||||
return String(targetFile.getAbsolutePath());
|
||||
} finally {
|
||||
safeClose(bufferedWriter, "bufferedWriter");
|
||||
safeClose(writer, "writer");
|
||||
safeClose(outputStream, "outputStream");
|
||||
}
|
||||
}
|
||||
|
||||
function writePageOutputFile(outputDir: any, pageNumber: number, content: string): string {
|
||||
const textFile = new File(outputDir, `page-${padPageNumber(pageNumber)}.txt`);
|
||||
return writeTextFile(textFile, content);
|
||||
}
|
||||
|
||||
function writeMergedOutputFile(outputDir: any, pages: InternalPageResult[]): string {
|
||||
if (!outputDir) {
|
||||
return "";
|
||||
}
|
||||
const mergedFile = new File(outputDir, MERGED_OUTPUT_FILE_NAME);
|
||||
return writeTextFile(mergedFile, buildMergedText(pages));
|
||||
}
|
||||
|
||||
function padPageNumber(pageNumber: number): string {
|
||||
return String(pageNumber).padStart(4, "0");
|
||||
}
|
||||
|
||||
function assertVisionBinding(binding: FunctionModelConfigResultData): void {
|
||||
if (!binding || isBlank(binding.configId)) {
|
||||
throw new Error("IMAGE_RECOGNITION 当前没有可用绑定。");
|
||||
}
|
||||
if (!binding.config || binding.config.enableDirectImageProcessing !== true) {
|
||||
throw new Error("IMAGE_RECOGNITION 当前绑定的模型未启用识图能力。");
|
||||
}
|
||||
}
|
||||
|
||||
function validatePdfFile(pdfPath: string): any {
|
||||
if (isBlank(pdfPath)) {
|
||||
throw new Error("pdf_path 不能为空。");
|
||||
}
|
||||
|
||||
const pdfFile = new File(pdfPath);
|
||||
if (!pdfFile.exists() || !pdfFile.isFile()) {
|
||||
throw new Error(`PDF 文件不存在: ${pdfPath}`);
|
||||
}
|
||||
|
||||
if (!String(pdfFile.getName()).toLowerCase().endsWith(".pdf")) {
|
||||
throw new Error(`文件不是 PDF: ${pdfPath}`);
|
||||
}
|
||||
|
||||
return pdfFile;
|
||||
}
|
||||
|
||||
function stripThinkingContent(content: string): string {
|
||||
return String(content ?? "")
|
||||
.replace(/<think(?:ing)?>[\s\S]*?(<\/think(?:ing)?>|\z)/gi, "")
|
||||
.replace(/<search>[\s\S]*?(<\/search>|\z)/gi, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function sortPageResults(pages: InternalPageResult[]): InternalPageResult[] {
|
||||
return [...pages].sort((left, right) => left.page_number - right.page_number);
|
||||
}
|
||||
|
||||
function normalizeErrorMessage(error: unknown): string {
|
||||
return String(error instanceof Error ? error.message : error);
|
||||
}
|
||||
|
||||
async function analyzePageImage(
|
||||
service: JavaBridgeInstance,
|
||||
imagePath: string,
|
||||
prompt: string
|
||||
): Promise<string> {
|
||||
const rawAnalysis = String(
|
||||
await service.callSuspend("analyzeImageWithIntent", imagePath, prompt)
|
||||
).trim();
|
||||
const analysis = stripThinkingContent(rawAnalysis);
|
||||
|
||||
if (analysis.length === 0) {
|
||||
throw new Error("识图模型返回了空结果。");
|
||||
}
|
||||
if (/^Image recognition failed:/i.test(rawAnalysis) || /^Image recognition failed:/i.test(analysis)) {
|
||||
throw new Error(rawAnalysis || analysis);
|
||||
}
|
||||
|
||||
return analysis;
|
||||
}
|
||||
|
||||
async function processSinglePage(
|
||||
pdfFile: any,
|
||||
outputDir: any,
|
||||
pageNumber: number,
|
||||
prompt: string,
|
||||
service: JavaBridgeInstance
|
||||
): Promise<InternalPageResult> {
|
||||
console.log(`[${TOOL_NAME}] 开始处理第 ${pageNumber} 页`);
|
||||
|
||||
const zeroBasedPageIndex = pageNumber - 1;
|
||||
const imageFile = new File(
|
||||
outputDir,
|
||||
`page-${padPageNumber(pageNumber)}.${IMAGE_FORMAT}`
|
||||
);
|
||||
const imagePath = String(imageFile.getAbsolutePath());
|
||||
|
||||
let fileDescriptor: any = null;
|
||||
let pdfRenderer: any = null;
|
||||
let page: any = null;
|
||||
let bitmap: any = null;
|
||||
let outputStream: any = null;
|
||||
let stage: FailedPageInfo["stage"] = "render";
|
||||
|
||||
try {
|
||||
fileDescriptor = ParcelFileDescriptor.open(
|
||||
pdfFile,
|
||||
ParcelFileDescriptor.MODE_READ_ONLY
|
||||
);
|
||||
pdfRenderer = new PdfRenderer(fileDescriptor);
|
||||
page = pdfRenderer.openPage(zeroBasedPageIndex);
|
||||
|
||||
const width = Number(page.getWidth()) * RENDER_SCALE;
|
||||
const height = Number(page.getHeight()) * RENDER_SCALE;
|
||||
|
||||
bitmap = Bitmap.createBitmap(width, height, BitmapConfig.ARGB_8888);
|
||||
bitmap.eraseColor(Color.WHITE);
|
||||
page.render(
|
||||
bitmap,
|
||||
null,
|
||||
null,
|
||||
PdfRendererPage.RENDER_MODE_FOR_DISPLAY
|
||||
);
|
||||
|
||||
outputStream = new FileOutputStream(imageFile);
|
||||
const compressed = bitmap.compress(
|
||||
BitmapCompressFormat.PNG,
|
||||
100,
|
||||
outputStream
|
||||
);
|
||||
if (!compressed) {
|
||||
throw new Error(`页面 ${pageNumber} 图片写入失败。`);
|
||||
}
|
||||
|
||||
safeClose(outputStream, `page-${pageNumber} output stream`);
|
||||
outputStream = null;
|
||||
|
||||
const imageLink = String(
|
||||
NativeInterface.registerImageFromPath(imagePath) || ""
|
||||
).trim();
|
||||
if (!imageLink) {
|
||||
throw new Error(`页面 ${pageNumber} 图片链接注册失败。`);
|
||||
}
|
||||
|
||||
stage = "vision";
|
||||
const analysis = await analyzePageImage(service, imagePath, prompt);
|
||||
const pageOutputPath = writePageOutputFile(outputDir, pageNumber, analysis);
|
||||
|
||||
console.log(`[${TOOL_NAME}] 第 ${pageNumber} 页处理完成`);
|
||||
return {
|
||||
page_number: pageNumber,
|
||||
image_path: imagePath,
|
||||
image_link: imageLink,
|
||||
output_path: pageOutputPath,
|
||||
analysis
|
||||
};
|
||||
} catch (error) {
|
||||
throw {
|
||||
page_number: pageNumber,
|
||||
stage,
|
||||
error: normalizeErrorMessage(error),
|
||||
partial_results: []
|
||||
} as PageProcessingFailure;
|
||||
} finally {
|
||||
safeClose(outputStream, `page-${pageNumber} output stream`);
|
||||
safeRecycle(bitmap);
|
||||
safeClose(page, `page-${pageNumber}`);
|
||||
safeClose(pdfRenderer, `page-${pageNumber} pdfRenderer`);
|
||||
safeClose(fileDescriptor, `page-${pageNumber} parcelFileDescriptor`);
|
||||
}
|
||||
}
|
||||
|
||||
async function processPagesWithConcurrency(
|
||||
pageNumbers: number[],
|
||||
limit: number,
|
||||
worker: (pageNumber: number) => Promise<InternalPageResult>
|
||||
): Promise<InternalPageResult[]> {
|
||||
const results: Array<InternalPageResult | undefined> = new Array(pageNumbers.length);
|
||||
let nextIndex = 0;
|
||||
let failure: Omit<PageProcessingFailure, "partial_results"> | null = null;
|
||||
|
||||
async function runWorker(): Promise<void> {
|
||||
while (true) {
|
||||
if (failure) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIndex = nextIndex;
|
||||
nextIndex += 1;
|
||||
|
||||
if (currentIndex >= pageNumbers.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pageNumber = pageNumbers[currentIndex];
|
||||
try {
|
||||
results[currentIndex] = await worker(pageNumber);
|
||||
} catch (error) {
|
||||
if (!failure) {
|
||||
const errorData = error as Partial<PageProcessingFailure> | undefined;
|
||||
failure = {
|
||||
page_number: Number(errorData?.page_number ?? pageNumber),
|
||||
stage: (errorData?.stage as FailedPageInfo["stage"] | undefined) ?? "render",
|
||||
error: normalizeErrorMessage(errorData?.error ?? error)
|
||||
};
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workerCount = Math.max(1, Math.min(limit, pageNumbers.length));
|
||||
await Promise.all(
|
||||
Array.from({ length: workerCount }, () => runWorker())
|
||||
);
|
||||
|
||||
const partialResults = sortPageResults(
|
||||
results.filter((page): page is InternalPageResult => Boolean(page))
|
||||
);
|
||||
if (failure) {
|
||||
const failureInfo = failure as Omit<PageProcessingFailure, "partial_results">;
|
||||
throw {
|
||||
page_number: failureInfo.page_number,
|
||||
stage: failureInfo.stage,
|
||||
error: failureInfo.error,
|
||||
partial_results: partialResults
|
||||
} as PageProcessingFailure;
|
||||
}
|
||||
|
||||
return partialResults;
|
||||
}
|
||||
|
||||
async function parsePdfWithVisionInternal(
|
||||
params: ParsePdfWithVisionParams
|
||||
): Promise<ToolResponse> {
|
||||
let binding: FunctionModelConfigResultData;
|
||||
try {
|
||||
binding = await Tools.SoftwareSettings.getFunctionModelConfig(TOOL_FUNCTION_TYPE);
|
||||
assertVisionBinding(binding);
|
||||
} catch (error) {
|
||||
const message = String(error instanceof Error ? error.message : error);
|
||||
return buildPrecheckFailure(message);
|
||||
}
|
||||
|
||||
const visionModel = buildVisionModelInfo(binding);
|
||||
const rawPdfPath = toTrimmedString(params?.pdf_path);
|
||||
|
||||
let pdfFile: any;
|
||||
let startPage: number | undefined;
|
||||
let endPageInput: number | undefined;
|
||||
const resolvedPrompt =
|
||||
toTrimmedString(params?.page_prompt) || DEFAULT_PAGE_PROMPT;
|
||||
|
||||
try {
|
||||
pdfFile = validatePdfFile(rawPdfPath);
|
||||
startPage = parsePositiveInteger(params?.start_page, "start_page", {
|
||||
allowUndefined: true
|
||||
}) ?? 1;
|
||||
endPageInput = parsePositiveInteger(params?.end_page, "end_page", {
|
||||
allowUndefined: true
|
||||
});
|
||||
} catch (error) {
|
||||
const message = String(error instanceof Error ? error.message : error);
|
||||
return {
|
||||
success: false,
|
||||
message,
|
||||
data: {
|
||||
pdf_path: rawPdfPath,
|
||||
image_dir: "",
|
||||
output_path: "",
|
||||
total_page_count: 0,
|
||||
processed_page_count: 0,
|
||||
pages: [],
|
||||
vision_model: visionModel,
|
||||
failed_page: {
|
||||
page_number: 0,
|
||||
stage: "precheck",
|
||||
error: message
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const pages: InternalPageResult[] = [];
|
||||
const context = resolveAppContext();
|
||||
const enhancedAiService = EnhancedAIService.getInstance(context) as JavaBridgeInstance;
|
||||
let outputDir: any = null;
|
||||
let imageDir = "";
|
||||
let outputPath = "";
|
||||
|
||||
let fileDescriptor: any = null;
|
||||
let pdfRenderer: any = null;
|
||||
let totalPageCount = 0;
|
||||
|
||||
try {
|
||||
fileDescriptor = ParcelFileDescriptor.open(
|
||||
pdfFile,
|
||||
ParcelFileDescriptor.MODE_READ_ONLY
|
||||
);
|
||||
pdfRenderer = new PdfRenderer(fileDescriptor);
|
||||
totalPageCount = Number(pdfRenderer.getPageCount());
|
||||
|
||||
if (!Number.isInteger(totalPageCount) || totalPageCount <= 0) {
|
||||
const error = "PDF 没有可处理的页面。";
|
||||
return {
|
||||
success: false,
|
||||
message: error,
|
||||
data: buildData(rawPdfPath, imageDir, outputPath, 0, pages, visionModel, {
|
||||
page_number: 0,
|
||||
stage: "render",
|
||||
error
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
const resolvedEndPage = Math.min(endPageInput ?? totalPageCount, totalPageCount);
|
||||
if ((startPage ?? 1) > resolvedEndPage) {
|
||||
const error =
|
||||
(startPage ?? 1) > totalPageCount
|
||||
? `start_page 超出了总页数,文档总页数为 ${totalPageCount}。`
|
||||
: "start_page 不能大于 end_page。";
|
||||
return {
|
||||
success: false,
|
||||
message: error,
|
||||
data: buildData(rawPdfPath, imageDir, outputPath, totalPageCount, pages, visionModel, {
|
||||
page_number: 0,
|
||||
stage: "precheck",
|
||||
error
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
outputDir = createOutputDirectory();
|
||||
imageDir = String(outputDir.getAbsolutePath());
|
||||
const pageNumbers: number[] = [];
|
||||
for (let pageNumber = startPage ?? 1; pageNumber <= resolvedEndPage; pageNumber += 1) {
|
||||
pageNumbers.push(pageNumber);
|
||||
}
|
||||
|
||||
try {
|
||||
const processedPages = await processPagesWithConcurrency(
|
||||
pageNumbers,
|
||||
PAGE_CONCURRENCY,
|
||||
currentPageNumber =>
|
||||
processSinglePage(
|
||||
pdfFile,
|
||||
outputDir,
|
||||
currentPageNumber,
|
||||
resolvedPrompt,
|
||||
enhancedAiService
|
||||
)
|
||||
);
|
||||
pages.push(...processedPages);
|
||||
} catch (error) {
|
||||
const failedPage = error as PageProcessingFailure;
|
||||
pages.push(...sortPageResults(failedPage.partial_results ?? []));
|
||||
outputPath = writeMergedOutputFile(outputDir, pages);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: `第 ${failedPage.page_number} 页处理失败: ${failedPage.error}`,
|
||||
data: buildData(rawPdfPath, imageDir, outputPath, totalPageCount, pages, visionModel, {
|
||||
page_number: failedPage.page_number,
|
||||
stage: failedPage.stage,
|
||||
error: failedPage.error
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
outputPath = writeMergedOutputFile(outputDir, pages);
|
||||
return {
|
||||
success: true,
|
||||
message: `成功解析 ${pages.length} 页 PDF 并完成识图,结果已写入 ${outputPath}。`,
|
||||
data: buildData(rawPdfPath, imageDir, outputPath, totalPageCount, pages, visionModel)
|
||||
};
|
||||
} catch (error) {
|
||||
const message = String(error instanceof Error ? error.message : error);
|
||||
outputPath = writeMergedOutputFile(outputDir, pages);
|
||||
return {
|
||||
success: false,
|
||||
message,
|
||||
data: buildData(rawPdfPath, imageDir, outputPath, totalPageCount, pages, visionModel, {
|
||||
page_number: 0,
|
||||
stage: "render",
|
||||
error: message
|
||||
})
|
||||
};
|
||||
} finally {
|
||||
safeClose(pdfRenderer, "pdfRenderer");
|
||||
safeClose(fileDescriptor, "parcelFileDescriptor");
|
||||
}
|
||||
}
|
||||
|
||||
async function wrapToolExecution(
|
||||
func: (params: ParsePdfWithVisionParams) => Promise<ToolResponse>,
|
||||
params: ParsePdfWithVisionParams
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result = await func(params || ({} as ParsePdfWithVisionParams));
|
||||
complete(result);
|
||||
} catch (error) {
|
||||
const message = String(error instanceof Error ? error.message : error);
|
||||
complete({
|
||||
success: false,
|
||||
message,
|
||||
data: {
|
||||
pdf_path: toTrimmedString(params?.pdf_path),
|
||||
image_dir: "",
|
||||
output_path: "",
|
||||
total_page_count: 0,
|
||||
processed_page_count: 0,
|
||||
pages: [],
|
||||
vision_model: {
|
||||
function_type: TOOL_FUNCTION_TYPE,
|
||||
config_id: "",
|
||||
config_name: "",
|
||||
selected_model: ""
|
||||
},
|
||||
failed_page: {
|
||||
page_number: 0,
|
||||
stage: "precheck",
|
||||
error: message
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function parse_pdf_with_vision(
|
||||
params: ParsePdfWithVisionParams
|
||||
): Promise<void> {
|
||||
await wrapToolExecution(parsePdfWithVisionInternal, params);
|
||||
}
|
||||
|
||||
async function main(params: ParsePdfWithVisionParams): Promise<void> {
|
||||
await wrapToolExecution(parsePdfWithVisionInternal, params);
|
||||
}
|
||||
|
||||
return {
|
||||
parse_pdf_with_vision,
|
||||
main
|
||||
};
|
||||
})();
|
||||
|
||||
exports.parse_pdf_with_vision = PdfVisionParser.parse_pdf_with_vision;
|
||||
exports.main = PdfVisionParser.main;
|
||||
@@ -0,0 +1,410 @@
|
||||
/*
|
||||
METADATA
|
||||
{
|
||||
// QQ Intelligent Assistant Package
|
||||
name: Experimental_qq_intelligent
|
||||
|
||||
display_name: {
|
||||
zh: "实验性 QQ 智能助手"
|
||||
en: "Experimental QQ Intelligent Assistant"
|
||||
}description: { zh: "高级QQ智能助手,通过UI自动化技术实现QQ应用交互,支持消息自动回复、历史记录读取、联系人搜索与通讯等功能,为AI赋予QQ社交能力。适用于智能客服、自动回复、社交辅助等场景。", en: "Advanced QQ assistant powered by UI automation. Supports sending messages, reading chat history, searching contacts/groups, and basic communication workflows, enabling AI-driven social messaging scenarios." }
|
||||
|
||||
// Tools in this package
|
||||
category: "Media"
|
||||
tools: [
|
||||
{
|
||||
name: reply
|
||||
description: { zh: "在当前聊天窗口输入消息并发送。一般情况下,用户想要ai帮忙发送消息时,需要ai自己去生成回复的消息,如果不确定发送的内容,请不要调用工具。只要是停留在聊天界面,就可以直接调用这个。", en: "Type and send a message in the current chat window. Usually the AI should generate the message content by itself; if you are unsure what to send, do not call this tool. As long as you are staying on a chat page, you can call it directly." }
|
||||
parameters: [
|
||||
{
|
||||
name: message
|
||||
description: { zh: "要发送的消息", en: "Message text to send." }
|
||||
type: string
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: click_send
|
||||
description: { zh: "是否点击发送按钮", en: "Whether to tap the Send button." }
|
||||
type: boolean
|
||||
required: false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: find_user
|
||||
description: { zh: "在QQ联系人或群成员中查找用户", en: "Find a user in QQ contacts or group chats." }
|
||||
parameters: [
|
||||
{
|
||||
name: user_name
|
||||
description: { zh: "搜索用户名称", en: "User name keyword to search." }
|
||||
type: string
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: user_type
|
||||
description: { zh: "搜索类型(contacts/groups)", en: "Search type: contacts or groups." }
|
||||
type: string
|
||||
required: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: find_and_reply
|
||||
description: { zh: "查找用户并发送消息", en: "Find a user and send a message." }
|
||||
parameters: [
|
||||
{
|
||||
name: message
|
||||
description: { zh: "要发送的消息", en: "Message text to send." }
|
||||
type: string
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: user_name
|
||||
description: { zh: "发送目标用户名称", en: "Target user name keyword." }
|
||||
type: string
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: user_type
|
||||
description: { zh: "用户类型(contacts/groups)", en: "Target type: contacts or groups." }
|
||||
type: string
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: click_send
|
||||
description: { zh: "是否点击发送按钮", en: "Whether to tap the Send button." }
|
||||
type: boolean
|
||||
required: false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: get_history
|
||||
description: { zh: "获取当前聊天窗口的历史消息。如果用户要求读取聊天记录,那么可以用这个工具或者find_and_get_history。", en: "Get chat history from the current chat window. If the user asks to read chat history, you can use this tool or `find_and_get_history`." }
|
||||
parameters: [
|
||||
{
|
||||
name: message_num
|
||||
description: { zh: "获取的消息数量", en: "Number of messages to retrieve." }
|
||||
type: number
|
||||
required: false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: find_and_get_history
|
||||
description: { zh: "查找用户并获取聊天历史记录。一般情况下,用户想要让ai帮忙回消息的时候,需要先调用这个,得到历史记录后直接调用reply。", en: "Find a user and retrieve chat history. Typically, when the user wants the AI to reply, call this first to get context, then call `reply`." }
|
||||
parameters: [
|
||||
{
|
||||
name: user_name
|
||||
description: { zh: "搜索用户名称", en: "User name keyword to search." }
|
||||
type: string
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: user_type
|
||||
description: { zh: "搜索类型(contacts/groups)", en: "Search type: contacts or groups." }
|
||||
type: string
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: message_num
|
||||
description: { zh: "获取的消息数量", en: "Number of messages to retrieve." }
|
||||
type: number
|
||||
required: false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}*/
|
||||
|
||||
interface Array<T> {
|
||||
at(index: number): T | undefined;
|
||||
}
|
||||
|
||||
const QQIntelligent = (function () {
|
||||
async function close_keyboard() {
|
||||
await Tools.UI.pressKey("KEYCODE_BACK");
|
||||
}
|
||||
|
||||
async function is_in_group() {
|
||||
let page = await UINode.getCurrentPage();
|
||||
return page.findAllByContentDesc('语音').findIndex(e => e.className?.includes('ImageButton') && (e.centerPoint?.y ?? 0) > 1000) === -1;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
Array.prototype.at = function (index: number) {
|
||||
if (index < 0) {
|
||||
index = this.length + index;
|
||||
}
|
||||
return this[index];
|
||||
}
|
||||
|
||||
async function get_history(params: { message_num: number | string }) {
|
||||
const message_num = Number(params.message_num) || 10;
|
||||
let page = await UINode.getCurrentPage();
|
||||
|
||||
//获取群名称
|
||||
const chat_title = page.findById('com.tencent.mobileqq:id/ivTitleBtnLeft')?.parent?.allTexts()[0] ?? "";
|
||||
// const is_group = await is_in_group();
|
||||
console.log("chat_title", chat_title);
|
||||
// console.log("is_group", is_group);
|
||||
//先滑动到最底部
|
||||
let tryMax = 0;
|
||||
while (tryMax < 1) {
|
||||
tryMax++;
|
||||
await Tools.UI.swipe(100, 1000, 100, 200);
|
||||
await Tools.System.sleep(500);
|
||||
}
|
||||
|
||||
let messageList: { message: string, sender: string }[] = [];
|
||||
let allMessages: { message: string, sender: string }[] = [];
|
||||
|
||||
//获取历史消息
|
||||
tryMax = 0;
|
||||
while (tryMax < 5) {
|
||||
tryMax++;
|
||||
|
||||
page = await UINode.getCurrentPage();
|
||||
console.log("page", page.toFormattedString!());
|
||||
const list_view = page.findByClass('RecyclerView');
|
||||
if (!list_view) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// 清空临时消息列表
|
||||
messageList = [];
|
||||
|
||||
// 获取当前可见的消息列表
|
||||
for (const message of list_view.children) {
|
||||
let message_text = message.allTexts().join('/');
|
||||
|
||||
let sender = "other";
|
||||
//判断头像再左边还是右边
|
||||
const avatar = message.findByClass('ImageView');
|
||||
if (avatar) {
|
||||
if ((avatar.centerPoint?.x ?? 0) < 300) {
|
||||
sender = "other";
|
||||
console.log("other", avatar.centerPoint?.x);
|
||||
} else {
|
||||
sender = "self";
|
||||
console.log("self", avatar.centerPoint?.x);
|
||||
}
|
||||
} else {
|
||||
sender = "self";
|
||||
console.log("self");
|
||||
}
|
||||
messageList.push({ message: message_text, sender: sender });
|
||||
}
|
||||
|
||||
// 将当前视图中的消息添加到总集合中,保持顺序
|
||||
// 从下往上滑动时,将新获取的消息放在前面(保持旧消息在前,新消息在后的顺序)
|
||||
allMessages = [...messageList, ...allMessages];
|
||||
|
||||
// 如果已经获取了足够多的消息,停止滚动
|
||||
if (allMessages.length >= message_num) {
|
||||
break;
|
||||
}
|
||||
await Tools.UI.swipe(100, 300, 100, 1000);
|
||||
await Tools.System.sleep(500);
|
||||
}
|
||||
|
||||
// 删除重复的消息,但保持原始顺序
|
||||
const uniqueMessages: { message: string, sender: string }[] = [];
|
||||
const seen = new Set();
|
||||
|
||||
for (const msg of allMessages) {
|
||||
const key = `${msg.message}-${msg.sender}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
uniqueMessages.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// 返回符合数量要求的消息列表(保持原始顺序)
|
||||
return { messages: uniqueMessages.slice(0, message_num), chat_title: chat_title };
|
||||
}
|
||||
|
||||
async function reply(params: { message: string, click_send: boolean }) {
|
||||
// 提取参数
|
||||
const message = params.message || "";
|
||||
const click_send = params.click_send || false;
|
||||
await Tools.UI.setText(message);
|
||||
await Tools.System.sleep(500);
|
||||
|
||||
if (click_send) {
|
||||
await Tools.UI.clickElement({
|
||||
resourceId: "com.tencent.mobileqq:id/send_btn",
|
||||
index: "0"
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function find_and_reply(params: { message: string, user_name: string, user_type: "contacts" | "groups", click_send: boolean }) {
|
||||
// 提取参数
|
||||
const message = params.message || "";
|
||||
const user_name = params.user_name || "";
|
||||
const user_type = params.user_type || "contacts";
|
||||
const click_send = params.click_send || false;
|
||||
let result = await find_user({ user_name: user_name, user_type: user_type });
|
||||
if (!result) {
|
||||
return false;
|
||||
}
|
||||
await Tools.System.sleep(1000);
|
||||
return await reply({ message: message, click_send: click_send });
|
||||
}
|
||||
|
||||
async function ensureActivity(activityName: string = "", packageName: string = "com.tencent.mobileqq", enterActivity: () => Promise<boolean> = async () => true, tryMax: number = 1) {
|
||||
let android = new Android();
|
||||
let activity = await Tools.UI.getPageInfo();
|
||||
if (activity.activityName?.includes(activityName)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
while (tryMax > 0) {
|
||||
tryMax--;
|
||||
await Tools.System.stopApp(packageName);
|
||||
await Tools.System.sleep(2000);
|
||||
await Tools.System.startApp(packageName);
|
||||
// let intent = android.createIntent();
|
||||
// intent.setComponent(packageName, activityName);
|
||||
// await intent.start();
|
||||
await Tools.System.sleep(3000); // Give some time for app to launch
|
||||
if (await enterActivity()) {
|
||||
activity = await Tools.UI.getPageInfo();
|
||||
if (activity.activityName?.includes(activityName)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function find_user(params: { user_name: string, user_type: "contacts" | "groups" }) {
|
||||
// 提取参数
|
||||
const user_name = params.user_name || "";
|
||||
const user_type = params.user_type || "contacts";
|
||||
|
||||
if (!await ensureActivity("com.tencent.mobileqq.search.activity.UniteSearchActivity", "com.tencent.mobileqq", async () => {
|
||||
const search_btn = (await UINode.getCurrentPage()).findByText("搜索");
|
||||
if (search_btn) {
|
||||
await search_btn.click();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let firstTarget: UINode | undefined = undefined;
|
||||
let tryMax = 0;
|
||||
while (tryMax < 2) {
|
||||
tryMax++;
|
||||
const search_btn = (await UINode.getCurrentPage()).findByText("搜索");
|
||||
if (search_btn) {
|
||||
await search_btn.click();
|
||||
await Tools.System.sleep(500);
|
||||
}
|
||||
// 输入搜索内容
|
||||
await Tools.UI.setText(user_name);
|
||||
await Tools.System.sleep(3000 * tryMax);
|
||||
await close_keyboard();
|
||||
|
||||
const currentPage = await UINode.getCurrentPage();
|
||||
const searchResult = currentPage.findAllById('com.tencent.mobileqq:id/title');
|
||||
|
||||
let isNeedToCatch = false;
|
||||
for (const child of searchResult!) {
|
||||
if (isNeedToCatch) {
|
||||
firstTarget = child;
|
||||
break;
|
||||
}
|
||||
// const title = child.findById('com.tencent.mobileqq:id/title');
|
||||
const title = child;
|
||||
if (title) {
|
||||
if (user_type == "contacts" && title.text == "联系人") {
|
||||
isNeedToCatch = true;
|
||||
} else if (user_type == "groups" && title.text == "群聊") {
|
||||
isNeedToCatch = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (firstTarget) {
|
||||
await firstTarget.click();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function find_and_get_history(params: { user_name: string, user_type: "contacts" | "groups", message_num: number }) {
|
||||
const user_name = params.user_name || "";
|
||||
const user_type = params.user_type || "contacts";
|
||||
const message_num = params.message_num || 10;
|
||||
let result = await find_user({ user_name: user_name, user_type: user_type });
|
||||
if (!result) {
|
||||
return undefined;
|
||||
}
|
||||
await Tools.System.sleep(1000);
|
||||
return await get_history({ message_num: message_num });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// let result = await find_and_reply({ message: "你好你好!我是OPERIT,很高兴认识你!", user_name: "韩韩韩", user_type: "contacts", click_send: true });
|
||||
// await find_user({ user_name: "Wind", user_type: "contacts" });
|
||||
// await find_user({ user_name: "Dec", user_type: "groups" });
|
||||
// await Tools.System.sleep(1000);
|
||||
let result = await find_and_get_history({ user_name: "Dec", user_type: "groups", message_num: 20 });
|
||||
console.log(result);
|
||||
complete({
|
||||
success: result
|
||||
});
|
||||
}
|
||||
|
||||
async function wrap_bool(func: (params: any) => Promise<boolean>, params: any, successMessage: string, failMessage: string, additionalMessage: string = "") {
|
||||
if (await func(params)) {
|
||||
complete({
|
||||
success: true,
|
||||
message: successMessage,
|
||||
additionalMessage: additionalMessage
|
||||
})
|
||||
} else {
|
||||
complete({
|
||||
success: false,
|
||||
message: failMessage,
|
||||
additionalMessage: additionalMessage
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function wrap_data(func: (params: any) => Promise<any>, params: any, successMessage: string, failMessage: string, additionalMessage: string = "") {
|
||||
const result = await func(params);
|
||||
complete({
|
||||
success: true,
|
||||
message: successMessage,
|
||||
additionalMessage: additionalMessage,
|
||||
data: result
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
main: main,
|
||||
reply: async (params) => await wrap_bool(reply, params, "发送成功", "发送失败"),
|
||||
find_user: async (params) => await wrap_bool(find_user, params, "查找成功", "查找失败,停留在界面", (await UINode.getCurrentPage()).toFormattedString!()),
|
||||
find_and_reply: async (params) => await wrap_bool(find_and_reply, params, "发送成功", "发送失败,停留在界面", (await UINode.getCurrentPage()).toFormattedString!()),
|
||||
get_history: async (params) => await wrap_data(get_history, params, "获取历史消息成功", "获取历史消息失败"),
|
||||
find_and_get_history: async (params) => await wrap_data(find_and_get_history, params, "获取历史消息成功", "获取历史消息失败")
|
||||
}
|
||||
})();
|
||||
|
||||
//逐个导出
|
||||
exports.reply = QQIntelligent.reply;
|
||||
exports.find_user = QQIntelligent.find_user;
|
||||
exports.find_and_reply = QQIntelligent.find_and_reply;
|
||||
exports.get_history = QQIntelligent.get_history;
|
||||
exports.find_and_get_history = QQIntelligent.find_and_get_history;
|
||||
exports.main = QQIntelligent.main;
|
||||
@@ -0,0 +1,506 @@
|
||||
/*
|
||||
欢迎来到 TypeScript/JavaScript AI 工具开发终极入门教程 (v2.0 详细版)
|
||||
====================================================================
|
||||
|
||||
本教程为初学者设计,特别是那些熟悉其他脚本语言(如Python)但对TypeScript/JavaScript不熟悉的开发者。
|
||||
我们将从最基础的语法开始,一步步带你构建一个功能完整、结构优雅的AI工具,并详细解释每一步背后的“为什么”。
|
||||
*/
|
||||
|
||||
|
||||
// =================================================================
|
||||
// Part 0: 关于运行环境 (A Note on the Environment)
|
||||
// =================================================================
|
||||
/*
|
||||
在开始之前,你需要知道,你的代码并不是在真空中运行。它在一个特殊的“沙箱”环境中执行。
|
||||
这个环境为你预先提供了一些全局可用的函数和对象,最重要的有:
|
||||
|
||||
1. `complete(result: object)`:
|
||||
- 这是你的工具与AI系统沟通的【唯一】桥梁。
|
||||
- 当你的工具执行完毕,无论成功还是失败,都必须调用这个函数来返回结果。
|
||||
- AI会根据你传入这个函数的对象内容,来决定下一步的行为。
|
||||
|
||||
2. `exports`:
|
||||
- 这是一个特殊的对象,你可以把它看作是你代码文件的“公开接口”。
|
||||
- 你需要将你的工具函数“挂载”到 `exports` 对象上,AI才能找到并调用它们。
|
||||
- 示例: `exports.myToolName = myToolFunction;`
|
||||
|
||||
3. `Tools`:
|
||||
- 这是一个内置的工具集,提供了很多实用的辅助功能,比如文件读写、网络请求、系统命令等。
|
||||
- 我们在本教程中会用到 `Tools.System.sleep()` 来演示异步操作。
|
||||
|
||||
本教程的所有代码都在一个TypeScript文件(.ts)中。TypeScript是JavaScript的超集,它增加了“类型系统”,
|
||||
能帮助我们编写更健壮、更不容易出错的代码。
|
||||
*/
|
||||
|
||||
|
||||
// =================================================================
|
||||
// Part 1: JavaScript / TypeScript 核心语法快速入门
|
||||
// =================================================================
|
||||
/*
|
||||
本部分将详细介绍编写AI工具必需的JS/TS语法,并为每个知识点提供代码示例。
|
||||
*/
|
||||
|
||||
// --- 1.1 变量声明 (Variables) ---
|
||||
/*
|
||||
- `const` (常量): 用于声明一个值不会再改变的变量。这是首选的声明方式,能让代码更可预测。
|
||||
- `let` (变量): 用于声明一个值将来可能需要被修改的变量。
|
||||
*/
|
||||
function variable_example() {
|
||||
const toolName = "My Greeter Tool"; // 一旦设定,toolName就不能再被赋值
|
||||
let executionCount = 0; // executionCount 的值可以改变
|
||||
console.log(`工具名称: ${toolName}`);
|
||||
|
||||
executionCount = executionCount + 1;
|
||||
console.log(`这是第 ${executionCount} 次执行。`);
|
||||
|
||||
// 下面这行代码会报错,因为 toolName 是一个常量
|
||||
// toolName = "New Name";
|
||||
}
|
||||
// variable_example(); // 你可以取消注释来在本地测试这些函数
|
||||
|
||||
|
||||
// --- 1.2 数据类型 (Data Types) ---
|
||||
/*
|
||||
TypeScript 强制我们思考数据的类型,这极大地减少了运行时错误。
|
||||
- `string`: 文本字符串。
|
||||
- `number`: 数字(整数或浮点数)。
|
||||
- `boolean`: 布尔值 (`true` 或 `false`)。
|
||||
- `object`: 对象,键值对的集合,类似于Python的字典。
|
||||
- `array`: 数组,有序的数据列表,类似于Python的列表。
|
||||
- `any`: 特殊类型,表示“任何类型都可以”。应尽量避免使用,因为它会削弱TypeScript的优势。
|
||||
*/
|
||||
function data_types_example() {
|
||||
// 我们可以明确地为变量注解类型
|
||||
const userName: string = "Alice";
|
||||
const userAge: number = 30;
|
||||
const isActive: boolean = true;
|
||||
|
||||
// 对象类型注解,描述了一个对象的“形状”
|
||||
const userInfo: { name: string; age: number; premium: boolean } = {
|
||||
name: "Bob",
|
||||
age: 25,
|
||||
premium: false
|
||||
};
|
||||
console.log(`${userInfo.name} 的年龄是 ${userInfo.age}`);
|
||||
|
||||
// 数组类型注解,`string[]` 表示一个“只包含字符串的数组”
|
||||
const permissions: string[] = ["read", "write", "execute"];
|
||||
console.log(`用户的第一个权限是: ${permissions[0]}`);
|
||||
|
||||
// 遍历数组
|
||||
permissions.forEach(permission => {
|
||||
console.log(`权限: ${permission}`);
|
||||
});
|
||||
}
|
||||
// data_types_example();
|
||||
|
||||
|
||||
// --- 1.3 函数 (Functions) 与 异步操作 (Async Operations) ---
|
||||
/*
|
||||
- `async function`: 这是定义AI工具函数的标准方式。`async` 关键字表明该函数内部可能包含需要“等待”的操作。
|
||||
- `await`: 必须在 `async` 函数内部使用。它会暂停函数的执行,直到其后的异步操作(通常是一个`Promise`)完成,然后返回结果。
|
||||
- `Promise<T>`: 代表一个异步操作最终完成(或失败)的结果。`async` 函数的返回值总是一个`Promise`。
|
||||
例如 `Promise<string>` 意味着这个异步函数最终会返回一个字符串。
|
||||
*/
|
||||
async function async_function_example(name: string): Promise<string> {
|
||||
console.log(`开始向 ${name} 发送问候...`);
|
||||
|
||||
// `await` 在这里暂停函数,等待 Tools.System.sleep(1500) 完成
|
||||
// 这模拟了一个耗时1.5秒的网络请求或文件操作
|
||||
await Tools.System.sleep(1500);
|
||||
|
||||
const greetingMessage = `你好, ${name}! 异步问候已送达。`;
|
||||
console.log(greetingMessage);
|
||||
|
||||
// async 函数通过 return 返回的值,会被包装在 Promise 中
|
||||
return greetingMessage;
|
||||
}
|
||||
// async_function_example("Charlie");
|
||||
|
||||
|
||||
// --- 1.4 错误处理 (Error Handling) ---
|
||||
/*
|
||||
- `try...catch`: 这是处理潜在错误的标准方式,对于健壮的工具至关重要。
|
||||
- `throw new Error(...)`: 当函数执行不下去时,主动“抛出”一个错误,中断当前`try`块的执行,并被`catch`块捕获。
|
||||
*/
|
||||
function error_handling_example(input: any) {
|
||||
try {
|
||||
console.log("尝试处理输入...");
|
||||
|
||||
// 步骤1: 检查输入是否为非空字符串
|
||||
if (typeof input !== 'string' || input.trim() === '') {
|
||||
// 如果检查失败,就“抛出”一个错误对象
|
||||
throw new Error("输入必须是一个非空的字符串。");
|
||||
}
|
||||
|
||||
// 步骤2: 如果代码能执行到这里,说明没有错误发生
|
||||
console.log(`输入有效,内容是: "${input}"`);
|
||||
return { success: true, data: input };
|
||||
|
||||
} catch (error) {
|
||||
// 步骤3: 如果`try`块中任何地方抛出了错误,程序会立即跳转到这里
|
||||
console.error("发生了一个错误!");
|
||||
console.error(`错误详情: ${error.message}`); // error.message 包含了我们抛出时提供的信息
|
||||
|
||||
// 在实际工具中,这里我们会调用 complete() 来报告失败
|
||||
return { success: false, message: error.message };
|
||||
}
|
||||
}
|
||||
// error_handling_example("这是一个有效的输入");
|
||||
// error_handling_example(""); // 这个会触发错误
|
||||
// error_handling_example(123); // 这个也会触发错误
|
||||
|
||||
|
||||
// =================================================================
|
||||
// Part 2: AI工具的本质 与 `complete()` 函数
|
||||
// =================================================================
|
||||
/*
|
||||
如前所述,一个AI工具就是一个函数,它通过 `complete()` 函数与AI系统交互。
|
||||
下面是一个最基础的、结构完整的AI工具示例,我们为其中的关键部分添加了注释。
|
||||
*/
|
||||
async function simple_greeter_tool(params: { user_name: string }) {
|
||||
// 典型的工具函数总是在一个大的 try...catch 块中
|
||||
try {
|
||||
// 1. 从 AI 接收到的参数中解构出需要的值
|
||||
const { user_name } = params;
|
||||
|
||||
// 2. 参数校验:永远不要相信输入!
|
||||
if (!user_name) {
|
||||
throw new Error("参数 'user_name' 缺失,无法生成问候语。");
|
||||
}
|
||||
|
||||
// 3. 执行核心业务逻辑
|
||||
const greeting = `你好, ${user_name}! 欢迎来到AI工具的世界。`;
|
||||
|
||||
// 4. 调用 complete(),报告成功
|
||||
// - success: true 表示工具成功完成了它的任务。
|
||||
// - data: 存放工具的执行结果,这是AI最关心的部分。
|
||||
complete({
|
||||
success: true,
|
||||
data: greeting
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
// 5. 如果 try 块中任何地方出错(无论是参数校验还是业务逻辑),都会进入这里
|
||||
console.error("simple_greeter_tool 执行失败:", error);
|
||||
|
||||
// 6. 调用 complete(),报告失败
|
||||
// - success: false 告诉AI任务失败了。
|
||||
// - message: 存放清晰的错误信息,帮助AI理解失败原因。
|
||||
complete({
|
||||
success: false,
|
||||
message: `工具执行失败: ${error.message}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// =================================================================
|
||||
// Part 3: Metadata 和 函数导出 - 连接AI与代码的桥梁
|
||||
// =================================================================
|
||||
/*
|
||||
我们已经写好了工具函数,但AI如何知道它的存在、功能和用法呢?
|
||||
这需要两样东西:
|
||||
1. `METADATA` (元数据): 就像是工具的“说明书”。
|
||||
2. `exports` (导出): 像是把工具函数“注册”到系统里。
|
||||
|
||||
--- 3.1 详解 METADATA ---
|
||||
文件顶部的 ` METADATA ... ` 注释块就是这份说明书。AI在加载时会解析它。
|
||||
- `name`: (string) 工具集的唯一ID。
|
||||
- `description`: (string) 对整个工具集的详细描述。
|
||||
- `tools`: (array) 一个数组,包含此文件中所有可用的工具。
|
||||
- `name`: (string) 单个工具的函数名。这个名字【必须】和后面 `exports` 中使用的名字完全一致。
|
||||
- `description`: (string) 对这个工具能做什么的清晰描述。AI会根据这个描述来决定何时调用它。
|
||||
- `parameters`: (array) 定义了此工具需要哪些参数。
|
||||
- `name`: (string) 参数名。
|
||||
- `description`: (string) 对参数用途的描述。
|
||||
- `type`: (string) 参数的类型 (e.g., 'string', 'number', 'boolean')。
|
||||
- `required`: (boolean) 这个参数是否是必需的。
|
||||
*/
|
||||
|
||||
/* METADATA
|
||||
{
|
||||
"name": "greeter_tool_v2",
|
||||
|
||||
"display_name": {
|
||||
"zh": "问候工具 V2",
|
||||
"en": "Greeting Tool V2"
|
||||
},
|
||||
"description": { "zh": "一个提供多种问候方式的教学工具集。", "en": "A tutorial tool package that demonstrates multiple greeting methods." },
|
||||
"category": "Utility",
|
||||
"tools": [
|
||||
{
|
||||
"name": "greet",
|
||||
"description": { "zh": "向指定的人发送一个标准的问候。", "en": "Send a standard greeting to a specified person." },
|
||||
"parameters": [
|
||||
{
|
||||
"name": "user_name",
|
||||
"description": { "zh": "要问候的人的名字。", "en": "Name of the person to greet." },
|
||||
"type": "string",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "system_inspector",
|
||||
"description": { "zh": "获取设备摘要信息,并在用户的HOME目录下查找符合特定模式的文件。", "en": "Get device summary information and find files matching a pattern under the user's HOME directory." },
|
||||
"parameters": [
|
||||
{
|
||||
"name": "search_pattern",
|
||||
"description": { "zh": "用于文件搜索的通配符模式,例如:'*.txt' 或 'documents/*.pdf'。", "en": "Glob pattern for file search, e.g. '*.txt' or 'documents/*.pdf'." },
|
||||
"type": "string",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}*/
|
||||
|
||||
// --- 3.2 详解 exports ---
|
||||
/*
|
||||
`exports` 是一个空对象,等待你来填充。
|
||||
你必须将你的工具函数赋值给 `exports` 对象的一个属性,属性名必须和 `METADATA` 中定义的工具 `name` 完全一致。
|
||||
只有这样,AI才能把“说明书”和“实际的函数实现”关联起来。
|
||||
|
||||
`exports.greet = simple_greeter_tool;`
|
||||
|
||||
上面这行代码的意思是:
|
||||
“当AI想要调用名为 'greet' 的工具时,请执行 `simple_greeter_tool` 这个函数。”
|
||||
(我们将在教程的最后进行统一导出,所以暂时注释掉这行)
|
||||
*/
|
||||
|
||||
|
||||
// =================================================================
|
||||
// Part 4: IIFE模式 - 隔离作用域,避免代码冲突
|
||||
// =================================================================
|
||||
/*
|
||||
当项目变大,你可能会定义很多辅助函数、变量。如果都放在全局(文件的顶层),很容易与其他工具或系统库产生命名冲突。
|
||||
IIFE (Immediately Invoked Function Expression - 立即调用函数表达式) 是解决这个问题的经典JS模式。
|
||||
|
||||
它的写法是 `(function() { ... })();`
|
||||
- `(function() { ... })` 定义了一个匿名函数。
|
||||
- 最后的 `()` 表示“立即执行”这个刚刚定义的匿名函数。
|
||||
|
||||
这样做的好处是创建了一个“私有作用域”。在IIFE内部声明的任何变量或函数,在外部都是不可见的,从而避免了污染全局命名空间。
|
||||
我们可以通过 `return` 语句,有选择性地将需要公开的函数暴露出来。
|
||||
*/
|
||||
|
||||
// 使用IIFE模式重构我们的问候工具
|
||||
const greeterToolBox = (function () {
|
||||
// --- 这里是私有作用域 ---
|
||||
|
||||
// `version` 和 `logWithTimestamp` 在IIFE外部无法被访问,是我们的内部实现细节。
|
||||
const version = "2.0";
|
||||
|
||||
function logWithTimestamp(message: string) {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
console.log(`[${timestamp}][v${version}] ${message}`);
|
||||
}
|
||||
|
||||
// 这是我们真正的业务逻辑实现
|
||||
async function performGreeting(params: { user_name: string }) {
|
||||
try {
|
||||
logWithTimestamp(`开始执行问候工具,参数: ${JSON.stringify(params)}`);
|
||||
const { user_name } = params;
|
||||
if (!user_name) {
|
||||
throw new Error("参数 'user_name' 缺失。");
|
||||
}
|
||||
const greeting = `你好, ${user_name}! (来自IIFE模式 v${version})`;
|
||||
complete({ success: true, data: greeting });
|
||||
} catch (error) {
|
||||
logWithTimestamp(`问候工具失败: ${error.message}`);
|
||||
complete({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// --- 公开接口 ---
|
||||
// 通过 return 一个对象,我们将 `performGreeting` 函数以 `greet` 的名字暴露出去。
|
||||
return {
|
||||
greet: performGreeting
|
||||
// 如果有其他工具,可以继续在这里暴露: anotherTool: someInternalFunction
|
||||
};
|
||||
|
||||
})(); // 注意这里,函数被立即执行了
|
||||
|
||||
// 现在,IIFE已经执行完毕,`greeterToolBox` 变量包含了我们返回的对象。
|
||||
// 我们可以这样导出它:
|
||||
// `exports.greet = greeterToolBox.greet;`
|
||||
|
||||
|
||||
// =================================================================
|
||||
// Part 5: Wrapper模式 - 终极形态,让代码更健壮、更简洁
|
||||
// =================================================================
|
||||
/*
|
||||
观察上面的代码,即使使用了IIFE,`performGreeting` 函数内部仍然有重复的 `try/catch` 和 `complete()` 调用。
|
||||
在软件工程中,重复的代码(boilerplate)是维护的噩梦。
|
||||
|
||||
我们可以创建一个 `wrapper` (包装器) 函数来终结这种重复。
|
||||
Wrapper 的职责非常清晰:
|
||||
1. 接收一个“核心业务逻辑函数”作为输入。
|
||||
2. 在内部建立 `try/catch` 安全网。
|
||||
3. 负责调用核心函数,并等待其结果。
|
||||
4. 无论成功还是失败,都由它来调用 `complete()` 并返回标准格式的结果。
|
||||
|
||||
这样,我们编写的工具函数就可以变得非常纯粹:只关心输入、业务处理和输出(通过`return`和`throw`),
|
||||
完全不用理会 `try/catch` 和 `complete` 这些模板代码。
|
||||
*/
|
||||
|
||||
// --- 最终的、最推荐的开发模式 ---
|
||||
|
||||
const finalTool = (function () {
|
||||
|
||||
// 1. 定义我们的通用 Wrapper 函数
|
||||
// 它接收一个核心逻辑函数和其所需的参数。
|
||||
async function wrap(coreFunction: (params: any) => Promise<any>, params: any) {
|
||||
try {
|
||||
// 调用核心业务逻辑,并使用 await 等待其完成
|
||||
const result = await coreFunction(params);
|
||||
|
||||
// 核心逻辑成功返回,Wrapper负责包装成标准成功对象并调用complete
|
||||
complete({
|
||||
success: true,
|
||||
message: "工具执行成功。",
|
||||
data: result // 将核心函数的返回值放入data字段
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`工具 [${coreFunction.name}] 执行时捕获到错误:`, error);
|
||||
|
||||
// 核心逻辑抛出异常,Wrapper负责捕获并包装成标准失败对象
|
||||
complete({
|
||||
success: false,
|
||||
message: `错误: ${error.message}`, // 将错误信息放入message字段
|
||||
error_stack: error.stack // 附带完整的错误堆栈以供调试
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// =================================================
|
||||
// 核心业务逻辑区 (Core Logic Functions)
|
||||
// =================================================
|
||||
|
||||
// 2a. “问候”工具的核心逻辑
|
||||
async function greet_core_logic(params: { user_name: string }): Promise<string> {
|
||||
if (!params.user_name || params.user_name.trim() === '') {
|
||||
throw new Error("user_name 不能为空。");
|
||||
}
|
||||
const greeting = `你好, ${params.user_name}! 这是一个采用终极Wrapper模式的工具。`;
|
||||
return greeting;
|
||||
}
|
||||
|
||||
// 2b. “系统检查器”工具的核心逻辑
|
||||
async function inspect_system_core_logic(params: { search_pattern: string }): Promise<object> {
|
||||
if (!params.search_pattern) {
|
||||
throw new Error("search_pattern 不能为空。");
|
||||
}
|
||||
|
||||
console.log(`开始系统检查,搜索模式: '${params.search_pattern}'`);
|
||||
|
||||
// 为了让逻辑更清晰,我们一步一步地、顺序地调用工具
|
||||
// 1. 首先,获取设备信息
|
||||
console.log("正在获取设备信息...");
|
||||
const deviceInfo = await Tools.System.getDeviceInfo();
|
||||
console.log("设备信息获取成功。");
|
||||
|
||||
// 2. 然后,查找文件
|
||||
console.log(`正在主目录 ('~') 下查找文件,模式: '${params.search_pattern}'`);
|
||||
const foundFiles = await Tools.Files.find('~', params.search_pattern);
|
||||
console.log("文件查找成功。");
|
||||
|
||||
// 组合结果并返回一个结构化的对象
|
||||
return {
|
||||
device: deviceInfo,
|
||||
files: foundFiles
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// =================================================
|
||||
// 导出函数封装区 (Exported Functions)
|
||||
// =================================================
|
||||
|
||||
// 3a. 暴露给AI的“问候”函数
|
||||
async function greet_exported_function(params: { user_name: string }) {
|
||||
await wrap(greet_core_logic, params);
|
||||
}
|
||||
|
||||
// 3b. 暴露给AI的“系统检查器”函数
|
||||
async function inspect_system_exported_function(params: { search_pattern: string }) {
|
||||
await wrap(inspect_system_core_logic, params);
|
||||
}
|
||||
|
||||
|
||||
// 4. 从IIFE中返回所有需要导出的工具
|
||||
return {
|
||||
greet: greet_exported_function,
|
||||
system_inspector: inspect_system_exported_function
|
||||
};
|
||||
|
||||
})();
|
||||
|
||||
|
||||
// =================================================================
|
||||
// Part 6: 使用内置工具集 (Using the Built-in `Tools`)
|
||||
// =================================================================
|
||||
/*
|
||||
沙箱环境提供了一个强大的全局对象 `Tools`,它是你与外部世界交互的主要入口。
|
||||
`Tools` 对象被组织成多个模块,每个模块负责一类特定的功能。
|
||||
|
||||
主要的模块包括:
|
||||
- `Tools.System`: 系统级操作。
|
||||
- `getDeviceInfo()`: 获取设备信息(型号、操作系统版本等)。
|
||||
- `sleep(ms)`: 让工具暂停指定的毫秒数。
|
||||
- `exec(command)`: 执行shell命令。
|
||||
- `Tools.Files`: 文件系统操作。
|
||||
- `read(path)`: 读取文件内容。
|
||||
- `write(path, content)`: 写入内容到文件。
|
||||
- `find(directory, pattern)`: 在指定目录中查找匹配模式的文件。
|
||||
- `remove(path)`: 删除文件或目录。
|
||||
- `Tools.Net`: 网络操作。
|
||||
- `fetch(url, options)`: 发起HTTP请求,类似于浏览器中的`fetch`。
|
||||
- `Tools.UI`: 与设备UI交互(高级功能)。
|
||||
- `getScreenshot()`: 获取当前屏幕截图。
|
||||
- `findAndClick(text)`: 查找并点击包含特定文本的UI元素。
|
||||
- `Tools.Memory`: 记忆管理功能。
|
||||
- `query(query)`: 查询记忆库。
|
||||
- `create(title, content, ...)`: 创建新记忆。
|
||||
- `update(oldTitle, updates)`: 更新记忆。
|
||||
- `deleteMemory(title)`: 删除记忆。
|
||||
- `link(sourceTitle, targetTitle, ...)`: 链接记忆。
|
||||
- `Tools.FFmpeg`: 音视频处理(高级功能)。
|
||||
|
||||
所有的 `Tools` 函数都是【异步】的,意味着你必须使用 `await` 来调用它们,
|
||||
并且调用它们的代码必须位于 `async` 函数内部。
|
||||
|
||||
在上面的 `Part 5` 中,我们已经为你演示了如何实现一个名为 `system_inspector` 的新工具。
|
||||
它完美地展示了:
|
||||
1. 如何在核心逻辑函数中调用 `Tools.System.getDeviceInfo()` 和 `Tools.Files.find()`。
|
||||
2. 如何按顺序地调用多个异步工具,等待每一个操作完成后再进行下一步。
|
||||
3. 如何将这些调用集成到我们健壮的 `Wrapper` 模式中。
|
||||
|
||||
请回头仔细阅读 `Part 5` 中 `inspect_system_core_logic` 函数的实现,
|
||||
它是你学习使用内置 `Tools` 的最佳起点。
|
||||
*/
|
||||
|
||||
|
||||
// ==========================================
|
||||
// 最终导出 (Final Export)
|
||||
// ==========================================
|
||||
// 将我们在最终IIFE模式中返回的函数,挂载到exports上。
|
||||
// 这里的 `greet` 和 `system_inspector` 必须和METADATA中定义的工具 `name` 完全一致。
|
||||
exports.greet = finalTool.greet;
|
||||
exports.system_inspector = finalTool.system_inspector;
|
||||
|
||||
/*
|
||||
🎓 恭喜! 你已经学完了从零到一的完整开发流程。
|
||||
|
||||
现在回顾一下我们构建最终代码形态的旅程:
|
||||
1. **基础语法**: 掌握了编写代码的基本工具。
|
||||
2. **基础工具+complete()**: 理解了AI工具的本质和与系统的交互方式。
|
||||
3. **Metadata+exports**: 学会了如何让AI“发现”并“使用”你的工具。
|
||||
4. **IIFE模式**: 学会了如何通过作用域隔离来组织代码,避免命名冲突,使代码更模块化。
|
||||
5. **Wrapper模式**: 学会了如何通过分离“业务逻辑”和“模板代码”来编写更简洁、更健壮、更易于维护的工具代码。
|
||||
6. **内置工具 `Tools`**: 学会了如何利用系统提供的强大工具与文件、网络和操作系统进行交互。
|
||||
|
||||
这套“Wrapper + IIFE + Tools”的组合是经过实践检验的最佳实践,强烈建议你在开发自己的工具时采用它。
|
||||
*/
|
||||
@@ -0,0 +1,451 @@
|
||||
/* METADATA
|
||||
{
|
||||
"name": "qwen_draw",
|
||||
|
||||
"display_name": {
|
||||
"zh": "Qwen 绘图",
|
||||
"en": "Qwen Draw"
|
||||
},
|
||||
"description": {
|
||||
"zh": "使用阿里云百炼/DashScope 文生图接口(通义万相/通义千问图像)根据提示词画图(异步任务轮询),将图片保存到本地 /sdcard/Download/Operit/plugins/draw/qwen_draw/draws/ 目录,并返回 Markdown 图片提示。",
|
||||
"en": "Generate images via Alibaba Cloud Model Studio (DashScope) text-to-image API (async task polling), save to /sdcard/Download/Operit/plugins/draw/qwen_draw/draws/, and return a Markdown image reference."
|
||||
},
|
||||
"env": [
|
||||
{
|
||||
"name": "DASHSCOPE_API_KEY",
|
||||
"description": {
|
||||
"zh": "DashScope API Key(必填)",
|
||||
"en": "DashScope API key (required)"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "DASHSCOPE_API_BASE_URL",
|
||||
"description": {
|
||||
"zh": "DashScope API Base URL(可选,不填则默认 https://dashscope.aliyuncs.com;国际站可用 https://dashscope-intl.aliyuncs.com)",
|
||||
"en": "DashScope API base URL (optional; defaults to https://dashscope.aliyuncs.com; intl: https://dashscope-intl.aliyuncs.com)"
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "QWEN_IMAGE_MODEL",
|
||||
"description": {
|
||||
"zh": "默认文生图模型(可选;当 draw_image 未传 model 时使用,例如 qwen-image-plus 或 wan2.2-t2i-flash)",
|
||||
"en": "Default image model (optional; used when draw_image doesn't pass model), e.g. qwen-image-plus or wan2.2-t2i-flash"
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"category": "Draw",
|
||||
"tools": [
|
||||
{
|
||||
"name": "draw_image",
|
||||
"description": {
|
||||
"zh": "根据提示词调用 DashScope 文生图接口生成图片,保存到本地并返回 Markdown 图片提示。",
|
||||
"en": "Generate an image via DashScope text-to-image API using a prompt, save it locally, and return a Markdown image reference."
|
||||
},
|
||||
"parameters": [
|
||||
{ "name": "prompt", "description": { "zh": "绘图提示词(英文或中文皆可)", "en": "Prompt for image generation (Chinese or English)" }, "type": "string", "required": true },
|
||||
{ "name": "model", "description": { "zh": "模型名称(可选;不传则使用环境变量 QWEN_IMAGE_MODEL,再不行使用默认值)", "en": "Model name (optional; falls back to env QWEN_IMAGE_MODEL, then default)" }, "type": "string", "required": false },
|
||||
{ "name": "size", "description": { "zh": "输出图像分辨率,如 '1664*928' 或 '1024x1024'(可选)", "en": "Output image resolution, e.g. '1664*928' or '1024x1024' (optional)" }, "type": "string", "required": false },
|
||||
{ "name": "n", "description": { "zh": "生成图片数量(可选,默认 1)", "en": "Number of images (optional; default 1)" }, "type": "number", "required": false },
|
||||
{ "name": "negative_prompt", "description": { "zh": "负面提示词(可选)", "en": "Negative prompt (optional)" }, "type": "string", "required": false },
|
||||
{ "name": "prompt_extend", "description": { "zh": "是否开启 prompt 智能改写(可选,默认 true)", "en": "Enable prompt extension (optional; default true)" }, "type": "boolean", "required": false },
|
||||
{ "name": "watermark", "description": { "zh": "是否加水印(可选,默认 false)", "en": "Enable watermark (optional; default false)" }, "type": "boolean", "required": false },
|
||||
{ "name": "file_name", "description": { "zh": "自定义保存到本地的文件名(不含路径和扩展名)", "en": "Custom output file name (without path or extension)" }, "type": "string", "required": false },
|
||||
{ "name": "api_base_url", "description": { "zh": "DashScope API Base URL(不传则取环境变量 DASHSCOPE_API_BASE_URL 或默认 https://dashscope.aliyuncs.com )", "en": "DashScope API base URL (optional; falls back to env DASHSCOPE_API_BASE_URL or https://dashscope.aliyuncs.com)" }, "type": "string", "required": false },
|
||||
{ "name": "poll_interval_ms", "description": { "zh": "轮询间隔(毫秒),默认 2000", "en": "Polling interval (milliseconds), default 2000" }, "type": "number", "required": false },
|
||||
{ "name": "max_wait_time_ms", "description": { "zh": "最长等待时间(毫秒),默认 10 分钟", "en": "Max wait time (milliseconds), default 10 minutes" }, "type": "number", "required": false }
|
||||
]
|
||||
}
|
||||
]
|
||||
}*/
|
||||
|
||||
/// <reference path="./types/index.d.ts" />
|
||||
|
||||
const qwenDraw = (function () {
|
||||
const HTTP_TIMEOUT_MS = 600000;
|
||||
const client = OkHttp.newBuilder()
|
||||
.connectTimeout(HTTP_TIMEOUT_MS)
|
||||
.readTimeout(HTTP_TIMEOUT_MS)
|
||||
.writeTimeout(HTTP_TIMEOUT_MS)
|
||||
.build();
|
||||
|
||||
const DEFAULT_API_BASE_URL = "https://dashscope.aliyuncs.com";
|
||||
const DEFAULT_MODEL = "qwen-image-plus";
|
||||
|
||||
const DRAW_ROOT_DIR = getPluginConfigDir("draw");
|
||||
const STORAGE_DIR = `${DRAW_ROOT_DIR}/qwen_draw`;
|
||||
const DRAWS_DIR = `${STORAGE_DIR}/draws`;
|
||||
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
const MAX_WAIT_TIME_MS = 600000;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function getErrorStack(error: unknown): string | undefined {
|
||||
if (error instanceof Error) return error.stack;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizePositiveInt(value: unknown, fallback: number): number {
|
||||
if (value === undefined || value === null) return fallback;
|
||||
const n = typeof value === "number" ? value : parseInt(String(value), 10);
|
||||
if (!Number.isFinite(n) || n <= 0) return fallback;
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
function joinUrl(baseUrl: string, path: string): string {
|
||||
const normalizedBase = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
||||
const normalizedPath = path.startsWith("/") ? path.slice(1) : path;
|
||||
return `${normalizedBase}${normalizedPath}`;
|
||||
}
|
||||
|
||||
function getApiKey(): string {
|
||||
const apiKey = getEnv("DASHSCOPE_API_KEY");
|
||||
if (!apiKey) {
|
||||
throw new Error("DASHSCOPE_API_KEY 未配置,请在环境变量中设置 DashScope 的 API Key。");
|
||||
}
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
function getApiBaseUrl(customBaseUrl?: string | null): string {
|
||||
const fromParam = (customBaseUrl || "").trim();
|
||||
if (fromParam) return fromParam;
|
||||
|
||||
const fromEnv = (getEnv("DASHSCOPE_API_BASE_URL") || "").trim();
|
||||
if (fromEnv) return fromEnv;
|
||||
|
||||
return DEFAULT_API_BASE_URL;
|
||||
}
|
||||
|
||||
function getTextToImageEndpoint(baseUrl: string): string {
|
||||
const trimmed = baseUrl.trim();
|
||||
if (!trimmed) return joinUrl(DEFAULT_API_BASE_URL, "api/v1/services/aigc/text2image/image-synthesis");
|
||||
|
||||
if (trimmed.includes("/api/v1/services/aigc/text2image/image-synthesis")) return trimmed;
|
||||
|
||||
if (trimmed.endsWith("/api/v1")) return joinUrl(trimmed, "services/aigc/text2image/image-synthesis");
|
||||
if (trimmed.endsWith("/api/v1/")) return joinUrl(trimmed, "services/aigc/text2image/image-synthesis");
|
||||
|
||||
return joinUrl(trimmed, "api/v1/services/aigc/text2image/image-synthesis");
|
||||
}
|
||||
|
||||
function getTaskEndpoint(baseUrl: string, taskId: string): string {
|
||||
const trimmed = baseUrl.trim();
|
||||
const safeBase = trimmed || DEFAULT_API_BASE_URL;
|
||||
if (safeBase.includes("/api/v1/tasks/")) {
|
||||
const idx = safeBase.indexOf("/api/v1/tasks/");
|
||||
return safeBase.substring(0, idx) + `/api/v1/tasks/${taskId}`;
|
||||
}
|
||||
if (safeBase.endsWith("/api/v1")) return joinUrl(safeBase, `tasks/${taskId}`);
|
||||
if (safeBase.endsWith("/api/v1/")) return joinUrl(safeBase, `tasks/${taskId}`);
|
||||
return joinUrl(safeBase, `api/v1/tasks/${taskId}`);
|
||||
}
|
||||
|
||||
function sanitizeFileName(name: string): string {
|
||||
const safe = name.replace(/[\\/:*?"<>|]/g, "_").trim();
|
||||
if (!safe) return `qwen_draw_${Date.now()}`;
|
||||
return safe.substring(0, 80);
|
||||
}
|
||||
|
||||
function buildFileName(prompt: string, customName?: string | null): string {
|
||||
if (customName && customName.trim().length > 0) {
|
||||
return sanitizeFileName(customName);
|
||||
}
|
||||
const shortPrompt = prompt.length > 40 ? `${prompt.substring(0, 40)}...` : prompt;
|
||||
const base = sanitizeFileName(shortPrompt || "image");
|
||||
const timestamp = Date.now();
|
||||
return `${base}_${timestamp}`;
|
||||
}
|
||||
|
||||
function normalizeSize(size?: string | null): string | undefined {
|
||||
const raw = String(size || "").trim();
|
||||
if (!raw) return undefined;
|
||||
|
||||
const cleaned = raw.replace(/×/g, "*").replace(/x/gi, "*").replace(/\s+/g, "");
|
||||
if (!/^\d+\*\d+$/.test(cleaned)) return undefined;
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
function guessExtensionFromUrl(url: string): string {
|
||||
const match = url.match(/\.(png|jpg|jpeg|webp|gif)(?:\?|#|$)/i);
|
||||
if (match && match[1]) return match[1].toLowerCase();
|
||||
return "png";
|
||||
}
|
||||
|
||||
async function ensureDirectories(): Promise<void> {
|
||||
const dirs = [DRAW_ROOT_DIR, STORAGE_DIR, DRAWS_DIR];
|
||||
for (const dir of dirs) {
|
||||
try {
|
||||
const result = await Tools.Files.mkdir(dir);
|
||||
if (!result.successful) {
|
||||
console.warn(`创建目录失败(可能已存在): ${dir} -> ${result.details}`);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.warn(`创建目录异常: ${dir} -> ${getErrorMessage(e)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createTask(params: {
|
||||
prompt: string;
|
||||
model?: string;
|
||||
size?: string;
|
||||
n?: number;
|
||||
negative_prompt?: string;
|
||||
prompt_extend?: boolean;
|
||||
watermark?: boolean;
|
||||
api_base_url?: string;
|
||||
}): Promise<{ task_id: string; effective_model: string }> {
|
||||
const apiKey = getApiKey();
|
||||
const apiBaseUrl = getApiBaseUrl(params.api_base_url);
|
||||
const endpoint = getTextToImageEndpoint(apiBaseUrl);
|
||||
|
||||
const modelFromParam = (params.model || "").trim();
|
||||
const modelFromEnv = (getEnv("QWEN_IMAGE_MODEL") || "").trim();
|
||||
const effectiveModel = modelFromParam || modelFromEnv || DEFAULT_MODEL;
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: effectiveModel,
|
||||
input: {
|
||||
prompt: params.prompt
|
||||
},
|
||||
parameters: {
|
||||
n: typeof params.n === "number" && Number.isFinite(params.n) && params.n > 0 ? Math.floor(params.n) : 1,
|
||||
prompt_extend: params.prompt_extend === undefined ? true : !!params.prompt_extend,
|
||||
watermark: params.watermark === undefined ? false : !!params.watermark
|
||||
}
|
||||
};
|
||||
|
||||
const normalizedSize = normalizeSize(params.size);
|
||||
if (normalizedSize) {
|
||||
(body.parameters as Record<string, unknown>)["size"] = normalizedSize;
|
||||
}
|
||||
|
||||
const negativePrompt = (params.negative_prompt || "").trim();
|
||||
if (negativePrompt) {
|
||||
(body.parameters as Record<string, unknown>)["negative_prompt"] = negativePrompt;
|
||||
}
|
||||
|
||||
const headers = {
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
"X-DashScope-Async": "enable"
|
||||
};
|
||||
|
||||
const request = client
|
||||
.newRequest()
|
||||
.url(endpoint)
|
||||
.method("POST")
|
||||
.headers(headers)
|
||||
.body(JSON.stringify(body), "json");
|
||||
|
||||
const response = await request.build().execute();
|
||||
|
||||
if (!response.isSuccessful()) {
|
||||
throw new Error(`DashScope 文生图创建任务失败: ${response.statusCode} - ${response.content}`);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(response.content) as unknown;
|
||||
} catch (e: unknown) {
|
||||
throw new Error(`解析 DashScope 创建任务响应失败: ${getErrorMessage(e)}`);
|
||||
}
|
||||
|
||||
const output = isRecord(parsed) && isRecord(parsed["output"]) ? (parsed["output"] as Record<string, unknown>) : null;
|
||||
const taskId = output && typeof output["task_id"] === "string" ? (output["task_id"] as string) : "";
|
||||
if (!taskId) {
|
||||
throw new Error(`DashScope 创建任务响应中未找到 output.task_id: ${response.content}`);
|
||||
}
|
||||
|
||||
return { task_id: taskId, effective_model: effectiveModel };
|
||||
}
|
||||
|
||||
async function pollTask(params: {
|
||||
task_id: string;
|
||||
api_base_url?: string;
|
||||
poll_interval_ms?: number;
|
||||
max_wait_time_ms?: number;
|
||||
}): Promise<{ image_url: string; task_status: string }> {
|
||||
const apiKey = getApiKey();
|
||||
const apiBaseUrl = getApiBaseUrl(params.api_base_url);
|
||||
|
||||
const pollIntervalMs = normalizePositiveInt(params.poll_interval_ms, POLL_INTERVAL_MS);
|
||||
const maxWaitTimeMs = normalizePositiveInt(params.max_wait_time_ms, MAX_WAIT_TIME_MS);
|
||||
|
||||
const startTime = Date.now();
|
||||
let attempts = 0;
|
||||
|
||||
while (Date.now() - startTime < maxWaitTimeMs) {
|
||||
attempts++;
|
||||
|
||||
const endpoint = getTaskEndpoint(apiBaseUrl, params.task_id);
|
||||
const headers = {
|
||||
"accept": "application/json",
|
||||
"Authorization": `Bearer ${apiKey}`
|
||||
};
|
||||
|
||||
const request = client
|
||||
.newRequest()
|
||||
.url(endpoint)
|
||||
.method("GET")
|
||||
.headers(headers);
|
||||
|
||||
const response = await request.build().execute();
|
||||
|
||||
if (!response.isSuccessful()) {
|
||||
throw new Error(`DashScope 查询任务失败: ${response.statusCode} - ${response.content}`);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(response.content) as unknown;
|
||||
} catch (e: unknown) {
|
||||
throw new Error(`解析 DashScope 查询任务响应失败: ${getErrorMessage(e)}`);
|
||||
}
|
||||
|
||||
const output = isRecord(parsed) && isRecord(parsed["output"]) ? (parsed["output"] as Record<string, unknown>) : null;
|
||||
const taskStatus = output && typeof output["task_status"] === "string" ? String(output["task_status"]) : "";
|
||||
|
||||
if (taskStatus === "SUCCEEDED") {
|
||||
const results = output ? output["results"] : null;
|
||||
const first = Array.isArray(results) && results.length > 0 ? results[0] : null;
|
||||
const url = isRecord(first) ? first["url"] : undefined;
|
||||
if ((typeof url !== "string" && typeof url !== "number") || String(url).trim().length === 0) {
|
||||
throw new Error(`任务已完成但未找到图片 URL: ${response.content}`);
|
||||
}
|
||||
return { image_url: String(url), task_status: taskStatus };
|
||||
}
|
||||
|
||||
if (taskStatus === "FAILED") {
|
||||
throw new Error(`任务失败: ${response.content}`);
|
||||
}
|
||||
|
||||
if (attempts % 5 === 0) {
|
||||
console.log(`任务状态: ${taskStatus || "UNKNOWN"},继续等待...`);
|
||||
}
|
||||
|
||||
await Tools.System.sleep(pollIntervalMs);
|
||||
}
|
||||
|
||||
throw new Error(`任务超时: 等待超过${Math.ceil(maxWaitTimeMs / 60000)}分钟仍未完成`);
|
||||
}
|
||||
|
||||
async function draw_image(params: {
|
||||
prompt: string;
|
||||
model?: string;
|
||||
size?: string;
|
||||
n?: number;
|
||||
negative_prompt?: string;
|
||||
prompt_extend?: boolean;
|
||||
watermark?: boolean;
|
||||
file_name?: string;
|
||||
api_base_url?: string;
|
||||
poll_interval_ms?: number;
|
||||
max_wait_time_ms?: number;
|
||||
}) {
|
||||
if (!params || !params.prompt || params.prompt.trim().length === 0) {
|
||||
throw new Error("参数 prompt 不能为空。");
|
||||
}
|
||||
|
||||
const prompt = params.prompt.trim();
|
||||
|
||||
await ensureDirectories();
|
||||
|
||||
const createResult = await createTask({
|
||||
prompt,
|
||||
model: params.model,
|
||||
size: params.size,
|
||||
n: params.n,
|
||||
negative_prompt: params.negative_prompt,
|
||||
prompt_extend: params.prompt_extend,
|
||||
watermark: params.watermark,
|
||||
api_base_url: params.api_base_url
|
||||
});
|
||||
|
||||
const pollResult = await pollTask({
|
||||
task_id: createResult.task_id,
|
||||
api_base_url: params.api_base_url,
|
||||
poll_interval_ms: params.poll_interval_ms,
|
||||
max_wait_time_ms: params.max_wait_time_ms
|
||||
});
|
||||
|
||||
const ext = guessExtensionFromUrl(pollResult.image_url);
|
||||
const baseName = buildFileName(prompt, params.file_name);
|
||||
const filePath = `${DRAWS_DIR}/${baseName}.${ext}`;
|
||||
|
||||
const downloadResult = await Tools.Files.download(pollResult.image_url, filePath);
|
||||
if (!downloadResult.successful) {
|
||||
throw new Error(`下载图片失败: ${downloadResult.details}`);
|
||||
}
|
||||
|
||||
const fileUri = `file://${filePath}`;
|
||||
const markdown = ``;
|
||||
|
||||
const hintLines: string[] = [];
|
||||
hintLines.push(`图片已生成并保存在本地 ${DRAWS_DIR}。`);
|
||||
hintLines.push(`本地路径: ${filePath}`);
|
||||
hintLines.push("");
|
||||
hintLines.push("在后续回答中,请直接输出下面这一行 Markdown 来展示这张图片:");
|
||||
hintLines.push("");
|
||||
hintLines.push(markdown);
|
||||
|
||||
return {
|
||||
file_path: filePath,
|
||||
file_uri: fileUri,
|
||||
markdown,
|
||||
prompt,
|
||||
model: createResult.effective_model,
|
||||
task_id: createResult.task_id,
|
||||
task_status: pollResult.task_status,
|
||||
image_url: pollResult.image_url,
|
||||
hint: hintLines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
async function draw_image_wrapper(params: {
|
||||
prompt: string;
|
||||
model?: string;
|
||||
size?: string;
|
||||
n?: number;
|
||||
negative_prompt?: string;
|
||||
prompt_extend?: boolean;
|
||||
watermark?: boolean;
|
||||
file_name?: string;
|
||||
api_base_url?: string;
|
||||
poll_interval_ms?: number;
|
||||
max_wait_time_ms?: number;
|
||||
}) {
|
||||
try {
|
||||
const result = await draw_image(params);
|
||||
complete({
|
||||
success: true,
|
||||
message: `图片生成成功,已保存到 ${DRAWS_DIR},并返回 Markdown 图片提示。`,
|
||||
data: result
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error("draw_image 执行失败:", error);
|
||||
complete({
|
||||
success: false,
|
||||
message: `图片生成失败: ${getErrorMessage(error)}`,
|
||||
error_stack: getErrorStack(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
draw_image: draw_image_wrapper
|
||||
};
|
||||
})();
|
||||
|
||||
exports.draw_image = qwenDraw.draw_image;
|
||||
@@ -0,0 +1,794 @@
|
||||
/* METADATA
|
||||
{
|
||||
name: code_reader
|
||||
|
||||
display_name: {
|
||||
zh: "代码读取器"
|
||||
en: "Code Reader"
|
||||
}
|
||||
description: { zh: "提供代码匹配抓取功能", en: "Provide code searching and extraction utilities." },
|
||||
enabledByDefault: true
|
||||
|
||||
category: "File"
|
||||
tools: [
|
||||
{
|
||||
name: search_code_in_folder
|
||||
description: { zh: "在文件夹中搜索匹配的代码", en: "Search for matching code in a folder." }
|
||||
parameters: [
|
||||
{
|
||||
name: folder_path
|
||||
description: { zh: "要搜索的文件夹路径", en: "Folder path to search." }
|
||||
type: string
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: pattern
|
||||
description: { zh: "要搜索的代码模式(字符串或正则表达式)", en: "Pattern to search for (string or regular expression)." }
|
||||
type: string
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: file_extensions
|
||||
description: { zh: "要搜索的文件扩展名列表(如 \".ts,.js\")", en: "File extension list to include (e.g. \".ts,.js\")." }
|
||||
type: string
|
||||
required: false
|
||||
},
|
||||
{
|
||||
name: recursive
|
||||
description: { zh: "是否递归搜索子文件夹(true/false)", en: "Whether to search subfolders recursively (true/false)." }
|
||||
type: string
|
||||
required: false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: extract_functions
|
||||
description: { zh: "提取文件夹中的所有函数定义", en: "Extract all function definitions from a folder." }
|
||||
parameters: [
|
||||
{
|
||||
name: folder_path
|
||||
description: { zh: "要搜索的文件夹路径", en: "Folder path to search." }
|
||||
type: string
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: function_name_pattern
|
||||
description: { zh: "函数名匹配模式(可选)", en: "Function name matching pattern (optional)." }
|
||||
type: string
|
||||
required: false
|
||||
},
|
||||
{
|
||||
name: file_extensions
|
||||
description: { zh: "要搜索的文件扩展名列表(如 \".ts,.js\")", en: "File extension list to include (e.g. \".ts,.js\")." }
|
||||
type: string
|
||||
required: false
|
||||
},
|
||||
{
|
||||
name: recursive
|
||||
description: { zh: "是否递归搜索子文件夹(true/false)", en: "Whether to search subfolders recursively (true/false)." }
|
||||
type: string
|
||||
required: false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: extract_function_block
|
||||
description: { zh: "提取特定函数的代码块", en: "Extract the code block for a specific function." }
|
||||
parameters: [
|
||||
{
|
||||
name: file_path
|
||||
description: { zh: "要读取的文件路径", en: "File path to read." }
|
||||
type: string
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: function_name
|
||||
description: { zh: "要提取的函数名称", en: "Function name to extract." }
|
||||
type: string
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: include_line_numbers
|
||||
description: { zh: "是否在结果中包含行号(true/false)", en: "Whether to include line numbers in the result (true/false)." }
|
||||
type: string
|
||||
required: false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: find_code_blocks
|
||||
description: { zh: "查找文件中的代码块", en: "Find code blocks in a file." }
|
||||
parameters: [
|
||||
{
|
||||
name: file_path
|
||||
description: { zh: "要读取的文件路径", en: "File path to read." }
|
||||
type: string
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: block_pattern
|
||||
description: { zh: "代码块匹配模式(如类定义、接口等)", en: "Code block matching pattern (e.g., class definition, interface, etc.)." }
|
||||
type: string
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: include_line_numbers
|
||||
description: { zh: "是否在结果中包含行号(true/false)", en: "Whether to include line numbers in the result (true/false)." }
|
||||
type: string
|
||||
required: false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: read_regex_matches
|
||||
description: { zh: "读取文件中匹配正则表达式的内容", en: "Read content in a file that matches a regular expression." }
|
||||
parameters: [
|
||||
{
|
||||
name: file_path
|
||||
description: { zh: "要读取的文件路径", en: "File path to read." }
|
||||
type: string
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: regex
|
||||
description: { zh: "正则表达式字符串", en: "Regular expression string." }
|
||||
type: string
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: include_line_numbers
|
||||
description: { zh: "是否在结果中包含行号(true/false)", en: "Whether to include line numbers in the result (true/false)." }
|
||||
type: string
|
||||
required: false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}*/
|
||||
|
||||
/**
|
||||
* Writer模块 - 提供代码抓取和分析功能
|
||||
*/
|
||||
const writer = (function () {
|
||||
|
||||
/**
|
||||
* 参数类型转换函数 - 将输入参数转换为期望的数据类型
|
||||
* @param params 输入参数对象
|
||||
* @param paramTypes 参数类型定义
|
||||
* @returns 转换后的参数对象
|
||||
*/
|
||||
function convertParamTypes(params: Record<string, any>, paramTypes: Record<string, string>): Record<string, any> {
|
||||
if (!params || !paramTypes) return params;
|
||||
|
||||
const result: Record<string, any> = {};
|
||||
|
||||
for (const key in params) {
|
||||
if (params[key] === undefined || params[key] === undefined) {
|
||||
result[key] = params[key];
|
||||
continue;
|
||||
}
|
||||
|
||||
const expectedType = paramTypes[key];
|
||||
if (!expectedType) {
|
||||
// 如果没有指定类型,保持原样
|
||||
result[key] = params[key];
|
||||
continue;
|
||||
}
|
||||
|
||||
// 获取参数值
|
||||
const value = params[key];
|
||||
|
||||
try {
|
||||
switch (expectedType.toLowerCase()) {
|
||||
case 'number':
|
||||
// 将字符串转换为数字
|
||||
if (typeof value === 'string') {
|
||||
if (value.includes('.')) {
|
||||
result[key] = parseFloat(value);
|
||||
} else {
|
||||
result[key] = parseInt(value, 10);
|
||||
}
|
||||
|
||||
// 检查转换结果是否为有效数字
|
||||
if (isNaN(result[key])) {
|
||||
throw new Error(`参数 ${key} 无法转换为数字: ${value}`);
|
||||
}
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'boolean':
|
||||
// 将字符串转换为布尔值
|
||||
if (typeof value === 'string') {
|
||||
const lowerValue = value.toLowerCase();
|
||||
if (lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes') {
|
||||
result[key] = true;
|
||||
} else if (lowerValue === 'false' || lowerValue === '0' || lowerValue === 'no') {
|
||||
result[key] = false;
|
||||
} else {
|
||||
throw new Error(`参数 ${key} 无法转换为布尔值: ${value}`);
|
||||
}
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'array':
|
||||
// 将字符串转换为数组
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
result[key] = JSON.parse(value);
|
||||
if (!Array.isArray(result[key])) {
|
||||
throw new Error('解析结果不是数组');
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error(`参数 ${key} 无法转换为数组: ${value}`);
|
||||
}
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'object':
|
||||
// 将字符串转换为对象
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
result[key] = JSON.parse(value);
|
||||
if (Array.isArray(result[key]) || typeof result[key] !== 'object') {
|
||||
throw new Error('解析结果不是对象');
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error(`参数 ${key} 无法转换为对象: ${value}`);
|
||||
}
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// 其他类型或未指定类型,保持原样
|
||||
result[key] = value;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`参数类型转换错误: ${error.message}`);
|
||||
// 转换失败时保留原始值
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 包装函数 - 统一处理所有Writer函数的返回结果
|
||||
* @param func 原始函数
|
||||
* @param params 函数参数
|
||||
* @param successMessage 成功消息
|
||||
* @param failMessage 失败消息
|
||||
*/
|
||||
async function writer_wrap<T>(
|
||||
func: (params: any) => Promise<any>,
|
||||
params: any,
|
||||
successMessage: string,
|
||||
failMessage: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
console.log(`开始执行函数: ${func.name || '匿名函数'}`);
|
||||
console.log(`参数:`, JSON.stringify(params, undefined, 2));
|
||||
|
||||
// 执行原始函数
|
||||
const result = await func(params);
|
||||
|
||||
console.log(`函数 ${func.name || '匿名函数'} 执行结果:`, JSON.stringify(result, undefined, 2));
|
||||
|
||||
// 如果原始函数已经调用了complete,就不需要再次调用
|
||||
if (result === undefined) return;
|
||||
|
||||
// 根据结果类型处理
|
||||
if (typeof result === "boolean") {
|
||||
// 布尔类型结果
|
||||
complete({
|
||||
success: result,
|
||||
message: result ? successMessage : failMessage
|
||||
});
|
||||
} else {
|
||||
// 数据类型结果
|
||||
complete({
|
||||
success: true,
|
||||
message: successMessage,
|
||||
data: result
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// 详细记录错误信息
|
||||
console.error(`函数 ${func.name || '匿名函数'} 执行失败!`);
|
||||
console.error(`错误信息: ${error.message}`);
|
||||
console.error(`错误堆栈: ${error.stack}`);
|
||||
|
||||
// 处理错误
|
||||
complete({
|
||||
success: false,
|
||||
message: `${failMessage}: ${error.message}`,
|
||||
error_stack: error.stack
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归读取目录中的所有文件
|
||||
* @param dir 目录路径
|
||||
* @param fileList 文件列表累加器
|
||||
* @param extensions 文件扩展名过滤列表
|
||||
* @returns 文件路径列表
|
||||
*/
|
||||
async function readFilesRecursively(dir: string, fileList: string[] = [], extensions?: string[]): Promise<string[]> {
|
||||
try {
|
||||
// 使用Tools.Files.list替代fs.readdirSync
|
||||
const listResult = await Tools.Files.list(dir);
|
||||
const entries = listResult.entries || [];
|
||||
|
||||
// 遍历目录条目
|
||||
for (const entry of entries) {
|
||||
const filePath = `${dir}/${entry.name}`;
|
||||
|
||||
if (entry.isDirectory) {
|
||||
// 递归处理子目录
|
||||
await readFilesRecursively(filePath, fileList, extensions);
|
||||
} else {
|
||||
// 过滤文件扩展名
|
||||
if (!extensions || extensions.length === 0 ||
|
||||
extensions.some(ext => entry.name.toLowerCase().endsWith(ext.toLowerCase()))) {
|
||||
fileList.push(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`读取目录失败: ${error.message}`);
|
||||
}
|
||||
|
||||
return fileList;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在文件夹中搜索匹配的代码
|
||||
* @param params 搜索参数
|
||||
* @returns 搜索结果
|
||||
*/
|
||||
async function search_code_in_folder(params: any): Promise<any> {
|
||||
const paramTypes = {
|
||||
recursive: 'boolean',
|
||||
};
|
||||
|
||||
params = convertParamTypes(params, paramTypes);
|
||||
|
||||
const {
|
||||
folder_path,
|
||||
pattern,
|
||||
file_extensions = '.js,.ts,.jsx,.tsx,.java,.cs,.py',
|
||||
recursive = true
|
||||
} = params;
|
||||
|
||||
// 验证必需参数
|
||||
if (!folder_path) {
|
||||
throw new Error('文件夹路径不能为空');
|
||||
}
|
||||
if (!pattern) {
|
||||
throw new Error('搜索模式不能为空');
|
||||
}
|
||||
|
||||
try {
|
||||
const patternRegex = new RegExp(pattern, 'gm');
|
||||
const extensions = file_extensions.split(',');
|
||||
let files: string[];
|
||||
|
||||
// 根据recursive参数决定是否递归搜索
|
||||
if (recursive) {
|
||||
files = await readFilesRecursively(folder_path, [], extensions);
|
||||
} else {
|
||||
// 使用Tools.Files.list替代fs.readdirSync
|
||||
const listResult = await Tools.Files.list(folder_path);
|
||||
const entries = listResult.entries || [];
|
||||
|
||||
files = entries
|
||||
.filter(entry =>
|
||||
!entry.isDirectory &&
|
||||
extensions.some(ext => entry.name.toLowerCase().endsWith(ext.toLowerCase()))
|
||||
)
|
||||
.map(entry => `${folder_path}/${entry.name}`);
|
||||
}
|
||||
|
||||
// 搜索结果
|
||||
const results: any[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
// 使用Tools.Files.read替代fs.readFileSync
|
||||
const fileResult = await Tools.Files.read(file);
|
||||
const content = fileResult.content || '';
|
||||
const matches: any[] = [];
|
||||
let match;
|
||||
|
||||
// 使用正则表达式查找所有匹配项
|
||||
patternRegex.lastIndex = 0; // 重置正则表达式索引
|
||||
while ((match = patternRegex.exec(content)) !== undefined) {
|
||||
const lineNumber = content.substring(0, match.index).split('\n').length;
|
||||
const lineContent = content.split('\n')[lineNumber - 1];
|
||||
|
||||
matches.push({
|
||||
match: match[0],
|
||||
line: lineNumber,
|
||||
content: lineContent
|
||||
});
|
||||
}
|
||||
|
||||
if (matches.length > 0) {
|
||||
results.push({
|
||||
file: file,
|
||||
matches: matches
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`处理文件 ${file} 时出错: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
throw new Error(`搜索代码时出错: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取文件夹中的所有函数定义
|
||||
* @param params 提取参数
|
||||
* @returns 提取结果
|
||||
*/
|
||||
async function extract_functions(params: any): Promise<any> {
|
||||
const paramTypes = {
|
||||
recursive: 'boolean',
|
||||
};
|
||||
|
||||
params = convertParamTypes(params, paramTypes);
|
||||
|
||||
const {
|
||||
folder_path,
|
||||
function_name_pattern = '.*',
|
||||
file_extensions = '.js,.ts,.jsx,.tsx',
|
||||
recursive = true
|
||||
} = params;
|
||||
|
||||
// 验证必需参数
|
||||
if (!folder_path) {
|
||||
throw new Error('文件夹路径不能为空');
|
||||
}
|
||||
|
||||
try {
|
||||
// 函数定义的正则表达式模式
|
||||
// 匹配函数声明、函数表达式、箭头函数、类方法等
|
||||
const functionPatterns = [
|
||||
// 函数声明: function name() {}
|
||||
`function\\s+(${function_name_pattern})\\s*\\([^)]*\\)\\s*\\{`,
|
||||
// 函数表达式: const name = function() {}
|
||||
`(?:const|let|var)\\s+(${function_name_pattern})\\s*=\\s*function\\s*\\([^)]*\\)\\s*\\{`,
|
||||
// 箭头函数: const name = () => {}
|
||||
`(?:const|let|var)\\s+(${function_name_pattern})\\s*=\\s*\\([^)]*\\)\\s*=>\\s*\\{`,
|
||||
// 类方法: name() {}
|
||||
`(?:async\\s+)?(${function_name_pattern})\\s*\\([^)]*\\)\\s*\\{`
|
||||
];
|
||||
|
||||
// 合并所有模式
|
||||
const combinedPattern = functionPatterns.join('|');
|
||||
|
||||
// 使用通用代码搜索功能查找所有函数
|
||||
return await search_code_in_folder({
|
||||
folder_path,
|
||||
pattern: combinedPattern,
|
||||
file_extensions,
|
||||
recursive
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`提取函数时出错: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取特定函数的代码块
|
||||
* @param params 提取参数
|
||||
* @returns 提取结果
|
||||
*/
|
||||
async function extract_function_block(params: any): Promise<any> {
|
||||
const paramTypes = {
|
||||
include_line_numbers: 'boolean',
|
||||
};
|
||||
|
||||
params = convertParamTypes(params, paramTypes);
|
||||
|
||||
const {
|
||||
file_path,
|
||||
function_name,
|
||||
include_line_numbers = false
|
||||
} = params;
|
||||
|
||||
// 验证必需参数
|
||||
if (!file_path) {
|
||||
throw new Error('文件路径不能为空');
|
||||
}
|
||||
if (!function_name) {
|
||||
throw new Error('函数名称不能为空');
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用Tools.Files.read替代fs.readFileSync
|
||||
const fileResult = await Tools.Files.read(file_path);
|
||||
const content = fileResult.content || '';
|
||||
const lines = content.split('\n');
|
||||
|
||||
// 函数定义的正则表达式模式
|
||||
const functionPatterns = [
|
||||
// 函数声明: function name() {}
|
||||
`function\\s+${function_name}\\s*\\([^)]*\\)\\s*\\{`,
|
||||
// 函数表达式: const name = function() {}
|
||||
`(?:const|let|var)\\s+${function_name}\\s*=\\s*function\\s*\\([^)]*\\)\\s*\\{`,
|
||||
// 箭头函数: const name = () => {}
|
||||
`(?:const|let|var)\\s+${function_name}\\s*=\\s*\\([^)]*\\)\\s*=>\\s*\\{`,
|
||||
// 类方法: name() {}
|
||||
`(?:async\\s+)?${function_name}\\s*\\([^)]*\\)\\s*\\{`
|
||||
];
|
||||
|
||||
// 合并所有模式
|
||||
const combinedPattern = functionPatterns.join('|');
|
||||
const functionRegex = new RegExp(combinedPattern);
|
||||
|
||||
// 查找函数定义行
|
||||
let startLine = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (functionRegex.test(lines[i])) {
|
||||
startLine = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (startLine === -1) {
|
||||
return { error: `未找到函数: ${function_name}` };
|
||||
}
|
||||
|
||||
// 追踪括号匹配,找到函数结束位置
|
||||
let bracesCount = 0;
|
||||
let endLine = startLine;
|
||||
|
||||
// 检查函数定义那一行的花括号
|
||||
const openingBraces = (lines[startLine].match(/{/g) || []).length;
|
||||
const closingBraces = (lines[startLine].match(/}/g) || []).length;
|
||||
bracesCount = openingBraces - closingBraces;
|
||||
|
||||
// 继续向下查找匹配的闭合括号
|
||||
for (let i = startLine + 1; i < lines.length; i++) {
|
||||
const openBraces = (lines[i].match(/{/g) || []).length;
|
||||
const closeBraces = (lines[i].match(/}/g) || []).length;
|
||||
|
||||
bracesCount += openBraces;
|
||||
bracesCount -= closeBraces;
|
||||
|
||||
// 当括号计数归零,找到函数结束位置
|
||||
if (bracesCount === 0) {
|
||||
endLine = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 提取函数代码块
|
||||
const functionBlock = lines.slice(startLine, endLine + 1).join('\n');
|
||||
|
||||
const result: any = {
|
||||
function_name,
|
||||
code: functionBlock,
|
||||
};
|
||||
|
||||
if (include_line_numbers) {
|
||||
result.start_line = startLine + 1; // 转换为1-based索引
|
||||
result.end_line = endLine + 1;
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`提取函数代码块时出错: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找文件中的代码块
|
||||
* @param params 查找参数
|
||||
* @returns 查找结果
|
||||
*/
|
||||
async function find_code_blocks(params: any): Promise<any> {
|
||||
const paramTypes = {
|
||||
include_line_numbers: 'boolean',
|
||||
};
|
||||
|
||||
params = convertParamTypes(params, paramTypes);
|
||||
|
||||
const {
|
||||
file_path,
|
||||
block_pattern,
|
||||
include_line_numbers = false
|
||||
} = params;
|
||||
|
||||
// 验证必需参数
|
||||
if (!file_path) {
|
||||
throw new Error('文件路径不能为空');
|
||||
}
|
||||
if (!block_pattern) {
|
||||
throw new Error('代码块匹配模式不能为空');
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用Tools.Files.read替代fs.readFileSync
|
||||
const fileResult = await Tools.Files.read(file_path);
|
||||
const content = fileResult.content || '';
|
||||
const blockRegex = new RegExp(block_pattern, 'gm');
|
||||
|
||||
const matches: any[] = [];
|
||||
let match;
|
||||
|
||||
// 使用正则表达式查找所有匹配项
|
||||
while ((match = blockRegex.exec(content)) !== undefined) {
|
||||
const matchText = match[0];
|
||||
const startIndex = match.index;
|
||||
|
||||
// 查找代码块的开始和结束位置
|
||||
const lines = content.substring(0, startIndex).split('\n');
|
||||
const startLine = lines.length;
|
||||
|
||||
let endIndex = startIndex + matchText.length;
|
||||
let bracesCount = 0;
|
||||
|
||||
// 如果匹配到的是代码块开始,需要找到对应的结束
|
||||
if (matchText.includes('{')) {
|
||||
// 计算初始左括号数量
|
||||
const initialOpenBraces = (matchText.match(/{/g) || []).length;
|
||||
const initialCloseBraces = (matchText.match(/}/g) || []).length;
|
||||
bracesCount = initialOpenBraces - initialCloseBraces;
|
||||
|
||||
// 如果括号不平衡,继续向后查找
|
||||
if (bracesCount > 0) {
|
||||
const remainingContent = content.substring(endIndex);
|
||||
let currentPos = 0;
|
||||
|
||||
// 逐字符扫描,跟踪括号嵌套
|
||||
for (let i = 0; i < remainingContent.length; i++) {
|
||||
const char = remainingContent[i];
|
||||
|
||||
if (char === '{') {
|
||||
bracesCount++;
|
||||
} else if (char === '}') {
|
||||
bracesCount--;
|
||||
|
||||
// 找到匹配的闭合括号
|
||||
if (bracesCount === 0) {
|
||||
currentPos = i + 1; // +1 包含闭合括号
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新块的结束位置
|
||||
endIndex += currentPos;
|
||||
}
|
||||
}
|
||||
|
||||
// 提取完整的代码块
|
||||
const codeBlock = content.substring(startIndex, endIndex);
|
||||
|
||||
// 确定结束行号
|
||||
const blockLines = codeBlock.split('\n');
|
||||
const endLine = startLine + blockLines.length - 1;
|
||||
|
||||
const result: any = {
|
||||
block: codeBlock,
|
||||
};
|
||||
|
||||
if (include_line_numbers) {
|
||||
result.start_line = startLine;
|
||||
result.end_line = endLine;
|
||||
}
|
||||
|
||||
matches.push(result);
|
||||
}
|
||||
|
||||
return {
|
||||
file: file_path,
|
||||
blocks: matches
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`查找代码块时出错: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件中匹配正则表达式的内容
|
||||
* @param params 读取参数
|
||||
* @returns 读取结果
|
||||
*/
|
||||
async function read_regex_matches(params: any): Promise<any> {
|
||||
const paramTypes = {
|
||||
include_line_numbers: 'boolean',
|
||||
};
|
||||
|
||||
params = convertParamTypes(params, paramTypes);
|
||||
|
||||
const {
|
||||
file_path,
|
||||
regex,
|
||||
include_line_numbers = false
|
||||
} = params;
|
||||
|
||||
// 验证必需参数
|
||||
if (!file_path) {
|
||||
throw new Error('文件路径不能为空');
|
||||
}
|
||||
if (!regex) {
|
||||
throw new Error('正则表达式不能为空');
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用Tools.Files.read替代fs.readFileSync
|
||||
const fileResult = await Tools.Files.read(file_path);
|
||||
const content = fileResult.content || '';
|
||||
const lines = content.split('\n');
|
||||
const pattern = new RegExp(regex, 'gm');
|
||||
|
||||
const matches: any[] = [];
|
||||
let match;
|
||||
|
||||
// 使用正则表达式查找所有匹配项
|
||||
while ((match = pattern.exec(content)) !== undefined) {
|
||||
const lineNumber = content.substring(0, match.index).split('\n').length;
|
||||
const lineContent = content.split('\n')[lineNumber - 1];
|
||||
|
||||
const result: any = {
|
||||
match: match[0],
|
||||
content: lineContent,
|
||||
};
|
||||
|
||||
if (include_line_numbers) {
|
||||
result.line = lineNumber;
|
||||
}
|
||||
|
||||
// 添加捕获组
|
||||
if (match.length > 1) {
|
||||
result.groups = match.slice(1);
|
||||
}
|
||||
|
||||
matches.push(result);
|
||||
}
|
||||
|
||||
return {
|
||||
file: file_path,
|
||||
matches: matches
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(`匹配正则表达式时出错: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 返回模块公共方法
|
||||
return {
|
||||
search_code_in_folder: (params: any) => writer_wrap(search_code_in_folder, params, '代码搜索完成', '代码搜索失败'),
|
||||
extract_functions: (params: any) => writer_wrap(extract_functions, params, '函数提取完成', '函数提取失败'),
|
||||
extract_function_block: (params: any) => writer_wrap(extract_function_block, params, '函数代码块提取完成', '函数代码块提取失败'),
|
||||
find_code_blocks: (params: any) => writer_wrap(find_code_blocks, params, '代码块查找完成', '代码块查找失败'),
|
||||
read_regex_matches: (params: any) => writer_wrap(read_regex_matches, params, '正则表达式匹配完成', '正则表达式匹配失败')
|
||||
};
|
||||
})();
|
||||
|
||||
// 导出所有函数
|
||||
exports.search_code_in_folder = writer.search_code_in_folder;
|
||||
exports.extract_functions = writer.extract_functions;
|
||||
exports.extract_function_block = writer.extract_function_block;
|
||||
exports.find_code_blocks = writer.find_code_blocks;
|
||||
exports.read_regex_matches = writer.read_regex_matches;
|
||||
@@ -0,0 +1,668 @@
|
||||
/* METADATA
|
||||
{
|
||||
"name": "siliconflow_draw",
|
||||
"display_name": {
|
||||
"zh": "硅基流动绘图",
|
||||
"en": "SiliconFlow Draw"
|
||||
},
|
||||
"description": {
|
||||
"zh": "使用 SiliconFlow 官方图像与视频接口生成图片和视频。图片走 /v1/images/generations,视频走 /v1/video/submit + /v1/video/status;生成结果会立即下载到本地,避免官方临时链接过期。",
|
||||
"en": "Generate images and videos with SiliconFlow official image and video APIs. Images use /v1/images/generations; videos use /v1/video/submit + /v1/video/status. Generated assets are downloaded locally immediately before temporary URLs expire."
|
||||
},
|
||||
"category": "Draw",
|
||||
"env": [
|
||||
{
|
||||
"name": "SILICONFLOW_API_KEY",
|
||||
"description": {
|
||||
"zh": "SiliconFlow API Key(必填)",
|
||||
"en": "SiliconFlow API key (required)"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "SILICONFLOW_API_BASE_URL",
|
||||
"description": {
|
||||
"zh": "SiliconFlow API Base URL(可选,不填默认 https://api.siliconflow.cn )",
|
||||
"en": "SiliconFlow API base URL (optional; defaults to https://api.siliconflow.cn)"
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "SILICONFLOW_IMAGE_MODEL",
|
||||
"description": {
|
||||
"zh": "默认生图模型(可选;例如 Kwai-Kolors/Kolors、Qwen/Qwen-Image)",
|
||||
"en": "Default image model (optional), e.g. Kwai-Kolors/Kolors or Qwen/Qwen-Image"
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "SILICONFLOW_VIDEO_MODEL",
|
||||
"description": {
|
||||
"zh": "默认生视频模型(可选;例如 Wan-AI/Wan2.2-T2V-A14B 或 Wan-AI/Wan2.2-I2V-A14B)",
|
||||
"en": "Default video model (optional), e.g. Wan-AI/Wan2.2-T2V-A14B or Wan-AI/Wan2.2-I2V-A14B"
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"name": "draw_image",
|
||||
"description": {
|
||||
"zh": "调用 SiliconFlow 官方图片生成接口生图,下载到本地并返回 Markdown 图片提示。",
|
||||
"en": "Generate images with SiliconFlow official image API, download locally, and return Markdown image hints."
|
||||
},
|
||||
"parameters": [
|
||||
{ "name": "prompt", "description": { "zh": "生图提示词", "en": "Image prompt" }, "type": "string", "required": true },
|
||||
{ "name": "model", "description": { "zh": "模型名;不传则优先取 SILICONFLOW_IMAGE_MODEL,再取默认值 Kwai-Kolors/Kolors", "en": "Model name; falls back to SILICONFLOW_IMAGE_MODEL, then Kwai-Kolors/Kolors" }, "type": "string", "required": false },
|
||||
{ "name": "image_size", "description": { "zh": "图片尺寸,例如 1024x1024;未传时默认 1024x1024", "en": "Image size, e.g. 1024x1024; defaults to 1024x1024" }, "type": "string", "required": false },
|
||||
{ "name": "negative_prompt", "description": { "zh": "负面提示词(可选)", "en": "Negative prompt (optional)" }, "type": "string", "required": false },
|
||||
{ "name": "batch_size", "description": { "zh": "一次生成多少张图(可选)", "en": "Number of images to generate (optional)" }, "type": "number", "required": false },
|
||||
{ "name": "num_inference_steps", "description": { "zh": "推理步数(可选)", "en": "Inference steps (optional)" }, "type": "number", "required": false },
|
||||
{ "name": "guidance_scale", "description": { "zh": "提示词引导强度(可选)", "en": "Guidance scale (optional)" }, "type": "number", "required": false },
|
||||
{ "name": "seed", "description": { "zh": "随机种子(可选)", "en": "Seed (optional)" }, "type": "number", "required": false },
|
||||
{ "name": "file_name", "description": { "zh": "本地保存文件名(不含扩展名)", "en": "Local output filename without extension" }, "type": "string", "required": false },
|
||||
{ "name": "api_base_url", "description": { "zh": "自定义 API Base URL(可选)", "en": "Custom API base URL (optional)" }, "type": "string", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "draw_video",
|
||||
"description": {
|
||||
"zh": "调用 SiliconFlow 官方视频生成接口生视频,轮询状态,下载到本地并返回本地视频链接提示。",
|
||||
"en": "Generate videos with SiliconFlow official video API, poll status, download locally, and return local video link hints."
|
||||
},
|
||||
"parameters": [
|
||||
{ "name": "prompt", "description": { "zh": "生视频提示词", "en": "Video prompt" }, "type": "string", "required": true },
|
||||
{ "name": "model", "description": { "zh": "模型名;不传则优先取 SILICONFLOW_VIDEO_MODEL,若传了 image_url/image_path 默认走 Wan-AI/Wan2.2-I2V-A14B,否则默认 Wan-AI/Wan2.2-T2V-A14B", "en": "Model name; falls back to SILICONFLOW_VIDEO_MODEL. If image_url/image_path is provided, defaults to Wan-AI/Wan2.2-I2V-A14B; otherwise Wan-AI/Wan2.2-T2V-A14B" }, "type": "string", "required": false },
|
||||
{ "name": "image_size", "description": { "zh": "视频尺寸,可选 1280x720、720x1280、960x960;默认 1280x720", "en": "Video size: 1280x720, 720x1280, or 960x960; defaults to 1280x720" }, "type": "string", "required": false },
|
||||
{ "name": "negative_prompt", "description": { "zh": "负面提示词(可选)", "en": "Negative prompt (optional)" }, "type": "string", "required": false },
|
||||
{ "name": "image_url", "description": { "zh": "图生视频输入图 URL(可选)", "en": "Input image URL for image-to-video (optional)" }, "type": "string", "required": false },
|
||||
{ "name": "image_path", "description": { "zh": "图生视频输入图本地路径(可选,会转成 data URL 上传)", "en": "Local input image path for image-to-video (optional; converted to data URL)" }, "type": "string", "required": false },
|
||||
{ "name": "seed", "description": { "zh": "随机种子(可选)", "en": "Seed (optional)" }, "type": "number", "required": false },
|
||||
{ "name": "file_name", "description": { "zh": "本地保存文件名(不含扩展名)", "en": "Local output filename without extension" }, "type": "string", "required": false },
|
||||
{ "name": "api_base_url", "description": { "zh": "自定义 API Base URL(可选)", "en": "Custom API base URL (optional)" }, "type": "string", "required": false },
|
||||
{ "name": "poll_interval_ms", "description": { "zh": "轮询间隔毫秒数,默认 5000", "en": "Polling interval in ms, default 5000" }, "type": "number", "required": false },
|
||||
{ "name": "max_wait_time_ms", "description": { "zh": "最大等待毫秒数,默认 600000", "en": "Maximum wait time in ms, default 600000" }, "type": "number", "required": false }
|
||||
]
|
||||
}
|
||||
]
|
||||
}*/
|
||||
/// <reference path="./types/index.d.ts" />
|
||||
|
||||
const siliconflowDraw = (function () {
|
||||
const HTTP_TIMEOUT_MS = 600000;
|
||||
const client = OkHttp.newBuilder()
|
||||
.connectTimeout(HTTP_TIMEOUT_MS)
|
||||
.readTimeout(HTTP_TIMEOUT_MS)
|
||||
.writeTimeout(HTTP_TIMEOUT_MS)
|
||||
.build();
|
||||
|
||||
const DEFAULT_API_BASE_URL = "https://api.siliconflow.cn";
|
||||
const DEFAULT_IMAGE_MODEL = "Kwai-Kolors/Kolors";
|
||||
const DEFAULT_IMAGE_SIZE = "1024x1024";
|
||||
const DEFAULT_VIDEO_TEXT_MODEL = "Wan-AI/Wan2.2-T2V-A14B";
|
||||
const DEFAULT_VIDEO_IMAGE_MODEL = "Wan-AI/Wan2.2-I2V-A14B";
|
||||
const DEFAULT_VIDEO_SIZE = "1280x720";
|
||||
const DEFAULT_POLL_INTERVAL_MS = 5000;
|
||||
const DEFAULT_MAX_WAIT_TIME_MS = 600000;
|
||||
const VIDEO_SIZE_OPTIONS = ["1280x720", "720x1280", "960x960"];
|
||||
|
||||
const DRAW_ROOT_DIR = getPluginConfigDir("draw");
|
||||
const STORAGE_DIR = `${DRAW_ROOT_DIR}/siliconflow_draw`;
|
||||
const DRAWS_DIR = `${STORAGE_DIR}/draws`;
|
||||
const VIDEOS_DIR = `${STORAGE_DIR}/videos`;
|
||||
|
||||
type JsonMap = Record<string, any>;
|
||||
|
||||
type ImageParams = {
|
||||
prompt: string;
|
||||
model?: string;
|
||||
image_size?: string;
|
||||
negative_prompt?: string;
|
||||
batch_size?: number;
|
||||
num_inference_steps?: number;
|
||||
guidance_scale?: number;
|
||||
seed?: number;
|
||||
file_name?: string;
|
||||
api_base_url?: string;
|
||||
};
|
||||
|
||||
type VideoParams = {
|
||||
prompt: string;
|
||||
model?: string;
|
||||
image_size?: string;
|
||||
negative_prompt?: string;
|
||||
image_url?: string;
|
||||
image_path?: string;
|
||||
seed?: number;
|
||||
file_name?: string;
|
||||
api_base_url?: string;
|
||||
poll_interval_ms?: number;
|
||||
max_wait_time_ms?: number;
|
||||
};
|
||||
|
||||
type ImageApiResult = {
|
||||
effective_model: string;
|
||||
image_urls: string[];
|
||||
seed: any;
|
||||
};
|
||||
|
||||
type VideoTaskResult = {
|
||||
api_base_url: string;
|
||||
effective_model: string;
|
||||
request_id: string;
|
||||
seed: any;
|
||||
};
|
||||
|
||||
type VideoStatusResult = {
|
||||
status: string;
|
||||
reason: string;
|
||||
video_url: string;
|
||||
seed: any;
|
||||
};
|
||||
|
||||
type ImageFileItem = {
|
||||
file_path: string;
|
||||
file_uri: string;
|
||||
markdown: string;
|
||||
};
|
||||
|
||||
function getApiKey() {
|
||||
const apiKey = getEnv("SILICONFLOW_API_KEY");
|
||||
if (!apiKey) {
|
||||
throw new Error("SILICONFLOW_API_KEY 未配置,请先在环境变量中设置硅基流动 API Key。");
|
||||
}
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
function getApiBaseUrl(customBaseUrl) {
|
||||
const fromParam = String(customBaseUrl || "").trim();
|
||||
if (fromParam) return fromParam;
|
||||
|
||||
const fromEnv = String(getEnv("SILICONFLOW_API_BASE_URL") || "").trim();
|
||||
if (fromEnv) return fromEnv;
|
||||
|
||||
return DEFAULT_API_BASE_URL;
|
||||
}
|
||||
|
||||
function joinUrl(baseUrl, path) {
|
||||
const normalizedBase = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
||||
const normalizedPath = path.startsWith("/") ? path.slice(1) : path;
|
||||
return `${normalizedBase}${normalizedPath}`;
|
||||
}
|
||||
|
||||
function getImageEndpoint(baseUrl) {
|
||||
const trimmed = String(baseUrl || "").trim();
|
||||
if (!trimmed) return joinUrl(DEFAULT_API_BASE_URL, "v1/images/generations");
|
||||
if (trimmed.includes("/v1/images/generations")) return trimmed;
|
||||
if (trimmed.endsWith("/v1")) return joinUrl(trimmed, "images/generations");
|
||||
if (trimmed.endsWith("/v1/")) return joinUrl(trimmed, "images/generations");
|
||||
return joinUrl(trimmed, "v1/images/generations");
|
||||
}
|
||||
|
||||
function getVideoSubmitEndpoint(baseUrl) {
|
||||
const trimmed = String(baseUrl || "").trim();
|
||||
if (!trimmed) return joinUrl(DEFAULT_API_BASE_URL, "v1/video/submit");
|
||||
if (trimmed.includes("/v1/video/submit")) return trimmed;
|
||||
if (trimmed.endsWith("/v1")) return joinUrl(trimmed, "video/submit");
|
||||
if (trimmed.endsWith("/v1/")) return joinUrl(trimmed, "video/submit");
|
||||
return joinUrl(trimmed, "v1/video/submit");
|
||||
}
|
||||
|
||||
function getVideoStatusEndpoint(baseUrl) {
|
||||
const trimmed = String(baseUrl || "").trim();
|
||||
if (!trimmed) return joinUrl(DEFAULT_API_BASE_URL, "v1/video/status");
|
||||
if (trimmed.includes("/v1/video/status")) return trimmed;
|
||||
if (trimmed.endsWith("/v1")) return joinUrl(trimmed, "video/status");
|
||||
if (trimmed.endsWith("/v1/")) return joinUrl(trimmed, "video/status");
|
||||
return joinUrl(trimmed, "v1/video/status");
|
||||
}
|
||||
|
||||
function sanitizeFileName(name, prefix) {
|
||||
const safe = String(name || "").replace(/[\\/:*?"<>|]/g, "_").trim();
|
||||
if (!safe) return `${prefix}_${Date.now()}`;
|
||||
return safe.substring(0, 80);
|
||||
}
|
||||
|
||||
function buildFileBaseName(prompt, customName, prefix) {
|
||||
if (customName && String(customName).trim().length > 0) {
|
||||
return sanitizeFileName(customName, prefix);
|
||||
}
|
||||
const rawPrompt = String(prompt || "").trim();
|
||||
const shortPrompt = rawPrompt.length > 40 ? `${rawPrompt.substring(0, 40)}...` : rawPrompt;
|
||||
return `${sanitizeFileName(shortPrompt || prefix, prefix)}_${Date.now()}`;
|
||||
}
|
||||
|
||||
function getErrorMessage(error) {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function guessMimeTypeFromPath(filePath) {
|
||||
const lower = String(filePath || "").toLowerCase();
|
||||
if (lower.endsWith(".png")) return "image/png";
|
||||
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
||||
if (lower.endsWith(".webp")) return "image/webp";
|
||||
if (lower.endsWith(".gif")) return "image/gif";
|
||||
return "image/png";
|
||||
}
|
||||
|
||||
function guessExtensionFromUrl(url, fallbackExtension) {
|
||||
const match = String(url || "").match(/\.([a-z0-9]{2,5})(?:[?#].*)?$/i);
|
||||
if (match && match[1]) return match[1].toLowerCase();
|
||||
return fallbackExtension;
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value, fieldName) {
|
||||
if (value === undefined || value === null || value === "") return undefined;
|
||||
const numberValue = Number(value);
|
||||
if (!Number.isFinite(numberValue) || numberValue <= 0) {
|
||||
throw new Error(`${fieldName} 必须是正整数。`);
|
||||
}
|
||||
return Math.floor(numberValue);
|
||||
}
|
||||
|
||||
function parseFiniteNumber(value, fieldName) {
|
||||
if (value === undefined || value === null || value === "") return undefined;
|
||||
const numberValue = Number(value);
|
||||
if (!Number.isFinite(numberValue)) {
|
||||
throw new Error(`${fieldName} 必须是有效数字。`);
|
||||
}
|
||||
return numberValue;
|
||||
}
|
||||
|
||||
function parseSeed(value) {
|
||||
if (value === undefined || value === null || value === "") return undefined;
|
||||
const numberValue = Number(value);
|
||||
if (!Number.isFinite(numberValue)) {
|
||||
throw new Error("seed 必须是有效数字。");
|
||||
}
|
||||
return Math.floor(numberValue);
|
||||
}
|
||||
|
||||
function normalizeImageSize(imageSize, model) {
|
||||
const trimmedModel = String(model || "").trim().toLowerCase();
|
||||
const rawSize = String(imageSize || "").trim();
|
||||
const isQwenEditModel = trimmedModel.startsWith("qwen/qwen-image-edit");
|
||||
if (isQwenEditModel) {
|
||||
if (rawSize) {
|
||||
throw new Error("当前模型为 Qwen 图像编辑模型,官方文档标注不支持 image_size 参数,请去掉 image_size。");
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (!rawSize) return DEFAULT_IMAGE_SIZE;
|
||||
if (!/^\d+x\d+$/i.test(rawSize)) {
|
||||
throw new Error("image_size 格式必须是 widthxheight,例如 1024x1024。");
|
||||
}
|
||||
return rawSize.toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeVideoSize(imageSize) {
|
||||
const rawSize = String(imageSize || "").trim();
|
||||
if (!rawSize) return DEFAULT_VIDEO_SIZE;
|
||||
if (!VIDEO_SIZE_OPTIONS.includes(rawSize)) {
|
||||
throw new Error(`image_size 仅支持 ${VIDEO_SIZE_OPTIONS.join("、")}。`);
|
||||
}
|
||||
return rawSize;
|
||||
}
|
||||
|
||||
function isProbablyUrl(value) {
|
||||
return /^https?:\/\//i.test(String(value || "").trim());
|
||||
}
|
||||
|
||||
async function ensureDirectories() {
|
||||
const dirs = [DRAW_ROOT_DIR, STORAGE_DIR, DRAWS_DIR, VIDEOS_DIR];
|
||||
for (const dir of dirs) {
|
||||
try {
|
||||
const result = await Tools.Files.mkdir(dir);
|
||||
if (!result.successful) {
|
||||
console.warn(`创建目录失败(可能已存在): ${dir} -> ${result.details}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`创建目录异常: ${dir} -> ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readLocalImageAsDataUrl(filePath) {
|
||||
const trimmedPath = String(filePath || "").trim();
|
||||
if (!trimmedPath) {
|
||||
throw new Error("image_path 不能为空。");
|
||||
}
|
||||
const existsResult = await Tools.Files.exists(trimmedPath);
|
||||
if (!existsResult.exists) {
|
||||
throw new Error(`本地图片不存在: ${trimmedPath}`);
|
||||
}
|
||||
const binaryResult = await Tools.Files.readBinary(trimmedPath);
|
||||
const base64Content = binaryResult && binaryResult.contentBase64 ? String(binaryResult.contentBase64).trim() : "";
|
||||
if (!base64Content) {
|
||||
throw new Error(`读取本地图片失败: ${trimmedPath}`);
|
||||
}
|
||||
return `data:${guessMimeTypeFromPath(trimmedPath)};base64,${base64Content}`;
|
||||
}
|
||||
|
||||
async function resolveVideoImageInput(imageUrl, imagePath) {
|
||||
const trimmedUrl = String(imageUrl || "").trim();
|
||||
const trimmedPath = String(imagePath || "").trim();
|
||||
if (trimmedUrl && trimmedPath) {
|
||||
throw new Error("image_url 和 image_path 只能二选一,请不要同时传。");
|
||||
}
|
||||
if (trimmedUrl) {
|
||||
if (!isProbablyUrl(trimmedUrl)) {
|
||||
throw new Error("image_url 必须是 http 或 https 链接。");
|
||||
}
|
||||
return trimmedUrl;
|
||||
}
|
||||
if (trimmedPath) {
|
||||
return await readLocalImageAsDataUrl(trimmedPath);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function executeJsonRequest(url: string, body: JsonMap): Promise<any> {
|
||||
const apiKey = getApiKey();
|
||||
const request = client
|
||||
.newRequest()
|
||||
.url(url)
|
||||
.method("POST")
|
||||
.headers({
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
"Authorization": `Bearer ${apiKey}`
|
||||
})
|
||||
.body(JSON.stringify(body), "json");
|
||||
|
||||
const response = await request.build().execute();
|
||||
if (!response.isSuccessful()) {
|
||||
throw new Error(`HTTP ${response.statusCode}: ${response.content}`);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(response.content);
|
||||
} catch (error) {
|
||||
throw new Error(`解析响应 JSON 失败: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function callImageApi(params: ImageParams): Promise<ImageApiResult> {
|
||||
const apiBaseUrl = getApiBaseUrl(params.api_base_url);
|
||||
const endpoint = getImageEndpoint(apiBaseUrl);
|
||||
|
||||
const modelFromParam = String(params.model || "").trim();
|
||||
const modelFromEnv = String(getEnv("SILICONFLOW_IMAGE_MODEL") || "").trim();
|
||||
const effectiveModel = modelFromParam || modelFromEnv || DEFAULT_IMAGE_MODEL;
|
||||
|
||||
const body: JsonMap = {
|
||||
model: effectiveModel,
|
||||
prompt: String(params.prompt || "").trim()
|
||||
};
|
||||
|
||||
const imageSize = normalizeImageSize(params.image_size, effectiveModel);
|
||||
if (imageSize) body.image_size = imageSize;
|
||||
|
||||
const negativePrompt = String(params.negative_prompt || "").trim();
|
||||
if (negativePrompt) body.negative_prompt = negativePrompt;
|
||||
|
||||
const batchSize = parsePositiveInteger(params.batch_size, "batch_size");
|
||||
if (batchSize !== undefined) body.batch_size = batchSize;
|
||||
|
||||
const numInferenceSteps = parsePositiveInteger(params.num_inference_steps, "num_inference_steps");
|
||||
if (numInferenceSteps !== undefined) body.num_inference_steps = numInferenceSteps;
|
||||
|
||||
const guidanceScale = parseFiniteNumber(params.guidance_scale, "guidance_scale");
|
||||
if (guidanceScale !== undefined) body.guidance_scale = guidanceScale;
|
||||
|
||||
const seed = parseSeed(params.seed);
|
||||
if (seed !== undefined) body.seed = seed;
|
||||
|
||||
const parsed: any = await executeJsonRequest(endpoint, body);
|
||||
const images = Array.isArray(parsed && parsed.images) ? parsed.images : [];
|
||||
const imageUrls = images
|
||||
.map(item => item && item.url ? String(item.url).trim() : "")
|
||||
.filter(item => item.length > 0);
|
||||
|
||||
if (imageUrls.length === 0) {
|
||||
throw new Error("图片接口返回中未找到 images[].url,请检查模型与参数是否匹配。");
|
||||
}
|
||||
|
||||
return {
|
||||
effective_model: effectiveModel,
|
||||
image_urls: imageUrls,
|
||||
seed: parsed && parsed.seed !== undefined ? parsed.seed : (seed !== undefined ? seed : null)
|
||||
};
|
||||
}
|
||||
|
||||
async function createVideoTask(params: VideoParams, imageInput?: string): Promise<VideoTaskResult> {
|
||||
const apiBaseUrl = getApiBaseUrl(params.api_base_url);
|
||||
const endpoint = getVideoSubmitEndpoint(apiBaseUrl);
|
||||
|
||||
const modelFromParam = String(params.model || "").trim();
|
||||
const modelFromEnv = String(getEnv("SILICONFLOW_VIDEO_MODEL") || "").trim();
|
||||
const defaultVideoModel = imageInput ? DEFAULT_VIDEO_IMAGE_MODEL : DEFAULT_VIDEO_TEXT_MODEL;
|
||||
const effectiveModel = modelFromParam || modelFromEnv || defaultVideoModel;
|
||||
|
||||
if (effectiveModel === DEFAULT_VIDEO_IMAGE_MODEL && !imageInput) {
|
||||
throw new Error(`当前模型 ${DEFAULT_VIDEO_IMAGE_MODEL} 为图生视频模型,必须传 image_url 或 image_path。`);
|
||||
}
|
||||
|
||||
const body: JsonMap = {
|
||||
model: effectiveModel,
|
||||
prompt: String(params.prompt || "").trim(),
|
||||
image_size: normalizeVideoSize(params.image_size)
|
||||
};
|
||||
|
||||
const negativePrompt = String(params.negative_prompt || "").trim();
|
||||
if (negativePrompt) body.negative_prompt = negativePrompt;
|
||||
|
||||
if (imageInput) body.image = imageInput;
|
||||
|
||||
const seed = parseSeed(params.seed);
|
||||
if (seed !== undefined) body.seed = seed;
|
||||
|
||||
const parsed: any = await executeJsonRequest(endpoint, body);
|
||||
const requestId = parsed && parsed.requestId ? String(parsed.requestId).trim() : "";
|
||||
if (!requestId) {
|
||||
throw new Error("视频接口返回中未找到 requestId。");
|
||||
}
|
||||
|
||||
return {
|
||||
api_base_url: apiBaseUrl,
|
||||
effective_model: effectiveModel,
|
||||
request_id: requestId,
|
||||
seed: seed !== undefined ? seed : null
|
||||
};
|
||||
}
|
||||
|
||||
async function queryVideoStatus(apiBaseUrl: string, requestId: string): Promise<VideoStatusResult> {
|
||||
const endpoint = getVideoStatusEndpoint(apiBaseUrl);
|
||||
const parsed: any = await executeJsonRequest(endpoint, { requestId });
|
||||
const status = parsed && parsed.status ? String(parsed.status).trim() : "";
|
||||
const reason = parsed && parsed.reason ? String(parsed.reason).trim() : "";
|
||||
const videos: any[] = parsed && parsed.results && Array.isArray(parsed.results.videos) ? parsed.results.videos : [];
|
||||
const videoUrl = videos.length > 0 && videos[0] && videos[0].url ? String(videos[0].url).trim() : "";
|
||||
const seed = parsed && parsed.results && parsed.results.seed !== undefined ? parsed.results.seed : null;
|
||||
return {
|
||||
status,
|
||||
reason,
|
||||
video_url: videoUrl,
|
||||
seed
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeVideoStatus(status) {
|
||||
return String(status || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
async function draw_image(params: ImageParams) {
|
||||
const prompt = String(params && params.prompt ? params.prompt : "").trim();
|
||||
if (!prompt) {
|
||||
throw new Error("prompt 不能为空。");
|
||||
}
|
||||
|
||||
await ensureDirectories();
|
||||
|
||||
const apiResult = await callImageApi(params);
|
||||
const baseName = buildFileBaseName(prompt, params.file_name, "siliconflow_image");
|
||||
const files: ImageFileItem[] = [];
|
||||
|
||||
for (let index = 0; index < apiResult.image_urls.length; index += 1) {
|
||||
const imageUrl = apiResult.image_urls[index];
|
||||
const extension = guessExtensionFromUrl(imageUrl, "png");
|
||||
const suffix = apiResult.image_urls.length > 1 ? `_${index + 1}` : "";
|
||||
const filePath = `${DRAWS_DIR}/${baseName}${suffix}.${extension}`;
|
||||
const downloadResult = await Tools.Files.download(imageUrl, filePath);
|
||||
if (!downloadResult.successful) {
|
||||
throw new Error(`下载图片失败: ${downloadResult.details}`);
|
||||
}
|
||||
const fileUri = `file://${filePath}`;
|
||||
files.push({
|
||||
file_path: filePath,
|
||||
file_uri: fileUri,
|
||||
markdown: ``
|
||||
});
|
||||
}
|
||||
|
||||
const hintLines: string[] = [];
|
||||
hintLines.push(`图片已生成并下载到 ${DRAWS_DIR}。`);
|
||||
hintLines.push(`共生成 ${files.length} 张。`);
|
||||
hintLines.push("");
|
||||
hintLines.push("后续回答如果需要展示图片,请直接输出下面这些 Markdown:");
|
||||
hintLines.push("");
|
||||
for (const fileItem of files) {
|
||||
hintLines.push(fileItem.markdown);
|
||||
}
|
||||
|
||||
return {
|
||||
prompt,
|
||||
model: apiResult.effective_model,
|
||||
seed: apiResult.seed,
|
||||
file_path: files[0].file_path,
|
||||
file_uri: files[0].file_uri,
|
||||
markdown: files[0].markdown,
|
||||
files,
|
||||
hint: hintLines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
async function draw_video(params: VideoParams) {
|
||||
const prompt = String(params && params.prompt ? params.prompt : "").trim();
|
||||
if (!prompt) {
|
||||
throw new Error("prompt 不能为空。");
|
||||
}
|
||||
|
||||
await ensureDirectories();
|
||||
|
||||
const imageInput = await resolveVideoImageInput(params.image_url, params.image_path);
|
||||
const createdTask = await createVideoTask(params, imageInput);
|
||||
const pollIntervalMs = parsePositiveInteger(params.poll_interval_ms, "poll_interval_ms") || DEFAULT_POLL_INTERVAL_MS;
|
||||
const maxWaitTimeMs = parsePositiveInteger(params.max_wait_time_ms, "max_wait_time_ms") || DEFAULT_MAX_WAIT_TIME_MS;
|
||||
const deadline = Date.now() + maxWaitTimeMs;
|
||||
|
||||
let latestStatus = "";
|
||||
let latestReason = "";
|
||||
let remoteVideoUrl = "";
|
||||
let resultSeed = createdTask.seed;
|
||||
|
||||
while (Date.now() <= deadline) {
|
||||
const statusResult = await queryVideoStatus(createdTask.api_base_url, createdTask.request_id);
|
||||
latestStatus = statusResult.status;
|
||||
latestReason = statusResult.reason;
|
||||
resultSeed = statusResult.seed !== null && statusResult.seed !== undefined ? statusResult.seed : resultSeed;
|
||||
|
||||
const normalizedStatus = normalizeVideoStatus(statusResult.status);
|
||||
if (normalizedStatus === "succeed") {
|
||||
remoteVideoUrl = statusResult.video_url;
|
||||
if (!remoteVideoUrl) {
|
||||
throw new Error("视频任务已成功,但返回中未找到 results.videos[0].url。");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (normalizedStatus === "failed") {
|
||||
throw new Error(`视频生成失败: ${latestReason || "接口未返回失败原因"}`);
|
||||
}
|
||||
|
||||
await Tools.System.sleep(pollIntervalMs);
|
||||
}
|
||||
|
||||
if (!remoteVideoUrl) {
|
||||
throw new Error(`视频生成超时,最后状态为 ${latestStatus || "未知"}${latestReason ? `,原因:${latestReason}` : ""}`);
|
||||
}
|
||||
|
||||
const baseName = buildFileBaseName(prompt, params.file_name, "siliconflow_video");
|
||||
const extension = guessExtensionFromUrl(remoteVideoUrl, "mp4");
|
||||
const filePath = `${VIDEOS_DIR}/${baseName}.${extension}`;
|
||||
const downloadResult = await Tools.Files.download(remoteVideoUrl, filePath);
|
||||
if (!downloadResult.successful) {
|
||||
throw new Error(`下载视频失败: ${downloadResult.details}`);
|
||||
}
|
||||
|
||||
const fileUri = `file://${filePath}`;
|
||||
const markdownLink = `[点击查看生成的视频](${fileUri})`;
|
||||
const htmlVideo = `<video controls src="${fileUri}"></video>`;
|
||||
const hintLines: string[] = [];
|
||||
hintLines.push(`视频已生成并下载到 ${VIDEOS_DIR}。`);
|
||||
hintLines.push(`本地路径: ${filePath}`);
|
||||
hintLines.push("");
|
||||
hintLines.push("后续回答如果要给出视频,请优先返回这个本地链接:");
|
||||
hintLines.push(markdownLink);
|
||||
hintLines.push("");
|
||||
hintLines.push("如果当前渲染环境支持 HTML 视频标签,也可以使用:");
|
||||
hintLines.push(htmlVideo);
|
||||
|
||||
return {
|
||||
prompt,
|
||||
model: createdTask.effective_model,
|
||||
request_id: createdTask.request_id,
|
||||
seed: resultSeed,
|
||||
file_path: filePath,
|
||||
file_uri: fileUri,
|
||||
remote_video_url: remoteVideoUrl,
|
||||
markdown_link: markdownLink,
|
||||
html_video: htmlVideo,
|
||||
hint: hintLines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
async function draw_image_wrapper(params: ImageParams) {
|
||||
try {
|
||||
const result = await draw_image(params || {});
|
||||
complete({
|
||||
success: true,
|
||||
message: "SiliconFlow 图片生成成功,已下载到本地。",
|
||||
data: result
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("draw_image 执行失败:", error);
|
||||
complete({
|
||||
success: false,
|
||||
message: `SiliconFlow 图片生成失败: ${getErrorMessage(error)}`,
|
||||
error_stack: error && error.stack ? error.stack : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function draw_video_wrapper(params: VideoParams) {
|
||||
try {
|
||||
const result = await draw_video(params || {});
|
||||
complete({
|
||||
success: true,
|
||||
message: "SiliconFlow 视频生成成功,已下载到本地。",
|
||||
data: result
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("draw_video 执行失败:", error);
|
||||
complete({
|
||||
success: false,
|
||||
message: `SiliconFlow 视频生成失败: ${getErrorMessage(error)}`,
|
||||
error_stack: error && error.stack ? error.stack : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
draw_image: draw_image_wrapper,
|
||||
draw_video: draw_video_wrapper
|
||||
};
|
||||
})();
|
||||
|
||||
exports.draw_image = siliconflowDraw.draw_image;
|
||||
exports.draw_video = siliconflowDraw.draw_video;
|
||||
@@ -0,0 +1,423 @@
|
||||
/*
|
||||
METADATA
|
||||
{
|
||||
"name": "super_admin",
|
||||
|
||||
"display_name": {
|
||||
"zh": "超级管理员",
|
||||
"en": "Super Admin"
|
||||
},
|
||||
"description": { "zh": "超级管理员工具集,提供终端命令和Shell操作的高级功能。terminal工具运行在Ubuntu环境中(已正确挂载sdcard和storage),shell工具通过Shizuku/Root直接执行Android系统命令。适合需要进行底层系统管理和命令行操作的场景。", "en": "Super admin toolkit providing advanced terminal and shell capabilities. The terminal tool runs in an Ubuntu environment (with sdcard/storage mounted). The shell tool executes Android system commands directly via Shizuku/Root. Useful for low-level system administration and CLI operations." },
|
||||
"enabledByDefault": true,
|
||||
"category": "System",
|
||||
"tools": [
|
||||
{
|
||||
"name": "terminal",
|
||||
"description": { "zh": "在Ubuntu环境中执行命令并收集输出结果。运行环境:完整的Ubuntu系统,已正确挂载sdcard和storage目录,可访问Android存储空间。所有命令将会在相同的会话执行且上下文连贯。强烈建议每次都显式传 timeoutMs,避免命令卡住。禁止使用 `set -e`、`set -o errexit` 等会改变 shell 退出行为的命令,这会导致终端会话直接退出并卡死。若未传,前台默认15秒超时;background=true 时不使用该默认超时。命令超时时不会被自动取消,不需要重新执行命令,请继续通过 terminal_getscreen 跟踪当前屏幕内容。", "en": "Execute commands in an Ubuntu environment and collect output. Environment: full Ubuntu system with sdcard/storage mounted, allowing access to Android storage. Automatically preserves working-directory context. Strongly recommend explicitly passing timeoutMs every time to avoid hangs. Do not use commands such as `set -e` or `set -o errexit` that change shell exit behavior, because they can cause the terminal session to exit and hang. If omitted, foreground mode defaults to 15s timeout; background=true does not use this default timeout. When a command times out, it is not automatically cancelled. Do not rerun the command; continue tracking the current screen via terminal_getscreen." },
|
||||
"parameters": [
|
||||
{
|
||||
"name": "command",
|
||||
"description": { "zh": "要执行的命令", "en": "Command to execute." },
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "background",
|
||||
"description": { "zh": "是否在后台运行命令,\"true\" 表示后台执行并立即返回,适合启动服务器等长时间运行的任务(AI 不会收到该命令的输出结果),\"false\" 或未提供则前台执行并等待并返回命令结果", "en": "Run command in background. 'true' runs in background and returns immediately (good for long-running tasks like servers; AI will not receive output). 'false' or omitted runs in foreground and returns the command result." },
|
||||
"type": "string",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "timeoutMs",
|
||||
"description": { "zh": "可选超时(毫秒,最低3000ms)。强烈建议显式传入;未传时前台默认15000ms,background=true时不使用默认超时。", "en": "Optional timeout (ms, minimum 3000ms). Strongly recommended to pass explicitly; if omitted, foreground defaults to 15000ms, and background=true does not use the default timeout." },
|
||||
"type": "string",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "terminal_wait",
|
||||
"description": { "zh": "等待同一终端会话中的上一条命令执行完成。适用于安装/编译等长命令在 timeout 后继续后台执行的场景。与 sleep 不同,本工具会在命令实际完成时提前返回,而不是固定睡眠。", "en": "Wait until the previous command in the same terminal session finishes. Useful when long install/build commands continue running after a timeout. Unlike sleep, this tool can return early as soon as the command actually completes." },
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionId",
|
||||
"description": { "zh": "可选目标会话ID。不传则使用当前对话的默认会话;无 chatId 时为 super_admin_default_session。", "en": "Optional target session ID. If omitted, uses the current chat's default session; without chatId it is super_admin_default_session." },
|
||||
"type": "string",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "timeoutMs",
|
||||
"description": { "zh": "可选超时(毫秒,最低3000ms)。未传时默认300000ms(5分钟)。", "en": "Optional timeout (ms, minimum 3000ms). Defaults to 300000ms (5 minutes) if omitted." },
|
||||
"type": "string",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "terminal_getscreen",
|
||||
"description": { "zh": "获取当前终端会话可见屏幕内容(仅一屏,不包含历史滚动缓冲)。", "en": "Get the current visible screen content for the active terminal session (single screen only, no scrollback history)." },
|
||||
"parameters": []
|
||||
},
|
||||
{
|
||||
"name": "terminal_input",
|
||||
"description": { "zh": "向当前终端会话写入输入。input 与 control 至少传一个。常见用法:先写 input,再写 control=enter 提交;control=ctrl 且 input=c 可发送 Ctrl+C。", "en": "Write input to the active terminal session. Provide at least one of input or control. Typical usage: send input first, then control=enter to submit; use control=ctrl with input=c for Ctrl+C." },
|
||||
"parameters": [
|
||||
{
|
||||
"name": "input",
|
||||
"description": { "zh": "写入终端的文本", "en": "Text to write to terminal." },
|
||||
"type": "string",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "control",
|
||||
"description": { "zh": "控制键,例如 enter / tab / esc / ctrl", "en": "Control key, e.g. enter / tab / esc / ctrl." },
|
||||
"type": "string",
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "shell",
|
||||
"description": { "zh": "通过Shizuku/Root权限直接在Android系统中执行Shell命令。运行环境:直接访问Android系统,具有系统级权限,适用于需要操作Android系统底层的场景(如pm、am等系统命令)。", "en": "Execute shell commands directly on Android with Shizuku/Root. Environment: direct Android system access with system-level privileges, suitable for low-level commands such as pm/am." },
|
||||
"parameters": [
|
||||
{
|
||||
"name": "command",
|
||||
"description": { "zh": "要执行的Shell命令", "en": "Shell command to execute." },
|
||||
"type": "string",
|
||||
"required": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}*/
|
||||
|
||||
const superAdmin = (function () {
|
||||
const MAX_INLINE_TERMINAL_OUTPUT_CHARS = 12_000;
|
||||
const DEFAULT_FOREGROUND_TIMEOUT_MS = 15_000;
|
||||
const DEFAULT_WAIT_TIMEOUT_MS = 300_000;
|
||||
const MIN_TIMEOUT_MS = 3_000;
|
||||
const DEFAULT_TERMINAL_SESSION_NAME = "super_admin_default_session";
|
||||
const BACKGROUND_TERMINAL_SESSION_PREFIX = "super_admin_background";
|
||||
|
||||
function getCurrentChatSessionSuffix(): string {
|
||||
const chatId = getChatId();
|
||||
if (chatId === undefined) {
|
||||
return "";
|
||||
}
|
||||
const normalizedChatId = chatId.trim();
|
||||
if (!normalizedChatId) {
|
||||
return "";
|
||||
}
|
||||
return normalizedChatId.replace(/[^a-zA-Z0-9._-]+/g, "_");
|
||||
}
|
||||
|
||||
function getDefaultTerminalSessionName(): string {
|
||||
const chatSuffix = getCurrentChatSessionSuffix();
|
||||
return chatSuffix
|
||||
? `${DEFAULT_TERMINAL_SESSION_NAME}_${chatSuffix}`
|
||||
: DEFAULT_TERMINAL_SESSION_NAME;
|
||||
}
|
||||
|
||||
function getBackgroundTerminalSessionName(): string {
|
||||
const chatSuffix = getCurrentChatSessionSuffix();
|
||||
const prefix = chatSuffix
|
||||
? `${BACKGROUND_TERMINAL_SESSION_PREFIX}_${chatSuffix}`
|
||||
: BACKGROUND_TERMINAL_SESSION_PREFIX;
|
||||
return `${prefix}_${Date.now()}`;
|
||||
}
|
||||
|
||||
async function persistTerminalOutputIfTooLong(command: string, result: any): Promise<any | null> {
|
||||
const outputStr = typeof result?.output === "string"
|
||||
? result.output
|
||||
: String(result?.output ?? "");
|
||||
|
||||
if (outputStr.length <= MAX_INLINE_TERMINAL_OUTPUT_CHARS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await Tools.Files.mkdir(OPERIT_CLEAN_ON_EXIT_DIR, true);
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const rand = Math.floor(Math.random() * 1_000_000);
|
||||
const filePath = `${OPERIT_CLEAN_ON_EXIT_DIR}/terminal_output_${timestamp}_${rand}.log`;
|
||||
|
||||
await Tools.Files.write(filePath, outputStr, false);
|
||||
|
||||
return {
|
||||
command,
|
||||
output: "(saved_to_file)",
|
||||
exitCode: result?.exitCode,
|
||||
sessionId: result?.sessionId,
|
||||
context_preserved: true,
|
||||
output_saved_to: filePath,
|
||||
output_chars: outputStr.length,
|
||||
operit_clean_on_exit_dir: OPERIT_CLEAN_ON_EXIT_DIR,
|
||||
hint: "Output is large and saved to file. Use read_file_part or grep_code to inspect it.",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 在Ubuntu环境中执行终端命令并收集输出结果
|
||||
* 运行环境:完整的Ubuntu系统,已正确挂载sdcard和storage目录
|
||||
* 禁止使用 set -e / set -o errexit 等会改变 shell 退出行为的命令,否则可能导致终端会话退出并卡死
|
||||
* @param command - 要执行的命令
|
||||
* @param background - 是否后台运行("true" 为后台执行并立即返回,适合启动服务器等长时间运行任务,AI 不会收到该命令的输出结果)
|
||||
* @param timeoutMs - 可选的超时时间(毫秒,最低 3000ms)。强烈建议显式传入;前台未传时默认 15000ms,后台模式不应用该默认值。
|
||||
*/
|
||||
async function terminal(params: { command: string, background?: string, timeoutMs?: string }): Promise<any> {
|
||||
try {
|
||||
if (!params.command) {
|
||||
throw new Error("命令不能为空");
|
||||
}
|
||||
|
||||
const command = params.command;
|
||||
const background = params.background;
|
||||
const timeoutMs = params.timeoutMs;
|
||||
|
||||
console.log(`执行终端命令: ${command}`);
|
||||
|
||||
const isBackground = background === "true";
|
||||
let timeout: number | undefined;
|
||||
if (!isBackground) {
|
||||
if (timeoutMs !== undefined) {
|
||||
const parsedTimeout = parseInt(timeoutMs, 10);
|
||||
if (!Number.isFinite(parsedTimeout) || parsedTimeout < MIN_TIMEOUT_MS) {
|
||||
throw new Error(`timeoutMs必须是整数且不少于${MIN_TIMEOUT_MS}毫秒`);
|
||||
}
|
||||
timeout = parsedTimeout;
|
||||
} else {
|
||||
timeout = DEFAULT_FOREGROUND_TIMEOUT_MS;
|
||||
}
|
||||
}
|
||||
|
||||
if (isBackground) {
|
||||
const session = await Tools.System.terminal.create(getBackgroundTerminalSessionName());
|
||||
const sessionId = session.sessionId;
|
||||
|
||||
// 调用系统工具执行终端命令
|
||||
(async () => {
|
||||
try {
|
||||
await Tools.System.terminal.exec(sessionId, command);
|
||||
} catch (error) {
|
||||
console.error(`[terminal/background] 错误: ${error.message}`);
|
||||
console.error(error.stack);
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
command: command,
|
||||
background: true,
|
||||
sessionId: sessionId,
|
||||
started: true
|
||||
};
|
||||
}
|
||||
|
||||
// 创建或获取一个默认会话
|
||||
const session = await Tools.System.terminal.create(getDefaultTerminalSessionName());
|
||||
const sessionId = session.sessionId;
|
||||
|
||||
// 调用系统工具执行终端命令
|
||||
const result = await Tools.System.terminal.exec(sessionId, command, timeout);
|
||||
const timedOut = result.timedOut === true;
|
||||
|
||||
let timeoutScreen: { sessionId: string, rows: number, cols: number, content: string } | null = null;
|
||||
if (timedOut) {
|
||||
const screenResult = await Tools.System.terminal.screen(sessionId);
|
||||
timeoutScreen = {
|
||||
sessionId: screenResult.sessionId ?? sessionId,
|
||||
rows: screenResult.rows,
|
||||
cols: screenResult.cols,
|
||||
content: screenResult.content
|
||||
};
|
||||
}
|
||||
|
||||
const persistedResult = await persistTerminalOutputIfTooLong(command, result);
|
||||
if (persistedResult) {
|
||||
persistedResult.timeoutMsUsed = timeout;
|
||||
if (timeoutScreen) {
|
||||
persistedResult.timeoutScreen = timeoutScreen;
|
||||
}
|
||||
return persistedResult;
|
||||
}
|
||||
|
||||
return {
|
||||
command: command,
|
||||
output: result.output,
|
||||
exitCode: result.exitCode,
|
||||
sessionId: result.sessionId,
|
||||
timedOut: timedOut,
|
||||
timeoutMsUsed: timeout,
|
||||
timeoutScreen: timeoutScreen,
|
||||
context_preserved: true // 标记此命令保留了目录上下文
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[terminal] 错误: ${error.message}`);
|
||||
console.error(error.stack);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待同一终端会话中的上一条命令执行完成
|
||||
* 原理:向同会话追加一个内部 marker 命令。由于会话按序执行,marker 开始执行即代表前序命令已完成。
|
||||
* @param sessionId - 可选会话ID;不传时使用当前对话的默认会话,无 chatId 时为 super_admin_default_session
|
||||
* @param timeoutMs - 可选超时(毫秒,最低 3000ms);未传默认 300000ms
|
||||
*/
|
||||
async function terminal_wait(params: { sessionId?: string, timeoutMs?: string } = {}): Promise<any> {
|
||||
try {
|
||||
const timeoutMs = params.timeoutMs;
|
||||
let timeout = DEFAULT_WAIT_TIMEOUT_MS;
|
||||
if (timeoutMs !== undefined) {
|
||||
const parsedTimeout = parseInt(timeoutMs, 10);
|
||||
if (!Number.isFinite(parsedTimeout) || parsedTimeout < MIN_TIMEOUT_MS) {
|
||||
throw new Error(`timeoutMs必须是整数且不少于${MIN_TIMEOUT_MS}毫秒`);
|
||||
}
|
||||
timeout = parsedTimeout;
|
||||
}
|
||||
|
||||
const session =
|
||||
params.sessionId
|
||||
? { sessionId: params.sessionId }
|
||||
: await Tools.System.terminal.create(getDefaultTerminalSessionName());
|
||||
const sessionId = session.sessionId;
|
||||
|
||||
const marker = `__OPERIT_TERMINAL_WAIT_DONE_${Date.now()}_${Math.floor(Math.random() * 1_000_000)}__`;
|
||||
const waitCommand = `printf '${marker}\\n'`;
|
||||
const startedAt = Date.now();
|
||||
const result = await Tools.System.terminal.exec(sessionId, waitCommand, timeout);
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
const timedOut = result?.timedOut === true;
|
||||
|
||||
let timeoutScreen: { sessionId: string, rows: number, cols: number, content: string } | null = null;
|
||||
if (timedOut) {
|
||||
const screenResult = await Tools.System.terminal.screen(sessionId);
|
||||
timeoutScreen = {
|
||||
sessionId: screenResult.sessionId ?? sessionId,
|
||||
rows: screenResult.rows,
|
||||
cols: screenResult.cols,
|
||||
content: screenResult.content
|
||||
};
|
||||
}
|
||||
|
||||
const outputStr = typeof result?.output === "string"
|
||||
? result.output
|
||||
: String(result?.output ?? "");
|
||||
const markerSeen = outputStr.includes(marker);
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
timedOut,
|
||||
timeoutMsUsed: timeout,
|
||||
elapsedMs,
|
||||
waitCompleted: !timedOut && markerSeen,
|
||||
markerSeen,
|
||||
exitCode: result?.exitCode,
|
||||
timeoutScreen,
|
||||
context_preserved: true
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[terminal_wait] 错误: ${error.message}`);
|
||||
console.error(error.stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过Shizuku/Root权限在Android系统中执行Shell命令
|
||||
* 运行环境:直接访问Android系统,具有系统级权限
|
||||
* @param command - 要执行的Shell命令
|
||||
*/
|
||||
async function shell(params: { command: string }): Promise<any> {
|
||||
try {
|
||||
if (!params.command) {
|
||||
throw new Error("命令不能为空");
|
||||
}
|
||||
const command = params.command;
|
||||
|
||||
console.log(`执行Shell命令: ${command}`);
|
||||
|
||||
// 通过Shizuku/Root权限执行shell操作
|
||||
const result = await Tools.System.shell(`${command}`);
|
||||
|
||||
return {
|
||||
command: command,
|
||||
output: result.output,
|
||||
exitCode: result.exitCode
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[shell] 错误: ${error.message}`);
|
||||
console.error(error.stack);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前终端会话可见屏幕内容(仅一屏,不包含历史)
|
||||
*/
|
||||
async function terminal_getscreen(_params: {} = {}): Promise<any> {
|
||||
try {
|
||||
const session = await Tools.System.terminal.create(getDefaultTerminalSessionName());
|
||||
const sessionId = session.sessionId;
|
||||
const result = await Tools.System.terminal.screen(sessionId);
|
||||
return {
|
||||
sessionId: result.sessionId ?? sessionId,
|
||||
rows: result.rows,
|
||||
cols: result.cols,
|
||||
content: result.content,
|
||||
commandRunning: result.commandRunning === true
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[terminal_getscreen] 错误: ${error.message}`);
|
||||
console.error(error.stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 向当前终端会话写入输入
|
||||
* @param input - 文本输入
|
||||
* @param control - 控制键
|
||||
*/
|
||||
async function terminal_input(params: { input?: string, control?: string } = {}): Promise<any> {
|
||||
try {
|
||||
if (params.input === undefined && params.control === undefined) {
|
||||
throw new Error("input和control至少需要提供一个");
|
||||
}
|
||||
|
||||
const session = await Tools.System.terminal.create(getDefaultTerminalSessionName());
|
||||
const sessionId = session.sessionId;
|
||||
const result = await Tools.System.terminal.input(sessionId, {
|
||||
input: params.input,
|
||||
control: params.control
|
||||
});
|
||||
|
||||
return {
|
||||
sessionId: sessionId,
|
||||
input: params.input,
|
||||
control: params.control,
|
||||
result: result?.value ?? String(result ?? "")
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[terminal_input] 错误: ${error.message}`);
|
||||
console.error(error.stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
terminal,
|
||||
terminal_wait,
|
||||
terminal_getscreen,
|
||||
terminal_input,
|
||||
shell
|
||||
};
|
||||
})();
|
||||
|
||||
// 逐个导出
|
||||
exports.terminal = superAdmin.terminal;
|
||||
exports.terminal_wait = superAdmin.terminal_wait;
|
||||
exports.terminal_getscreen = superAdmin.terminal_getscreen;
|
||||
exports.terminal_input = superAdmin.terminal_input;
|
||||
exports.shell = superAdmin.shell;
|
||||
@@ -0,0 +1,774 @@
|
||||
/* METADATA
|
||||
{
|
||||
"name": "system_tools",
|
||||
|
||||
"display_name": {
|
||||
"zh": "系统工具",
|
||||
"en": "System Tools"
|
||||
},
|
||||
"description": {
|
||||
"zh": "提供系统级操作工具,包括设置管理、应用安装卸载与启动、通知获取、位置服务、设备信息查询,以及 Intent/广播调用。",
|
||||
"en": "System-level operations: settings management, app install/uninstall & launch, notification retrieval, location services, device info queries, plus Intent/broadcast execution."
|
||||
},
|
||||
"enabledByDefault": true,
|
||||
"category": "System",
|
||||
"tools": [
|
||||
{
|
||||
"name": "get_system_setting",
|
||||
"description": { "zh": "获取系统设置的值。需要用户授权。", "en": "Get the value of a system setting. Requires user authorization." },
|
||||
"parameters": [
|
||||
{ "name": "setting", "description": { "zh": "设置名称", "en": "Setting key/name" }, "type": "string", "required": true },
|
||||
{ "name": "namespace", "description": { "zh": "命名空间:system/secure/global,默认system", "en": "Namespace: system/secure/global (default: system)" }, "type": "string", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "modify_system_setting",
|
||||
"description": { "zh": "修改系统设置的值。需要用户授权。", "en": "Modify the value of a system setting. Requires user authorization." },
|
||||
"parameters": [
|
||||
{ "name": "setting", "description": { "zh": "设置名称", "en": "Setting key/name" }, "type": "string", "required": true },
|
||||
{ "name": "value", "description": { "zh": "设置值", "en": "Setting value" }, "type": "string", "required": true },
|
||||
{ "name": "namespace", "description": { "zh": "命名空间:system/secure/global,默认system", "en": "Namespace: system/secure/global (default: system)" }, "type": "string", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "install_app",
|
||||
"description": { "zh": "安装应用程序。需要用户授权。", "en": "Install an app. Requires user authorization." },
|
||||
"parameters": [
|
||||
{ "name": "path", "description": { "zh": "APK文件路径", "en": "APK file path" }, "type": "string", "required": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "uninstall_app",
|
||||
"description": { "zh": "卸载应用程序。需要用户授权。", "en": "Uninstall an app. Requires user authorization." },
|
||||
"parameters": [
|
||||
{ "name": "package_name", "description": { "zh": "应用包名", "en": "App package name" }, "type": "string", "required": true },
|
||||
{ "name": "keep_data", "description": { "zh": "是否保留数据,默认false", "en": "Whether to keep app data (default: false)" }, "type": "boolean", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "list_installed_apps",
|
||||
"description": { "zh": "获取已安装应用程序列表。需要用户授权。", "en": "List installed apps. Requires user authorization." },
|
||||
"parameters": [
|
||||
{ "name": "include_system_apps", "description": { "zh": "是否包含系统应用,默认false", "en": "Whether to include system apps (default: false)" }, "type": "boolean", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "start_app",
|
||||
"description": { "zh": "启动应用程序。需要用户授权。", "en": "Launch an app. Requires user authorization." },
|
||||
"parameters": [
|
||||
{ "name": "package_name", "description": { "zh": "应用包名", "en": "App package name" }, "type": "string", "required": true },
|
||||
{ "name": "activity", "description": { "zh": "可选活动名称", "en": "Optional activity name" }, "type": "string", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "stop_app",
|
||||
"description": { "zh": "停止正在运行的应用程序。需要用户授权。", "en": "Force stop a running app. Requires user authorization." },
|
||||
"parameters": [
|
||||
{ "name": "package_name", "description": { "zh": "应用包名", "en": "App package name" }, "type": "string", "required": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "send_broadcast",
|
||||
"description": { "zh": "发送广播(Broadcast Intent)。需要用户授权。", "en": "Send a broadcast (Broadcast Intent). Requires user authorization." },
|
||||
"parameters": [
|
||||
{ "name": "action", "description": { "zh": "Intent action,例如 android.intent.action.VIEW", "en": "Intent action, e.g. android.intent.action.VIEW" }, "type": "string", "required": true },
|
||||
{ "name": "package_name", "description": { "zh": "可选:限制广播目标包名", "en": "Optional: restrict target package" }, "type": "string", "required": false },
|
||||
{ "name": "component", "description": { "zh": "可选:组件名 package/class,优先于package_name", "en": "Optional: component package/class, takes priority over package_name" }, "type": "string", "required": false },
|
||||
{ "name": "uri", "description": { "zh": "可选:data uri", "en": "Optional: data uri" }, "type": "string", "required": false },
|
||||
{ "name": "extras", "description": { "zh": "可选:extras(对象,可用于传参)", "en": "Optional: extras (object for parameters)" }, "type": "object", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "execute_intent",
|
||||
"description": { "zh": "执行 Intent(Activity/Service/Broadcast),支持 extras 传参。需要用户授权。", "en": "Execute an Intent (Activity/Service/Broadcast) with extras parameters. Requires user authorization." },
|
||||
"parameters": [
|
||||
{ "name": "type", "description": { "zh": "类型:activity/broadcast/service,默认activity", "en": "Type: activity/broadcast/service (default: activity)" }, "type": "string", "required": false },
|
||||
{ "name": "action", "description": { "zh": "Intent action(action 或 component 至少一个必填)", "en": "Intent action (either action or component is required)" }, "type": "string", "required": false },
|
||||
{ "name": "package_name", "description": { "zh": "可选:包名", "en": "Optional: package name" }, "type": "string", "required": false },
|
||||
{ "name": "component", "description": { "zh": "可选:组件名 package/class", "en": "Optional: component package/class" }, "type": "string", "required": false },
|
||||
{ "name": "uri", "description": { "zh": "可选:data uri", "en": "Optional: data uri" }, "type": "string", "required": false },
|
||||
{ "name": "flags", "description": { "zh": "可选:flags(整数或JSON数组字符串)", "en": "Optional: flags (integer or JSON array string)" }, "type": "string", "required": false },
|
||||
{ "name": "extras", "description": { "zh": "可选:extras(对象,可用于传参)", "en": "Optional: extras (object for parameters)" }, "type": "object", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "get_notifications",
|
||||
"description": { "zh": "获取设备通知内容。", "en": "Retrieve device notifications." },
|
||||
"parameters": [
|
||||
{ "name": "limit", "description": { "zh": "最大返回条数,默认10", "en": "Max number of entries to return (default: 10)" }, "type": "number", "required": false },
|
||||
{ "name": "include_ongoing", "description": { "zh": "是否包含常驻通知,默认false", "en": "Whether to include ongoing notifications (default: false)" }, "type": "boolean", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "get_app_usage_time",
|
||||
"description": { "zh": "获取应用前台使用时长。需要授予“使用情况访问权限”。", "en": "Get app foreground usage time. Requires Usage Access permission." },
|
||||
"parameters": [
|
||||
{ "name": "package_name", "description": { "zh": "可选:精确应用包名", "en": "Optional exact package name" }, "type": "string", "required": false },
|
||||
{ "name": "since_hours", "description": { "zh": "向前统计多少小时,默认24", "en": "How many hours to look back (default: 24)" }, "type": "number", "required": false },
|
||||
{ "name": "limit", "description": { "zh": "不传包名时最多返回多少个应用,默认10", "en": "Max apps to return when package_name is omitted (default: 10)" }, "type": "number", "required": false },
|
||||
{ "name": "include_system_apps", "description": { "zh": "不传包名时是否包含系统应用,默认false", "en": "Whether to include system apps when package_name is omitted (default: false)" }, "type": "boolean", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "get_device_location",
|
||||
"description": { "zh": "获取设备当前位置信息。", "en": "Get current device location." },
|
||||
"parameters": [
|
||||
{ "name": "high_accuracy", "description": { "zh": "是否使用高精度模式,默认false", "en": "Use high accuracy mode (default: false)" }, "type": "boolean", "required": false },
|
||||
{ "name": "timeout", "description": { "zh": "超时时间(秒),默认10", "en": "Timeout in seconds (default: 10)" }, "type": "number", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "request_bluetooth_permission",
|
||||
"description": { "zh": "请求蓝牙附近设备权限。", "en": "Request Bluetooth nearby devices permission." },
|
||||
"parameters": []
|
||||
},
|
||||
{
|
||||
"name": "get_bluetooth_state",
|
||||
"description": { "zh": "获取蓝牙适配器状态。", "en": "Get Bluetooth adapter state." },
|
||||
"parameters": []
|
||||
},
|
||||
{
|
||||
"name": "request_enable_bluetooth",
|
||||
"description": { "zh": "打开系统蓝牙开启对话框。", "en": "Open the system dialog to enable Bluetooth." },
|
||||
"parameters": []
|
||||
},
|
||||
{
|
||||
"name": "list_bluetooth_bonded_devices",
|
||||
"description": { "zh": "列出已配对蓝牙设备。", "en": "List bonded Bluetooth devices." },
|
||||
"parameters": []
|
||||
},
|
||||
{
|
||||
"name": "scan_bluetooth_devices",
|
||||
"description": { "zh": "扫描附近蓝牙 Classic 与 BLE 设备。", "en": "Scan nearby Bluetooth classic and BLE devices." },
|
||||
"parameters": [
|
||||
{ "name": "duration_ms", "description": { "zh": "扫描时长毫秒。", "en": "Scan duration in milliseconds." }, "type": "number", "required": false },
|
||||
{ "name": "include_ble", "description": { "zh": "是否包含 BLE 扫描。", "en": "Whether to include BLE scanning." }, "type": "boolean", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "bluetooth_connect",
|
||||
"description": { "zh": "连接蓝牙 Classic 设备。", "en": "Connect to a Bluetooth classic device." },
|
||||
"parameters": [
|
||||
{ "name": "address", "description": { "zh": "蓝牙 MAC 地址", "en": "Bluetooth MAC address" }, "type": "string", "required": true },
|
||||
{ "name": "uuid", "description": { "zh": "RFCOMM UUID。", "en": "RFCOMM UUID." }, "type": "string", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "bluetooth_listen",
|
||||
"description": { "zh": "监听别人连接本机的蓝牙 Classic 通道。", "en": "Listen for another device connecting to this phone over Bluetooth classic." },
|
||||
"parameters": [
|
||||
{ "name": "name", "description": { "zh": "服务名。", "en": "Service name." }, "type": "string", "required": false },
|
||||
{ "name": "uuid", "description": { "zh": "RFCOMM UUID。", "en": "RFCOMM UUID." }, "type": "string", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "bluetooth_accept",
|
||||
"description": { "zh": "接受蓝牙 Classic 监听会话中的一个传入连接。", "en": "Accept one incoming connection from a Bluetooth classic listener." },
|
||||
"parameters": [
|
||||
{ "name": "listener_session_id", "description": { "zh": "监听会话 ID", "en": "Listener session ID" }, "type": "string", "required": true },
|
||||
{ "name": "timeout_ms", "description": { "zh": "等待毫秒数。", "en": "Wait time in milliseconds." }, "type": "number", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "bluetooth_send",
|
||||
"description": { "zh": "向蓝牙 Classic 会话发送文本或 base64 字节。", "en": "Send text or base64 bytes to a Bluetooth classic session." },
|
||||
"parameters": [
|
||||
{ "name": "session_id", "description": { "zh": "会话 ID", "en": "Session ID" }, "type": "string", "required": true },
|
||||
{ "name": "text", "description": { "zh": "UTF-8 文本", "en": "UTF-8 text" }, "type": "string", "required": false },
|
||||
{ "name": "data_base64", "description": { "zh": "base64 字节", "en": "Base64 bytes" }, "type": "string", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "bluetooth_read",
|
||||
"description": { "zh": "从蓝牙 Classic 会话读取文本或字节。", "en": "Read text or bytes from a Bluetooth classic session." },
|
||||
"parameters": [
|
||||
{ "name": "session_id", "description": { "zh": "会话 ID", "en": "Session ID" }, "type": "string", "required": true },
|
||||
{ "name": "max_bytes", "description": { "zh": "最大读取字节数。", "en": "Maximum bytes to read." }, "type": "number", "required": false },
|
||||
{ "name": "timeout_ms", "description": { "zh": "等待毫秒数。", "en": "Wait time in milliseconds." }, "type": "number", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "bluetooth_send_and_read",
|
||||
"description": { "zh": "向蓝牙 Classic 会话发送后读取响应。", "en": "Send to a Bluetooth classic session and read the response." },
|
||||
"parameters": [
|
||||
{ "name": "session_id", "description": { "zh": "会话 ID", "en": "Session ID" }, "type": "string", "required": true },
|
||||
{ "name": "text", "description": { "zh": "UTF-8 文本", "en": "UTF-8 text" }, "type": "string", "required": false },
|
||||
{ "name": "data_base64", "description": { "zh": "base64 字节", "en": "Base64 bytes" }, "type": "string", "required": false },
|
||||
{ "name": "max_bytes", "description": { "zh": "最大读取字节数。", "en": "Maximum bytes to read." }, "type": "number", "required": false },
|
||||
{ "name": "timeout_ms", "description": { "zh": "等待毫秒数。", "en": "Wait time in milliseconds." }, "type": "number", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "bluetooth_close",
|
||||
"description": { "zh": "关闭蓝牙 Classic、监听或 BLE 会话。", "en": "Close a Bluetooth classic, listener, or BLE session." },
|
||||
"parameters": [
|
||||
{ "name": "session_id", "description": { "zh": "会话 ID", "en": "Session ID" }, "type": "string", "required": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "bluetooth_ble_connect",
|
||||
"description": { "zh": "连接 BLE 设备。", "en": "Connect to a BLE device." },
|
||||
"parameters": [
|
||||
{ "name": "address", "description": { "zh": "蓝牙 MAC 地址", "en": "Bluetooth MAC address" }, "type": "string", "required": true },
|
||||
{ "name": "auto_connect", "description": { "zh": "是否自动连接。", "en": "Whether to use auto connect." }, "type": "boolean", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "bluetooth_ble_discover_services",
|
||||
"description": { "zh": "发现 BLE 服务和 characteristic。", "en": "Discover BLE services and characteristics." },
|
||||
"parameters": [
|
||||
{ "name": "session_id", "description": { "zh": "BLE 会话 ID", "en": "BLE session ID" }, "type": "string", "required": true },
|
||||
{ "name": "timeout_ms", "description": { "zh": "等待毫秒数。", "en": "Wait time in milliseconds." }, "type": "number", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "bluetooth_ble_read_characteristic",
|
||||
"description": { "zh": "读取 BLE characteristic。", "en": "Read a BLE characteristic." },
|
||||
"parameters": [
|
||||
{ "name": "session_id", "description": { "zh": "BLE 会话 ID", "en": "BLE session ID" }, "type": "string", "required": true },
|
||||
{ "name": "service_uuid", "description": { "zh": "Service UUID", "en": "Service UUID" }, "type": "string", "required": true },
|
||||
{ "name": "characteristic_uuid", "description": { "zh": "Characteristic UUID", "en": "Characteristic UUID" }, "type": "string", "required": true },
|
||||
{ "name": "timeout_ms", "description": { "zh": "等待毫秒数。", "en": "Wait time in milliseconds." }, "type": "number", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "bluetooth_ble_write_characteristic",
|
||||
"description": { "zh": "写入 BLE characteristic。", "en": "Write a BLE characteristic." },
|
||||
"parameters": [
|
||||
{ "name": "session_id", "description": { "zh": "BLE 会话 ID", "en": "BLE session ID" }, "type": "string", "required": true },
|
||||
{ "name": "service_uuid", "description": { "zh": "Service UUID", "en": "Service UUID" }, "type": "string", "required": true },
|
||||
{ "name": "characteristic_uuid", "description": { "zh": "Characteristic UUID", "en": "Characteristic UUID" }, "type": "string", "required": true },
|
||||
{ "name": "text", "description": { "zh": "UTF-8 文本", "en": "UTF-8 text" }, "type": "string", "required": false },
|
||||
{ "name": "data_base64", "description": { "zh": "base64 字节", "en": "Base64 bytes" }, "type": "string", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "bluetooth_ble_write_and_read_characteristic",
|
||||
"description": { "zh": "写入 BLE characteristic 后读取另一个 characteristic 响应。", "en": "Write a BLE characteristic and read another characteristic response." },
|
||||
"parameters": [
|
||||
{ "name": "session_id", "description": { "zh": "BLE 会话 ID", "en": "BLE session ID" }, "type": "string", "required": true },
|
||||
{ "name": "write_service_uuid", "description": { "zh": "写入 Service UUID", "en": "Write service UUID" }, "type": "string", "required": true },
|
||||
{ "name": "write_characteristic_uuid", "description": { "zh": "写入 Characteristic UUID", "en": "Write characteristic UUID" }, "type": "string", "required": true },
|
||||
{ "name": "read_service_uuid", "description": { "zh": "读取 Service UUID", "en": "Read service UUID" }, "type": "string", "required": true },
|
||||
{ "name": "read_characteristic_uuid", "description": { "zh": "读取 Characteristic UUID", "en": "Read characteristic UUID" }, "type": "string", "required": true },
|
||||
{ "name": "text", "description": { "zh": "UTF-8 文本", "en": "UTF-8 text" }, "type": "string", "required": false },
|
||||
{ "name": "data_base64", "description": { "zh": "base64 字节", "en": "Base64 bytes" }, "type": "string", "required": false },
|
||||
{ "name": "timeout_ms", "description": { "zh": "等待毫秒数。", "en": "Wait time in milliseconds." }, "type": "number", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "bluetooth_ble_subscribe_characteristic",
|
||||
"description": { "zh": "订阅或取消订阅 BLE characteristic 通知。", "en": "Subscribe or unsubscribe BLE characteristic notifications." },
|
||||
"parameters": [
|
||||
{ "name": "session_id", "description": { "zh": "BLE 会话 ID", "en": "BLE session ID" }, "type": "string", "required": true },
|
||||
{ "name": "service_uuid", "description": { "zh": "Service UUID", "en": "Service UUID" }, "type": "string", "required": true },
|
||||
{ "name": "characteristic_uuid", "description": { "zh": "Characteristic UUID", "en": "Characteristic UUID" }, "type": "string", "required": true },
|
||||
{ "name": "enable", "description": { "zh": "是否订阅。", "en": "Whether to subscribe." }, "type": "boolean", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "bluetooth_ble_read_notifications",
|
||||
"description": { "zh": "读取已收到的 BLE 通知。", "en": "Read received BLE notifications." },
|
||||
"parameters": [
|
||||
{ "name": "session_id", "description": { "zh": "BLE 会话 ID", "en": "BLE session ID" }, "type": "string", "required": true },
|
||||
{ "name": "limit", "description": { "zh": "读取条数。", "en": "Number of notifications to read." }, "type": "number", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "get_device_info",
|
||||
"description": { "zh": "获取详细的设备信息,包括型号、操作系统版本、内存、存储、网络状态等。", "en": "Get detailed device information, including model, OS version, memory, storage, network status, etc." },
|
||||
"parameters": []
|
||||
}
|
||||
]
|
||||
}*/
|
||||
|
||||
const SystemTools = (function () {
|
||||
|
||||
interface ToolResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
type BluetoothScanParams = { duration_ms?: number | string, include_ble?: boolean };
|
||||
type BluetoothConnectParams = { address: string, uuid?: string };
|
||||
type BluetoothListenParams = { name?: string, uuid?: string };
|
||||
type BluetoothAcceptParams = { listener_session_id: string, timeout_ms?: number | string };
|
||||
type BluetoothPayloadParams = { session_id: string, text?: string, data_base64?: string };
|
||||
type BluetoothReadParams = { session_id: string, max_bytes?: number | string, timeout_ms?: number | string };
|
||||
type BluetoothSendAndReadParams = BluetoothPayloadParams & { max_bytes?: number | string, timeout_ms?: number | string };
|
||||
type BluetoothCloseParams = { session_id: string };
|
||||
type BluetoothBleConnectParams = { address: string, auto_connect?: boolean };
|
||||
type BluetoothBleDiscoverParams = { session_id: string, timeout_ms?: number | string };
|
||||
type BluetoothBleCharacteristicParams = { session_id: string, service_uuid: string, characteristic_uuid: string, timeout_ms?: number | string };
|
||||
type BluetoothBleWriteParams = { session_id: string, service_uuid: string, characteristic_uuid: string, text?: string, data_base64?: string };
|
||||
type BluetoothBleWriteAndReadParams = {
|
||||
session_id: string,
|
||||
write_service_uuid: string,
|
||||
write_characteristic_uuid: string,
|
||||
read_service_uuid: string,
|
||||
read_characteristic_uuid: string,
|
||||
text?: string,
|
||||
data_base64?: string,
|
||||
timeout_ms?: number | string
|
||||
};
|
||||
type BluetoothBleSubscribeParams = { session_id: string, service_uuid: string, characteristic_uuid: string, enable?: boolean };
|
||||
type BluetoothBleReadNotificationsParams = { session_id: string, limit?: number | string };
|
||||
|
||||
async function get_system_setting(params: { setting: string, namespace?: string }): Promise<ToolResponse> {
|
||||
const result = await Tools.System.getSetting(params.setting, params.namespace || 'system');
|
||||
return { success: true, message: '成功获取系统设置', data: result };
|
||||
}
|
||||
|
||||
async function modify_system_setting(params: { setting: string, value: string, namespace?: string }): Promise<ToolResponse> {
|
||||
const result = await Tools.System.setSetting(params.setting, params.value, params.namespace || 'system');
|
||||
const success = result && result.value === params.value;
|
||||
return { success: success, message: success ? '成功修改系统设置' : '修改系统设置失败', data: result };
|
||||
}
|
||||
|
||||
async function install_app(params: { path: string }): Promise<ToolResponse> {
|
||||
const result = await Tools.System.installApp(params.path);
|
||||
return { success: result.success, message: result.success ? '应用安装成功' : '应用安装失败', data: result };
|
||||
}
|
||||
|
||||
async function uninstall_app(params: { package_name: string, keep_data?: boolean }): Promise<ToolResponse> {
|
||||
const result = await Tools.System.uninstallApp(params.package_name);
|
||||
return { success: result.success, message: result.success ? '应用卸载成功' : '应用卸载失败', data: result };
|
||||
}
|
||||
|
||||
async function list_installed_apps(params: { include_system_apps?: boolean }): Promise<ToolResponse> {
|
||||
const result = await Tools.System.listApps(params.include_system_apps || false);
|
||||
return { success: true, message: '成功获取应用列表', data: result };
|
||||
}
|
||||
|
||||
async function start_app(params: { package_name: string, activity?: string }): Promise<ToolResponse> {
|
||||
const result = await Tools.System.startApp(params.package_name, params.activity);
|
||||
return { success: result.success, message: result.success ? '应用启动成功' : '应用启动失败', data: result };
|
||||
}
|
||||
|
||||
async function stop_app(params: { package_name: string }): Promise<ToolResponse> {
|
||||
const result = await Tools.System.stopApp(params.package_name);
|
||||
return { success: result.success, message: result.success ? '应用停止成功' : '应用停止失败', data: result };
|
||||
}
|
||||
|
||||
async function send_broadcast(params: { action: string, package_name?: string, component?: string, uri?: string, extras?: any, extra_key?: string, extra_value?: string, extra_key2?: string, extra_value2?: string }): Promise<ToolResponse> {
|
||||
const result = await Tools.System.sendBroadcast({
|
||||
action: params.action,
|
||||
uri: params.uri,
|
||||
package: params.package_name,
|
||||
component: params.component,
|
||||
extras: params.extras,
|
||||
extra_key: params.extra_key,
|
||||
extra_value: params.extra_value,
|
||||
extra_key2: params.extra_key2,
|
||||
extra_value2: params.extra_value2
|
||||
});
|
||||
return { success: true, message: '广播发送成功', data: result };
|
||||
}
|
||||
|
||||
async function execute_intent(params: { type?: 'activity' | 'broadcast' | 'service' | string, action?: string, uri?: string, package_name?: string, component?: string, flags?: number | number[] | string, extras?: any }): Promise<ToolResponse> {
|
||||
const flags = Array.isArray(params.flags) ? JSON.stringify(params.flags) : params.flags;
|
||||
const result = await Tools.System.intent({
|
||||
action: params.action,
|
||||
uri: params.uri,
|
||||
package: params.package_name,
|
||||
component: params.component,
|
||||
flags: flags as any,
|
||||
extras: params.extras,
|
||||
type: (params.type as any) || 'activity'
|
||||
});
|
||||
return { success: true, message: 'Intent 执行成功', data: result };
|
||||
}
|
||||
|
||||
async function get_notifications(params: { limit?: number, include_ongoing?: boolean }): Promise<ToolResponse> {
|
||||
const result = await Tools.System.getNotifications(params.limit || 10, params.include_ongoing || false);
|
||||
return { success: true, message: '成功获取通知', data: result };
|
||||
}
|
||||
|
||||
async function get_app_usage_time(params: { package_name?: string, since_hours?: number, limit?: number, include_system_apps?: boolean }): Promise<ToolResponse> {
|
||||
const result = await Tools.System.getAppUsageTime({
|
||||
packageName: params.package_name,
|
||||
sinceHours: params.since_hours || 24,
|
||||
limit: params.limit || 10,
|
||||
includeSystemApps: params.include_system_apps || false
|
||||
});
|
||||
return { success: true, message: '成功获取应用使用时长', data: result };
|
||||
}
|
||||
|
||||
async function get_device_location(params: { high_accuracy?: boolean, timeout?: number }): Promise<ToolResponse> {
|
||||
const result = await Tools.System.getLocation(params.high_accuracy || false, params.timeout || 10);
|
||||
return { success: true, message: '成功获取位置信息', data: result };
|
||||
}
|
||||
|
||||
async function request_bluetooth_permission(params: Record<string, never>): Promise<ToolResponse> {
|
||||
const result = await Tools.System.bluetooth.requestPermission();
|
||||
return { success: true, message: '成功请求蓝牙权限', data: result };
|
||||
}
|
||||
|
||||
async function get_bluetooth_state(params: Record<string, never>): Promise<ToolResponse> {
|
||||
const result = await Tools.System.bluetooth.getState();
|
||||
return { success: true, message: '成功获取蓝牙状态', data: result };
|
||||
}
|
||||
|
||||
async function request_enable_bluetooth(params: Record<string, never>): Promise<ToolResponse> {
|
||||
const result = await Tools.System.bluetooth.requestEnable();
|
||||
return { success: true, message: '已打开蓝牙开启请求', data: result };
|
||||
}
|
||||
|
||||
async function list_bluetooth_bonded_devices(params: Record<string, never>): Promise<ToolResponse> {
|
||||
const result = await Tools.System.bluetooth.listBondedDevices();
|
||||
return { success: true, message: '成功获取已配对蓝牙设备', data: result };
|
||||
}
|
||||
|
||||
async function scan_bluetooth_devices(params: BluetoothScanParams): Promise<ToolResponse> {
|
||||
const result = await Tools.System.bluetooth.scan({
|
||||
durationMs: params.duration_ms,
|
||||
includeBle: params.include_ble
|
||||
});
|
||||
return { success: true, message: '成功扫描蓝牙设备', data: result };
|
||||
}
|
||||
|
||||
async function bluetooth_connect(params: BluetoothConnectParams): Promise<ToolResponse> {
|
||||
const result = await Tools.System.bluetooth.connect({
|
||||
address: params.address,
|
||||
uuid: params.uuid
|
||||
});
|
||||
return { success: true, message: '成功连接蓝牙设备', data: result };
|
||||
}
|
||||
|
||||
async function bluetooth_listen(params: BluetoothListenParams): Promise<ToolResponse> {
|
||||
const result = await Tools.System.bluetooth.listen({
|
||||
name: params.name,
|
||||
uuid: params.uuid
|
||||
});
|
||||
return { success: true, message: '成功创建蓝牙监听', data: result };
|
||||
}
|
||||
|
||||
async function bluetooth_accept(params: BluetoothAcceptParams): Promise<ToolResponse> {
|
||||
const result = await Tools.System.bluetooth.accept(params.listener_session_id, params.timeout_ms);
|
||||
return { success: true, message: '成功接受蓝牙连接', data: result };
|
||||
}
|
||||
|
||||
async function bluetooth_send(params: BluetoothPayloadParams): Promise<ToolResponse> {
|
||||
const result = await Tools.System.bluetooth.send(params.session_id, {
|
||||
text: params.text,
|
||||
dataBase64: params.data_base64
|
||||
});
|
||||
return { success: true, message: '成功发送蓝牙数据', data: result };
|
||||
}
|
||||
|
||||
async function bluetooth_read(params: BluetoothReadParams): Promise<ToolResponse> {
|
||||
const result = await Tools.System.bluetooth.read(params.session_id, {
|
||||
maxBytes: params.max_bytes,
|
||||
timeoutMs: params.timeout_ms
|
||||
});
|
||||
return { success: true, message: '成功读取蓝牙数据', data: result };
|
||||
}
|
||||
|
||||
async function bluetooth_send_and_read(params: BluetoothSendAndReadParams): Promise<ToolResponse> {
|
||||
const result = await Tools.System.bluetooth.sendAndRead(params.session_id, {
|
||||
text: params.text,
|
||||
dataBase64: params.data_base64,
|
||||
maxBytes: params.max_bytes,
|
||||
timeoutMs: params.timeout_ms
|
||||
});
|
||||
return { success: true, message: '成功发送并读取蓝牙数据', data: result };
|
||||
}
|
||||
|
||||
async function bluetooth_close(params: BluetoothCloseParams): Promise<ToolResponse> {
|
||||
const result = await Tools.System.bluetooth.close(params.session_id);
|
||||
return { success: true, message: '成功关闭蓝牙会话', data: result };
|
||||
}
|
||||
|
||||
async function bluetooth_ble_connect(params: BluetoothBleConnectParams): Promise<ToolResponse> {
|
||||
const result = await Tools.System.bluetooth.ble.connect({
|
||||
address: params.address,
|
||||
autoConnect: params.auto_connect
|
||||
});
|
||||
return { success: true, message: '成功连接 BLE 设备', data: result };
|
||||
}
|
||||
|
||||
async function bluetooth_ble_discover_services(params: BluetoothBleDiscoverParams): Promise<ToolResponse> {
|
||||
const result = await Tools.System.bluetooth.ble.discoverServices(params.session_id, params.timeout_ms);
|
||||
return { success: true, message: '成功发现 BLE 服务', data: result };
|
||||
}
|
||||
|
||||
async function bluetooth_ble_read_characteristic(params: BluetoothBleCharacteristicParams): Promise<ToolResponse> {
|
||||
const result = await Tools.System.bluetooth.ble.readCharacteristic(params.session_id, {
|
||||
serviceUuid: params.service_uuid,
|
||||
characteristicUuid: params.characteristic_uuid,
|
||||
timeoutMs: params.timeout_ms
|
||||
});
|
||||
return { success: true, message: '成功读取 BLE characteristic', data: result };
|
||||
}
|
||||
|
||||
async function bluetooth_ble_write_characteristic(params: BluetoothBleWriteParams): Promise<ToolResponse> {
|
||||
const result = await Tools.System.bluetooth.ble.writeCharacteristic(params.session_id, {
|
||||
serviceUuid: params.service_uuid,
|
||||
characteristicUuid: params.characteristic_uuid,
|
||||
text: params.text,
|
||||
dataBase64: params.data_base64
|
||||
});
|
||||
return { success: true, message: '成功写入 BLE characteristic', data: result };
|
||||
}
|
||||
|
||||
async function bluetooth_ble_write_and_read_characteristic(params: BluetoothBleWriteAndReadParams): Promise<ToolResponse> {
|
||||
const result = await Tools.System.bluetooth.ble.writeAndReadCharacteristic(params.session_id, {
|
||||
writeServiceUuid: params.write_service_uuid,
|
||||
writeCharacteristicUuid: params.write_characteristic_uuid,
|
||||
readServiceUuid: params.read_service_uuid,
|
||||
readCharacteristicUuid: params.read_characteristic_uuid,
|
||||
text: params.text,
|
||||
dataBase64: params.data_base64,
|
||||
timeoutMs: params.timeout_ms
|
||||
});
|
||||
return { success: true, message: '成功写入并读取 BLE characteristic', data: result };
|
||||
}
|
||||
|
||||
async function bluetooth_ble_subscribe_characteristic(params: BluetoothBleSubscribeParams): Promise<ToolResponse> {
|
||||
const result = await Tools.System.bluetooth.ble.subscribe(params.session_id, {
|
||||
serviceUuid: params.service_uuid,
|
||||
characteristicUuid: params.characteristic_uuid,
|
||||
enable: params.enable
|
||||
});
|
||||
return { success: true, message: '成功更新 BLE 订阅', data: result };
|
||||
}
|
||||
|
||||
async function bluetooth_ble_read_notifications(params: BluetoothBleReadNotificationsParams): Promise<ToolResponse> {
|
||||
const result = await Tools.System.bluetooth.ble.readNotifications(params.session_id, params.limit);
|
||||
return { success: true, message: '成功读取 BLE 通知', data: result };
|
||||
}
|
||||
|
||||
async function get_device_info(params: {}): Promise<ToolResponse> {
|
||||
const result = await Tools.System.getDeviceInfo();
|
||||
return { success: true, message: '成功获取设备信息', data: result };
|
||||
}
|
||||
|
||||
async function wrapToolExecution<P>(func: (params: P) => Promise<ToolResponse>, params: P) {
|
||||
try {
|
||||
const result = await func(params);
|
||||
complete(result);
|
||||
} catch (error: any) {
|
||||
console.error(`Tool ${func.name} failed unexpectedly`, error);
|
||||
complete({
|
||||
success: false,
|
||||
message: `工具执行时发生意外错误: ${error.message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("=== System Tools 全面测试开始 ===\n");
|
||||
const results: any[] = [];
|
||||
|
||||
try {
|
||||
// 1. 测试 get_device_info(新增工具)
|
||||
console.log("1. 测试 get_device_info...");
|
||||
try {
|
||||
const deviceInfoResult = await get_device_info({});
|
||||
results.push({ tool: 'get_device_info', result: deviceInfoResult });
|
||||
console.log("✓ get_device_info 测试完成");
|
||||
if (deviceInfoResult.data) {
|
||||
console.log(` 设备信息: ${JSON.stringify(deviceInfoResult.data).substring(0, 100)}...`);
|
||||
}
|
||||
console.log();
|
||||
} catch (error: any) {
|
||||
console.log("⚠ get_device_info 测试失败:", error.message, "\n");
|
||||
results.push({ tool: 'get_device_info', result: { success: false, message: error.message } });
|
||||
}
|
||||
|
||||
// 2. 测试 get_notifications
|
||||
console.log("2. 测试 get_notifications...");
|
||||
try {
|
||||
const notificationsResult = await get_notifications({ limit: 5, include_ongoing: false });
|
||||
results.push({ tool: 'get_notifications', result: notificationsResult });
|
||||
console.log("✓ get_notifications 测试完成");
|
||||
if (notificationsResult.data) {
|
||||
console.log(` 获取到 ${notificationsResult.data.length || 0} 条通知`);
|
||||
}
|
||||
console.log();
|
||||
} catch (error: any) {
|
||||
console.log("⚠ get_notifications 测试失败:", error.message, "\n");
|
||||
results.push({ tool: 'get_notifications', result: { success: false, message: error.message } });
|
||||
}
|
||||
|
||||
// 3. 测试 get_app_usage_time
|
||||
console.log("3. 测试 get_app_usage_time...");
|
||||
try {
|
||||
const usageResult = await get_app_usage_time({ since_hours: 24, limit: 5, include_system_apps: false });
|
||||
results.push({ tool: 'get_app_usage_time', result: usageResult });
|
||||
console.log("✓ get_app_usage_time 测试完成");
|
||||
if (usageResult.data) {
|
||||
console.log(` 返回条目数: ${usageResult.data.entries ? usageResult.data.entries.length : 0}`);
|
||||
}
|
||||
console.log();
|
||||
} catch (error: any) {
|
||||
console.log("⚠ get_app_usage_time 测试失败(可能需要使用情况访问权限):", error.message, "\n");
|
||||
results.push({ tool: 'get_app_usage_time', result: { success: false, message: error.message } });
|
||||
}
|
||||
|
||||
// 4. 测试 get_device_location
|
||||
console.log("4. 测试 get_device_location...");
|
||||
try {
|
||||
const locationResult = await get_device_location({ high_accuracy: false, timeout: 5 });
|
||||
results.push({ tool: 'get_device_location', result: locationResult });
|
||||
console.log("✓ get_device_location 测试完成");
|
||||
if (locationResult.data) {
|
||||
console.log(` 位置: ${JSON.stringify(locationResult.data)}`);
|
||||
}
|
||||
console.log();
|
||||
} catch (error: any) {
|
||||
console.log("⚠ get_device_location 测试失败(可能需要位置权限):", error.message, "\n");
|
||||
results.push({ tool: 'get_device_location', result: { success: false, message: error.message } });
|
||||
}
|
||||
|
||||
// 5. 测试 list_installed_apps
|
||||
console.log("5. 测试 list_installed_apps...");
|
||||
try {
|
||||
const appsResult = await list_installed_apps({ include_system_apps: false });
|
||||
results.push({ tool: 'list_installed_apps', result: appsResult });
|
||||
console.log("✓ list_installed_apps 测试完成");
|
||||
if (appsResult.data) {
|
||||
console.log(` 已安装应用数量: ${appsResult.data.length || 0}`);
|
||||
}
|
||||
console.log();
|
||||
} catch (error: any) {
|
||||
console.log("⚠ list_installed_apps 测试失败(可能需要用户授权):", error.message, "\n");
|
||||
results.push({ tool: 'list_installed_apps', result: { success: false, message: error.message } });
|
||||
}
|
||||
|
||||
// 6. 测试 get_system_setting(读取一个常见的系统设置)
|
||||
console.log("6. 测试 get_system_setting...");
|
||||
try {
|
||||
const settingResult = await get_system_setting({ setting: 'screen_brightness', namespace: 'system' });
|
||||
results.push({ tool: 'get_system_setting', result: settingResult });
|
||||
console.log("✓ get_system_setting 测试完成");
|
||||
if (settingResult.data) {
|
||||
console.log(` screen_brightness = ${settingResult.data.value || settingResult.data}`);
|
||||
}
|
||||
console.log();
|
||||
} catch (error: any) {
|
||||
console.log("⚠ get_system_setting 测试失败:", error.message, "\n");
|
||||
results.push({ tool: 'get_system_setting', result: { success: false, message: error.message } });
|
||||
}
|
||||
|
||||
// 7-11. 其他需要用户授权或可能造成系统变更的工具,仅做说明不实际执行
|
||||
console.log("7-11. 跳过破坏性/需要特殊权限的工具测试:");
|
||||
console.log(" ⊘ modify_system_setting - 需要WRITE_SETTINGS权限,会修改系统设置");
|
||||
console.log(" ⊘ install_app - 需要INSTALL_PACKAGES权限");
|
||||
console.log(" ⊘ uninstall_app - 需要DELETE_PACKAGES权限");
|
||||
console.log(" ⊘ start_app - 需要应用包名,可能会启动应用");
|
||||
console.log(" ⊘ stop_app - 需要KILL_BACKGROUND_PROCESSES权限\n");
|
||||
|
||||
results.push({ tool: 'modify_system_setting', result: { success: null, message: '未测试(避免修改系统)' } });
|
||||
results.push({ tool: 'install_app', result: { success: null, message: '未测试(需要APK文件)' } });
|
||||
results.push({ tool: 'uninstall_app', result: { success: null, message: '未测试(避免卸载应用)' } });
|
||||
results.push({ tool: 'start_app', result: { success: null, message: '未测试(避免启动应用)' } });
|
||||
results.push({ tool: 'stop_app', result: { success: null, message: '未测试(避免停止应用)' } });
|
||||
|
||||
console.log("=== System Tools 测试完成 ===\n");
|
||||
console.log("测试结果汇总:");
|
||||
results.forEach((r, i) => {
|
||||
const status = r.result.success === true ? '✓' : r.result.success === false ? '✗' : '⊘';
|
||||
console.log(`${i + 1}. ${status} ${r.tool}: ${r.result.message}`);
|
||||
});
|
||||
|
||||
const successCount = results.filter(r => r.result.success === true).length;
|
||||
const failCount = results.filter(r => r.result.success === false).length;
|
||||
const skipCount = results.filter(r => r.result.success === null).length;
|
||||
|
||||
console.log(`\n总计: ${successCount} 成功, ${failCount} 失败, ${skipCount} 跳过`);
|
||||
|
||||
complete({
|
||||
success: true,
|
||||
message: "系统工具全面测试完成",
|
||||
data: {
|
||||
results,
|
||||
summary: {
|
||||
total: results.length,
|
||||
success: successCount,
|
||||
failed: failCount,
|
||||
skipped: skipCount
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("测试过程中发生错误:", error);
|
||||
complete({
|
||||
success: false,
|
||||
message: `测试失败: ${error.message}`,
|
||||
data: results
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
get_system_setting: (params: { setting: string, namespace?: string }) => wrapToolExecution(get_system_setting, params),
|
||||
modify_system_setting: (params: { setting: string, value: string, namespace?: string }) => wrapToolExecution(modify_system_setting, params),
|
||||
install_app: (params: { path: string }) => wrapToolExecution(install_app, params),
|
||||
uninstall_app: (params: { package_name: string, keep_data?: boolean }) => wrapToolExecution(uninstall_app, params),
|
||||
list_installed_apps: (params: { include_system_apps?: boolean }) => wrapToolExecution(list_installed_apps, params),
|
||||
start_app: (params: { package_name: string, activity?: string }) => wrapToolExecution(start_app, params),
|
||||
stop_app: (params: { package_name: string }) => wrapToolExecution(stop_app, params),
|
||||
send_broadcast: (params: { action: string, package_name?: string, component?: string, uri?: string, extras?: any }) => wrapToolExecution(send_broadcast, params),
|
||||
execute_intent: (params: { type?: 'activity' | 'broadcast' | 'service' | string, action?: string, uri?: string, package_name?: string, component?: string, flags?: number | number[] | string, extras?: any }) => wrapToolExecution(execute_intent, params),
|
||||
get_notifications: (params: { limit?: number, include_ongoing?: boolean }) => wrapToolExecution(get_notifications, params),
|
||||
get_app_usage_time: (params: { package_name?: string, since_hours?: number, limit?: number, include_system_apps?: boolean }) => wrapToolExecution(get_app_usage_time, params),
|
||||
get_device_location: (params: { high_accuracy?: boolean, timeout?: number }) => wrapToolExecution(get_device_location, params),
|
||||
request_bluetooth_permission: (params: Record<string, never>) => wrapToolExecution(request_bluetooth_permission, params),
|
||||
get_bluetooth_state: (params: Record<string, never>) => wrapToolExecution(get_bluetooth_state, params),
|
||||
request_enable_bluetooth: (params: Record<string, never>) => wrapToolExecution(request_enable_bluetooth, params),
|
||||
list_bluetooth_bonded_devices: (params: Record<string, never>) => wrapToolExecution(list_bluetooth_bonded_devices, params),
|
||||
scan_bluetooth_devices: (params: BluetoothScanParams) => wrapToolExecution(scan_bluetooth_devices, params),
|
||||
bluetooth_connect: (params: BluetoothConnectParams) => wrapToolExecution(bluetooth_connect, params),
|
||||
bluetooth_listen: (params: BluetoothListenParams) => wrapToolExecution(bluetooth_listen, params),
|
||||
bluetooth_accept: (params: BluetoothAcceptParams) => wrapToolExecution(bluetooth_accept, params),
|
||||
bluetooth_send: (params: BluetoothPayloadParams) => wrapToolExecution(bluetooth_send, params),
|
||||
bluetooth_read: (params: BluetoothReadParams) => wrapToolExecution(bluetooth_read, params),
|
||||
bluetooth_send_and_read: (params: BluetoothSendAndReadParams) => wrapToolExecution(bluetooth_send_and_read, params),
|
||||
bluetooth_close: (params: BluetoothCloseParams) => wrapToolExecution(bluetooth_close, params),
|
||||
bluetooth_ble_connect: (params: BluetoothBleConnectParams) => wrapToolExecution(bluetooth_ble_connect, params),
|
||||
bluetooth_ble_discover_services: (params: BluetoothBleDiscoverParams) => wrapToolExecution(bluetooth_ble_discover_services, params),
|
||||
bluetooth_ble_read_characteristic: (params: BluetoothBleCharacteristicParams) => wrapToolExecution(bluetooth_ble_read_characteristic, params),
|
||||
bluetooth_ble_write_characteristic: (params: BluetoothBleWriteParams) => wrapToolExecution(bluetooth_ble_write_characteristic, params),
|
||||
bluetooth_ble_write_and_read_characteristic: (params: BluetoothBleWriteAndReadParams) => wrapToolExecution(bluetooth_ble_write_and_read_characteristic, params),
|
||||
bluetooth_ble_subscribe_characteristic: (params: BluetoothBleSubscribeParams) => wrapToolExecution(bluetooth_ble_subscribe_characteristic, params),
|
||||
bluetooth_ble_read_notifications: (params: BluetoothBleReadNotificationsParams) => wrapToolExecution(bluetooth_ble_read_notifications, params),
|
||||
get_device_info: (params: {}) => wrapToolExecution(get_device_info, params),
|
||||
main,
|
||||
};
|
||||
})();
|
||||
|
||||
exports.get_system_setting = SystemTools.get_system_setting;
|
||||
exports.modify_system_setting = SystemTools.modify_system_setting;
|
||||
exports.install_app = SystemTools.install_app;
|
||||
exports.uninstall_app = SystemTools.uninstall_app;
|
||||
exports.list_installed_apps = SystemTools.list_installed_apps;
|
||||
exports.start_app = SystemTools.start_app;
|
||||
exports.stop_app = SystemTools.stop_app;
|
||||
exports.send_broadcast = SystemTools.send_broadcast;
|
||||
exports.execute_intent = SystemTools.execute_intent;
|
||||
exports.get_notifications = SystemTools.get_notifications;
|
||||
exports.get_app_usage_time = SystemTools.get_app_usage_time;
|
||||
exports.get_device_location = SystemTools.get_device_location;
|
||||
exports.request_bluetooth_permission = SystemTools.request_bluetooth_permission;
|
||||
exports.get_bluetooth_state = SystemTools.get_bluetooth_state;
|
||||
exports.request_enable_bluetooth = SystemTools.request_enable_bluetooth;
|
||||
exports.list_bluetooth_bonded_devices = SystemTools.list_bluetooth_bonded_devices;
|
||||
exports.scan_bluetooth_devices = SystemTools.scan_bluetooth_devices;
|
||||
exports.bluetooth_connect = SystemTools.bluetooth_connect;
|
||||
exports.bluetooth_listen = SystemTools.bluetooth_listen;
|
||||
exports.bluetooth_accept = SystemTools.bluetooth_accept;
|
||||
exports.bluetooth_send = SystemTools.bluetooth_send;
|
||||
exports.bluetooth_read = SystemTools.bluetooth_read;
|
||||
exports.bluetooth_send_and_read = SystemTools.bluetooth_send_and_read;
|
||||
exports.bluetooth_close = SystemTools.bluetooth_close;
|
||||
exports.bluetooth_ble_connect = SystemTools.bluetooth_ble_connect;
|
||||
exports.bluetooth_ble_discover_services = SystemTools.bluetooth_ble_discover_services;
|
||||
exports.bluetooth_ble_read_characteristic = SystemTools.bluetooth_ble_read_characteristic;
|
||||
exports.bluetooth_ble_write_characteristic = SystemTools.bluetooth_ble_write_characteristic;
|
||||
exports.bluetooth_ble_write_and_read_characteristic = SystemTools.bluetooth_ble_write_and_read_characteristic;
|
||||
exports.bluetooth_ble_subscribe_characteristic = SystemTools.bluetooth_ble_subscribe_characteristic;
|
||||
exports.bluetooth_ble_read_notifications = SystemTools.bluetooth_ble_read_notifications;
|
||||
exports.get_device_info = SystemTools.get_device_info;
|
||||
exports.main = SystemTools.main;
|
||||
@@ -0,0 +1,60 @@
|
||||
/* METADATA
|
||||
{
|
||||
"name": "tasker",
|
||||
|
||||
"display_name": {
|
||||
"zh": "Tasker 触发器",
|
||||
"en": "Tasker Trigger"
|
||||
},
|
||||
"description": {
|
||||
"zh": "集成 Tasker 插件事件触发工具,通过本包可向 Tasker 发送事件。",
|
||||
"en": "Integration for triggering Tasker plugin events. This package lets you send events to Tasker."
|
||||
},
|
||||
"enabledByDefault": false,
|
||||
"category": "Workflow",
|
||||
"tools": [
|
||||
{
|
||||
"name": "trigger_tasker_event",
|
||||
"description": {
|
||||
"zh": "触发一个 Tasker 事件。使用 task_type 指定事件类型,可传 arg1..arg5 或 args_json。",
|
||||
"en": "Trigger a Tasker event. Use task_type to specify the event type. You can pass arg1..arg5 or args_json."
|
||||
},
|
||||
"parameters": [
|
||||
{ "name": "task_type", "description": { "zh": "事件类型标识", "en": "Event type identifier" }, "type": "string", "required": true },
|
||||
{ "name": "arg1", "description": { "zh": "可选参数1", "en": "Optional argument 1" }, "type": "string", "required": false },
|
||||
{ "name": "arg2", "description": { "zh": "可选参数2", "en": "Optional argument 2" }, "type": "string", "required": false },
|
||||
{ "name": "arg3", "description": { "zh": "可选参数3", "en": "Optional argument 3" }, "type": "string", "required": false },
|
||||
{ "name": "arg4", "description": { "zh": "可选参数4", "en": "Optional argument 4" }, "type": "string", "required": false },
|
||||
{ "name": "arg5", "description": { "zh": "可选参数5", "en": "Optional argument 5" }, "type": "string", "required": false },
|
||||
{ "name": "args_json", "description": { "zh": "以JSON形式传递任意参数", "en": "Pass arbitrary parameters as a JSON string" }, "type": "string", "required": false }
|
||||
]
|
||||
}
|
||||
]
|
||||
}*/
|
||||
/// <reference path="./types/index.d.ts" />
|
||||
const TaskerIntegration = (function () {
|
||||
async function trigger_tasker_event(params: Tasker.TriggerTaskerEventParams) {
|
||||
const data: string = await Tools.Tasker.triggerEvent(params);
|
||||
return {
|
||||
success: true,
|
||||
message: "Tasker 事件已触发",
|
||||
data
|
||||
};
|
||||
}
|
||||
|
||||
async function wrapToolExecution<T>(func: (params: any) => Promise<T>, params?: any) {
|
||||
try {
|
||||
const result = await func(params || {});
|
||||
complete(result);
|
||||
} catch (error: any) {
|
||||
console.error(`Tool ${func.name} failed unexpectedly`, error);
|
||||
complete({ success: false, message: String(error && error.message ? error.message : error) });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
trigger_tasker_event: (params?: Tasker.TriggerTaskerEventParams) => wrapToolExecution(trigger_tasker_event, params)
|
||||
};
|
||||
})();
|
||||
|
||||
exports.trigger_tasker_event = TaskerIntegration.trigger_tasker_event;
|
||||
@@ -0,0 +1,198 @@
|
||||
/* METADATA
|
||||
{
|
||||
"name": "tavily",
|
||||
|
||||
"display_name": {
|
||||
"zh": "Tavily 搜索",
|
||||
"en": "Tavily Search"
|
||||
},
|
||||
"description": {
|
||||
"zh": "使用Tavily API执行高级网络搜索、内容提取、网站爬取和站点地图生成。",
|
||||
"en": "Use the Tavily API for advanced web search, content extraction, website crawling, and sitemap generation."
|
||||
},
|
||||
"env": [
|
||||
"TAVILY_API_KEY"
|
||||
],
|
||||
"category": "Search",
|
||||
"tools": [
|
||||
{
|
||||
"name": "search",
|
||||
"description": { "zh": "一个强大的网络搜索工具,使用Tavily的AI搜索引擎提供全面、实时的结果。", "en": "Powerful web search using Tavily's AI search engine for comprehensive, up-to-date results." },
|
||||
"parameters": [
|
||||
{ "name": "query", "description": { "zh": "搜索查询", "en": "Search query" }, "type": "string", "required": true },
|
||||
{ "name": "search_depth", "description": { "zh": "搜索深度,可以是 'basic' 或 'advanced'", "en": "Search depth: 'basic' or 'advanced'" }, "type": "string", "required": false, "default": "basic" },
|
||||
{ "name": "topic", "description": { "zh": "搜索类别,可以是 'general' 或 'news'", "en": "Search topic: 'general' or 'news'" }, "type": "string", "required": false, "default": "general" },
|
||||
{ "name": "days", "description": { "zh": "包含在搜索结果中的过去天数", "en": "Number of past days to include in results" }, "type": "number", "required": false },
|
||||
{ "name": "max_results", "description": { "zh": "返回的最大搜索结果数", "en": "Maximum number of search results" }, "type": "number", "required": false, "default": 10 },
|
||||
{ "name": "include_images", "description": { "zh": "在响应中包含与查询相关的图像列表", "en": "Include a list of images related to the query in the response" }, "type": "boolean", "required": false, "default": false },
|
||||
{ "name": "include_raw_content", "description": { "zh": "包含每个搜索结果的已清理和解析的HTML内容", "en": "Include cleaned and parsed HTML content for each search result" }, "type": "boolean", "required": false, "default": false },
|
||||
{ "name": "include_domains", "description": { "zh": "要专门包含在搜索结果中的域名列表", "en": "Domain list to specifically include in results" }, "type": "array", "required": false, "default": [] },
|
||||
{ "name": "exclude_domains", "description": { "zh": "要专门排除的域名列表", "en": "Domain list to specifically exclude from results" }, "type": "array", "required": false, "default": [] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "extract",
|
||||
"description": { "zh": "一个强大的网页内容提取工具,从指定的URL检索和处理原始内容。", "en": "Extract web content from specified URLs and process the raw content." },
|
||||
"parameters": [
|
||||
{ "name": "urls", "description": { "zh": "要提取内容的URL列表", "en": "List of URLs to extract content from" }, "type": "array", "required": true },
|
||||
{ "name": "extract_depth", "description": { "zh": "提取深度 - 'basic' 或 'advanced'", "en": "Extraction depth: 'basic' or 'advanced'" }, "type": "string", "required": false, "default": "basic" },
|
||||
{ "name": "include_images", "description": { "zh": "在响应中包含从URL中提取的图像列表", "en": "Include a list of images extracted from the URL(s)" }, "type": "boolean", "required": false, "default": false },
|
||||
{ "name": "format", "description": { "zh": "提取内容的格式,'markdown' 或 'text'", "en": "Output format: 'markdown' or 'text'" }, "type": "string", "required": false, "default": "markdown" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "crawl",
|
||||
"description": { "zh": "一个强大的网络爬虫,从指定的基URL开始结构化地爬取网站。", "en": "Structured website crawler starting from a base URL." },
|
||||
"parameters": [
|
||||
{ "name": "url", "description": { "zh": "开始爬取的根URL", "en": "Root URL to start crawling from" }, "type": "string", "required": true },
|
||||
{ "name": "max_depth", "description": { "zh": "爬取的最大深度", "en": "Maximum crawl depth" }, "type": "number", "required": false, "default": 1 },
|
||||
{ "name": "max_breadth", "description": { "zh": "每层要跟随的最大链接数", "en": "Maximum number of links to follow per depth level" }, "type": "number", "required": false, "default": 20 },
|
||||
{ "name": "limit", "description": { "zh": "爬虫将处理的总链接数", "en": "Total number of links the crawler will process" }, "type": "number", "required": false, "default": 50 },
|
||||
{ "name": "instructions", "description": { "zh": "给爬虫的自然语言指令", "en": "Natural-language instructions for the crawler" }, "type": "string", "required": false },
|
||||
{ "name": "allow_external", "description": { "zh": "是否允许跟随外部域名的链接", "en": "Whether to allow following links to external domains" }, "type": "boolean", "required": false, "default": false },
|
||||
{ "name": "format", "description": { "zh": "提取内容的格式,'markdown' 或 'text'", "en": "Output format: 'markdown' or 'text'" }, "type": "string", "required": false, "default": "markdown" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "map",
|
||||
"description": { "zh": "一个强大的网站地图工具,创建网站URL的结构化地图。", "en": "Generate a structured sitemap of website URLs." },
|
||||
"parameters": [
|
||||
{ "name": "url", "description": { "zh": "开始映射的根URL", "en": "Root URL to start mapping from" }, "type": "string", "required": true },
|
||||
{ "name": "max_depth", "description": { "zh": "映射的最大深度", "en": "Maximum mapping depth" }, "type": "number", "required": false, "default": 1 },
|
||||
{ "name": "max_breadth", "description": { "zh": "每层要跟随的最大链接数", "en": "Maximum number of links to follow per depth level" }, "type": "number", "required": false, "default": 20 },
|
||||
{ "name": "limit", "description": { "zh": "爬虫将处理的总链接数", "en": "Total number of links the crawler will process" }, "type": "number", "required": false, "default": 50 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}*/
|
||||
|
||||
const tavily = (function () {
|
||||
const client = OkHttp.newClient();
|
||||
const BASE_URLS = {
|
||||
search: 'https://api.tavily.com/search',
|
||||
extract: 'https://api.tavily.com/extract',
|
||||
crawl: 'https://api.tavily.com/crawl',
|
||||
map: 'https://api.tavily.com/map'
|
||||
};
|
||||
|
||||
async function makeTavilyRequest(endpoint: string, params: any): Promise<any> {
|
||||
const apiKey = getEnv("TAVILY_API_KEY");
|
||||
if (!apiKey) {
|
||||
throw new Error("Tavily API key is not set. Please configure it in the environment variables.");
|
||||
}
|
||||
|
||||
const requestBody = { ...params };
|
||||
|
||||
const headers = {
|
||||
'accept': 'application/json',
|
||||
'content-type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`
|
||||
};
|
||||
|
||||
try {
|
||||
const request = client.newRequest()
|
||||
.url(endpoint)
|
||||
.method('POST')
|
||||
.headers(headers)
|
||||
.body(JSON.stringify(requestBody), 'json');
|
||||
|
||||
const response = await request.build().execute();
|
||||
|
||||
if (!response.isSuccessful()) {
|
||||
throw new Error(`Tavily API Error: ${response.statusCode} - ${response.content}`);
|
||||
}
|
||||
return JSON.parse(response.content);
|
||||
} catch (error: any) {
|
||||
console.error(`Tavily request failed: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function formatSearchResults(response: any): string {
|
||||
const output: string[] = [];
|
||||
if (response.answer) {
|
||||
output.push(`Answer: ${response.answer}`);
|
||||
}
|
||||
output.push('Detailed Results:');
|
||||
response.results.forEach((result: any) => {
|
||||
output.push(`\nTitle: ${result.title}`);
|
||||
output.push(`URL: ${result.url}`);
|
||||
output.push(`Content: ${result.content}`);
|
||||
if (result.raw_content) {
|
||||
output.push(`Raw Content: ${result.raw_content}`);
|
||||
}
|
||||
});
|
||||
return output.join('\n');
|
||||
}
|
||||
|
||||
function formatCrawlResults(response: any): string {
|
||||
const output: string[] = [];
|
||||
output.push(`Crawl Results:`);
|
||||
output.push(`Base URL: ${response.base_url}`);
|
||||
output.push('\nCrawled Pages:');
|
||||
response.results.forEach((page: any, index: number) => {
|
||||
output.push(`\n[${index + 1}] URL: ${page.url}`);
|
||||
if (page.raw_content) {
|
||||
const contentPreview = page.raw_content.length > 200
|
||||
? page.raw_content.substring(0, 200) + "..."
|
||||
: page.raw_content;
|
||||
output.push(`Content: ${contentPreview}`);
|
||||
}
|
||||
});
|
||||
return output.join('\n');
|
||||
}
|
||||
|
||||
function formatMapResults(response: any): string {
|
||||
const output: string[] = [];
|
||||
output.push(`Site Map Results:`);
|
||||
output.push(`Base URL: ${response.base_url}`);
|
||||
output.push('\nMapped Pages:');
|
||||
response.results.forEach((page: string, index: number) => {
|
||||
output.push(`\n[${index + 1}] URL: ${page}`);
|
||||
});
|
||||
return output.join('\n');
|
||||
}
|
||||
|
||||
async function search(params: any) {
|
||||
const response = await makeTavilyRequest(BASE_URLS.search, params);
|
||||
return formatSearchResults(response);
|
||||
}
|
||||
|
||||
async function extract(params: any) {
|
||||
const response = await makeTavilyRequest(BASE_URLS.extract, params);
|
||||
// Extract uses the same format as search
|
||||
return formatSearchResults(response);
|
||||
}
|
||||
|
||||
async function crawl(params: any) {
|
||||
const response = await makeTavilyRequest(BASE_URLS.crawl, params);
|
||||
return formatCrawlResults(response);
|
||||
}
|
||||
|
||||
async function map(params: any) {
|
||||
const response = await makeTavilyRequest(BASE_URLS.map, params);
|
||||
return formatMapResults(response);
|
||||
}
|
||||
|
||||
async function wrap(func: (params: any) => Promise<any>, params: any, successMessage: string, failMessage: string) {
|
||||
try {
|
||||
const result = await func(params);
|
||||
complete({ success: true, message: successMessage, data: result });
|
||||
} catch (error: any) {
|
||||
console.error(`Function ${func.name} failed! Error: ${error.message}`);
|
||||
complete({ success: false, message: `${failMessage}: ${error.message}`, error_stack: error.stack });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
search: (p: any) => wrap(search, p, '搜索成功', '搜索失败'),
|
||||
extract: (p: any) => wrap(extract, p, '提取成功', '提取失败'),
|
||||
crawl: (p: any) => wrap(crawl, p, '爬取成功', '爬取失败'),
|
||||
map: (p: any) => wrap(map, p, '映射成功', '映射失败')
|
||||
};
|
||||
})();
|
||||
|
||||
exports.search = tavily.search;
|
||||
exports.extract = tavily.extract;
|
||||
exports.crawl = tavily.crawl;
|
||||
exports.map = tavily.map;
|
||||
@@ -0,0 +1,88 @@
|
||||
/* METADATA
|
||||
{
|
||||
name: time
|
||||
|
||||
display_name: {
|
||||
zh: "时间"
|
||||
en: "Time"
|
||||
}
|
||||
description: {
|
||||
zh: "提供时间相关功能。实际上,激活本包的同时已经能够获取时间了。"
|
||||
en: "Provides time-related utilities. In practice, current time is already available once this package is enabled."
|
||||
}
|
||||
enabledByDefault: true
|
||||
category: "Utility"
|
||||
tools: [
|
||||
{
|
||||
name: get_time
|
||||
description: {
|
||||
zh: "获取当前时间。当使用此包时,AI已经自动获取了当前的时间信息。"
|
||||
en: "Get the current time. When using this package, the AI may already have the current time context."
|
||||
}
|
||||
parameters: []
|
||||
},
|
||||
{
|
||||
name: format_time
|
||||
description: {
|
||||
zh: "格式化时间。提供各种时间格式化选项。"
|
||||
en: "Format time. Provides various time formatting options."
|
||||
}
|
||||
parameters: []
|
||||
}
|
||||
]
|
||||
}*/
|
||||
|
||||
const timePackage = (function () {
|
||||
async function get_time(): Promise<any> {
|
||||
const now = new Date();
|
||||
|
||||
return {
|
||||
timestamp: now.getTime(),
|
||||
iso: now.toISOString(),
|
||||
local: now.toLocaleString(),
|
||||
date: {
|
||||
year: now.getFullYear(),
|
||||
month: now.getMonth() + 1,
|
||||
day: now.getDate(),
|
||||
weekday: now.toLocaleDateString(undefined, { weekday: 'long' })
|
||||
},
|
||||
time: {
|
||||
hours: now.getHours(),
|
||||
minutes: now.getMinutes(),
|
||||
seconds: now.getSeconds()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function format_time(): Promise<any> {
|
||||
const now = new Date();
|
||||
|
||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||
|
||||
const hours = now.getHours();
|
||||
const minutes = now.getMinutes();
|
||||
const seconds = now.getSeconds();
|
||||
|
||||
const time24h = `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
|
||||
|
||||
const suffix = hours >= 12 ? 'PM' : 'AM';
|
||||
const hour12 = hours % 12 === 0 ? 12 : hours % 12;
|
||||
const time12h = `${pad(hour12)}:${pad(minutes)}:${pad(seconds)} ${suffix}`;
|
||||
|
||||
return {
|
||||
iso: now.toISOString(),
|
||||
date: `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`,
|
||||
time24h,
|
||||
time12h
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
get_time,
|
||||
format_time
|
||||
};
|
||||
})();
|
||||
|
||||
// 逐个导出
|
||||
exports.get_time = timePackage.get_time;
|
||||
exports.format_time = timePackage.format_time;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,74 @@
|
||||
/* METADATA
|
||||
{
|
||||
name: "vflow_trigger"
|
||||
display_name: {
|
||||
zh: "VFlow 触发器"
|
||||
en: "VFlow Trigger"
|
||||
}
|
||||
description: {
|
||||
zh: "触发vflow app的工作流。"
|
||||
en: "Trigger VFlow app workflows."
|
||||
}
|
||||
enabledByDefault: false
|
||||
category: "Workflow"
|
||||
tools: [
|
||||
{
|
||||
name: "trigger_vflow_workflow"
|
||||
description: {
|
||||
zh: "根据 workflow_id 触发 VFlow 工作流(需安装 com.chaomixian.vflow)。"
|
||||
en: "Trigger a VFlow workflow by workflow_id (requires com.chaomixian.vflow installed)."
|
||||
}
|
||||
parameters: [
|
||||
{ name: "workflow_id", description: { zh: "工作流 ID", en: "Workflow ID" }, type: "string", required: true }
|
||||
]
|
||||
}
|
||||
]
|
||||
}*/
|
||||
|
||||
/// <reference path="./types/index.d.ts" />
|
||||
|
||||
const VFlowTrigger = (function () {
|
||||
const DEFAULT_ACTION = "com.chaomixian.vflow.EXECUTE_WORKFLOW_SHORTCUT";
|
||||
const DEFAULT_COMPONENT = "com.chaomixian.vflow/.ui.common.ShortcutExecutorActivity";
|
||||
|
||||
interface TriggerParams {
|
||||
workflow_id: string;
|
||||
}
|
||||
|
||||
async function trigger_vflow_workflow(params: TriggerParams): Promise<any> {
|
||||
if (!params || !params.workflow_id) {
|
||||
return { success: false, message: "workflow_id 不能为空" };
|
||||
}
|
||||
|
||||
const result = await Tools.System.intent({
|
||||
type: "activity",
|
||||
action: DEFAULT_ACTION,
|
||||
component: DEFAULT_COMPONENT,
|
||||
extras: {
|
||||
workflow_id: params.workflow_id
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "已触发 VFlow 工作流",
|
||||
data: result
|
||||
};
|
||||
}
|
||||
|
||||
async function wrapToolExecution<P>(func: (params: P) => Promise<any>, params: P): Promise<void> {
|
||||
try {
|
||||
const result = await func(params);
|
||||
complete(result);
|
||||
} catch (error: any) {
|
||||
console.error(`Tool ${func.name} failed unexpectedly`, error);
|
||||
complete({ success: false, message: String(error && error.message ? error.message : error) });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
trigger_vflow_workflow: (params: TriggerParams) => wrapToolExecution(trigger_vflow_workflow, params)
|
||||
};
|
||||
})();
|
||||
|
||||
exports.trigger_vflow_workflow = VFlowTrigger.trigger_vflow_workflow;
|
||||
@@ -0,0 +1,565 @@
|
||||
/* METADATA
|
||||
{
|
||||
name: "workflow"
|
||||
|
||||
display_name: {
|
||||
zh: "工作流管理"
|
||||
en: "Workflow Management"
|
||||
}
|
||||
description: {
|
||||
zh: '''工作流管理工具:创建/查询/更新/启用/禁用/删除/触发执行;支持 on_success/on_error 分支;支持语音触发(speech)。'''
|
||||
en: '''Workflow management tools for creating/querying/updating/enabling/disabling/deleting workflows, triggering execution, and branching via on_success/on_error. Supports speech trigger (speech).'''
|
||||
}
|
||||
"category": "Workflow",
|
||||
enabledByDefault: true
|
||||
|
||||
tools: [
|
||||
{
|
||||
name: "usage_advice"
|
||||
description: {
|
||||
zh: '''
|
||||
工作流工具使用建议(给 AI):
|
||||
|
||||
- 核心概念(请优先对齐这些语义):
|
||||
- 节点类型:trigger/execute/condition/logic/extract
|
||||
- 触发节点类型:manual/schedule/tasker/intent/speech
|
||||
- 参数引用(ParameterValue):静态值 vs 引用其他节点输出
|
||||
- 分支连线 condition 的语义:on_success/on_error/true/false/regex
|
||||
|
||||
- 重要:create_workflow/update_workflow 的 nodes/connections 参数底层类型是 string(JSON 数组字符串)。
|
||||
- 本示例封装允许你直接传对象数组,封装层会自动 JSON.stringify。
|
||||
|
||||
- 推荐流程:
|
||||
1) 先 get_all_workflows 找到候选 workflow_id。
|
||||
2) 再 get_workflow 获取 nodes/connections 全量结构。
|
||||
- 3) 如果你要“整体替换” nodes/connections:构造完整的新数组后用 update_workflow 一次性覆盖。
|
||||
- 4) 如果你只想“增量修改” nodes/connections:优先使用 patch_workflow(node_patches/connection_patches)。
|
||||
|
||||
- 节点与连线的 ID:
|
||||
- 节点 id 可省略(服务端会生成),但如果你要创建 connections,强烈建议你在 nodes 里显式写好 id。
|
||||
- connections 里 source/target 可以用:
|
||||
- sourceNodeId/targetNodeId(推荐)
|
||||
- 或 source/target/from/to
|
||||
- 或 sourceIndex/targetIndex(按 nodes 数组下标)
|
||||
- 或 sourceNodeName/targetNodeName(不推荐:同名会歧义)
|
||||
|
||||
- 分支连线 condition(核心):
|
||||
- 通用关键字(适用于任何节点类型):
|
||||
- condition = "on_success" | "success" | "ok":源节点成功时触发
|
||||
- condition = "on_error" | "error" | "failed":源节点失败时触发(失败分支/补救逻辑)
|
||||
- 对 ConditionNode / LogicNode:
|
||||
- condition 为空:默认代表 true 分支(相当于 "true")
|
||||
- condition = "false":false 分支
|
||||
- condition = 其它字符串:当作 Regex 匹配源节点输出字符串
|
||||
- 对非 Condition/Logic 节点:
|
||||
- condition 为空:等价于 on_success(表示“源节点执行成功就走”)
|
||||
- condition = on_error:表示“源节点失败就走”(失败分支)
|
||||
|
||||
- 参数引用(ParameterValue):
|
||||
- 静态值:直接写字符串/数字/布尔值即可(会被当作 StaticValue)
|
||||
- 引用某节点输出:写对象 { nodeId: "<node-id>" }
|
||||
- 兼容字段:nodeId / ref / refNodeId
|
||||
|
||||
- 触发节点类型(TriggerNode.triggerType):
|
||||
- manual:手动触发(UI 点“触发工作流”)
|
||||
- schedule:定时触发(由 WorkManager 调度)
|
||||
- tasker:Tasker 事件触发
|
||||
- intent:系统广播 Intent 触发
|
||||
- speech:语音识别事件触发(当识别文本命中正则时触发;可多工作流同时触发)
|
||||
|
||||
触发配置 TriggerNode.triggerConfig(注意:值全是 string):
|
||||
- schedule:
|
||||
- schedule_type: interval | specific_time | cron
|
||||
- interval_ms: "900000" (15分钟)
|
||||
- specific_time: "2026-01-04 10:30" (格式依实现)
|
||||
- cron_expression: "15 * * * *" (简化 cron)
|
||||
- repeat: "true"/"false"
|
||||
- enabled: "true"/"false"
|
||||
- tasker:
|
||||
- command: "start_meeting" (当 Tasker params 中包含该字符串则触发)
|
||||
- intent:
|
||||
- action: "com.example.MY_ACTION" (当收到该 action 的 Intent 则触发)
|
||||
- speech:
|
||||
- pattern: ".*(打开|启动).*(对话|聊天|悬浮窗).*" (正则;匹配识别文本)
|
||||
- ignore_case: "true"/"false" (可选,默认 true)
|
||||
- require_final: "true"/"false" (可选,默认 true;true 表示仅 final 结果触发)
|
||||
- cooldown_ms: "3000" (可选,默认 3000;每个节点的触发冷却)
|
||||
'''
|
||||
en: '''
|
||||
Workflow tool usage advice (for the AI):
|
||||
|
||||
- Core concepts (align your reasoning with these semantics):
|
||||
- Node types: trigger/execute/condition/logic/extract
|
||||
- Trigger node types: manual/schedule/tasker/intent/speech
|
||||
- Parameter references (ParameterValue): static values vs references to another node output
|
||||
- Connection "condition" meaning: on_success/on_error/true/false/regex
|
||||
|
||||
- Important: in create_workflow/update_workflow, nodes/connections are strings (JSON array strings) at the API layer.
|
||||
- This example wrapper lets you pass object arrays directly; it will JSON.stringify automatically.
|
||||
|
||||
- Recommended flow:
|
||||
1) Call get_all_workflows to find the candidate workflow_id.
|
||||
2) Call get_workflow to retrieve the full nodes/connections structure.
|
||||
- 3) If you want to replace nodes/connections entirely: build the full new arrays and use update_workflow once to overwrite.
|
||||
- 4) If you only want incremental changes: prefer patch_workflow (node_patches/connection_patches).
|
||||
|
||||
- Node and connection IDs:
|
||||
- Node id can be omitted (server will generate it), but if you need to create connections, strongly recommend explicitly setting node ids in nodes.
|
||||
- In connections, source/target can be provided as:
|
||||
- sourceNodeId/targetNodeId (recommended)
|
||||
- or source/target/from/to
|
||||
- or sourceIndex/targetIndex (index within nodes array)
|
||||
- or sourceNodeName/targetNodeName (not recommended: duplicates are ambiguous)
|
||||
|
||||
- Connection condition (core):
|
||||
- Global keywords (works for any node type):
|
||||
- condition = "on_success" | "success" | "ok": trigger when the source node succeeds
|
||||
- condition = "on_error" | "error" | "failed": trigger when the source node fails (error branch / recovery)
|
||||
- For ConditionNode / LogicNode:
|
||||
- empty condition: defaults to true branch (equivalent to "true")
|
||||
- condition = "false": false branch
|
||||
- other string: treated as a Regex to match the source node output string
|
||||
- For non-Condition/Logic nodes:
|
||||
- empty condition: equivalent to on_success (proceed if the source node succeeded)
|
||||
- condition = on_error: proceed if the source node failed (error branch)
|
||||
|
||||
- Parameter references (ParameterValue):
|
||||
- Static value: write a string/number/boolean directly (treated as StaticValue)
|
||||
- Reference another node output: write an object { nodeId: "<node-id>" }
|
||||
- compatible fields: nodeId / ref / refNodeId
|
||||
|
||||
- Trigger node types (TriggerNode.triggerType):
|
||||
- manual: manual trigger (tap "trigger workflow" in UI)
|
||||
- schedule: scheduled trigger (WorkManager)
|
||||
- tasker: triggered by Tasker events
|
||||
- intent: triggered by Android broadcast intents
|
||||
- speech: triggered by speech recognition events (fires when recognized text matches a regex; multiple workflows can match)
|
||||
|
||||
Trigger configuration TriggerNode.triggerConfig (note: all values are strings):
|
||||
- schedule:
|
||||
- schedule_type: interval | specific_time | cron
|
||||
- interval_ms: "900000" (15 minutes)
|
||||
- specific_time: "2026-01-04 10:30" (format depends on implementation)
|
||||
- cron_expression: "15 * * * *" (simplified cron)
|
||||
- repeat: "true"/"false"
|
||||
- enabled: "true"/"false"
|
||||
- tasker:
|
||||
- command: "start_meeting" (triggered when Tasker params contains this string)
|
||||
- intent:
|
||||
- action: "com.example.MY_ACTION" (triggered when receiving this action)
|
||||
- speech:
|
||||
- pattern: ".*(open|start).*(chat|floating).*" (regex; matches recognized text)
|
||||
- ignore_case: "true"/"false" (optional, default true)
|
||||
- require_final: "true"/"false" (optional, default true; if true, only final results trigger)
|
||||
- cooldown_ms: "3000" (optional, default 3000; per-node cooldown)
|
||||
'''
|
||||
}
|
||||
parameters: []
|
||||
}
|
||||
|
||||
{
|
||||
name: "get_all_workflows"
|
||||
description: { zh: "获取所有工作流列表(只含概要信息:ID/名称/启用/统计等)。", en: "List all workflows (summary only: id/name/enabled/stats, etc.)." }
|
||||
parameters: []
|
||||
}
|
||||
|
||||
{
|
||||
name: "get_workflow"
|
||||
description: { zh: "获取指定工作流完整详情(nodes + connections)。", en: "Get full details of a specific workflow (nodes + connections)." }
|
||||
parameters: [
|
||||
{ name: "workflow_id", description: { zh: "工作流 ID", en: "Workflow ID" }, type: "string", required: true }
|
||||
]
|
||||
}
|
||||
|
||||
{
|
||||
name: "create_workflow"
|
||||
description: {
|
||||
zh: '''
|
||||
创建工作流。
|
||||
|
||||
参数说明:
|
||||
- nodes: JSON 数组字符串(推荐传对象数组,让封装自动 stringify)
|
||||
- connections: JSON 数组字符串(同上)
|
||||
|
||||
节点类型(node.type):
|
||||
- trigger / execute / condition / logic / extract
|
||||
|
||||
Execute 节点:
|
||||
- actionType: 工具名(如 "visit_web" / "list_files" / "get_system_setting" ...)
|
||||
- actionConfig: 工具参数对象,支持 ParameterValue(静态值/节点引用)
|
||||
|
||||
Condition 节点:
|
||||
- left/right: ParameterValue
|
||||
- operator: EQ/NE/GT/GTE/LT/LTE/CONTAINS/NOT_CONTAINS/IN/NOT_IN
|
||||
|
||||
Logic 节点:
|
||||
- operator: AND/OR
|
||||
|
||||
Extract(运算)节点:
|
||||
- 说明:node.type 仍然是 "extract"(兼容旧数据),但语义更接近“运算器/计算节点”。
|
||||
- source: ParameterValue(部分模式不需要,例如 RANDOM_INT)
|
||||
- mode:
|
||||
- REGEX:正则提取
|
||||
- expression: 正则表达式
|
||||
- group/defaultValue 可选
|
||||
- JSON:JSON 路径提取
|
||||
- expression: JSON 路径(简化实现)
|
||||
- defaultValue 可选
|
||||
- SUB:字符串截取
|
||||
- startIndex: 起始下标
|
||||
- length: 长度(-1 表示到结尾)
|
||||
- defaultValue 可选
|
||||
- CONCAT:字符串拼接
|
||||
- others: ParameterValue[](被拼接项列表)
|
||||
- RANDOM_INT:随机整数
|
||||
- randomMin/randomMax
|
||||
- useFixed: 是否使用固定值(可选)
|
||||
- fixedValue: 固定整数(可选,仅 useFixed=true 时生效;输出仍为数字字符串)
|
||||
- RANDOM_STRING:随机字符串
|
||||
- randomStringLength: 长度
|
||||
- randomStringCharset: 字符集(可选,默认字母数字)
|
||||
- useFixed: 是否使用固定值(可选)
|
||||
- fixedValue: 固定字符串(可选,仅 useFixed=true 时生效)
|
||||
'''
|
||||
en: '''
|
||||
Create a workflow.
|
||||
|
||||
Parameter notes:
|
||||
- nodes: JSON array string (recommended: pass object arrays and let the wrapper stringify)
|
||||
- connections: JSON array string (same as above)
|
||||
|
||||
Node types (node.type):
|
||||
- trigger / execute / condition / logic / extract
|
||||
|
||||
Execute node:
|
||||
- actionType: tool name (e.g. "visit_web" / "list_files" / "get_system_setting" ...)
|
||||
- actionConfig: tool parameter object, supports ParameterValue (static value / node reference)
|
||||
|
||||
Condition node:
|
||||
- left/right: ParameterValue
|
||||
- operator: EQ/NE/GT/GTE/LT/LTE/CONTAINS/NOT_CONTAINS/IN/NOT_IN
|
||||
|
||||
Logic node:
|
||||
- operator: AND/OR
|
||||
|
||||
Extract (operator) node:
|
||||
- Note: node.type is still "extract" for backward compatibility, but it behaves like an "operator" node.
|
||||
- source: ParameterValue (not required for some modes like RANDOM_INT)
|
||||
- mode:
|
||||
- REGEX: regex extraction
|
||||
- expression: regex pattern
|
||||
- group/defaultValue are optional
|
||||
- JSON: JSON path extraction
|
||||
- expression: JSON path (simplified)
|
||||
- defaultValue optional
|
||||
- SUB: substring
|
||||
- startIndex
|
||||
- length (-1 means to end)
|
||||
- defaultValue optional
|
||||
- CONCAT: string concatenation
|
||||
- others: ParameterValue[]
|
||||
- RANDOM_INT: random integer
|
||||
- randomMin/randomMax
|
||||
- useFixed: whether to use a fixed value (optional)
|
||||
- fixedValue: fixed integer (optional, only effective when useFixed=true; output is still a numeric string)
|
||||
- RANDOM_STRING: random string
|
||||
- randomStringLength
|
||||
- randomStringCharset (optional, default: alphanumeric)
|
||||
- useFixed: whether to use a fixed value (optional)
|
||||
- fixedValue: fixed string (optional, only effective when useFixed=true)
|
||||
'''
|
||||
}
|
||||
parameters: [
|
||||
{ name: "name", description: { zh: "工作流名称", en: "Workflow name" }, type: "string", required: true }
|
||||
{ name: "description", description: { zh: "工作流描述(可选)", en: "Workflow description (optional)" }, type: "string", required: false }
|
||||
{ name: "nodes", description: { zh: "可选,节点 JSON 数组字符串(或直接传节点数组,由封装 stringify)", en: "Optional. Nodes JSON array string (or pass an array and the wrapper will stringify)." }, type: "string", required: false }
|
||||
{ name: "connections", description: { zh: "可选,连线 JSON 数组字符串(或直接传连线数组,由封装 stringify)", en: "Optional. Connections JSON array string (or pass an array and the wrapper will stringify)." }, type: "string", required: false }
|
||||
{ name: "enabled", description: { zh: "可选,是否启用(默认 true)", en: "Optional. Whether to enable (default: true)." }, type: "boolean", required: false }
|
||||
]
|
||||
}
|
||||
|
||||
{
|
||||
name: "update_workflow"
|
||||
description: {
|
||||
zh: '''
|
||||
更新工作流。
|
||||
|
||||
注意:update_workflow 的 nodes / connections 是“整体覆盖”。
|
||||
- 若你只改其中一部分,推荐使用 patch_workflow。
|
||||
- 或者:get_workflow 取回旧结构 -> 本地构造新数组(保留未改部分)-> update_workflow 一次性传回。
|
||||
'''
|
||||
en: '''
|
||||
Update a workflow.
|
||||
|
||||
Note: nodes/connections in update_workflow are full overwrites.
|
||||
- If you only change part of them, prefer patch_workflow.
|
||||
- Or: call get_workflow to fetch the old structure -> build new arrays locally (keeping unchanged parts) -> call update_workflow once.
|
||||
'''
|
||||
}
|
||||
parameters: [
|
||||
{ name: "workflow_id", description: { zh: "工作流 ID", en: "Workflow ID" }, type: "string", required: true }
|
||||
{ name: "name", description: { zh: "可选,新名称", en: "Optional. New name." }, type: "string", required: false }
|
||||
{ name: "description", description: { zh: "可选,新描述", en: "Optional. New description." }, type: "string", required: false }
|
||||
{ name: "nodes", description: { zh: "可选,节点 JSON 数组字符串(整体覆盖)", en: "Optional. Nodes JSON array string (full overwrite)." }, type: "string", required: false }
|
||||
{ name: "connections", description: { zh: "可选,连线 JSON 数组字符串(整体覆盖)", en: "Optional. Connections JSON array string (full overwrite)." }, type: "string", required: false }
|
||||
{ name: "enabled", description: { zh: "可选,是否启用", en: "Optional. Whether to enable." }, type: "boolean", required: false }
|
||||
]
|
||||
}
|
||||
|
||||
{
|
||||
name: "patch_workflow"
|
||||
description: {
|
||||
zh: '''
|
||||
差异更新工作流(增量 patch)。
|
||||
|
||||
使用 node_patches / connection_patches 传入 JSON 数组字符串:
|
||||
- op: add | update | remove
|
||||
- id: 可选
|
||||
- node / connection: 对象
|
||||
|
||||
说明:
|
||||
- add:必须提供 node/connection
|
||||
- update:必须提供 id 或 node.id/connection.id
|
||||
- remove:必须提供 id
|
||||
'''
|
||||
en: '''
|
||||
Patch a workflow (incremental update).
|
||||
|
||||
Use node_patches / connection_patches as JSON array strings:
|
||||
- op: add | update | remove
|
||||
- id: optional
|
||||
- node / connection: object
|
||||
|
||||
Notes:
|
||||
- add: must provide node/connection
|
||||
- update: must provide id OR node.id/connection.id
|
||||
- remove: must provide id
|
||||
'''
|
||||
}
|
||||
parameters: [
|
||||
{ name: "workflow_id", description: { zh: "工作流 ID", en: "Workflow ID" }, type: "string", required: true }
|
||||
{ name: "name", description: { zh: "可选,新名称", en: "Optional. New name." }, type: "string", required: false }
|
||||
{ name: "description", description: { zh: "可选,新描述", en: "Optional. New description." }, type: "string", required: false }
|
||||
{ name: "enabled", description: { zh: "可选,是否启用", en: "Optional. Whether to enable." }, type: "boolean", required: false }
|
||||
{ name: "node_patches", description: { zh: "可选,节点 patch JSON 数组字符串", en: "Optional. Node patch JSON array string." }, type: "string", required: false }
|
||||
{ name: "connection_patches", description: { zh: "可选,连线 patch JSON 数组字符串", en: "Optional. Connection patch JSON array string." }, type: "string", required: false }
|
||||
]
|
||||
}
|
||||
|
||||
{
|
||||
name: "enable_workflow"
|
||||
description: { zh: "启用指定工作流。", en: "Enable a specific workflow." }
|
||||
parameters: [
|
||||
{ name: "workflow_id", description: { zh: "工作流 ID", en: "Workflow ID" }, type: "string", required: true }
|
||||
]
|
||||
}
|
||||
|
||||
{
|
||||
name: "disable_workflow"
|
||||
description: { zh: "禁用指定工作流。", en: "Disable a specific workflow." }
|
||||
parameters: [
|
||||
{ name: "workflow_id", description: { zh: "工作流 ID", en: "Workflow ID" }, type: "string", required: true }
|
||||
]
|
||||
}
|
||||
|
||||
{
|
||||
name: "delete_workflow"
|
||||
description: { zh: "删除指定工作流。", en: "Delete a specific workflow." }
|
||||
parameters: [
|
||||
{ name: "workflow_id", description: { zh: "工作流 ID", en: "Workflow ID" }, type: "string", required: true }
|
||||
]
|
||||
}
|
||||
|
||||
{
|
||||
name: "trigger_workflow"
|
||||
description: { zh: "触发指定工作流执行(相当于 UI 手动触发)。", en: "Trigger execution of a workflow (equivalent to manual trigger in UI)." }
|
||||
parameters: [
|
||||
{ name: "workflow_id", description: { zh: "工作流 ID", en: "Workflow ID" }, type: "string", required: true }
|
||||
]
|
||||
}
|
||||
]
|
||||
}*/
|
||||
|
||||
/// <reference path="./types/index.d.ts" />
|
||||
|
||||
const WorkflowIntegration = (function () {
|
||||
async function usage_advice(_params: {}): Promise<any> {
|
||||
return {
|
||||
success: true,
|
||||
message: "请阅读 workflow 工具 METADATA 中的 usage_advice 说明:包含节点/连线 schema、分支 condition 规则、触发类型与配置示例。建议在脚本里直接使用 Tools.Workflow.getAll/get/create/update/patch/setEnabled/enable/disable/delete/trigger 等封装方法。"
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有工作流
|
||||
* @returns 工作流列表
|
||||
*/
|
||||
async function get_all_workflows(): Promise<any> {
|
||||
const data = await Tools.Workflow.getAll();
|
||||
return {
|
||||
success: true,
|
||||
message: "成功获取工作流列表",
|
||||
data
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新工作流
|
||||
* @param params 工作流参数
|
||||
* @returns 创建结果
|
||||
*/
|
||||
async function create_workflow(params: Workflow.CreateParams): Promise<any> {
|
||||
const data = await Tools.Workflow.create(
|
||||
params.name,
|
||||
params.description || "",
|
||||
params.nodes || null,
|
||||
params.connections || null,
|
||||
params.enabled
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
message: "成功创建工作流",
|
||||
data
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工作流详情
|
||||
* @param params 获取参数
|
||||
* @returns 工作流详情
|
||||
*/
|
||||
async function get_workflow(params: Workflow.GetParams): Promise<any> {
|
||||
const data = await Tools.Workflow.get(params.workflow_id);
|
||||
return {
|
||||
success: true,
|
||||
message: "成功获取工作流详情",
|
||||
data
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新工作流
|
||||
* @param params 更新参数
|
||||
* @returns 更新结果
|
||||
*/
|
||||
async function update_workflow(params: Workflow.UpdateParams): Promise<any> {
|
||||
const { workflow_id, ...updates } = params;
|
||||
const data = await Tools.Workflow.update(workflow_id, updates);
|
||||
return {
|
||||
success: true,
|
||||
message: "成功更新工作流",
|
||||
data
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 差异更新工作流(增量 patch)
|
||||
* @param params patch 参数
|
||||
* @returns 更新结果
|
||||
*/
|
||||
async function patch_workflow(params: Workflow.PatchParams): Promise<any> {
|
||||
const { workflow_id, ...patch } = params;
|
||||
const data = await Tools.Workflow.patch(workflow_id, patch);
|
||||
return {
|
||||
success: true,
|
||||
message: "成功差异更新工作流",
|
||||
data
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用工作流
|
||||
* @param params 启用参数
|
||||
* @returns 更新结果
|
||||
*/
|
||||
async function enable_workflow(params: Workflow.EnableParams): Promise<any> {
|
||||
const data = await Tools.Workflow.enable(params.workflow_id);
|
||||
return {
|
||||
success: true,
|
||||
message: "成功启用工作流",
|
||||
data
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用工作流
|
||||
* @param params 禁用参数
|
||||
* @returns 更新结果
|
||||
*/
|
||||
async function disable_workflow(params: Workflow.DisableParams): Promise<any> {
|
||||
const data = await Tools.Workflow.disable(params.workflow_id);
|
||||
return {
|
||||
success: true,
|
||||
message: "成功禁用工作流",
|
||||
data
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除工作流
|
||||
* @param params 删除参数
|
||||
* @returns 删除结果
|
||||
*/
|
||||
async function delete_workflow(params: Workflow.DeleteParams): Promise<any> {
|
||||
const data = await Tools.Workflow['delete'](params.workflow_id);
|
||||
return {
|
||||
success: true,
|
||||
message: "成功删除工作流",
|
||||
data
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发工作流执行
|
||||
* @param params 触发参数
|
||||
* @returns 执行结果
|
||||
*/
|
||||
async function trigger_workflow(params: Workflow.TriggerParams): Promise<any> {
|
||||
const data = await Tools.Workflow.trigger(params.workflow_id);
|
||||
return {
|
||||
success: true,
|
||||
message: "成功触发工作流",
|
||||
data
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 包装工具执行,处理错误和结果
|
||||
* @param func 要执行的函数
|
||||
* @param params 函数参数
|
||||
*/
|
||||
async function wrapToolExecution<P>(func: (params: P) => Promise<any>, params: P): Promise<void> {
|
||||
try {
|
||||
const result = await func(params);
|
||||
complete(result);
|
||||
} catch (error: any) {
|
||||
console.error(`Tool ${func.name} failed unexpectedly`, error);
|
||||
complete({ success: false, message: String(error && error.message ? error.message : error) });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
usage_advice: (params: {}) => wrapToolExecution(usage_advice, params),
|
||||
get_all_workflows: () => wrapToolExecution(get_all_workflows, {} as any),
|
||||
create_workflow: (params: Workflow.CreateParams) => wrapToolExecution(create_workflow, params),
|
||||
get_workflow: (params: Workflow.GetParams) => wrapToolExecution(get_workflow, params),
|
||||
update_workflow: (params: Workflow.UpdateParams) => wrapToolExecution(update_workflow, params),
|
||||
patch_workflow: (params: Workflow.PatchParams) => wrapToolExecution(patch_workflow, params),
|
||||
enable_workflow: (params: Workflow.EnableParams) => wrapToolExecution(enable_workflow, params),
|
||||
disable_workflow: (params: Workflow.DisableParams) => wrapToolExecution(disable_workflow, params),
|
||||
delete_workflow: (params: Workflow.DeleteParams) => wrapToolExecution(delete_workflow, params),
|
||||
trigger_workflow: (params: Workflow.TriggerParams) => wrapToolExecution(trigger_workflow, params)
|
||||
};
|
||||
})();
|
||||
|
||||
exports.usage_advice = WorkflowIntegration.usage_advice;
|
||||
exports.get_all_workflows = WorkflowIntegration.get_all_workflows;
|
||||
exports.create_workflow = WorkflowIntegration.create_workflow;
|
||||
exports.get_workflow = WorkflowIntegration.get_workflow;
|
||||
exports.update_workflow = WorkflowIntegration.update_workflow;
|
||||
exports.patch_workflow = WorkflowIntegration.patch_workflow;
|
||||
exports.enable_workflow = WorkflowIntegration.enable_workflow;
|
||||
exports.disable_workflow = WorkflowIntegration.disable_workflow;
|
||||
exports.delete_workflow = WorkflowIntegration.delete_workflow;
|
||||
exports.trigger_workflow = WorkflowIntegration.trigger_workflow;
|
||||
|
||||
@@ -0,0 +1,707 @@
|
||||
/* METADATA
|
||||
{
|
||||
"name": "xai_draw",
|
||||
"display_name": {
|
||||
"zh": "xAI 图片与视频",
|
||||
"en": "xAI Images and Video"
|
||||
},
|
||||
"description": {
|
||||
"zh": "使用 xAI 官方接口生成图片和视频,并保存到本地。",
|
||||
"en": "Generate images and videos with the official xAI APIs and save them locally."
|
||||
},
|
||||
"env": [
|
||||
{
|
||||
"name": "XAI_API_KEY",
|
||||
"description": {
|
||||
"zh": "xAI API Key(必填)",
|
||||
"en": "xAI API key (required)"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "XAI_IMAGE_MODEL",
|
||||
"description": {
|
||||
"zh": "默认图片模型(可选;未传 model 时使用,默认 grok-2-image-1212)",
|
||||
"en": "Default image model (optional; used when model is omitted, default grok-2-image-1212)"
|
||||
},
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "XAI_VIDEO_MODEL",
|
||||
"description": {
|
||||
"zh": "默认视频模型(可选;未传 model 时使用,默认 grok-imagine-video)",
|
||||
"en": "Default video model (optional; used when model is omitted, default grok-imagine-video)"
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"category": "Draw",
|
||||
"tools": [
|
||||
{
|
||||
"name": "draw_image",
|
||||
"description": {
|
||||
"zh": "根据提示词调用 xAI 图像生成 API 生成图片,保存到本地并返回 Markdown 图片提示。",
|
||||
"en": "Generate an image via the xAI image generation API using a prompt, save it locally, and return a Markdown image reference."
|
||||
},
|
||||
"parameters": [
|
||||
{ "name": "prompt", "description": { "zh": "绘图提示词(英文或中文皆可)", "en": "Prompt for image generation (Chinese or English)" }, "type": "string", "required": true },
|
||||
{ "name": "model", "description": { "zh": "xAI 图像模型名称;不传则优先取 XAI_IMAGE_MODEL,再用默认值 grok-2-image-1212", "en": "xAI image model name; falls back to XAI_IMAGE_MODEL, then grok-2-image-1212" }, "type": "string", "required": false },
|
||||
{ "name": "size", "description": { "zh": "图片尺寸,例如 1024x1024(可选)", "en": "Image size, e.g. 1024x1024 (optional)" }, "type": "string", "required": false },
|
||||
{ "name": "file_name", "description": { "zh": "自定义保存到本地的文件名(不含路径和扩展名)", "en": "Custom output file name (without path or extension)" }, "type": "string", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "draw_video",
|
||||
"description": {
|
||||
"zh": "根据提示词调用 xAI 官方视频生成 API 生成视频,支持文生视频、图生视频和视频编辑,轮询完成后下载到本地并返回本地视频链接提示。",
|
||||
"en": "Generate a video with the official xAI video API. Supports text-to-video, image-to-video, and video editing. Polls until completion, downloads locally, and returns local video link hints."
|
||||
},
|
||||
"parameters": [
|
||||
{ "name": "prompt", "description": { "zh": "视频提示词", "en": "Video prompt" }, "type": "string", "required": true },
|
||||
{ "name": "model", "description": { "zh": "视频模型;不传则优先取 XAI_VIDEO_MODEL,再用默认值 grok-imagine-video", "en": "Video model; falls back to XAI_VIDEO_MODEL, then grok-imagine-video" }, "type": "string", "required": false },
|
||||
{ "name": "aspect_ratio", "description": { "zh": "输出比例,可选 1:1、16:9、9:16、4:3、3:4、3:2、2:3;默认 16:9;视频编辑模式不支持", "en": "Output aspect ratio. Supported: 1:1, 16:9, 9:16, 4:3, 3:4, 3:2, 2:3. Defaults to 16:9; not supported for video editing." }, "type": "string", "required": false },
|
||||
{ "name": "resolution", "description": { "zh": "输出分辨率,仅支持 480p 或 720p;默认 480p;视频编辑模式不支持", "en": "Output resolution, only 480p or 720p. Defaults to 480p; not supported for video editing." }, "type": "string", "required": false },
|
||||
{ "name": "duration", "description": { "zh": "输出时长,支持 1-15 秒;默认 5;视频编辑模式不支持", "en": "Output duration from 1 to 15 seconds. Defaults to 5; not supported for video editing." }, "type": "number", "required": false },
|
||||
{ "name": "image_url", "description": { "zh": "图生视频输入图 URL(可选)", "en": "Input image URL for image-to-video (optional)" }, "type": "string", "required": false },
|
||||
{ "name": "image_path", "description": { "zh": "图生视频输入图本地路径(可选,会转成 data URL)", "en": "Local input image path for image-to-video (optional; converted to a data URL)" }, "type": "string", "required": false },
|
||||
{ "name": "video_url", "description": { "zh": "视频编辑输入视频 URL(可选)", "en": "Input video URL for video editing (optional)" }, "type": "string", "required": false },
|
||||
{ "name": "file_name", "description": { "zh": "自定义保存到本地的文件名(不含路径和扩展名)", "en": "Custom output file name (without path or extension)" }, "type": "string", "required": false },
|
||||
{ "name": "poll_interval_ms", "description": { "zh": "轮询间隔毫秒数,默认 5000", "en": "Polling interval in milliseconds, default 5000" }, "type": "number", "required": false },
|
||||
{ "name": "max_wait_time_ms", "description": { "zh": "最大等待毫秒数,默认 600000", "en": "Maximum wait time in milliseconds, default 600000" }, "type": "number", "required": false }
|
||||
]
|
||||
}
|
||||
]
|
||||
}*/
|
||||
/// <reference path="./types/index.d.ts" />
|
||||
|
||||
const xaiDraw = (function () {
|
||||
const HTTP_TIMEOUT_MS = 600000;
|
||||
const client = OkHttp.newBuilder()
|
||||
.connectTimeout(HTTP_TIMEOUT_MS)
|
||||
.readTimeout(HTTP_TIMEOUT_MS)
|
||||
.writeTimeout(HTTP_TIMEOUT_MS)
|
||||
.build();
|
||||
|
||||
const DEFAULT_IMAGE_MODEL = "grok-2-image-1212";
|
||||
const DEFAULT_VIDEO_MODEL = "grok-imagine-video";
|
||||
const DEFAULT_POLL_INTERVAL_MS = 5000;
|
||||
const DEFAULT_MAX_WAIT_TIME_MS = 600000;
|
||||
const DEFAULT_VIDEO_ASPECT_RATIO = "16:9";
|
||||
const DEFAULT_VIDEO_RESOLUTION = "480p";
|
||||
const DEFAULT_VIDEO_DURATION = 5;
|
||||
const VIDEO_ASPECT_RATIOS = ["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3"];
|
||||
const VIDEO_RESOLUTIONS = ["480p", "720p"];
|
||||
const MIN_VIDEO_DURATION = 1;
|
||||
const MAX_VIDEO_DURATION = 15;
|
||||
|
||||
const API_BASE_URL = "https://api.x.ai/v1";
|
||||
const IMAGE_API_ENDPOINT = `${API_BASE_URL}/images/generations`;
|
||||
const VIDEO_GENERATION_ENDPOINT = `${API_BASE_URL}/videos/generations`;
|
||||
|
||||
const DRAW_ROOT_DIR = getPluginConfigDir("draw");
|
||||
const STORAGE_DIR = `${DRAW_ROOT_DIR}/xai_draw`;
|
||||
const DRAWS_DIR = `${STORAGE_DIR}/draws`;
|
||||
const VIDEOS_DIR = `${STORAGE_DIR}/videos`;
|
||||
|
||||
type JsonMap = Record<string, any>;
|
||||
|
||||
type VideoParams = {
|
||||
prompt: string;
|
||||
model?: string;
|
||||
aspect_ratio?: string;
|
||||
resolution?: string;
|
||||
duration?: number | string;
|
||||
image_url?: string;
|
||||
image_path?: string;
|
||||
video_url?: string;
|
||||
file_name?: string;
|
||||
poll_interval_ms?: number | string;
|
||||
max_wait_time_ms?: number | string;
|
||||
};
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function getErrorStack(error: unknown): string | undefined {
|
||||
if (error instanceof Error) return error.stack;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getApiKey(): string {
|
||||
const apiKey = getEnv("XAI_API_KEY");
|
||||
if (!apiKey) {
|
||||
throw new Error("XAI_API_KEY 未配置,请在环境变量中设置 xAI 的 API Key。");
|
||||
}
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
function getDefaultImageModel(): string {
|
||||
const fromEnv = String(getEnv("XAI_IMAGE_MODEL") || "").trim();
|
||||
return fromEnv || DEFAULT_IMAGE_MODEL;
|
||||
}
|
||||
|
||||
function getDefaultVideoModel(): string {
|
||||
const fromEnv = String(getEnv("XAI_VIDEO_MODEL") || "").trim();
|
||||
return fromEnv || DEFAULT_VIDEO_MODEL;
|
||||
}
|
||||
|
||||
function sanitizeFileName(name: string, fallbackPrefix: string): string {
|
||||
const safe = String(name || "").replace(/[\\/:*?"<>|]/g, "_").trim();
|
||||
if (!safe) {
|
||||
return `${fallbackPrefix}_${Date.now()}`;
|
||||
}
|
||||
return safe.substring(0, 80);
|
||||
}
|
||||
|
||||
function buildFileName(prompt: string, customName: string | null | undefined, fallbackPrefix: string): string {
|
||||
if (customName && customName.trim().length > 0) {
|
||||
return sanitizeFileName(customName, fallbackPrefix);
|
||||
}
|
||||
const shortPrompt = prompt.length > 40 ? `${prompt.substring(0, 40)}...` : prompt;
|
||||
const base = sanitizeFileName(shortPrompt || fallbackPrefix, fallbackPrefix);
|
||||
return `${base}_${Date.now()}`;
|
||||
}
|
||||
|
||||
function guessExtensionFromUrl(url: string, fallback: string): string {
|
||||
const match = String(url || "").match(/\.(png|jpg|jpeg|webp|gif|mp4|mov|webm|mkv)(?:\?|#|$)/i);
|
||||
if (match && match[1]) {
|
||||
return match[1].toLowerCase();
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function guessMimeTypeFromPath(path: string): string {
|
||||
const normalized = String(path || "").trim().toLowerCase();
|
||||
if (normalized.endsWith(".png")) return "image/png";
|
||||
if (normalized.endsWith(".webp")) return "image/webp";
|
||||
if (normalized.endsWith(".gif")) return "image/gif";
|
||||
if (normalized.endsWith(".jpg") || normalized.endsWith(".jpeg")) return "image/jpeg";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
function isProbablyUrl(value: string): boolean {
|
||||
return /^https?:\/\//i.test(String(value || "").trim());
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(value: unknown, fallback: number): number {
|
||||
if (value === undefined || value === null || value === "") return fallback;
|
||||
const parsed = typeof value === "number" ? value : parseInt(String(value), 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
function normalizeVideoAspectRatio(value: unknown): string {
|
||||
const raw = String(value || "").trim();
|
||||
if (!raw) return DEFAULT_VIDEO_ASPECT_RATIO;
|
||||
if (!VIDEO_ASPECT_RATIOS.includes(raw)) {
|
||||
throw new Error(`aspect_ratio 仅支持 ${VIDEO_ASPECT_RATIOS.join(" 或 ")}。`);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function normalizeVideoResolution(value: unknown): string {
|
||||
const raw = String(value || "").trim().toLowerCase();
|
||||
if (!raw) return DEFAULT_VIDEO_RESOLUTION;
|
||||
if (!VIDEO_RESOLUTIONS.includes(raw)) {
|
||||
throw new Error(`resolution 仅支持 ${VIDEO_RESOLUTIONS.join(" 或 ")}。`);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function normalizeVideoDuration(value: unknown): number {
|
||||
if (value === undefined || value === null || value === "") return DEFAULT_VIDEO_DURATION;
|
||||
const parsed = typeof value === "number" ? value : parseInt(String(value), 10);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
throw new Error("duration 必须是数字。");
|
||||
}
|
||||
const normalized = Math.floor(parsed);
|
||||
if (normalized < MIN_VIDEO_DURATION || normalized > MAX_VIDEO_DURATION) {
|
||||
throw new Error(`duration 仅支持 ${MIN_VIDEO_DURATION}-${MAX_VIDEO_DURATION} 秒。`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function ensureDirectories(): Promise<void> {
|
||||
const dirs = [DRAW_ROOT_DIR, STORAGE_DIR, DRAWS_DIR, VIDEOS_DIR];
|
||||
for (const dir of dirs) {
|
||||
try {
|
||||
const result = await Tools.Files.mkdir(dir);
|
||||
if (!result.successful) {
|
||||
console.warn(`创建目录失败(可能已存在): ${dir} -> ${result.details}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`创建目录异常: ${dir} -> ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function parseJsonResponse(response: { content: string }, label: string): Promise<JsonMap> {
|
||||
try {
|
||||
const parsed = JSON.parse(response.content) as unknown;
|
||||
if (!isRecord(parsed)) {
|
||||
throw new Error("响应不是对象");
|
||||
}
|
||||
return parsed as JsonMap;
|
||||
} catch (error) {
|
||||
throw new Error(`解析 ${label} 响应失败: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function extractApiErrorMessage(payload: JsonMap): string {
|
||||
const directError = payload.error;
|
||||
if (typeof directError === "string" && directError.trim()) {
|
||||
return directError.trim();
|
||||
}
|
||||
if (isRecord(directError)) {
|
||||
if (typeof directError.message === "string" && directError.message.trim()) {
|
||||
return directError.message.trim();
|
||||
}
|
||||
if (typeof directError.code === "string" && directError.code.trim()) {
|
||||
return directError.code.trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function readLocalImageAsDataUrl(filePath: string): Promise<string> {
|
||||
const trimmedPath = String(filePath || "").trim();
|
||||
if (!trimmedPath) {
|
||||
throw new Error("image_path 不能为空。");
|
||||
}
|
||||
|
||||
const existsResult = await Tools.Files.exists(trimmedPath);
|
||||
if (!existsResult.exists) {
|
||||
throw new Error(`本地图片不存在: ${trimmedPath}`);
|
||||
}
|
||||
|
||||
const binaryResult = await Tools.Files.readBinary(trimmedPath);
|
||||
const base64Content = binaryResult && binaryResult.contentBase64
|
||||
? String(binaryResult.contentBase64).trim()
|
||||
: "";
|
||||
if (!base64Content) {
|
||||
throw new Error(`读取本地图片失败: ${trimmedPath}`);
|
||||
}
|
||||
|
||||
return `data:${guessMimeTypeFromPath(trimmedPath)};base64,${base64Content}`;
|
||||
}
|
||||
|
||||
async function resolveVideoInputs(
|
||||
imageUrl: string | undefined,
|
||||
imagePath: string | undefined,
|
||||
videoUrl: string | undefined
|
||||
): Promise<{ image?: string; video_url?: string; input_type: string }> {
|
||||
const trimmedImageUrl = String(imageUrl || "").trim();
|
||||
const trimmedImagePath = String(imagePath || "").trim();
|
||||
const trimmedVideoUrl = String(videoUrl || "").trim();
|
||||
|
||||
const imageInputCount = (trimmedImageUrl ? 1 : 0) + (trimmedImagePath ? 1 : 0);
|
||||
if (imageInputCount > 1) {
|
||||
throw new Error("image_url 和 image_path 只能二选一,请不要同时传。");
|
||||
}
|
||||
if (trimmedVideoUrl && imageInputCount > 0) {
|
||||
throw new Error("视频生成一次只能使用一种输入源:纯文本、图片或视频。请不要同时传 image_* 和 video_url。");
|
||||
}
|
||||
|
||||
if (trimmedImageUrl) {
|
||||
if (!isProbablyUrl(trimmedImageUrl)) {
|
||||
throw new Error("image_url 必须是 http 或 https 链接。");
|
||||
}
|
||||
return {
|
||||
image: trimmedImageUrl,
|
||||
input_type: "image_url"
|
||||
};
|
||||
}
|
||||
|
||||
if (trimmedImagePath) {
|
||||
return {
|
||||
image: await readLocalImageAsDataUrl(trimmedImagePath),
|
||||
input_type: "image_path"
|
||||
};
|
||||
}
|
||||
|
||||
if (trimmedVideoUrl) {
|
||||
if (!isProbablyUrl(trimmedVideoUrl)) {
|
||||
throw new Error("video_url 必须是 http 或 https 链接。");
|
||||
}
|
||||
return {
|
||||
video_url: trimmedVideoUrl,
|
||||
input_type: "video_url"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
input_type: "text"
|
||||
};
|
||||
}
|
||||
|
||||
async function callXaiImageApi(params: {
|
||||
prompt: string;
|
||||
model?: string;
|
||||
size?: string;
|
||||
}): Promise<{ image_url: string; effective_model: string }> {
|
||||
const apiKey = getApiKey();
|
||||
const modelFromParam = String(params.model || "").trim();
|
||||
const effectiveModel = modelFromParam || getDefaultImageModel();
|
||||
|
||||
const body: JsonMap = {
|
||||
model: effectiveModel,
|
||||
prompt: params.prompt
|
||||
};
|
||||
|
||||
const size = String(params.size || "").trim();
|
||||
if (size) {
|
||||
body.size = size;
|
||||
}
|
||||
|
||||
const request = client
|
||||
.newRequest()
|
||||
.url(IMAGE_API_ENDPOINT)
|
||||
.method("POST")
|
||||
.headers({
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
"Authorization": `Bearer ${apiKey}`
|
||||
})
|
||||
.body(JSON.stringify(body), "json");
|
||||
|
||||
const response = await request.build().execute();
|
||||
if (!response.isSuccessful()) {
|
||||
throw new Error(`xAI 图片 API 调用失败: ${response.statusCode} - ${response.content}`);
|
||||
}
|
||||
|
||||
const parsed = await parseJsonResponse(response, "xAI 图片生成");
|
||||
const data = Array.isArray(parsed.data) ? parsed.data : [];
|
||||
const first = data.length > 0 && isRecord(data[0]) ? data[0] as JsonMap : null;
|
||||
const imageUrl = first && typeof first.url === "string" ? String(first.url).trim() : "";
|
||||
|
||||
if (!imageUrl) {
|
||||
throw new Error("xAI 响应中未找到图片 URL,请检查模型和参数是否正确。");
|
||||
}
|
||||
|
||||
return {
|
||||
image_url: imageUrl,
|
||||
effective_model: effectiveModel
|
||||
};
|
||||
}
|
||||
|
||||
async function createVideoTask(params: VideoParams & { image?: string; video_url?: string }) {
|
||||
const apiKey = getApiKey();
|
||||
const modelFromParam = String(params.model || "").trim();
|
||||
const effectiveModel = modelFromParam || getDefaultVideoModel();
|
||||
|
||||
const body: JsonMap = {
|
||||
model: effectiveModel,
|
||||
prompt: String(params.prompt || "").trim()
|
||||
};
|
||||
|
||||
const isVideoEdit = Boolean(params.video_url);
|
||||
if (!isVideoEdit) {
|
||||
body.aspect_ratio = normalizeVideoAspectRatio(params.aspect_ratio);
|
||||
body.resolution = normalizeVideoResolution(params.resolution);
|
||||
body.duration = normalizeVideoDuration(params.duration);
|
||||
}
|
||||
|
||||
if (params.image) {
|
||||
body.image = {
|
||||
url: params.image
|
||||
};
|
||||
}
|
||||
if (params.video_url) {
|
||||
body.video_url = params.video_url;
|
||||
}
|
||||
|
||||
const request = client
|
||||
.newRequest()
|
||||
.url(VIDEO_GENERATION_ENDPOINT)
|
||||
.method("POST")
|
||||
.headers({
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
"Authorization": `Bearer ${apiKey}`
|
||||
})
|
||||
.body(JSON.stringify(body), "json");
|
||||
|
||||
const response = await request.build().execute();
|
||||
if (!response.isSuccessful()) {
|
||||
throw new Error(`xAI 视频创建任务失败: ${response.statusCode} - ${response.content}`);
|
||||
}
|
||||
|
||||
const parsed = await parseJsonResponse(response, "xAI 视频创建");
|
||||
const requestId = typeof parsed.request_id === "string" ? parsed.request_id.trim() : "";
|
||||
const status = typeof parsed.status === "string" ? parsed.status.trim() : "";
|
||||
|
||||
if (!requestId) {
|
||||
throw new Error(`xAI 视频创建响应中未找到 request_id: ${response.content}`);
|
||||
}
|
||||
|
||||
return {
|
||||
request_id: requestId,
|
||||
status,
|
||||
effective_model: effectiveModel,
|
||||
aspect_ratio: typeof body.aspect_ratio === "string" ? body.aspect_ratio : null,
|
||||
resolution: typeof body.resolution === "string" ? body.resolution : null,
|
||||
duration: typeof body.duration === "number" ? body.duration : null
|
||||
};
|
||||
}
|
||||
|
||||
async function queryVideoTaskStatus(requestId: string): Promise<{
|
||||
status: string;
|
||||
video_url: string;
|
||||
content_type: string;
|
||||
expires_at: string;
|
||||
duration: number | null;
|
||||
respect_moderation: string;
|
||||
error_message: string;
|
||||
}> {
|
||||
const apiKey = getApiKey();
|
||||
const endpoint = `${API_BASE_URL}/videos/${encodeURIComponent(requestId)}`;
|
||||
const request = client
|
||||
.newRequest()
|
||||
.url(endpoint)
|
||||
.method("GET")
|
||||
.headers({
|
||||
"accept": "application/json",
|
||||
"Authorization": `Bearer ${apiKey}`
|
||||
});
|
||||
|
||||
const response = await request.build().execute();
|
||||
if (!response.isSuccessful()) {
|
||||
throw new Error(`xAI 视频查询失败: ${response.statusCode} - ${response.content}`);
|
||||
}
|
||||
|
||||
const parsed = await parseJsonResponse(response, "xAI 视频查询");
|
||||
const status = typeof parsed.status === "string" ? parsed.status.trim() : "";
|
||||
const video = isRecord(parsed.video) ? parsed.video as JsonMap : null;
|
||||
const videoUrl = video && typeof video.url === "string" ? video.url.trim() : "";
|
||||
const contentType = video && typeof video.content_type === "string" ? video.content_type.trim() : "";
|
||||
const expiresAt = video && typeof video.expires_at === "string" ? video.expires_at.trim() : "";
|
||||
const duration = video && typeof video.duration === "number" ? video.duration : null;
|
||||
const respectModeration = video && typeof video.respect_moderation === "string"
|
||||
? video.respect_moderation.trim()
|
||||
: "";
|
||||
const errorMessage = extractApiErrorMessage(parsed);
|
||||
|
||||
return {
|
||||
status,
|
||||
video_url: videoUrl,
|
||||
content_type: contentType,
|
||||
expires_at: expiresAt,
|
||||
duration,
|
||||
respect_moderation: respectModeration,
|
||||
error_message: errorMessage
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeVideoTaskStatus(status: string): "processing" | "completed" | "failed" {
|
||||
const normalized = String(status || "").trim().toLowerCase();
|
||||
if (normalized === "done") return "completed";
|
||||
if (normalized === "expired") return "failed";
|
||||
return "processing";
|
||||
}
|
||||
|
||||
async function draw_image(params: {
|
||||
prompt: string;
|
||||
model?: string;
|
||||
size?: string;
|
||||
file_name?: string;
|
||||
}) {
|
||||
if (!params || !params.prompt || params.prompt.trim().length === 0) {
|
||||
throw new Error("参数 prompt 不能为空。");
|
||||
}
|
||||
|
||||
const prompt = params.prompt.trim();
|
||||
await ensureDirectories();
|
||||
|
||||
const apiResult = await callXaiImageApi({
|
||||
prompt,
|
||||
model: params.model,
|
||||
size: params.size
|
||||
});
|
||||
|
||||
const ext = guessExtensionFromUrl(apiResult.image_url, "png");
|
||||
const baseName = buildFileName(prompt, params.file_name, "xai_draw");
|
||||
const filePath = `${DRAWS_DIR}/${baseName}.${ext}`;
|
||||
const downloadResult = await Tools.Files.download(apiResult.image_url, filePath);
|
||||
if (!downloadResult.successful) {
|
||||
throw new Error(`下载图片失败: ${downloadResult.details}`);
|
||||
}
|
||||
|
||||
const fileUri = `file://${filePath}`;
|
||||
const markdown = ``;
|
||||
|
||||
const hintLines: string[] = [];
|
||||
hintLines.push(`图片已生成并保存在本地 ${DRAWS_DIR}。`);
|
||||
hintLines.push(`本地路径: ${filePath}`);
|
||||
hintLines.push("");
|
||||
hintLines.push("在后续回答中,请直接输出下面这一行 Markdown 来展示这张图片:");
|
||||
hintLines.push("");
|
||||
hintLines.push(markdown);
|
||||
|
||||
return {
|
||||
prompt,
|
||||
model: apiResult.effective_model,
|
||||
remote_image_url: apiResult.image_url,
|
||||
file_path: filePath,
|
||||
file_uri: fileUri,
|
||||
markdown,
|
||||
hint: hintLines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
async function draw_video(params: VideoParams) {
|
||||
const prompt = String(params && params.prompt ? params.prompt : "").trim();
|
||||
if (!prompt) {
|
||||
throw new Error("prompt 不能为空。");
|
||||
}
|
||||
|
||||
await ensureDirectories();
|
||||
|
||||
const resolvedInputs = await resolveVideoInputs(params.image_url, params.image_path, params.video_url);
|
||||
const createdTask = await createVideoTask({
|
||||
...params,
|
||||
prompt,
|
||||
image: resolvedInputs.image,
|
||||
video_url: resolvedInputs.video_url
|
||||
});
|
||||
|
||||
const pollIntervalMs = normalizePositiveInteger(params.poll_interval_ms, DEFAULT_POLL_INTERVAL_MS);
|
||||
const maxWaitTimeMs = normalizePositiveInteger(params.max_wait_time_ms, DEFAULT_MAX_WAIT_TIME_MS);
|
||||
const deadline = Date.now() + maxWaitTimeMs;
|
||||
|
||||
let latestStatus = createdTask.status;
|
||||
let latestErrorMessage = "";
|
||||
let remoteVideoUrl = "";
|
||||
let remoteContentType = "";
|
||||
let expiresAt = "";
|
||||
let remoteDuration = createdTask.duration;
|
||||
let remoteRespectModeration = "";
|
||||
|
||||
while (Date.now() <= deadline) {
|
||||
const statusResult = await queryVideoTaskStatus(createdTask.request_id);
|
||||
latestStatus = statusResult.status;
|
||||
latestErrorMessage = statusResult.error_message;
|
||||
remoteContentType = statusResult.content_type;
|
||||
expiresAt = statusResult.expires_at;
|
||||
remoteDuration = statusResult.duration;
|
||||
remoteRespectModeration = statusResult.respect_moderation;
|
||||
|
||||
const normalizedStatus = normalizeVideoTaskStatus(statusResult.status);
|
||||
if (normalizedStatus === "completed") {
|
||||
remoteVideoUrl = statusResult.video_url;
|
||||
if (!remoteVideoUrl) {
|
||||
throw new Error("视频任务已完成,但响应中未找到 video.url。");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (normalizedStatus === "failed") {
|
||||
throw new Error(`视频生成失败或过期: ${latestErrorMessage || latestStatus || "接口未返回失败原因"}`);
|
||||
}
|
||||
|
||||
await Tools.System.sleep(pollIntervalMs);
|
||||
}
|
||||
|
||||
if (!remoteVideoUrl) {
|
||||
throw new Error(
|
||||
`视频生成超时,最后状态为 ${latestStatus || "未知"}${latestErrorMessage ? `,原因:${latestErrorMessage}` : ""}`
|
||||
);
|
||||
}
|
||||
|
||||
const extension = guessExtensionFromUrl(remoteVideoUrl, "mp4");
|
||||
const baseName = buildFileName(prompt, params.file_name, "xai_video");
|
||||
const filePath = `${VIDEOS_DIR}/${baseName}.${extension}`;
|
||||
const downloadResult = await Tools.Files.download(remoteVideoUrl, filePath);
|
||||
if (!downloadResult.successful) {
|
||||
throw new Error(`下载视频失败: ${downloadResult.details}`);
|
||||
}
|
||||
|
||||
const fileUri = `file://${filePath}`;
|
||||
const markdownLink = `[点击查看生成的视频](${fileUri})`;
|
||||
const htmlVideo = `<video controls src="${fileUri}"></video>`;
|
||||
|
||||
const hintLines: string[] = [];
|
||||
hintLines.push(`视频已生成并保存在本地 ${VIDEOS_DIR}。`);
|
||||
hintLines.push(`本地路径: ${filePath}`);
|
||||
hintLines.push("");
|
||||
hintLines.push("后续回答如果要给出视频,请优先返回这个本地链接:");
|
||||
hintLines.push(markdownLink);
|
||||
hintLines.push("");
|
||||
hintLines.push("如果当前渲染环境支持 HTML 视频标签,也可以使用:");
|
||||
hintLines.push(htmlVideo);
|
||||
|
||||
return {
|
||||
prompt,
|
||||
model: createdTask.effective_model,
|
||||
request_id: createdTask.request_id,
|
||||
status: latestStatus,
|
||||
input_type: resolvedInputs.input_type,
|
||||
aspect_ratio: createdTask.aspect_ratio,
|
||||
resolution: createdTask.resolution,
|
||||
duration: remoteDuration,
|
||||
remote_video_url: remoteVideoUrl,
|
||||
remote_content_type: remoteContentType,
|
||||
remote_expires_at: expiresAt,
|
||||
remote_respect_moderation: remoteRespectModeration,
|
||||
file_path: filePath,
|
||||
file_uri: fileUri,
|
||||
markdown_link: markdownLink,
|
||||
html_video: htmlVideo,
|
||||
hint: hintLines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
async function draw_image_wrapper(params: {
|
||||
prompt: string;
|
||||
model?: string;
|
||||
size?: string;
|
||||
file_name?: string;
|
||||
}) {
|
||||
try {
|
||||
const result = await draw_image(params);
|
||||
complete({
|
||||
success: true,
|
||||
message: "xAI 图片生成成功,已下载到本地。",
|
||||
data: result
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("draw_image 执行失败:", error);
|
||||
complete({
|
||||
success: false,
|
||||
message: `xAI 图片生成失败: ${getErrorMessage(error)}`,
|
||||
error_stack: getErrorStack(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function draw_video_wrapper(params: VideoParams) {
|
||||
try {
|
||||
const result = await draw_video((params || {}) as VideoParams);
|
||||
complete({
|
||||
success: true,
|
||||
message: "xAI 视频生成成功,已下载到本地。",
|
||||
data: result
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("draw_video 执行失败:", error);
|
||||
complete({
|
||||
success: false,
|
||||
message: `xAI 视频生成失败: ${getErrorMessage(error)}`,
|
||||
error_stack: getErrorStack(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
draw_image: draw_image_wrapper,
|
||||
draw_video: draw_video_wrapper
|
||||
};
|
||||
})();
|
||||
|
||||
exports.draw_image = xaiDraw.draw_image;
|
||||
exports.draw_video = xaiDraw.draw_video;
|
||||
@@ -0,0 +1,262 @@
|
||||
/* METADATA
|
||||
{
|
||||
"name": "zhipu_draw",
|
||||
"display_name": {
|
||||
"zh": "智谱生图",
|
||||
"en": "Zhipu Draw"
|
||||
},
|
||||
"description": {
|
||||
"zh": "使用智谱AI图像生成API根据提示词画图,将图片保存到本地 /sdcard/Download/Operit/plugins/draw/zhipu_draw/draws/ 目录,并返回 Markdown 图片提示。",
|
||||
"en": "Generate images via Zhipu AI image generation API from a prompt, save to /sdcard/Download/Operit/plugins/draw/zhipu_draw/draws/, and return a Markdown image reference."
|
||||
},
|
||||
"env": [
|
||||
{
|
||||
"name": "ZHIPU_API_KEY",
|
||||
"description": {
|
||||
"zh": "智谱API Key(必填)",
|
||||
"en": "Zhipu API key (required)"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "ZHIPU_IMAGE_MODEL",
|
||||
"description": {
|
||||
"zh": "默认绘图模型(可选;当 draw_image 未传 model 时使用)",
|
||||
"en": "Default image model (optional; used when draw_image doesn't pass model)"
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"category": "Draw",
|
||||
"tools": [
|
||||
{
|
||||
"name": "draw_image",
|
||||
"description": {
|
||||
"zh": "根据提示词调用智谱AI图像生成接口生成图片,保存到本地并返回 Markdown 图片提示。",
|
||||
"en": "Generate an image via Zhipu AI image generation API using a prompt, save it locally, and return a Markdown image reference."
|
||||
},
|
||||
"parameters": [
|
||||
{ "name": "prompt", "description": { "zh": "绘图提示词(中文或英文)", "en": "Prompt for image generation (Chinese or English)" }, "type": "string", "required": true },
|
||||
{ "name": "size", "description": { "zh": "图片尺寸,例如 '1024x1024' 或 '1280x1280'(可选,默认1024x1024)", "en": "Image size, e.g. '1024x1024' or '1280x1280' (optional, default 1024x1024)" }, "type": "string", "required": false },
|
||||
{ "name": "file_name", "description": { "zh": "自定义保存到本地的文件名(不含路径和扩展名)", "en": "Custom output file name (without path or extension)" }, "type": "string", "required": false },
|
||||
{ "name": "model", "description": { "zh": "模型名称(可选;不传则使用环境变量 ZHIPU_IMAGE_MODEL,否则默认glm-image)", "en": "Model name (optional; falls back to env ZHIPU_IMAGE_MODEL)" }, "type": "string", "required": false }
|
||||
]
|
||||
}
|
||||
]
|
||||
}*/
|
||||
|
||||
/// <reference path="./types/index.d.ts" />
|
||||
|
||||
const zhipuDraw = (function () {
|
||||
const HTTP_TIMEOUT_MS = 600000;
|
||||
const client = OkHttp.newBuilder()
|
||||
.connectTimeout(HTTP_TIMEOUT_MS)
|
||||
.readTimeout(HTTP_TIMEOUT_MS)
|
||||
.writeTimeout(HTTP_TIMEOUT_MS)
|
||||
.build();
|
||||
|
||||
const API_BASE_URL = "https://open.bigmodel.cn/api/paas/v4/images/generations";
|
||||
const DRAW_ROOT_DIR = getPluginConfigDir("draw");
|
||||
const STORAGE_DIR = `${DRAW_ROOT_DIR}/zhipu_draw`;
|
||||
const DRAWS_DIR = `${STORAGE_DIR}/draws`;
|
||||
|
||||
interface DrawImageParams {
|
||||
prompt: string;
|
||||
size?: string;
|
||||
file_name?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
interface ZhipuImageApiResult {
|
||||
url: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
interface DrawImageResult {
|
||||
file_path: string;
|
||||
file_uri: string;
|
||||
markdown: string;
|
||||
prompt: string;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function getErrorStack(error: unknown): string | undefined {
|
||||
if (error instanceof Error) return error.stack;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getApiKey(): string {
|
||||
const apiKey = getEnv("ZHIPU_API_KEY");
|
||||
if (!apiKey) {
|
||||
throw new Error("ZHIPU_API_KEY 未配置,请在环境变量中设置智谱的 API Key。");
|
||||
}
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
function sanitizeFileName(name: string): string {
|
||||
const safe = name.replace(/[\\/:*?"<>|]/g, "_").trim();
|
||||
if (!safe) {
|
||||
return `zhipu_draw_${Date.now()}`;
|
||||
}
|
||||
return safe.substring(0, 80);
|
||||
}
|
||||
|
||||
function buildFileName(prompt: string, customName?: string | null): string {
|
||||
if (customName && customName.trim().length > 0) {
|
||||
return sanitizeFileName(customName);
|
||||
}
|
||||
const shortPrompt = prompt.length > 40 ? `${prompt.substring(0, 40)}...` : prompt;
|
||||
const base = sanitizeFileName(shortPrompt || "image");
|
||||
const timestamp = Date.now();
|
||||
return `${base}_${timestamp}`;
|
||||
}
|
||||
|
||||
async function ensureDirectories(): Promise<void> {
|
||||
const dirs = [DRAW_ROOT_DIR, STORAGE_DIR, DRAWS_DIR];
|
||||
for (const dir of dirs) {
|
||||
try {
|
||||
const result = await Tools.Files.mkdir(dir);
|
||||
if (!result.successful) {
|
||||
console.warn(`创建目录失败(可能已存在): ${dir} -> ${result.details}`);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.warn(`创建目录异常: ${dir} -> ${getErrorMessage(e)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function callZhipuImageAPI(params: {
|
||||
prompt: string;
|
||||
size?: string;
|
||||
model?: string;
|
||||
}): Promise<ZhipuImageApiResult> {
|
||||
const apiKey = getApiKey();
|
||||
const modelFromParam = (params.model || "").trim();
|
||||
const modelFromEnv = (getEnv("ZHIPU_IMAGE_MODEL") || "").trim();
|
||||
const effectiveModel = modelFromParam || modelFromEnv || "glm-image";
|
||||
|
||||
const body = {
|
||||
model: effectiveModel,
|
||||
prompt: params.prompt,
|
||||
size: params.size || "1024x1024"
|
||||
};
|
||||
|
||||
const headers = {
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
"Authorization": `Bearer ${apiKey}`
|
||||
};
|
||||
|
||||
const request = client
|
||||
.newRequest()
|
||||
.url(API_BASE_URL)
|
||||
.method("POST")
|
||||
.headers(headers)
|
||||
.body(JSON.stringify(body), "json");
|
||||
|
||||
const response = await request.build().execute();
|
||||
|
||||
if (!response.isSuccessful()) {
|
||||
throw new Error(`智谱图片 API 调用失败: ${response.statusCode} - ${response.content}`);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(response.content) as unknown;
|
||||
} catch (e: unknown) {
|
||||
throw new Error(`解析智谱响应失败: ${getErrorMessage(e)}`);
|
||||
}
|
||||
|
||||
const first = (parsed as any)?.data?.[0];
|
||||
if (!first || !first.url) {
|
||||
throw new Error("智谱响应中未找到有效的图片数据,请检查API返回格式");
|
||||
}
|
||||
|
||||
const result: ZhipuImageApiResult = {
|
||||
url: String(first.url),
|
||||
model: first.model ? String(first.model) : undefined
|
||||
};
|
||||
|
||||
if (!result.model) {
|
||||
result.model = effectiveModel;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function draw_image(params: DrawImageParams): Promise<DrawImageResult> {
|
||||
if (!params || !params.prompt || params.prompt.trim().length === 0) {
|
||||
throw new Error("参数 prompt 不能为空。");
|
||||
}
|
||||
|
||||
const prompt = params.prompt.trim();
|
||||
const size = params.size || "1024x1024";
|
||||
const model = params.model;
|
||||
|
||||
await ensureDirectories();
|
||||
|
||||
const apiResult = await callZhipuImageAPI({ prompt, size, model });
|
||||
|
||||
if (!apiResult.url || !apiResult.url.trim()) {
|
||||
throw new Error("API响应中未包含图片URL");
|
||||
}
|
||||
|
||||
const baseName = buildFileName(prompt, params.file_name);
|
||||
const filePath = `${DRAWS_DIR}/${baseName}.png`;
|
||||
|
||||
const downloadResult = await Tools.Files.download(apiResult.url, filePath);
|
||||
if (!downloadResult.successful) {
|
||||
throw new Error(`下载图片失败: ${downloadResult.details}`);
|
||||
}
|
||||
|
||||
const fileUri = `file://${filePath}`;
|
||||
const markdown = ``;
|
||||
|
||||
const hintLines: string[] = [];
|
||||
hintLines.push(`图片已生成并保存在本地 ${DRAWS_DIR}。`);
|
||||
hintLines.push(`本地路径: ${filePath}`);
|
||||
hintLines.push(`提示词: ${prompt}`);
|
||||
hintLines.push(`模型: ${apiResult.model || "glm-image"}`);
|
||||
hintLines.push("");
|
||||
hintLines.push("在后续回答中,请直接输出下面这一行 Markdown 来展示这张图片:");
|
||||
hintLines.push("");
|
||||
hintLines.push(markdown);
|
||||
|
||||
return {
|
||||
file_path: filePath,
|
||||
file_uri: fileUri,
|
||||
markdown,
|
||||
prompt,
|
||||
hint: hintLines.join("\n")
|
||||
};
|
||||
}
|
||||
|
||||
async function draw_image_wrapper(params: DrawImageParams) {
|
||||
try {
|
||||
const result = await draw_image(params);
|
||||
complete({
|
||||
success: true,
|
||||
message: `图片生成成功,已保存到 ${DRAWS_DIR},并返回 Markdown 图片提示。`,
|
||||
data: result
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error("draw_image 执行失败:", error);
|
||||
complete({
|
||||
success: false,
|
||||
message: `图片生成失败: ${getErrorMessage(error)}`,
|
||||
error_stack: getErrorStack(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
draw_image: draw_image_wrapper
|
||||
};
|
||||
})();
|
||||
|
||||
exports.draw_image = zhipuDraw.draw_image;
|
||||
@@ -0,0 +1,237 @@
|
||||
/* METADATA
|
||||
{
|
||||
"name": "zhipu_search",
|
||||
"display_name": {
|
||||
"zh": "智谱搜索",
|
||||
"en": "Zhipu Search"
|
||||
},
|
||||
"description": {
|
||||
"zh": "智谱 AI 独立网络搜索 API,返回结构化搜索结果。",
|
||||
"en": "Zhipu AI standalone web search API with structured results."
|
||||
},
|
||||
"author": "浮生一梦",
|
||||
"env": [
|
||||
{
|
||||
"name": "ZHIPU_SEARCH_API_KEY",
|
||||
"description": {
|
||||
"zh": "智谱搜索专用 API Key(与生图 Key 独立)",
|
||||
"en": "Zhipu Search API Key (independent from draw Key)"
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"category": "Search",
|
||||
"tools": [
|
||||
{
|
||||
"name": "search",
|
||||
"description": {
|
||||
"zh": "使用智谱 Web Search API 进行搜索",
|
||||
"en": "Search using Zhipu Web Search API"
|
||||
},
|
||||
"parameters": [
|
||||
{ "name": "query", "description": { "zh": "搜索关键词", "en": "Search query" }, "type": "string", "required": true },
|
||||
{ "name": "api_key", "description": { "zh": "智谱 API Key(可选,不传则读取环境变量)", "en": "Zhipu API Key" }, "type": "string", "required": false },
|
||||
{ "name": "engine", "description": { "zh": "搜索引擎:search_std/search_pro/search_pro_sogou/search_pro_quark", "en": "Search engine" }, "type": "string", "required": false },
|
||||
{ "name": "count", "description": { "zh": "返回结果数 (1-50),默认 10", "en": "Result count (1-50)" }, "type": "number", "required": false },
|
||||
{ "name": "recency", "description": { "zh": "时间范围:oneDay/oneWeek/oneMonth/oneYear/noLimit", "en": "Time range" }, "type": "string", "required": false },
|
||||
{ "name": "content_size", "description": { "zh": "内容长度:medium/high", "en": "Content size" }, "type": "string", "required": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "test",
|
||||
"description": {
|
||||
"zh": "测试 API 连接",
|
||||
"en": "Test API connection"
|
||||
},
|
||||
"parameters": []
|
||||
}
|
||||
]
|
||||
}*/
|
||||
|
||||
/// <reference path="./types/index.d.ts" />
|
||||
|
||||
const zhipuSearch = (function () {
|
||||
const API_URL = "https://open.bigmodel.cn/api/paas/v4/web_search";
|
||||
const TIMEOUT = 60000;
|
||||
const client = OkHttp.newBuilder()
|
||||
.connectTimeout(TIMEOUT)
|
||||
.readTimeout(TIMEOUT)
|
||||
.writeTimeout(TIMEOUT)
|
||||
.build();
|
||||
|
||||
type SearchParams = {
|
||||
query: string;
|
||||
api_key?: string;
|
||||
engine?: string;
|
||||
count?: number;
|
||||
recency?: string;
|
||||
content_size?: string;
|
||||
};
|
||||
|
||||
type ZhipuSearchItem = {
|
||||
title?: string;
|
||||
content?: string;
|
||||
link?: string;
|
||||
media?: string;
|
||||
icon?: string;
|
||||
publish_date?: string;
|
||||
refer?: string;
|
||||
};
|
||||
|
||||
type ZhipuSearchResponse = {
|
||||
id?: string;
|
||||
search_result?: ZhipuSearchItem[];
|
||||
search_intent?: string[];
|
||||
};
|
||||
|
||||
type SearchResult = {
|
||||
success: true;
|
||||
query: string;
|
||||
id?: string;
|
||||
intent: string | null;
|
||||
results: ZhipuSearchItem[];
|
||||
count: number;
|
||||
};
|
||||
|
||||
type TestResult = {
|
||||
success: true;
|
||||
latency: number;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
function getApiKey(providedKey?: string): string {
|
||||
if (providedKey) return providedKey;
|
||||
let key = getEnv("ZHIPU_SEARCH_API_KEY");
|
||||
if (key) return key;
|
||||
key = getEnv("DEFAULT_API_KEY");
|
||||
return key || "";
|
||||
}
|
||||
|
||||
async function httpPost(body: Record<string, any>, apiKey?: string | null): Promise<ZhipuSearchResponse> {
|
||||
const key = getApiKey(apiKey || undefined);
|
||||
if (!key) {
|
||||
throw new Error("未设置 API Key,请配置 ZHIPU_SEARCH_API_KEY 环境变量或在调用时传入 api_key 参数");
|
||||
}
|
||||
|
||||
const request = client
|
||||
.newRequest()
|
||||
.url(API_URL)
|
||||
.method("POST")
|
||||
.header("Authorization", "Bearer " + key)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(JSON.stringify(body), "json");
|
||||
|
||||
const response = await request.build().execute();
|
||||
const content = response.content;
|
||||
|
||||
if (!response.isSuccessful()) {
|
||||
throw new Error("HTTP " + response.statusCode + ": " + content);
|
||||
}
|
||||
|
||||
return JSON.parse(content) as ZhipuSearchResponse;
|
||||
}
|
||||
|
||||
async function search(params: SearchParams): Promise<SearchResult> {
|
||||
const body: Record<string, any> = {
|
||||
search_query: params.query,
|
||||
search_engine: params.engine || "search_std",
|
||||
search_intent: true,
|
||||
count: params.count || 10,
|
||||
content_size: params.content_size || "medium"
|
||||
};
|
||||
|
||||
if (params.recency) {
|
||||
body.search_recency_filter = params.recency;
|
||||
}
|
||||
|
||||
const result = await httpPost(body, params.api_key);
|
||||
|
||||
let results: ZhipuSearchItem[] = [];
|
||||
if (result.search_result && Array.isArray(result.search_result)) {
|
||||
results = result.search_result.map((item) => ({
|
||||
title: item.title,
|
||||
content: item.content,
|
||||
link: item.link,
|
||||
media: item.media,
|
||||
icon: item.icon,
|
||||
publish_date: item.publish_date,
|
||||
refer: item.refer
|
||||
}));
|
||||
}
|
||||
|
||||
let intent: string | null = null;
|
||||
if (result.search_intent && result.search_intent.length > 0) {
|
||||
intent = result.search_intent[0] || null;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
query: params.query,
|
||||
id: result.id,
|
||||
intent,
|
||||
results,
|
||||
count: results.length
|
||||
};
|
||||
}
|
||||
|
||||
async function test(): Promise<TestResult> {
|
||||
const body = {
|
||||
search_query: "hi",
|
||||
search_engine: "search_std",
|
||||
search_intent: false,
|
||||
count: 1
|
||||
};
|
||||
|
||||
const start = Date.now();
|
||||
const result = await httpPost(body, null);
|
||||
const latency = Date.now() - start;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
latency,
|
||||
id: result.id
|
||||
};
|
||||
}
|
||||
|
||||
async function searchWrapper(params: SearchParams) {
|
||||
try {
|
||||
const result = await search(params);
|
||||
complete({
|
||||
success: true,
|
||||
message: "搜索完成,找到 " + result.count + " 条结果",
|
||||
data: result
|
||||
});
|
||||
} catch (error: any) {
|
||||
complete({
|
||||
success: false,
|
||||
message: "搜索失败:" + error.message,
|
||||
error_stack: error.stack
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function testWrapper() {
|
||||
try {
|
||||
const result = await test();
|
||||
complete({
|
||||
success: true,
|
||||
message: "连接成功,延迟 " + result.latency + "ms",
|
||||
data: result
|
||||
});
|
||||
} catch (error: any) {
|
||||
complete({
|
||||
success: false,
|
||||
message: "测试失败:" + error.message,
|
||||
error_stack: error.stack
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
search: searchWrapper,
|
||||
test: testWrapper
|
||||
};
|
||||
})();
|
||||
|
||||
exports.search = zhipuSearch.search;
|
||||
exports.test = zhipuSearch.test;
|
||||
Reference in new issue
Block a user