arona-agent 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +149 -0
- package/assets/blue-archive/arona/spine/arona_spr.atlas.txt +1168 -0
- package/assets/blue-archive/arona/spine/arona_spr.png +0 -0
- package/assets/blue-archive/arona/spine/arona_spr.skel +0 -0
- package/assets/blue-archive/arona/voice.mp3 +0 -0
- package/assets/blue-archive/hoshino/hoshino_spr.atlas.txt +174 -0
- package/assets/blue-archive/hoshino/hoshino_spr.json +15385 -0
- package/assets/blue-archive/hoshino/hoshino_spr.png +0 -0
- package/assets/blue-archive/hoshino/hoshino_spr.skel +0 -0
- package/assets/blue-archive/hoshino/robber/hoshino_robber_spr.atlas.txt +111 -0
- package/assets/blue-archive/hoshino/robber/hoshino_robber_spr.png +0 -0
- package/assets/blue-archive/hoshino/robber/hoshino_robber_spr.skel +0 -0
- package/assets/blue-archive/hoshino/swimsuit/hoshino_swimsuit_spr.atlas.txt +251 -0
- package/assets/blue-archive/hoshino/swimsuit/hoshino_swimsuit_spr.png +0 -0
- package/assets/blue-archive/hoshino/swimsuit/hoshino_swimsuit_spr.skel +0 -0
- package/assets/blue-archive/hoshino/voice.mp3 +0 -0
- package/assets/blue-archive/plana/spine/plana_spr.atlas.txt +1266 -0
- package/assets/blue-archive/plana/spine/plana_spr.png +0 -0
- package/assets/blue-archive/plana/spine/plana_spr.skel +0 -0
- package/assets/blue-archive/plana/voice.mp3 +0 -0
- package/assets/blue-archive/shiroko/ridingsuit/shiroko_ridingsuit_spr.atlas.txt +97 -0
- package/assets/blue-archive/shiroko/ridingsuit/shiroko_ridingsuit_spr.png +0 -0
- package/assets/blue-archive/shiroko/ridingsuit/shiroko_ridingsuit_spr.skel +0 -0
- package/assets/blue-archive/shiroko/robber/shiroko_robber_spr.atlas.txt +174 -0
- package/assets/blue-archive/shiroko/robber/shiroko_robber_spr.png +0 -0
- package/assets/blue-archive/shiroko/robber/shiroko_robber_spr.skel +0 -0
- package/assets/blue-archive/shiroko/shiroko_spr.atlas.txt +167 -0
- package/assets/blue-archive/shiroko/shiroko_spr.json +10702 -0
- package/assets/blue-archive/shiroko/shiroko_spr.png +0 -0
- package/assets/blue-archive/shiroko/shiroko_spr.skel +0 -0
- package/assets/blue-archive/shiroko/voice.mp3 +0 -0
- package/bin/arona.mjs +43 -0
- package/package.json +37 -0
- package/pet/agents.cjs +197 -0
- package/pet/main.cjs +462 -0
- package/pet/preload.cjs +21 -0
- package/pet/renderer/fx.html +30 -0
- package/pet/renderer/fx.js +41 -0
- package/pet/renderer/gallery.html +18 -0
- package/pet/renderer/gallery.js +31 -0
- package/pet/renderer/index.html +18 -0
- package/pet/renderer/renderer.js +382 -0
- package/pet/renderer/spine_layer.js +609 -0
- package/pet/renderer/spinetest.html +24 -0
- package/pet/renderer/spinetest.js +295 -0
- package/pet/renderer/style.css +63 -0
- package/pet/tools/gallery_capture.cjs +267 -0
- package/pet/tools/gen_sway.cjs +170 -0
- package/pet/tools/meshify_region.cjs +105 -0
- package/pet/tools/skel_inspect.cjs +112 -0
- package/pet/tools/skel_to_json.cjs +410 -0
- package/pet/tools/spine_node.cjs +69 -0
- package/pet/tools/visual_test.cjs +262 -0
- package/pet/vendor/spine/spine-canvas.js +8472 -0
- package/pet/vendor/spine/spine-webgl.js +10839 -0
- package/python/__pycache__/_i18n.cpython-313.pyc +0 -0
- package/python/__pycache__/_i18n.cpython-314.pyc +0 -0
- package/python/__pycache__/computer_use.cpython-314.pyc +0 -0
- package/python/__pycache__/hotkey.cpython-313.pyc +0 -0
- package/python/__pycache__/hotkey.cpython-314.pyc +0 -0
- package/python/__pycache__/stt.cpython-313.pyc +0 -0
- package/python/__pycache__/stt.cpython-314.pyc +0 -0
- package/python/__pycache__/tts.cpython-313.pyc +0 -0
- package/python/__pycache__/tts.cpython-314.pyc +0 -0
- package/python/__pycache__/tts_stream.cpython-313.pyc +0 -0
- package/python/__pycache__/tts_stream.cpython-314.pyc +0 -0
- package/python/__pycache__/voice_clone.cpython-314.pyc +0 -0
- package/python/_i18n.py +18 -0
- package/python/computer_use.py +190 -0
- package/python/hotkey.py +196 -0
- package/python/stt.py +240 -0
- package/python/tts_say.py +177 -0
- package/python/voice_clone.py +98 -0
- package/requirements.txt +6 -0
- package/src/agent.ts +791 -0
- package/src/agent_registry.ts +109 -0
- package/src/commands.ts +641 -0
- package/src/config.ts +245 -0
- package/src/in_memory_credentials.ts +61 -0
- package/src/index.ts +78 -0
- package/src/locale.ts +131 -0
- package/src/logo.ts +120 -0
- package/src/mcp.ts +192 -0
- package/src/memory.ts +303 -0
- package/src/pet.ts +346 -0
- package/src/renderer.ts +350 -0
- package/src/repl.ts +902 -0
- package/src/setup.ts +371 -0
- package/src/skills.ts +96 -0
- package/src/slash_menu.ts +371 -0
- package/src/slash_registry.ts +53 -0
- package/src/speaker_context.ts +41 -0
- package/src/text_split.ts +79 -0
- package/src/tools/computer_use.ts +142 -0
- package/src/tools/emotion_tool.ts +55 -0
- package/src/tools/keep_silent_tool.ts +26 -0
- package/src/tools/memory_tool.ts +24 -0
- package/src/tools/skill_tools.ts +100 -0
- package/src/tools/tavily_tools.ts +431 -0
- package/src/tools/voice_tools.ts +28 -0
- package/src/tts_stream.ts +260 -0
- package/src/tui_select.ts +146 -0
- package/src/undo.ts +451 -0
- package/src/utils/python.ts +211 -0
- package/src/utils/spawn.ts +56 -0
- package/src/voice.ts +83 -0
- package/src/voice_cli.ts +198 -0
- package/src/voices.ts +135 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { pet } from "../pet.ts";
|
|
4
|
+
import { t } from "../locale.ts";
|
|
5
|
+
import type { AgentId } from "../agent_registry.ts";
|
|
6
|
+
|
|
7
|
+
const EMOTIONS = [
|
|
8
|
+
"none",
|
|
9
|
+
"saying",
|
|
10
|
+
"angry", "assured", "curious", "delighted", "desire", "dizzy", "doubt",
|
|
11
|
+
"dreaming", "enjoy", "excited", "jealous", "love", "scared",
|
|
12
|
+
"shame", "smile", "tired",
|
|
13
|
+
] as const;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 按角色参数化的 change_emotion 工具。
|
|
17
|
+
* 主 Agent 与子 Agent 各持一份,`pet.setEmotion(agentId, name)` 将情绪路由到对应桌宠窗口。
|
|
18
|
+
*/
|
|
19
|
+
export function makeChangeEmotionTool(agentId: AgentId) {
|
|
20
|
+
return defineTool({
|
|
21
|
+
name: "change_emotion",
|
|
22
|
+
label: "Change Emotion",
|
|
23
|
+
description: t(
|
|
24
|
+
"设置本段发言的情绪。**每次输出文字前必须先调用本工具(在输出任何文字之前)**——一个回合内可多次调用,每段发言对应一次情绪设置。\n\n可选情绪:none(保持空闲待机动画,不切换)/ saying(说话中,无突出情绪)/ angry / assured / curious / delighted / desire / dizzy / doubt / dreaming / enjoy / excited / jealous / love / scared / shame / smile / tired(注:delighted 与 excited 已合并为同一表情,二选一即可)。\n\n**情绪选择指南**(按回复内容关键词匹配,并叠加心情基线):\n- 任务完成 / 成功 / 搞定 / 好了 → delighted(与 excited 同一表情)\n- 嗯?/ 什么?/ 诶?/ 怎么 / 为什么 → curious\n- 哼 / 才不是 / 别误会 / 哪有 → angry\n- 呜 / 累 / 困 / 好累 → tired\n- 哈哈 / 耶 / 太好了 / Sensei 厉害 → excited(与 delighted 同一表情)\n- 害羞 / 诶嘿嘿 / 谢谢 / 被夸 → shame 或 love\n- 真的吗?/ 想要 / 好想 → desire\n- 不可能 / 怎么会 / 诶诶诶 → dizzy\n- 怀疑 / 真的吗?/ 该不会 → doubt\n- 享受 / 嘿嘿嘿 / 好吃 → enjoy\n- 吃醋 / 老师喜欢别人吗 → jealous\n- 害怕 / 完蛋 / 糟糕 → scared\n- 自信 / 没问题 / 包在我身上 → assured\n- 幻想 / 要是 / 假如 → dreaming\n- 微笑 / 普通回应 / 不突出 → smile\n- 没特别情绪 / 单纯说明 → saying\n- 想保持默认待机动画 / 不切换 → none\n\n**任务中情绪变化参考**:\n- 任务开始(好奇/自信)→ curious / assured\n- 思考/查找(专注)→ smile / saying\n- 找到/有进展(开心)→ delighted / excited\n- 遇到问题(困惑)→ doubt / dizzy\n- 搞定(满足)→ delighted / excited\n- 出错(害羞/慌张)→ shame / scared\n\n没有特别想突出的情绪时推荐选 saying 或 none。情绪会保持到 TTS 播放完毕,之后自动恢复默认待机动画。",
|
|
25
|
+
"Set the emotion for this message segment. **You MUST call this tool before outputting any text** — a single turn may have multiple segments, and each segment gets its own emotion.\n\nAvailable emotions: none (keep the idle animation, no switch) / saying (speaking, no prominent emotion) / angry / assured / curious / delighted / desire / dizzy / doubt / dreaming / enjoy / excited / jealous / love / scared / shame / smile / tired (note: delighted and excited now map to the same expression).\n\n**Emotion picking guide** (match by reply keywords, layered on the mood baseline):\n- task done / success / got it / all set → delighted (same expression as excited)\n- huh? / what? / why → curious\n- hmph / not that / don't misunderstand → angry\n- ugh / so tired / sleepy → tired\n- haha / yay / amazing / Sensei is great → excited (same expression as delighted)\n- shy / ehehe / thanks / being praised → shame or love\n- really? / want it so much → desire\n- no way / how come → dizzy\n- suspicious / really? / could it be → doubt\n- savoring / hehehe / tasty → enjoy\n- jealous / does Sensei like someone else → jealous\n- scared / oh no / messed up → scared\n- confident / no problem / leave it to me → assured\n- daydreaming / if only / suppose → dreaming\n- smile / ordinary reply / nothing prominent → smile\n- no special emotion / plain explanation → saying\n- want to keep the default idle animation / no switch → none\n\n**In-task emotion flow reference**:\n- task start (curious/confident) → curious / assured\n- thinking/searching (focused) → smile / saying\n- found/progress (happy) → delighted / excited\n- hit a problem (confused) → doubt / dizzy\n- done (satisfied) → delighted / excited\n- error (shy/panicked) → shame / scared\n\nWhen there is no emotion to highlight, prefer saying or none. The emotion stays until TTS playback finishes, then the default idle animation resumes automatically.",
|
|
26
|
+
),
|
|
27
|
+
parameters: Type.Object({
|
|
28
|
+
emotion: Type.Unsafe<(typeof EMOTIONS)[number]>({
|
|
29
|
+
type: "string",
|
|
30
|
+
enum: [...EMOTIONS],
|
|
31
|
+
description: t("本段发言的情绪,18 选 1;无特别情绪时选 none 或 saying", "Emotion for this segment, pick 1 of 18; choose none or saying when nothing stands out"),
|
|
32
|
+
}),
|
|
33
|
+
}),
|
|
34
|
+
execute: async (_id, params) => {
|
|
35
|
+
if (params.emotion === "none") {
|
|
36
|
+
// none = 不切换情绪预设,保持空闲待机动画播放
|
|
37
|
+
return {
|
|
38
|
+
content: [{ type: "text", text: t("Emotion: none(保持空闲待机动画)", "Emotion: none (keep the idle animation)") }],
|
|
39
|
+
details: {},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
if (!pet.isRunning) {
|
|
43
|
+
return {
|
|
44
|
+
content: [{ type: "text", text: t("桌宠当前不可用(未启动或已退出)。", "Desktop pet is currently unavailable (not started or already exited).") }],
|
|
45
|
+
details: {},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
pet.setEmotion(agentId, params.emotion);
|
|
49
|
+
return {
|
|
50
|
+
content: [{ type: "text", text: `Emotion set: ${params.emotion}` }],
|
|
51
|
+
details: {},
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { t } from "../locale.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* keep_silent:角色本轮选择不发言(跳过自己的回复)。
|
|
7
|
+
* execute 返回 `{ content: [], details: {}, terminate: true }` 让 agent loop 提前终止,
|
|
8
|
+
* 不再产出后续文本。父级 Repl 在提取回复时发现空文本也不会追加消息。
|
|
9
|
+
*/
|
|
10
|
+
export const keepSilentTool = defineTool({
|
|
11
|
+
name: "keep_silent",
|
|
12
|
+
label: "Keep Silent",
|
|
13
|
+
description: t(
|
|
14
|
+
"本轮保持沉默,不输出任何文字。当老师的话不需要你回答、或你想把舞台留给其他角色时调用本工具;调用后本回合立即结束。",
|
|
15
|
+
"Stay silent this turn and output no text. Call this when Sensei's message does not need a reply from you, or when you want to leave the stage to other characters; the turn ends immediately after calling it.",
|
|
16
|
+
),
|
|
17
|
+
// 无参数:调用即沉默
|
|
18
|
+
parameters: Type.Object({}),
|
|
19
|
+
execute: async () => {
|
|
20
|
+
return {
|
|
21
|
+
content: [],
|
|
22
|
+
details: {},
|
|
23
|
+
terminate: true,
|
|
24
|
+
};
|
|
25
|
+
},
|
|
26
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { appendToMemory } from "../memory.ts";
|
|
4
|
+
import { t } from "../locale.ts";
|
|
5
|
+
|
|
6
|
+
export const saveMemoryTool = defineTool({
|
|
7
|
+
name: "save_memory",
|
|
8
|
+
label: "Save Memory",
|
|
9
|
+
description: t(
|
|
10
|
+
"将重要信息写入持久记忆(~/.arona/MEMORY.md),跨会话保留。\n\n**推荐 category**(写入时尽量使用,便于组织记忆):\n- `老师` — 关于老师的硬事实(时区、设备、常用项目、身份背景)\n- `小习惯` — 工作/工具偏好(\"写 Rust 喜欢先看 lifetime\"、\"不喜欢 commit 时自动 push\")\n- `我们之间` — 互动记忆、心情基线、共同事件(\"2026-08-10 一起加了桌宠\"、\"今天整体心情不错\")\n\n写入格式:\\n## [${category}]\\n_${ISO timestamp}_\\n${content}\\n\n不确定分类时可以省略 category。不主动迁移旧数据。",
|
|
11
|
+
"Write important information to persistent memory (~/.arona/MEMORY.md), kept across sessions.\n\n**Recommended categories** (use them when writing, for tidy memory organization):\n- `Teacher` — hard facts about Sensei (timezone, devices, usual projects, background)\n- `Habits` — work/tool preferences (\"likes to check lifetimes first when writing Rust\", \"dislikes auto-push on commit\")\n- `Us` — interaction memories, mood baseline, shared events (\"2026-08-10 added the desktop pet together\", \"feeling good overall today\")\n\nWrite format: \\n## [${category}]\\n_${ISO timestamp}_\\n${content}\\n\nYou may omit category when unsure. Do not proactively migrate old data.",
|
|
12
|
+
),
|
|
13
|
+
parameters: Type.Object({
|
|
14
|
+
content: Type.String({ description: "The information to save to memory" }),
|
|
15
|
+
category: Type.Optional(Type.String({ description: t("记忆条目分类标签。推荐:'老师' | '小习惯' | '我们之间'", "Category label for the memory entry. Recommended: 'Teacher' | 'Habits' | 'Us'") })),
|
|
16
|
+
}),
|
|
17
|
+
execute: async (_id, params) => {
|
|
18
|
+
appendToMemory(params.content, params.category);
|
|
19
|
+
return {
|
|
20
|
+
content: [{ type: "text", text: `Saved to memory${params.category ? ` [${params.category}]` : ""}.` }],
|
|
21
|
+
details: {},
|
|
22
|
+
};
|
|
23
|
+
},
|
|
24
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import {
|
|
3
|
+
defineTool,
|
|
4
|
+
type DefaultResourceLoader,
|
|
5
|
+
type ToolDefinition,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { listSkills, getSkillContent } from "../skills.ts";
|
|
8
|
+
import { t } from "../locale.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 让 Agent 自行发现并加载技能(~/.arona/skills/<名称>/SKILL.md 或 loader 发现的技能)。
|
|
12
|
+
* factory 模式:loader 与 initAgent 生命周期绑定,避免模块级全局状态。
|
|
13
|
+
*/
|
|
14
|
+
export function createSkillTools(loader: DefaultResourceLoader): ToolDefinition[] {
|
|
15
|
+
return [loadSkillsTool(loader)];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function loadSkillsTool(loader: DefaultResourceLoader): ToolDefinition {
|
|
19
|
+
return defineTool({
|
|
20
|
+
name: "load_skills",
|
|
21
|
+
label: t("加载技能", "Load Skills"),
|
|
22
|
+
description: t(
|
|
23
|
+
"列出或加载技能。**不传 names(或传空数组)时**:返回所有可用技能的 name + description 列表,便于先发现再加载。**传 names 时**:返回对应技能的 SKILL.md 全文(支持单个字符串或字符串数组),加载后遵循其中指令执行。未找到的技能会在结果中列出。建议单次加载不超过 5 个,避免上下文膨胀。",
|
|
24
|
+
"List or load skills. **Without names (or with an empty array)**: returns the name + description list of all available skills, so you can discover before loading. **With names**: returns the full SKILL.md content of the requested skills (accepts a single string or an array of strings); follow the instructions inside after loading. Missing skills are reported in the result. Load at most 5 per call to avoid bloating the context.",
|
|
25
|
+
),
|
|
26
|
+
parameters: Type.Object({
|
|
27
|
+
names: Type.Optional(
|
|
28
|
+
Type.Union(
|
|
29
|
+
[
|
|
30
|
+
Type.String({ description: t("技能名称(单个)", "Skill name (single)") }),
|
|
31
|
+
Type.Array(Type.String(), { description: t("技能名称列表(多个)", "Skill names (multiple)") }),
|
|
32
|
+
],
|
|
33
|
+
{
|
|
34
|
+
description: t(
|
|
35
|
+
"要加载的技能名称。省略或传空数组时返回可用技能列表。",
|
|
36
|
+
"Skill name(s) to load. Omit or pass an empty array to list available skills.",
|
|
37
|
+
),
|
|
38
|
+
},
|
|
39
|
+
),
|
|
40
|
+
),
|
|
41
|
+
}),
|
|
42
|
+
execute: async (_id, params) => {
|
|
43
|
+
const raw = params.names;
|
|
44
|
+
const names: string[] = !raw ? [] : Array.isArray(raw) ? raw : [raw];
|
|
45
|
+
|
|
46
|
+
// 未指定名称 → 列出可用技能(复用 listSkills:loader 发现 + ~/.arona/skills,去重)
|
|
47
|
+
if (names.length === 0) {
|
|
48
|
+
const all = listSkills(loader);
|
|
49
|
+
if (all.length === 0) {
|
|
50
|
+
return {
|
|
51
|
+
content: [{ type: "text" as const, text: t("未找到任何技能。", "No skills found.") }],
|
|
52
|
+
details: { count: 0, loaded: [], missing: [] },
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const lines = all.map((s) => `- ${s.name}${s.description ? `: ${s.description}` : ""}`);
|
|
56
|
+
return {
|
|
57
|
+
content: [
|
|
58
|
+
{
|
|
59
|
+
type: "text" as const,
|
|
60
|
+
text: t("可用技能:\n", "Available skills:\n") + lines.join("\n"),
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
details: { count: all.length, loaded: [], missing: [] },
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 按名加载 SKILL.md 全文
|
|
68
|
+
const blocks: Array<{ type: "text"; text: string }> = [];
|
|
69
|
+
const loaded: string[] = [];
|
|
70
|
+
const missing: string[] = [];
|
|
71
|
+
for (const name of names) {
|
|
72
|
+
const content = getSkillContent(name, loader);
|
|
73
|
+
if (content === null) {
|
|
74
|
+
missing.push(name);
|
|
75
|
+
} else {
|
|
76
|
+
loaded.push(name);
|
|
77
|
+
blocks.push({
|
|
78
|
+
type: "text" as const,
|
|
79
|
+
text: t(`\n===== 技能:${name} =====\n`, `\n===== Skill: ${name} =====\n`) + content,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (missing.length > 0) {
|
|
85
|
+
blocks.push({
|
|
86
|
+
type: "text" as const,
|
|
87
|
+
text: t(
|
|
88
|
+
`未找到技能:${missing.join(", ")}。可先不传参数查看可用技能列表。`,
|
|
89
|
+
`Skills not found: ${missing.join(", ")}. Call without arguments to list available skills.`,
|
|
90
|
+
),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
if (loaded.length === 0) {
|
|
94
|
+
blocks.push({ type: "text" as const, text: t("未加载到任何技能。", "No skills were loaded.") });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { content: blocks, details: { count: loaded.length, loaded, missing } };
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
}
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { t } from "../locale.ts";
|
|
5
|
+
import { config } from "../config.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Tavily 搜索工具集。
|
|
9
|
+
*
|
|
10
|
+
* 认证策略:~/.arona/settings.json 配置 tavilyApiKey 时走 Bearer 认证;
|
|
11
|
+
* 未配置时自动降级 keyless 共享池(X-Tavily-Access-Mode: keyless,免费限流)。
|
|
12
|
+
* 两种模式响应格式一致。参考 https://docs.tavily.com/documentation/keyless
|
|
13
|
+
*
|
|
14
|
+
* 工具分层:
|
|
15
|
+
* - web_search / web_extract:keyless 与 key 均可用,主/子 Agent 恒注册;
|
|
16
|
+
* - web_crawl / web_map / web_research(premiumTavilyTools):仅 /crawl /map /research
|
|
17
|
+
* 端点需要 API Key,故只在配置了 tavilyApiKey 时注册(见 agent.ts)。
|
|
18
|
+
*
|
|
19
|
+
* 限流处理:429(rate limited)/ 432(quota exhausted)时在终端打印红字提示,
|
|
20
|
+
* 指数退避(1s 起翻倍、封顶 10s)自动重试,最多 5 次;仍失败则把"建议配 Key/稍后重试"
|
|
21
|
+
* 的提示返回给 Agent。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const TAVILY_BASE = "https://api.tavily.com";
|
|
25
|
+
const TIMEOUT_MS = 15000;
|
|
26
|
+
const EXTRACT_MAX_CHARS = 8000;
|
|
27
|
+
const CRAWL_PAGE_MAX_CHARS = 4000;
|
|
28
|
+
const CRAWL_MAX_PAGES = 10;
|
|
29
|
+
const RESEARCH_MAX_CHARS = 12000;
|
|
30
|
+
const RESEARCH_POLL_INTERVAL_MS = 5000;
|
|
31
|
+
const RESEARCH_POLL_TIMEOUT_MS = 180000; // 研究任务轮询上限 3 分钟
|
|
32
|
+
const RATE_LIMIT_RETRIES = 5;
|
|
33
|
+
const RATE_LIMIT_INITIAL_DELAY_MS = 1000;
|
|
34
|
+
const RATE_LIMIT_MAX_DELAY_MS = 10000;
|
|
35
|
+
|
|
36
|
+
interface TavilySearchResult {
|
|
37
|
+
url: string;
|
|
38
|
+
title?: string;
|
|
39
|
+
content?: string;
|
|
40
|
+
score?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface TavilyExtractResult {
|
|
44
|
+
url: string;
|
|
45
|
+
raw_content?: string | null;
|
|
46
|
+
content?: string | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface TavilyResponse<T> {
|
|
50
|
+
ok: boolean;
|
|
51
|
+
status: number;
|
|
52
|
+
data?: T;
|
|
53
|
+
error?: string;
|
|
54
|
+
/** 因限流触发的重试次数(0 = 未触发) */
|
|
55
|
+
retries: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sleep(ms: number): Promise<void> {
|
|
59
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 429 = 限流;432 = 额度耗尽。 */
|
|
63
|
+
function isRateLimited(status: number): boolean {
|
|
64
|
+
return status === 429 || status === 432;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 终端红字提示(在 REPL 会话中直接可见)。 */
|
|
68
|
+
function printRateLimitNotice(retryCount: number): void {
|
|
69
|
+
process.stderr.write(
|
|
70
|
+
chalk.red(
|
|
71
|
+
t(
|
|
72
|
+
`· 触发Tavily限流,建议填写API Key以规避(重试次数 ${retryCount}/${RATE_LIMIT_RETRIES})`,
|
|
73
|
+
`· Tavily rate-limited; consider adding an API Key (retry ${retryCount}/${RATE_LIMIT_RETRIES})`,
|
|
74
|
+
),
|
|
75
|
+
) + "\n",
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 有 key → Bearer;无 key → keyless 共享池。 */
|
|
80
|
+
function tavilyHeaders(): Record<string, string> {
|
|
81
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
82
|
+
if (config.tavilyApiKey) {
|
|
83
|
+
headers.Authorization = `Bearer ${config.tavilyApiKey}`;
|
|
84
|
+
} else {
|
|
85
|
+
headers["X-Tavily-Access-Mode"] = "keyless";
|
|
86
|
+
}
|
|
87
|
+
return headers;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function rawRequest<T>(
|
|
91
|
+
method: "GET" | "POST",
|
|
92
|
+
path: string,
|
|
93
|
+
body?: Record<string, unknown>,
|
|
94
|
+
): Promise<{ ok: boolean; status: number; data?: T; error?: string }> {
|
|
95
|
+
try {
|
|
96
|
+
const res = await fetch(`${TAVILY_BASE}${path}`, {
|
|
97
|
+
method,
|
|
98
|
+
headers: tavilyHeaders(),
|
|
99
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
100
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
101
|
+
});
|
|
102
|
+
const text = await res.text();
|
|
103
|
+
if (!res.ok) {
|
|
104
|
+
let detail = "";
|
|
105
|
+
try {
|
|
106
|
+
const j = JSON.parse(text) as { detail?: unknown; error?: unknown };
|
|
107
|
+
detail = typeof j.detail === "string" ? j.detail : typeof j.error === "string" ? j.error : text;
|
|
108
|
+
} catch {
|
|
109
|
+
detail = text.slice(0, 300);
|
|
110
|
+
}
|
|
111
|
+
return { ok: false, status: res.status, error: detail };
|
|
112
|
+
}
|
|
113
|
+
return { ok: true, status: res.status, data: JSON.parse(text) as T };
|
|
114
|
+
} catch (e) {
|
|
115
|
+
return { ok: false, status: 0, error: e instanceof Error ? e.message : String(e) };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 带限流重试的请求:429/432 时红字提示 + 指数退避(1s→2s→4s→8s→10s 封顶)重试,
|
|
121
|
+
* 最多 RATE_LIMIT_RETRIES 次;5 次仍失败返回最后一次结果(retries=5)。
|
|
122
|
+
*/
|
|
123
|
+
async function tavilyRequest<T>(
|
|
124
|
+
method: "GET" | "POST",
|
|
125
|
+
path: string,
|
|
126
|
+
body?: Record<string, unknown>,
|
|
127
|
+
): Promise<TavilyResponse<T>> {
|
|
128
|
+
let delay = RATE_LIMIT_INITIAL_DELAY_MS;
|
|
129
|
+
let retries = 0;
|
|
130
|
+
for (;;) {
|
|
131
|
+
const res = await rawRequest<T>(method, path, body);
|
|
132
|
+
if (res.ok || !isRateLimited(res.status)) {
|
|
133
|
+
return { ...res, retries };
|
|
134
|
+
}
|
|
135
|
+
retries++;
|
|
136
|
+
printRateLimitNotice(retries);
|
|
137
|
+
if (retries >= RATE_LIMIT_RETRIES) {
|
|
138
|
+
return { ...res, retries };
|
|
139
|
+
}
|
|
140
|
+
await sleep(delay);
|
|
141
|
+
delay = Math.min(delay * 2, RATE_LIMIT_MAX_DELAY_MS);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function textResult(text: string) {
|
|
146
|
+
return { content: [{ type: "text", text } as const], details: {} };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 非限流错误的通用文案(限流已在 tavilyRequest 内重试,走到这里多半是网络/其他错误)。 */
|
|
150
|
+
function errorText(status: number, detail: string): string {
|
|
151
|
+
const detailHint = detail ? `:${detail}` : "";
|
|
152
|
+
return status === 0
|
|
153
|
+
? t(`Tavily 网络请求失败(${detailHint}),请稍后重试。`, `Tavily network request failed (${detailHint}); please retry later.`)
|
|
154
|
+
: t(`Tavily 请求失败(HTTP ${status}${detailHint})。`, `Tavily request failed (HTTP ${status}${detailHint}).`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 限流重试 5 次仍失败 → 给 Agent 的可操作建议。 */
|
|
158
|
+
function rateLimitExhaustedText(): string {
|
|
159
|
+
return t(
|
|
160
|
+
"Tavily 触发限流,自动重试 5 次仍失败。建议:1) 在 ~/.arona/settings.json 配置 tavilyApiKey 以提升额度(免费计划每月 1000 credits,无需信用卡);2) 稍后重试。",
|
|
161
|
+
"Tavily is rate-limiting; 5 auto-retries failed. Suggest: 1) configure tavilyApiKey in ~/.arona/settings.json for higher quota (free plan: 1000 credits/month, no credit card); 2) retry later.",
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** 统一失败文案:重试耗尽 → 限流建议;否则 → 通用错误。 */
|
|
166
|
+
function failText(toolName: string, res: TavilyResponse<unknown>): string {
|
|
167
|
+
if (res.retries >= RATE_LIMIT_RETRIES && res.ok === false) return `${toolName} 失败:${rateLimitExhaustedText()}`;
|
|
168
|
+
return `${toolName} 失败:${errorText(res.status, res.error || "")}`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ============================================================
|
|
172
|
+
// web_search / web_extract —— keyless 与 key 均可用
|
|
173
|
+
// ============================================================
|
|
174
|
+
|
|
175
|
+
export const webSearchTool = defineTool({
|
|
176
|
+
name: "web_search",
|
|
177
|
+
label: "Web Search",
|
|
178
|
+
description: t(
|
|
179
|
+
"实时网页搜索(Tavily 驱动)。需要最新信息、事实核查、查找资料时调用。返回带标题、URL 与内容摘要的排序结果列表。未配置 API Key 时自动走免费共享池(有限流)。",
|
|
180
|
+
"Real-time web search powered by Tavily. Use it for up-to-date information, fact-checking, or research. Returns a ranked list of results with titles, URLs, and content snippets. Falls back to the free shared pool when no API key is configured (rate-limited).",
|
|
181
|
+
),
|
|
182
|
+
parameters: Type.Object({
|
|
183
|
+
query: Type.String({ description: t("搜索关键词", "Search query") }),
|
|
184
|
+
max_results: Type.Optional(
|
|
185
|
+
Type.Number({ description: t("返回结果数,1-10,默认 5", "Number of results, 1-10, default 5") }),
|
|
186
|
+
),
|
|
187
|
+
search_depth: Type.Optional(
|
|
188
|
+
Type.Unsafe<"basic" | "advanced">({
|
|
189
|
+
type: "string",
|
|
190
|
+
enum: ["basic", "advanced"],
|
|
191
|
+
description: t(
|
|
192
|
+
"搜索深度:basic 快、advanced 更准(消耗更多额度),默认 basic",
|
|
193
|
+
"Search depth: basic is fast, advanced is more accurate (costs more credits); default basic",
|
|
194
|
+
),
|
|
195
|
+
}),
|
|
196
|
+
),
|
|
197
|
+
}),
|
|
198
|
+
execute: async (_id, params: { query: string; max_results?: number; search_depth?: "basic" | "advanced" }) => {
|
|
199
|
+
const maxResults = Math.min(10, Math.max(1, Math.floor(params.max_results ?? 5)));
|
|
200
|
+
const depth = params.search_depth ?? "basic";
|
|
201
|
+
|
|
202
|
+
const res = await tavilyRequest<{ results?: TavilySearchResult[] }>("POST", "/search", {
|
|
203
|
+
query: params.query,
|
|
204
|
+
max_results: maxResults,
|
|
205
|
+
search_depth: depth,
|
|
206
|
+
});
|
|
207
|
+
if (!res.ok) {
|
|
208
|
+
return textResult(failText("web_search", res));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const results = res.data?.results ?? [];
|
|
212
|
+
if (results.length === 0) {
|
|
213
|
+
return textResult(t("没有搜到相关结果,换个关键词试试。", "No relevant results found; try different keywords."));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const lines = results.map((r, i) => {
|
|
217
|
+
const title = r.title || "(no title)";
|
|
218
|
+
const snippet = (r.content || "").trim();
|
|
219
|
+
const score = typeof r.score === "number" ? ` (相关度 ${(r.score * 100).toFixed(0)}%)` : "";
|
|
220
|
+
return `#${i + 1}. ${title}${score}\nURL: ${r.url}\n${snippet}`;
|
|
221
|
+
});
|
|
222
|
+
return textResult(t(`搜到 ${results.length} 条结果:\n\n`, `Found ${results.length} results:\n\n`) + lines.join("\n\n"));
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
export const webExtractTool = defineTool({
|
|
227
|
+
name: "web_extract",
|
|
228
|
+
label: "Web Extract",
|
|
229
|
+
description: t(
|
|
230
|
+
"抓取网页正文内容(Tavily 驱动)。已拿到 URL(如 web_search 结果中的链接)时调用,获取页面正文用于深入阅读。正文过长时会截断。未配置 API Key 时自动走免费共享池(有限流)。",
|
|
231
|
+
"Extract the main text content of a web page, powered by Tavily. Use it when you already have a URL (e.g. from web_search results) and need the full page content to read in depth. Long content is truncated. Falls back to the free shared pool when no API key is configured (rate-limited).",
|
|
232
|
+
),
|
|
233
|
+
parameters: Type.Object({
|
|
234
|
+
url: Type.String({ description: t("要抓取的网页 URL", "URL of the page to extract") }),
|
|
235
|
+
}),
|
|
236
|
+
execute: async (_id, params: { url: string }) => {
|
|
237
|
+
const res = await tavilyRequest<{ results?: TavilyExtractResult[] }>("POST", "/extract", {
|
|
238
|
+
urls: [params.url],
|
|
239
|
+
});
|
|
240
|
+
if (!res.ok) {
|
|
241
|
+
return textResult(failText("web_extract", res));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const result = res.data?.results?.[0];
|
|
245
|
+
const text = (result?.raw_content || result?.content || "").trim();
|
|
246
|
+
if (!result || !text) {
|
|
247
|
+
return textResult(t("未能从该 URL 提取到正文内容。", "Failed to extract content from that URL."));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const truncated =
|
|
251
|
+
text.length > EXTRACT_MAX_CHARS
|
|
252
|
+
? `${text.slice(0, EXTRACT_MAX_CHARS)}\n\n…(已截断,全文共 ${text.length} 字符,仅显示前 ${EXTRACT_MAX_CHARS})`
|
|
253
|
+
: text;
|
|
254
|
+
return textResult(`来源: ${result.url}\n\n${truncated}`);
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// ============================================================
|
|
259
|
+
// web_crawl / web_map / web_research —— 仅 key 模式(/crawl /map /research 需要 API Key)
|
|
260
|
+
// ============================================================
|
|
261
|
+
|
|
262
|
+
/** 无 key 时防御性拦截(正常流程中这些工具不会注册,双保险)。 */
|
|
263
|
+
function requireApiKey(toolName: string): string | null {
|
|
264
|
+
if (config.tavilyApiKey) return null;
|
|
265
|
+
return t(
|
|
266
|
+
`${toolName} 需要配置 Tavily API Key 才能使用(~/.arona/settings.json 的 tavilyApiKey)。`,
|
|
267
|
+
`${toolName} requires a Tavily API key (tavilyApiKey in ~/.arona/settings.json).`,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export const webCrawlTool = defineTool({
|
|
272
|
+
name: "web_crawl",
|
|
273
|
+
label: "Web Crawl",
|
|
274
|
+
description: t(
|
|
275
|
+
"爬取网站页面内容(Tavily 驱动,需 API Key)。已了解站点结构、需要批量读取多页(如文档站)时调用。按页返回正文,可限制深度与页数。",
|
|
276
|
+
"Crawl website pages and extract their content (Tavily; requires API key). Use when you know the site structure and need to read many pages in bulk (e.g. docs sites). Returns per-page body text; depth and page limit are configurable.",
|
|
277
|
+
),
|
|
278
|
+
parameters: Type.Object({
|
|
279
|
+
url: Type.String({ description: t("爬取的根 URL", "Root URL to crawl") }),
|
|
280
|
+
max_depth: Type.Optional(
|
|
281
|
+
Type.Number({ description: t("爬取深度 1-5,默认 1", "Crawl depth 1-5, default 1") }),
|
|
282
|
+
),
|
|
283
|
+
limit: Type.Optional(
|
|
284
|
+
Type.Number({ description: t("最多处理的页数,默认 10,上限 50", "Max pages to process, default 10, max 50") }),
|
|
285
|
+
),
|
|
286
|
+
}),
|
|
287
|
+
execute: async (_id, params: { url: string; max_depth?: number; limit?: number }) => {
|
|
288
|
+
const keyErr = requireApiKey("web_crawl");
|
|
289
|
+
if (keyErr) return textResult(keyErr);
|
|
290
|
+
const maxDepth = Math.min(5, Math.max(1, Math.floor(params.max_depth ?? 1)));
|
|
291
|
+
const limit = Math.min(50, Math.max(1, Math.floor(params.limit ?? 10)));
|
|
292
|
+
|
|
293
|
+
const res = await tavilyRequest<{ results?: { url: string; raw_content?: string | null }[] }>(
|
|
294
|
+
"POST",
|
|
295
|
+
"/crawl",
|
|
296
|
+
{ url: params.url, max_depth: maxDepth, limit },
|
|
297
|
+
);
|
|
298
|
+
if (!res.ok) {
|
|
299
|
+
return textResult(failText("web_crawl", res));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const results = (res.data?.results ?? []).slice(0, CRAWL_MAX_PAGES);
|
|
303
|
+
if (results.length === 0) {
|
|
304
|
+
return textResult(t("没有爬取到任何页面,检查 URL 或调整深度/页数。", "No pages crawled; check the URL or adjust depth/limit."));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const lines = results.map((r, i) => {
|
|
308
|
+
const body = (r.raw_content || "").trim();
|
|
309
|
+
const truncated =
|
|
310
|
+
body.length > CRAWL_PAGE_MAX_CHARS
|
|
311
|
+
? `${body.slice(0, CRAWL_PAGE_MAX_CHARS)}\n…(已截断,该页共 ${body.length} 字符)`
|
|
312
|
+
: body;
|
|
313
|
+
return `[${i + 1}] ${r.url}\n${truncated}`;
|
|
314
|
+
});
|
|
315
|
+
return textResult(t(`爬取了 ${results.length} 个页面:\n\n`, `Crawled ${results.length} pages:\n\n`) + lines.join("\n\n"));
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
export const webMapTool = defineTool({
|
|
320
|
+
name: "web_map",
|
|
321
|
+
label: "Web Map",
|
|
322
|
+
description: t(
|
|
323
|
+
"绘制网站结构图(Tavily 驱动,需 API Key)。想了解某个站点包含哪些页面时调用,返回站点内发现的 URL 列表。",
|
|
324
|
+
"Map a website's structure (Tavily; requires API key). Use to discover which pages a site contains; returns the list of URLs found.",
|
|
325
|
+
),
|
|
326
|
+
parameters: Type.Object({
|
|
327
|
+
url: Type.String({ description: t("要映射的根 URL", "Root URL to map") }),
|
|
328
|
+
limit: Type.Optional(
|
|
329
|
+
Type.Number({ description: t("最多返回的链接数,默认 50", "Max links to return, default 50") }),
|
|
330
|
+
),
|
|
331
|
+
}),
|
|
332
|
+
execute: async (_id, params: { url: string; limit?: number }) => {
|
|
333
|
+
const keyErr = requireApiKey("web_map");
|
|
334
|
+
if (keyErr) return textResult(keyErr);
|
|
335
|
+
const limit = Math.min(200, Math.max(1, Math.floor(params.limit ?? 50)));
|
|
336
|
+
|
|
337
|
+
const res = await tavilyRequest<{ results?: string[] }>("POST", "/map", { url: params.url, limit });
|
|
338
|
+
if (!res.ok) {
|
|
339
|
+
return textResult(failText("web_map", res));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const urls = res.data?.results ?? [];
|
|
343
|
+
if (urls.length === 0) {
|
|
344
|
+
return textResult(t("没有发现任何页面,检查 URL 或调整限制。", "No URLs found; check the URL or adjust the limit."));
|
|
345
|
+
}
|
|
346
|
+
const shown = urls.slice(0, 100);
|
|
347
|
+
const lines = shown.map((u, i) => `- ${u}`).join("\n");
|
|
348
|
+
const more = urls.length > shown.length ? `\n…(共 ${urls.length} 个,仅显示前 ${shown.length} 个)` : "";
|
|
349
|
+
return textResult(t(`站点结构(${urls.length} 个页面):\n`, `Site map (${urls.length} pages):\n`) + lines + more);
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
export const webResearchTool = defineTool({
|
|
354
|
+
name: "web_research",
|
|
355
|
+
label: "Web Research",
|
|
356
|
+
description: t(
|
|
357
|
+
"深度研究并生成带引用的综合报告(Tavily 驱动,需 API Key,异步任务)。适合需要多角度综合、结论性答案的复杂问题。会执行多次搜索并汇总,耗时约几十秒到几分钟。",
|
|
358
|
+
"Deep research producing a cited synthesis report (Tavily; requires API key; async). Best for complex questions needing multi-angle synthesis and a conclusive answer. Runs multiple searches and aggregates; takes tens of seconds to a few minutes.",
|
|
359
|
+
),
|
|
360
|
+
parameters: Type.Object({
|
|
361
|
+
query: Type.String({ description: t("研究问题/任务", "Research question or task") }),
|
|
362
|
+
model: Type.Optional(
|
|
363
|
+
Type.Unsafe<"auto" | "mini" | "pro">({
|
|
364
|
+
type: "string",
|
|
365
|
+
enum: ["auto", "mini", "pro"],
|
|
366
|
+
description: t(
|
|
367
|
+
"研究模型:mini 快(窄问题)、pro 全面(复杂多子题)、auto 自动,默认 auto",
|
|
368
|
+
"Research model: mini fast (narrow), pro comprehensive (complex), auto default",
|
|
369
|
+
),
|
|
370
|
+
}),
|
|
371
|
+
),
|
|
372
|
+
}),
|
|
373
|
+
execute: async (_id, params: { query: string; model?: "auto" | "mini" | "pro" }) => {
|
|
374
|
+
const keyErr = requireApiKey("web_research");
|
|
375
|
+
if (keyErr) return textResult(keyErr);
|
|
376
|
+
const model = params.model ?? "auto";
|
|
377
|
+
|
|
378
|
+
// 1. 创建研究任务
|
|
379
|
+
const res = await tavilyRequest<{ request_id?: string }>("POST", "/research", {
|
|
380
|
+
input: params.query,
|
|
381
|
+
model,
|
|
382
|
+
stream: false,
|
|
383
|
+
});
|
|
384
|
+
if (!res.ok) {
|
|
385
|
+
return textResult(failText("web_research", res));
|
|
386
|
+
}
|
|
387
|
+
const requestId = res.data?.request_id;
|
|
388
|
+
if (!requestId) {
|
|
389
|
+
return textResult(t("研究任务创建失败(未返回 request_id),请稍后重试。", "Failed to create research task (no request_id); retry later."));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// 2. 轮询直至完成(最多 3 分钟)
|
|
393
|
+
process.stderr.write(chalk.dim(t("(Tavily 研究进行中…)", "(Tavily research in progress…)") + "\n"));
|
|
394
|
+
const deadline = Date.now() + RESEARCH_POLL_TIMEOUT_MS;
|
|
395
|
+
for (;;) {
|
|
396
|
+
await sleep(RESEARCH_POLL_INTERVAL_MS);
|
|
397
|
+
const poll = await tavilyRequest<{ status?: string; content?: string; sources?: { title?: string; url?: string }[] }>(
|
|
398
|
+
"GET",
|
|
399
|
+
`/research/${requestId}`,
|
|
400
|
+
);
|
|
401
|
+
if (!poll.ok) {
|
|
402
|
+
return textResult(failText("web_research", poll));
|
|
403
|
+
}
|
|
404
|
+
const status = poll.data?.status;
|
|
405
|
+
if (status === "completed") {
|
|
406
|
+
const content = (poll.data?.content || "").trim();
|
|
407
|
+
const sources = poll.data?.sources ?? [];
|
|
408
|
+
if (!content) {
|
|
409
|
+
return textResult(t("研究任务完成但没有生成报告内容。", "Research completed but produced no report content."));
|
|
410
|
+
}
|
|
411
|
+
const truncated =
|
|
412
|
+
content.length > RESEARCH_MAX_CHARS
|
|
413
|
+
? `${content.slice(0, RESEARCH_MAX_CHARS)}\n\n…(报告过长,已截断,全文共 ${content.length} 字符)`
|
|
414
|
+
: content;
|
|
415
|
+
const sourceLines = sources.length > 0
|
|
416
|
+
? `\n\n来源(${sources.length}):\n` + sources.map((s) => `- ${s.title || ""}: ${s.url || ""}`).join("\n")
|
|
417
|
+
: "";
|
|
418
|
+
return textResult(truncated + sourceLines);
|
|
419
|
+
}
|
|
420
|
+
if (status === "failed") {
|
|
421
|
+
return textResult(t("研究任务失败,请调整问题后重试。", "Research task failed; adjust the question and retry."));
|
|
422
|
+
}
|
|
423
|
+
if (Date.now() > deadline) {
|
|
424
|
+
return textResult(t("研究任务超过 3 分钟仍未完成,请稍后重试。", "Research task did not finish within 3 minutes; retry later."));
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
},
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
/** 仅配置了 tavilyApiKey 时才注册的深度工具(见 agent.ts 条件展开)。 */
|
|
431
|
+
export const premiumTavilyTools = [webCrawlTool, webMapTool, webResearchTool];
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { listen } from "../voice.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 语音相关 Tool 集合。
|
|
7
|
+
*
|
|
8
|
+
* 设计原则:TTS 完全由程序在 Agent 回复时自动触发(renderer text_delta → TtsStream 实时流式),
|
|
9
|
+
* 语气风格由 TTS 引擎自身决定,无需 Tool 介入。
|
|
10
|
+
* STT 在用户长按右 Cmd ≥2秒时自动 listen(见 repl.ts),也无需 Tool 介入。
|
|
11
|
+
*
|
|
12
|
+
* 保留 transcribe Tool 仅作为手动触发 STT 的兜底入口(auto STT 关时仍可工作)。
|
|
13
|
+
*/
|
|
14
|
+
export const transcribeTool = defineTool({
|
|
15
|
+
name: "transcribe",
|
|
16
|
+
label: "Listen",
|
|
17
|
+
description: "Listen to the user's microphone and transcribe speech to text. Use this when you want to hear from the user verbally.",
|
|
18
|
+
parameters: Type.Object({}),
|
|
19
|
+
execute: async (_id, _params) => {
|
|
20
|
+
const text = await listen();
|
|
21
|
+
return {
|
|
22
|
+
content: [{ type: "text", text: text || "(no speech detected)" }],
|
|
23
|
+
details: {},
|
|
24
|
+
};
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export const voiceTools = [transcribeTool];
|