Merge p7/skills: skill files + install + skillify (P7+C8), restored prompt lines
This commit is contained in:
commit
b79dbe7501
|
|
@ -0,0 +1,33 @@
|
||||||
|
# 技能檔(P7)
|
||||||
|
|
||||||
|
技能是一張 `.md` 檔:給 Bot 的「做過的事的食譜」。Bot 做完較長的事會自己寫技能;你也可以自己寫一張、或把朋友的一張裝進來。
|
||||||
|
|
||||||
|
## 格式
|
||||||
|
|
||||||
|
~~~markdown
|
||||||
|
---
|
||||||
|
name: 每日打卡
|
||||||
|
description: 一句話說明這技能做什麼
|
||||||
|
---
|
||||||
|
|
||||||
|
做法寫在內文,用 Bot 看得懂的動作詞(browser、computer 的動作名、shell…)。
|
||||||
|
|
||||||
|
```steps
|
||||||
|
[{"tool":"shell","args":"{\"cmd\":\"date +%F\"}"}]
|
||||||
|
```
|
||||||
|
~~~
|
||||||
|
|
||||||
|
* `name`、`description` 必填;內文就是 Bot 照著做的說明。
|
||||||
|
* `steps` 選填:重播步驟(`replay_skill` 照做——每一步先在畫面上找到同樣的東西,對不上就停下來交給 Bot)。`tool` 是工具名,`args` 是參數的 JSON 字串。看畫面的步驟(截圖、讀網頁)不用寫——重播自己會看。
|
||||||
|
|
||||||
|
## 安裝
|
||||||
|
|
||||||
|
* 指令:`anybot --install-skill <路徑或網址>`
|
||||||
|
* 手機:設定 › 整合 › 技能 › 「從網址安裝技能」
|
||||||
|
* 檔案放在 `<資料夾>/skills/`,每次開機都會讀進來(同名以檔案為準);repo 的 `skills/` 有示例。
|
||||||
|
|
||||||
|
## 示範即教學(C8)
|
||||||
|
|
||||||
|
聊天裡長按**你自己那句話** →「教我做(學這一回合)」:AnyBot 把那回合裡**成功**的步驟存成可重播的技能(看畫面的步驟、按編號的步驟會濾掉——編號只活在那一次畫面)。下次說技能名字,Bot 照著做;中間對不上會停下來交給 Bot。重播的瀏覽器步驟對不上時,也會先重新讀一次頁面再交給 Bot(C8)。
|
||||||
|
|
||||||
|
技能本身不含密碼;要登入的網站照舊由人登入(hand-off)。
|
||||||
|
|
@ -1127,6 +1127,7 @@ function msgMenu(i) {
|
||||||
const items = [];
|
const items = [];
|
||||||
if (m.text) items.push({icon: "copy", label: "複製", on: () => copyText(m.text)});
|
if (m.text) items.push({icon: "copy", label: "複製", on: () => copyText(m.text)});
|
||||||
if (m.text && owner()) items.push({icon: "reply", label: "引用回覆", on: () => { S.quote = {text: plain(m.text).slice(0, 300), raw: m.text}; renderQuote(); $("input").focus(); }});
|
if (m.text && owner()) items.push({icon: "reply", label: "引用回覆", on: () => { S.quote = {text: plain(m.text).slice(0, 300), raw: m.text}; renderQuote(); $("input").focus(); }});
|
||||||
|
if (m.text && owner() && m.role === "user") items.push({icon: "sparkles", label: "教我做(學這一回合)", on: async () => { if (await app("skillify", {prompt: plain(m.text).slice(0, 200)}, 0, "已存成技能,下次說名字就會照做")) again(); }});
|
||||||
const lastBot = [...S.msgs].reverse().find((x) => x.role !== "user" && x.text);
|
const lastBot = [...S.msgs].reverse().find((x) => x.role !== "user" && x.text);
|
||||||
if (m === lastBot && owner()) items.push({icon: "refresh", label: "重新產生這個回答", on: regen});
|
if (m === lastBot && owner()) items.push({icon: "refresh", label: "重新產生這個回答", on: regen});
|
||||||
if (m.role === "user" && i === S.msgs.length - 1 && owner()) items.push({icon: "refresh", label: "重新傳送", on: () => act("reply", {bot: S.open})});
|
if (m.role === "user" && i === S.msgs.length - 1 && owner()) items.push({icon: "refresh", label: "重新傳送", on: () => act("reply", {bot: S.open})});
|
||||||
|
|
@ -1592,7 +1593,7 @@ function drawSettings(kind, quiet) {
|
||||||
const dot = (ok, on) => `<span style="width:8px;height:8px;border-radius:50%;flex:none;background:${!on ? "var(--text4)" : ok ? "var(--ok)" : "var(--warn)"}"></span>`;
|
const dot = (ok, on) => `<span style="width:8px;height:8px;border-radius:50%;flex:none;background:${!on ? "var(--text4)" : ok ? "var(--ok)" : "var(--warn)"}"></span>`;
|
||||||
h += group("訊息管道", (st.channels || []).map((c) => `<div class="item">${dot(c.ok, c.enabled)}<div class="mid"><div class="k">${esc(c.kind)} · ${esc(c.name)}</div><div class="v">由「${esc(c.bot)}」回覆${c.note ? " · " + esc(c.note) : ""}</div></div>${isOwner() ? `<label class="sw"><input type="checkbox" ${c.enabled ? "checked" : ""} data-ch="${c.id}"><i></i></label><button class="ib" data-chrm="${c.id}" title="移除">${ic("trash", 16)}</button>` : ""}</div>`).join("") || `<div class="note" style="padding:12px 16px">還沒有。Telegram、Slack、Discord… 要在電腦上的 AnyBot 新增(需要輸入 Token)。</div>`);
|
h += group("訊息管道", (st.channels || []).map((c) => `<div class="item">${dot(c.ok, c.enabled)}<div class="mid"><div class="k">${esc(c.kind)} · ${esc(c.name)}</div><div class="v">由「${esc(c.bot)}」回覆${c.note ? " · " + esc(c.note) : ""}</div></div>${isOwner() ? `<label class="sw"><input type="checkbox" ${c.enabled ? "checked" : ""} data-ch="${c.id}"><i></i></label><button class="ib" data-chrm="${c.id}" title="移除">${ic("trash", 16)}</button>` : ""}</div>`).join("") || `<div class="note" style="padding:12px 16px">還沒有。Telegram、Slack、Discord… 要在電腦上的 AnyBot 新增(需要輸入 Token)。</div>`);
|
||||||
h += group("連接器(MCP)", (st.mcp || []).map((m) => `<div class="item">${dot(m.tools != null, m.enabled)}<div class="mid"><div class="k">${esc(m.name)}</div><div class="v">${!m.enabled ? "已關閉" : m.tools != null ? `已連上,${m.tools} 個工具` : m.error ? esc(m.error) : "連線中…"}</div></div>${isOwner() ? `<label class="sw"><input type="checkbox" ${m.enabled ? "checked" : ""} data-mcp="${esc(m.name)}"><i></i></label><button class="ib" data-mcprm="${esc(m.name)}" title="移除">${ic("trash", 16)}</button>` : ""}</div>`).join("") || `<div class="note" style="padding:12px 16px">還沒有連接器</div>`);
|
h += group("連接器(MCP)", (st.mcp || []).map((m) => `<div class="item">${dot(m.tools != null, m.enabled)}<div class="mid"><div class="k">${esc(m.name)}</div><div class="v">${!m.enabled ? "已關閉" : m.tools != null ? `已連上,${m.tools} 個工具` : m.error ? esc(m.error) : "連線中…"}</div></div>${isOwner() ? `<label class="sw"><input type="checkbox" ${m.enabled ? "checked" : ""} data-mcp="${esc(m.name)}"><i></i></label><button class="ib" data-mcprm="${esc(m.name)}" title="移除">${ic("trash", 16)}</button>` : ""}</div>`).join("") || `<div class="note" style="padding:12px 16px">還沒有連接器</div>`);
|
||||||
h += group("技能", (st.skills || []).map((k, i) => item({icon: "sparkles", k: k.name, v: k.description, tap: 1, data: `data-skill="${i}"`})).join("") || `<div class="note" style="padding:12px 16px">還沒有技能。Bot 做完較長的事會自己整理成技能,下次照著做。</div>`);
|
h += group("技能", (st.skills || []).map((k, i) => item({icon: "sparkles", k: k.name, v: k.description, tap: 1, data: `data-skill="${i}"`})).join("") || `<div class="note" style="padding:12px 16px">還沒有技能。Bot 做完較長的事會自己整理成技能;你也可以從網址安裝一張,或在聊天裡長按你說過的話選「教我做」。</div>` + (isOwner() ? item({icon: "plus", k: "從網址安裝技能", v: "技能檔(.md)的網址,例如 Gitea raw 連結", tap: 1, data: `data-skilladd="1"`, chev: false}) : ""));
|
||||||
}
|
}
|
||||||
if (kind === "computers") h += computersHtml(st);
|
if (kind === "computers") h += computersHtml(st);
|
||||||
if (kind === "vault") h += `<div class="empty">${ic("lock", 28)}<br><br>保險庫裡的密碼、卡號和聯絡資訊,只在電腦上的 AnyBot 管理。<br>Bot 用到時由 AnyBot 直接填進網頁,不會交給模型,也不會經過手機。</div>`;
|
if (kind === "vault") h += `<div class="empty">${ic("lock", 28)}<br><br>保險庫裡的密碼、卡號和聯絡資訊,只在電腦上的 AnyBot 管理。<br>Bot 用到時由 AnyBot 直接填進網頁,不會交給模型,也不會經過手機。</div>`;
|
||||||
|
|
@ -1718,6 +1719,10 @@ function wireSettings(kind, st) {
|
||||||
const d = st.devices[+x.dataset.dev];
|
const d = st.devices[+x.dataset.dev];
|
||||||
if (await confirmSheet(`登出「${d.name}」?`, "那支手機要重新輸入密碼才能再連。如果是你現在用的這支,也會被登出。", "登出") && await act("device_remove", {name: d.name, created: d.created}, "已登出那支手機")) again();
|
if (await confirmSheet(`登出「${d.name}」?`, "那支手機要重新輸入密碼才能再連。如果是你現在用的這支,也會被登出。", "登出") && await act("device_remove", {name: d.name, created: d.created}, "已登出那支手機")) again();
|
||||||
});
|
});
|
||||||
|
on("[data-skilladd]", "onclick", async () => {
|
||||||
|
const url = await ask("安裝技能", "", "技能檔(.md)的網址");
|
||||||
|
if (url && await app("skill_install", {url}, 0, "已安裝技能")) again();
|
||||||
|
});
|
||||||
on("[data-skill]", "onclick", (x) => {
|
on("[data-skill]", "onclick", (x) => {
|
||||||
const k = st.skills[+x.dataset.skill];
|
const k = st.skills[+x.dataset.skill];
|
||||||
sheet("", isOwner() ? [{icon: "trash", label: "刪除技能", danger: true, on: async () => { if (await confirmSheet(`刪除技能「${k.name}」?`, "", "刪除") && await app("delete_skill", {name: k.name}, 0, "已刪除技能")) again(); }}] : [],
|
sheet("", isOwner() ? [{icon: "trash", label: "刪除技能", danger: true, on: async () => { if (await confirmSheet(`刪除技能「${k.name}」?`, "", "刪除") && await app("delete_skill", {name: k.name}, 0, "已刪除技能")) again(); }}] : [],
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
---
|
||||||
|
name: 每日打卡
|
||||||
|
description: 在記錄檔裡記下今天最重要的一件事
|
||||||
|
---
|
||||||
|
|
||||||
|
打開 ~/Documents/每日記錄.md(沒有就在文件資料夾建一個),在當天日期下寫一行:今天做完的最重要一件事,一句話就好。寫完把那一行唸給使用者聽。
|
||||||
|
|
||||||
|
```steps
|
||||||
|
[{"tool":"shell","args":"{\"cmd\":\"mkdir -p ~/Documents && touch ~/Documents/每日記錄.md && tail -n 5 ~/Documents/每日記錄.md\"}"}]
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
---
|
||||||
|
name: 看報快摘
|
||||||
|
description: 把一份長網頁讀成三句話
|
||||||
|
---
|
||||||
|
|
||||||
|
用 browser(action=open,url)打開那頁,讀內容後給三句話:在講什麼、對使用者最有的一個是什麼、要不要繼續讀(說理由)。不要貼原文,不要列表。要登入才看得到的用 browser 開(AnyBot 自己的瀏覽器有登入)。
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
---
|
||||||
|
name: 螢幕報表存檔
|
||||||
|
description: 把畫面上的報表截圖存進文件資料夾並命名好
|
||||||
|
---
|
||||||
|
|
||||||
|
截圖(computer,action=screenshot),認出那是什麼報表和日期,用 shell 存到 ~/Documents/報表/ 下,檔名用「日期-報表名.png」(macOS 可用 screencapture -x)。存完告訴使用者存到哪。
|
||||||
|
|
@ -982,6 +982,14 @@ pub fn spawn(job: Job, bot_id: u64, tx: Sender<(u64, StreamEvent)>, cancel: Arc<
|
||||||
let computer = tools::args_of(&next.args)["computer"].as_str().map(|c| serde_json::json!({"computer": c})).unwrap_or(serde_json::json!({}));
|
let computer = tools::args_of(&next.args)["computer"].as_str().map(|c| serde_json::json!({"computer": c})).unwrap_or(serde_json::json!({}));
|
||||||
scripted = Some(crate::model::ToolCall { id: format!("replay-look-{k}-{}", crate::model::now_ms()), name: "computer_use_screenshot".into(), args: computer.to_string(), status: ToolStatus::Running });
|
scripted = Some(crate::model::ToolCall { id: format!("replay-look-{k}-{}", crate::model::now_ms()), name: "computer_use_screenshot".into(), args: computer.to_string(), status: ToolStatus::Running });
|
||||||
}
|
}
|
||||||
|
// C8: a browser step that doesn't fit gets the same courtesy as a screen
|
||||||
|
// step: one fresh read of the page before the bot takes over, so the model
|
||||||
|
// hands off seeing the page, not guessing from the last step's.
|
||||||
|
Err(_) if r.looked != Some(k) => {
|
||||||
|
r.looked = Some(k);
|
||||||
|
let tab = tools::args_of(&next.args)["tab"].as_str().map(|t| serde_json::json!({"tab": t})).unwrap_or(serde_json::json!({}));
|
||||||
|
scripted = Some(crate::model::ToolCall { id: format!("replay-look-{k}-{}", crate::model::now_ms()), name: "browser_elements".into(), args: tab.to_string(), status: ToolStatus::Running });
|
||||||
|
}
|
||||||
Err(missing) => {
|
Err(missing) => {
|
||||||
if let Some(m) = history.last_mut().filter(|m| m.role == Role::Tool) {
|
if let Some(m) = history.last_mut().filter(|m| m.role == Role::Tool) {
|
||||||
m.text.push_str(&format!("\n\n(重播「{}」停在第 {k} 步:畫面上找不到「{missing}」。前面 {} 步已經照做了;接下來由你看畫面決定怎麼做完。)", r.name, k - 1));
|
m.text.push_str(&format!("\n\n(重播「{}」停在第 {k} 步:畫面上找不到「{missing}」。前面 {} 步已經照做了;接下來由你看畫面決定怎麼做完。)", r.name, k - 1));
|
||||||
|
|
|
||||||
|
|
@ -249,6 +249,8 @@ impl App {
|
||||||
// ANYBOT_SHOT_REAL=1: a screenshot of the real data (and connections), not the demo.
|
// ANYBOT_SHOT_REAL=1: a screenshot of the real data (and connections), not the demo.
|
||||||
let demo = screenshot.is_some() && std::env::var("ANYBOT_SHOT_REAL").is_err();
|
let demo = screenshot.is_some() && std::env::var("ANYBOT_SHOT_REAL").is_err();
|
||||||
let mut state = if demo { State::demo() } else { State::load() };
|
let mut state = if demo { State::demo() } else { State::load() };
|
||||||
|
// P7: skills kept as files in <data>/skills/ are part of the bot's head at every start.
|
||||||
|
crate::memory::merge_skill_files(&mut state.skills);
|
||||||
// 語意搜尋的地基 (P6): vectors come from the person's own Ollama; folders and vectors
|
// 語意搜尋的地基 (P6): vectors come from the person's own Ollama; folders and vectors
|
||||||
// fill themselves in the background. No Ollama: words only, everything still works.
|
// fill themselves in the background. No Ollama: words only, everything still works.
|
||||||
if !demo {
|
if !demo {
|
||||||
|
|
@ -4357,6 +4359,8 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu
|
||||||
s.push_str(&crate::memory::prompt_section(memories, &st.skills));
|
s.push_str(&crate::memory::prompt_section(memories, &st.skills));
|
||||||
s.push_str("使用者問「自己的檔案」(那份報表、寫過的筆記、存過的檔案在哪):先用 search_documents,它會用意思加關鍵字搜對方索引過的資料夾,不記得確切用詞也找得到。");
|
s.push_str("使用者問「自己的檔案」(那份報表、寫過的筆記、存過的檔案在哪):先用 search_documents,它會用意思加關鍵字搜對方索引過的資料夾,不記得確切用詞也找得到。");
|
||||||
s.push_str("系統捷徑:電腦現況列了「系統捷徑」的電腦,音量、Wi-Fi、鎖螢幕、深淺色、清空資源筒這類事要用 computer 的 intent 動作一步做完(intent_name+intent_arg),不要用截圖加點擊;捷徑回報失敗才回 GUI。");
|
s.push_str("系統捷徑:電腦現況列了「系統捷徑」的電腦,音量、Wi-Fi、鎖螢幕、深淺色、清空資源筒這類事要用 computer 的 intent 動作一步做完(intent_name+intent_arg),不要用截圖加點擊;捷徑回報失敗才回 GUI。");
|
||||||
|
s.push_str("視窗程式有三個一步到位的動作:menu_click(在選單列按「檔案→儲存」這種路徑)、fill_form(一次填好整張表单,name 對應標籤)、wait_for(等一段文字出現或消失再繼續);能用它們就不要截圖加點擊。選單裡有「從檔案選…插入」時用 menu_click 走到它,不要在縮圖裡找檔案(太小會按錯)。");
|
||||||
|
s.push_str("關於使用者的舊事(喜歡什麼、上次怎麼做、說過的决定):先想「記得的事」與本回合搜出來的記憶,找不到再問;查你自己經手的文件用 search_documents(使用者放進文件資料夾的東西都搜得到,含你寫過的報告)。");
|
||||||
let mcp_notes = crate::mcp::instructions(&st.mcp_servers);
|
let mcp_notes = crate::mcp::instructions(&st.mcp_servers);
|
||||||
let mcp_tools = crate::mcp::status();
|
let mcp_tools = crate::mcp::status();
|
||||||
if !mcp_tools.is_empty() {
|
if !mcp_tools.is_empty() {
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,10 @@ use crate::model::{self, Bot, Message, State, Theme, Accent};
|
||||||
use crate::providers::{ProviderKind, ALL_PROVIDERS};
|
use crate::providers::{ProviderKind, ALL_PROVIDERS};
|
||||||
use crate::routines::{self, Routine, Schedule};
|
use crate::routines::{self, Routine, Schedule};
|
||||||
|
|
||||||
pub const NAMES: [&str; 20] = [
|
pub const NAMES: [&str; 22] = [
|
||||||
"list_routines", "create_routine", "update_routine", "delete_routine", "run_routine_now",
|
"list_routines", "create_routine", "update_routine", "delete_routine", "run_routine_now",
|
||||||
"list_bots", "create_bot", "update_bot", "message_bot", "list_computers", "update_settings",
|
"list_bots", "create_bot", "update_bot", "message_bot", "list_computers", "update_settings",
|
||||||
"remember", "forget", "save_skill", "use_skill", "delete_skill",
|
"remember", "forget", "save_skill", "use_skill", "delete_skill", "skill_install", "skillify",
|
||||||
"create_group", "move_to_group", "rename_group", "delete_group",
|
"create_group", "move_to_group", "rename_group", "delete_group",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -38,6 +38,8 @@ pub fn label(name: &str) -> &'static str {
|
||||||
"save_skill" => "存成技能",
|
"save_skill" => "存成技能",
|
||||||
"use_skill" => "讀取技能",
|
"use_skill" => "讀取技能",
|
||||||
"delete_skill" => "刪除技能",
|
"delete_skill" => "刪除技能",
|
||||||
|
"skill_install" => "從網址安裝技能",
|
||||||
|
"skillify" => "把這一回合存成技能",
|
||||||
"create_group" => "建立群組",
|
"create_group" => "建立群組",
|
||||||
"move_to_group" => "移到群組",
|
"move_to_group" => "移到群組",
|
||||||
"rename_group" => "重新命名群組",
|
"rename_group" => "重新命名群組",
|
||||||
|
|
@ -453,8 +455,62 @@ pub fn apply(state: &mut State, bot_id: u64, name: &str, args: &str) -> Outcome
|
||||||
let name = s("name").unwrap_or_default();
|
let name = s("name").unwrap_or_default();
|
||||||
let before = state.skills.len();
|
let before = state.skills.len();
|
||||||
state.skills.retain(|k| k.name != name.trim());
|
state.skills.retain(|k| k.name != name.trim());
|
||||||
|
// a skill that came from a file goes back to the shelf it came from
|
||||||
|
let slug: String = name.trim().chars().map(|c| if c.is_alphanumeric() { c.to_ascii_lowercase() } else { '-' }).collect();
|
||||||
|
let _ = std::fs::remove_file(crate::memory::skill_dir().join(format!("{}.md", slug.trim_matches('-'))));
|
||||||
if state.skills.len() < before { ok(format!("已刪除技能「{name}」")) } else { err(format!("沒有叫「{name}」的技能")) }
|
if state.skills.len() < before { ok(format!("已刪除技能「{name}」")) } else { err(format!("沒有叫「{name}」的技能")) }
|
||||||
}
|
}
|
||||||
|
"skill_install" => {
|
||||||
|
// P7: a skill is a .md file you can keep in a repo and hand to a friend.
|
||||||
|
let src = s("url").unwrap_or_default().trim().to_string();
|
||||||
|
if src.is_empty() {
|
||||||
|
return err("要貼技能檔(.md)的網址或檔案路徑".into());
|
||||||
|
}
|
||||||
|
let text = if src.starts_with("http://") || src.starts_with("https://") {
|
||||||
|
{
|
||||||
|
use std::io::Read;
|
||||||
|
let agent: ureq::Agent = ureq::Agent::config_builder().timeout_global(Some(std::time::Duration::from_secs(20))).http_status_as_error(false).build().into();
|
||||||
|
let mut text = String::new();
|
||||||
|
match agent.get(&src).call() {
|
||||||
|
Ok(mut r) => {
|
||||||
|
if let Err(e) = r.body_mut().as_reader().take(400_000).read_to_string(&mut text) {
|
||||||
|
return err(format!("技能檔讀不完:{e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => return err(format!("抓不到技能檔:{e}")),
|
||||||
|
}
|
||||||
|
text
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match std::fs::read_to_string(&src) {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => return err(format!("讀不到檔案:{e}")),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match crate::memory::install_skill_text(&text) {
|
||||||
|
Ok(name) => {
|
||||||
|
crate::memory::merge_skill_files(&mut state.skills);
|
||||||
|
ok(format!("已安裝技能「{name}」"))
|
||||||
|
}
|
||||||
|
Err(e) => err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"skillify" => {
|
||||||
|
// C8: 示範即教學 — the person did it once, in this chat; keep the steps that worked.
|
||||||
|
let prompt = s("prompt").unwrap_or_default().trim().to_string();
|
||||||
|
if prompt.is_empty() {
|
||||||
|
return err("要學的是哪件事?".into());
|
||||||
|
}
|
||||||
|
let recs = crate::oplog::OpLog::for_bot(bot_id).read();
|
||||||
|
match crate::memory::skillify(&prompt, &recs) {
|
||||||
|
Some(sk) => {
|
||||||
|
let n = sk.steps.len();
|
||||||
|
crate::memory::upsert_skill(&mut state.skills, sk);
|
||||||
|
ok(format!("已把「{prompt}」那一回合存成技能({n} 步可重播)"))
|
||||||
|
}
|
||||||
|
None => err("那一回合沒有可重播的步驟(只有看畫面的動作,或都失敗了)".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
"create_bot" => {
|
"create_bot" => {
|
||||||
// 範本: a ready role and instructions.
|
// 範本: a ready role and instructions.
|
||||||
let template = s("template").and_then(|t| crate::memory::TEMPLATES.iter().find(|x| x.name == t.trim()));
|
let template = s("template").and_then(|t| crate::memory::TEMPLATES.iter().find(|x| x.name == t.trim()));
|
||||||
|
|
|
||||||
24
src/main.rs
24
src/main.rs
|
|
@ -244,8 +244,32 @@ const USAGE: &str = "用法:
|
||||||
anybot --screenshot 檔名 把視窗畫成一張圖
|
anybot --screenshot 檔名 把視窗畫成一張圖
|
||||||
資料夾:ANYBOT_DATA_DIR(沒設就用這個使用者的 AnyBot 資料夾)";
|
資料夾:ANYBOT_DATA_DIR(沒設就用這個使用者的 AnyBot 資料夾)";
|
||||||
|
|
||||||
|
fn fetch_skill_text(src: &str) -> Result<String, String> {
|
||||||
|
if src.starts_with("http://") || src.starts_with("https://") {
|
||||||
|
use std::io::Read;
|
||||||
|
let agent: ureq::Agent = ureq::Agent::config_builder().timeout_global(Some(std::time::Duration::from_secs(20))).http_status_as_error(false).build().into();
|
||||||
|
let mut r = agent.get(src).call().map_err(|e| format!("抓不到技能檔:{e}"))?;
|
||||||
|
let mut text = String::new();
|
||||||
|
r.body_mut().as_reader().take(400_000).read_to_string(&mut text).map_err(|e| format!("技能檔讀不完:{e}"))?;
|
||||||
|
Ok(text)
|
||||||
|
} else {
|
||||||
|
std::fs::read_to_string(src).map_err(|e| format!("讀不到檔案:{e}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn main() -> eframe::Result<()> {
|
fn main() -> eframe::Result<()> {
|
||||||
let args: Vec<String> = std::env::args().collect();
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
// P7: install a skill file (a path or a URL) and be done: skills are .md files you can keep in a repo.
|
||||||
|
if let Some(i) = args.iter().position(|a| a == "--install-skill") {
|
||||||
|
let src = args.get(i + 1).cloned().unwrap_or_default();
|
||||||
|
if src.is_empty() {
|
||||||
|
eprintln!("用法:anybot --install-skill <技能檔路徑或網址>");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
let text = match fetch_skill_text(&src) { Ok(t) => t, Err(e) => { eprintln!("{e}"); std::process::exit(1); } };
|
||||||
|
match crate::memory::install_skill_text(&text) { Ok(name) => println!("已安裝技能「{name}」(在 設定 › 整合 › 技能 可看)"), Err(e) => eprintln!("{e}") };
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
let screenshot = args.iter().position(|a| a == "--screenshot").and_then(|i| args.get(i + 1)).map(PathBuf::from);
|
let screenshot = args.iter().position(|a| a == "--screenshot").and_then(|i| args.get(i + 1)).map(PathBuf::from);
|
||||||
// For tests that start AnyBots of their own: a whole settings file (the sample bots) to edit.
|
// For tests that start AnyBots of their own: a whole settings file (the sample bots) to edit.
|
||||||
if args.get(1).map(String::as_str) == Some("--sample-state") {
|
if args.get(1).map(String::as_str) == Some("--sample-state") {
|
||||||
|
|
|
||||||
213
src/memory.rs
213
src/memory.rs
|
|
@ -48,6 +48,181 @@ pub const TEMPLATES: [Template; 5] = [
|
||||||
Template { name: "開發助手", description: "看程式、跑測試、追 GitHub 上的事", instructions: "改程式前先讀相關檔案和測試。每次修改後跑測試,回報結果。PR 或 CI 的事件來時,先看紀錄再下結論。", shape: "hexagon", color: "black" },
|
Template { name: "開發助手", description: "看程式、跑測試、追 GitHub 上的事", instructions: "改程式前先讀相關檔案和測試。每次修改後跑測試,回報結果。PR 或 CI 的事件來時,先看紀錄再下結論。", shape: "hexagon", color: "black" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/// ---- Skill files (P7): a skill you can keep in a file, share, and install ----
|
||||||
|
///
|
||||||
|
/// A `.md` file in `<data>/skills/`: front matter (`name:`, `description:`), the instructions
|
||||||
|
/// as the body, and an optional ```steps fence (the ReplayStep list a replay follows).
|
||||||
|
|
||||||
|
pub fn skill_dir() -> std::path::PathBuf {
|
||||||
|
crate::oplog::dir().join("skills")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slug_of(name: &str) -> String {
|
||||||
|
let mut o: String = name
|
||||||
|
.chars()
|
||||||
|
.map(|c| if c.is_alphanumeric() { c.to_ascii_lowercase() } else { '-' })
|
||||||
|
.collect();
|
||||||
|
while o.contains("--") {
|
||||||
|
o = o.replace("--", "-");
|
||||||
|
}
|
||||||
|
let o: String = o.trim_matches('-').chars().take(40).collect();
|
||||||
|
if o.is_empty() {
|
||||||
|
"skill".into()
|
||||||
|
} else {
|
||||||
|
o
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn skill_to_file(s: &Skill) -> String {
|
||||||
|
let mut o = format!("---\nname: {}\ndescription: {}\n---\n\n{}\n", s.name, s.description, s.instructions);
|
||||||
|
if !s.steps.is_empty() {
|
||||||
|
if let Ok(j) = serde_json::to_string(&s.steps) {
|
||||||
|
o.push_str(&format!("\n```steps\n{j}\n```\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
o
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_skill_file(text: &str) -> Option<Skill> {
|
||||||
|
let t = text.trim_start();
|
||||||
|
let mut name = String::new();
|
||||||
|
let mut description = String::new();
|
||||||
|
let mut rest = t;
|
||||||
|
if let Some(body) = t.strip_prefix("---") {
|
||||||
|
if let Some(end) = body.find("\n---") {
|
||||||
|
for line in body[..end].lines() {
|
||||||
|
if let Some(v) = line.strip_prefix("name:") {
|
||||||
|
name = v.trim().to_string();
|
||||||
|
} else if let Some(v) = line.strip_prefix("description:") {
|
||||||
|
description = v.trim().to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rest = body[end + 3..].trim_start_matches('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut steps: Vec<ReplayStep> = vec![];
|
||||||
|
let mut instructions = rest.to_string();
|
||||||
|
if let Some(start) = rest.find("```steps") {
|
||||||
|
if let Some(close) = rest[start..].find("```") {
|
||||||
|
// close is at the fence start inside rest[start..]; find the *second* fence
|
||||||
|
if let Some(end) = rest[start + 8..].find("```") {
|
||||||
|
let json = &rest[start + 8..start + 8 + end];
|
||||||
|
steps = serde_json::from_str(json.trim()).unwrap_or_default();
|
||||||
|
instructions = format!("{}\n{}", &rest[..start], &rest[start + 8 + end + 3..]).trim().to_string();
|
||||||
|
let _ = close;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Skill { name, description, instructions, steps })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save one skill file (the text is validated first). Returns the skill's name.
|
||||||
|
pub fn install_skill_text(text: &str) -> Result<String, String> {
|
||||||
|
let s = parse_skill_file(text).ok_or("這不是一張技能檔(開頭要有 name:)")?;
|
||||||
|
std::fs::create_dir_all(skill_dir()).map_err(|e| format!("開不了技能資料夾:{e}"))?;
|
||||||
|
std::fs::write(skill_dir().join(format!("{}.md", slug_of(&s.name))), text).map_err(|e| e.to_string())?;
|
||||||
|
Ok(s.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_skill_files() -> Vec<Skill> {
|
||||||
|
let mut out = vec![];
|
||||||
|
if let Ok(rd) = std::fs::read_dir(skill_dir()) {
|
||||||
|
for f in rd.flatten() {
|
||||||
|
let p = f.path();
|
||||||
|
if p.extension().and_then(|e| e.to_str()) == Some("md") {
|
||||||
|
if let Ok(text) = std::fs::read_to_string(&p) {
|
||||||
|
if let Some(s) = parse_skill_file(&text) {
|
||||||
|
out.push(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Files win: a skill file replaces the same-named skill in the bot's head at every start.
|
||||||
|
pub fn merge_skill_files(skills: &mut Vec<Skill>) -> usize {
|
||||||
|
let mut n = 0;
|
||||||
|
for f in load_skill_files() {
|
||||||
|
match skills.iter_mut().find(|s| s.name == f.name) {
|
||||||
|
Some(old) => {
|
||||||
|
*old = f;
|
||||||
|
}
|
||||||
|
None => skills.push(f),
|
||||||
|
}
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
n
|
||||||
|
}
|
||||||
|
|
||||||
|
/// C8: 示範即教學 — turn the most recent finished turn that answered `prompt` into a skill,
|
||||||
|
/// keeping the steps that worked and threw nothing away. Looking steps (screenshots, reads) are
|
||||||
|
/// dropped: a replay looks for itself. Steps that point at a number on the screen (ref) are
|
||||||
|
/// dropped too — numbers only live as long as the look that made them.
|
||||||
|
pub fn skillify(prompt: &str, recs: &[crate::oplog::Record]) -> Option<Skill> {
|
||||||
|
let key: String = prompt.chars().take(40).collect();
|
||||||
|
let mut op = String::new();
|
||||||
|
for r in recs.iter().rev() {
|
||||||
|
if let crate::oplog::Record::OperationStarted { op: o, prompt: Some(p), .. } = r {
|
||||||
|
if p.text.chars().take(40).collect::<String>() == key {
|
||||||
|
op = o.clone();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if op.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut done: Vec<(String, bool)> = vec![]; // index-parallel with steps: (call_id, finished ok)
|
||||||
|
let mut steps: Vec<(String, String)> = vec![];
|
||||||
|
for r in recs {
|
||||||
|
match r {
|
||||||
|
crate::oplog::Record::ToolStarted { op: o, call_id, name, args, .. } if o == &op => {
|
||||||
|
let a: serde_json::Value = serde_json::from_str(args).unwrap_or_default();
|
||||||
|
let looks = matches!(
|
||||||
|
name.as_str(),
|
||||||
|
"computer_use_screenshot" | "computer_use_zoom" | "computer_use_look" | "browser_screenshot" | "browser_read" | "browser_elements"
|
||||||
|
) || (name == "computer" && matches!(a["action"].as_str().unwrap_or(""), "screenshot" | "zoom" | "look" | "scroll" | "move"));
|
||||||
|
let numbered = a["ref"].is_number() || !a["actions"].is_null();
|
||||||
|
if !looks && !numbered {
|
||||||
|
steps.push((name.clone(), args.clone()));
|
||||||
|
done.push((call_id.clone(), true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
crate::oplog::Record::ToolFinished { op: o, call_id, status, .. } if o == &op => {
|
||||||
|
if status != "done" {
|
||||||
|
if let Some(e) = done.iter_mut().find(|(c, _)| c == call_id) {
|
||||||
|
e.1 = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let kept: Vec<ReplayStep> = steps
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(i, _)| done.get(*i).is_some_and(|(_, ok)| *ok))
|
||||||
|
.map(|(_, (tool, args))| ReplayStep { tool, args, targets: vec![] })
|
||||||
|
.take(24)
|
||||||
|
.collect();
|
||||||
|
if kept.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let name: String = prompt.chars().take(24).collect();
|
||||||
|
Some(Skill {
|
||||||
|
name,
|
||||||
|
description: format!("示範學會:{prompt}"),
|
||||||
|
instructions: format!("照著做過一次的那條路做完这件事:{prompt}"),
|
||||||
|
steps: kept,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Add a memory (no duplicates; the oldest drop off past the limit).
|
/// Add a memory (no duplicates; the oldest drop off past the limit).
|
||||||
pub fn remember(list: &mut Vec<String>, fact: &str) -> Result<String, String> {
|
pub fn remember(list: &mut Vec<String>, fact: &str) -> Result<String, String> {
|
||||||
let f: String = fact.trim().chars().take(MAX_MEMORY_CHARS).collect();
|
let f: String = fact.trim().chars().take(MAX_MEMORY_CHARS).collect();
|
||||||
|
|
@ -425,4 +600,42 @@ mod tests {
|
||||||
assert_eq!((list.len(), list[0].instructions.as_str()), (1, "新的"));
|
assert_eq!((list.len(), list[0].instructions.as_str()), (1, "新的"));
|
||||||
assert!(learned_note("Threads 發文附圖", true).contains("學會了新做法「Threads 發文附圖」"));
|
assert!(learned_note("Threads 發文附圖", true).contains("學會了新做法「Threads 發文附圖」"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_skill_round_trips_through_a_file() {
|
||||||
|
let sk = Skill {
|
||||||
|
name: "每日打卡".into(),
|
||||||
|
description: "在下載資料夾記一天".into(),
|
||||||
|
instructions: "打開終端機記個日期".into(),
|
||||||
|
steps: vec![ReplayStep { tool: "shell".into(), args: "{\"cmd\":\"date +%F\"}".into(), targets: vec![] }],
|
||||||
|
};
|
||||||
|
let text = skill_to_file(&sk);
|
||||||
|
let back = parse_skill_file(&text).unwrap();
|
||||||
|
assert_eq!(back.name, "每日打卡");
|
||||||
|
assert_eq!(back.steps, sk.steps);
|
||||||
|
assert!(parse_skill_file("no front matter at all").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_finished_turn_can_become_a_skill() {
|
||||||
|
use crate::oplog::{Prompt, Record, Replay};
|
||||||
|
let recs = vec![
|
||||||
|
Record::OperationStarted { op: "o1".into(), at: 1, source_leaf: 0, prompt: Some(Prompt { at: 1, text: "每天記一下日期".into() }) },
|
||||||
|
Record::ToolStarted { op: "o1".into(), call_id: "c1".into(), name: "computer".into(), args: "{\"action\":\"screenshot\"}".into(), replay: Replay::Safe, at: 2 },
|
||||||
|
Record::ToolFinished { op: "o1".into(), call_id: "c1".into(), status: "done".into(), at: 3 },
|
||||||
|
Record::ToolStarted { op: "o1".into(), call_id: "c2".into(), name: "shell".into(), args: "{\"cmd\":\"date\"}".into(), replay: Replay::Safe, at: 4 },
|
||||||
|
Record::ToolFinished { op: "o1".into(), call_id: "c2".into(), status: "done".into(), at: 5 },
|
||||||
|
Record::ToolStarted { op: "o1".into(), call_id: "c3".into(), name: "shell".into(), args: "{\"cmd\":\"rm\"}".into(), replay: Replay::Safe, at: 6 },
|
||||||
|
Record::ToolFinished { op: "o1".into(), call_id: "c3".into(), status: "failed".into(), at: 7 },
|
||||||
|
Record::ToolStarted { op: "o1".into(), call_id: "c4".into(), name: "computer".into(), args: "{\"action\":\"click\",\"ref\":3}".into(), replay: Replay::Safe, at: 8 },
|
||||||
|
Record::ToolFinished { op: "o1".into(), call_id: "c4".into(), status: "done".into(), at: 9 },
|
||||||
|
];
|
||||||
|
let sk = skillify("每天記一下日期", &recs).unwrap();
|
||||||
|
// only the shell date step survives: looks and numbered clicks are dropped, failures too
|
||||||
|
assert_eq!(sk.steps.len(), 1);
|
||||||
|
assert_eq!(sk.steps[0].tool, "shell");
|
||||||
|
assert!(sk.steps[0].args.contains("date"));
|
||||||
|
assert!(skillify("沒這回合", &recs).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue