8 Commits

Author SHA1 Message Date
5c1574be25 bump: sdk v0.9.2 (SetToolBlocks + DataDir) 2026-08-27 09:32:01 +08:00
68497b4092 feat(sdk): IOInjector.SetToolBlocks + ContentBlock/ImageURL/AudioURL 多模态类型
SDK 公共层新增多模态内容块类型;IOInjector 接口新增 SetToolBlocks
方法让插件工具注入 image_url/audio_url 块,内核 process.go 消费后
追加到 tool message 的 content 数组。详见 TrueAgent 仓库 multimodal。
2026-08-27 08:39:40 +08:00
130f805b6e feat(sdk): SettingsAPI.DataDir() + plugindev dispatchSettings 补 DataDir
SDK SettingsAPI 新增 DataDir() 插件专属数据目录;
plugindev 工具链 dispatchSettings 模板补 DataDir 实现(callString 51)。
ai_image example 改用 DataDir + 本地交付。详见 TrueAgent 仓库。
2026-08-26 21:41:28 +08:00
cd1984e26e feat(ai_image): base_url 设置项支持自定义 OpenAI 兼容网关 v1.1.0
generateOpenAI 支持配置 base_url 指向 OpenAI 兼容网关(如本机
llmsproxy),为空保持官方直连。已实测经 llmsproxy→siliconflow
(Kwai-Kolors/Kolors) 生图出有效 PNG。
2026-08-26 19:47:01 +08:00
6184736fd4 feat(examples): a2a/acp 会话历史查询 + 出站 session_id 透传
a2a tasks.get/session.get 返回会话近N条消息;acp 新增 session/get;
a2a_query/acp_query 接受 session_id 延续对方会话。详见 TrueAgent 仓库。
2026-08-26 19:30:34 +08:00
81bfdfce1d fix(examples): a2a/acp 回复闭环 + 会话延续 + 同步注入
入站请求从 InjectInterruptText(202 submitted) 改为 InjectInputSync
同步等待回复,直接返回回复文本;支持 params.session_id 延续多轮
上下文;注册为输出通道让回复有落点。详见 TrueAgent 仓库同名 commit。
2026-08-26 19:16:49 +08:00
e3f93e254b chore(recoverydiag): 补齐 go.mod 与 main.go(与其他插件结构一致) 2026-08-26 17:05:24 +08:00
d57c5eaf3e fix(examples): 全插件安全审查修复(qq/a2a/memo/calendar/rss/browser/bili/recoverydiag)
审查发现并修复 7 项问题:
- P1 qq: downloadURL 裸 http.Get 无超时 → 120s client
- P2 a2a: inbound http.Server 零超时 → Read 30s/Write 120s/Idle 60s
- P3 bili: output_dir 配置项零校验 → 系统目录黑名单(/、/etc、/usr、/var 等)
- P4 recoverydiag: db_path LLM 可控任意 sqlite → 强制限制 data 目录内
- P5 memo/calendar/rss: os.WriteFile 直写 → atomicWriteJSON (temp+rename)
- P6 qq: 3 处后台 goroutine(已读/rcon转发/下载)加 panic recover
- P7 browser: dump-dom failback Kill 后补 wait 回收僵尸进程

recoverydiag 此前被 .gitignore 排除,但其 db_path 安全修复
属生产代码,故取消忽略并入库。

全部经 plugindev 重打包升版安装验证 config_kept=true。
2026-08-26 17:04:41 +08:00
28 changed files with 2165 additions and 133 deletions

1
.gitignore vendored
View File

@ -30,4 +30,3 @@ z_entry.c
# plugindev binary in tools/
tools/plugindev/plugindev
example/recoverydiag/

View File

@ -2,11 +2,15 @@
"name": "a2a",
"name_zh": "A2A 代理通信",
"name_en": "A2A Agent Communication",
"version": "1.0.0",
"version": "1.3.0",
"description": "Agent-to-Agent 协议通信插件,支持双向 A2A 通信:可查询其他 Agent 并回复其请求。提供 HTTP 服务端暴露本 Agent 能力。",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": ["a2a", "agent", "interop"],
"tags": [
"a2a",
"agent",
"interop"
],
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,

View File

@ -21,15 +21,47 @@ type Plugin struct {
srvMu sync.Mutex
server *http.Server
serverAddr string
// 会话表session_id → 上下文前缀。A2A 无状态协议下由插件侧维护
// 多轮上下文:同 session 的后续请求会把之前的对话拼进注入文本。
sessMu sync.Mutex
sessions map[string]*a2aSession
}
// a2aSession 记录一个会话的轮次历史,用于延续上下文。
type a2aSession struct {
ID string
History []string // 轮次文本 [user1, agent1, user2, agent2, ...]
LastUsed time.Time
}
// maxSessionTurns 单会话保留的最大轮次对数(防上下文无限膨胀)。
const maxSessionTurns = 10
// sessionGCPeriod 会话过期清理周期;超过 2 小时未用的会话回收。
const sessionGCPeriod = 30 * time.Minute
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.SetAutoRestart(true)
p.sdk = s
p.sessions = make(map[string]*a2aSession)
tp := p.name + "_"
// 注册自身为输出通道agent 回复 emit 到本通道时有落点,
// 且 output_list_channels 可见agent 能主动向 a2a 会话推送消息)。
if err := s.RegisterOutputChannel(p.name, 1, "A2A Agent 互联通道(外部 agent 查询的回复由此返回)", sdk.ChannelDef{}, func(args map[string]interface{}) (interface{}, error) {
payload, _ := args["payload"].(string)
log.Printf("[%s] channel output: %s", p.name, truncateRunes(payload, 120))
return map[string]interface{}{"status": "ok"}, nil
}); err != nil {
log.Printf("[%s] register output channel: %v", p.name, err)
}
// 会话 GC后台周期回收长期不用的会话
go p.sessionGCLoop()
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "listen", Default: "127.0.0.1:12000",
Type: "string", DisplayName: "监听地址",
@ -45,6 +77,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
"properties": map[string]interface{}{
"agent_url": map[string]interface{}{"type": "string", "description": "目标 Agent 的 A2A 端点 URL"},
"query": map[string]interface{}{"type": "string", "description": "发送给目标 Agent 的文本查询"},
"session_id": map[string]interface{}{"type": "string", "description": "可选。上次调用返回的 session_id传入可延续与该 agent 的多轮对话上下文"},
"timeout": map[string]interface{}{"type": "integer", "description": "超时时间(秒),默认 60"},
},
"required": []string{"agent_url", "query"},
@ -114,6 +147,66 @@ func (p *Plugin) Stop() error {
return nil
}
// sessionGCLoop 周期清理超时会话。
func (p *Plugin) sessionGCLoop() {
ticker := time.NewTicker(sessionGCPeriod)
defer ticker.Stop()
for range ticker.C {
p.sessMu.Lock()
for id, sess := range p.sessions {
if time.Since(sess.LastUsed) > 2*time.Hour {
delete(p.sessions, id)
}
}
p.sessMu.Unlock()
}
}
func truncateRunes(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "..."
}
// sessionMessages 返回指定会话的近 limit 条消息(时间正序),
// 会话不存在返回 nil。消息格式 [{role, text, ts}]。
func (p *Plugin) sessionMessages(sessionID string, limit int) []map[string]interface{} {
p.sessMu.Lock()
sess := p.sessions[sessionID]
var hist []string
var lastUsed time.Time
if sess != nil {
hist = append([]string{}, sess.History...)
lastUsed = sess.LastUsed
}
p.sessMu.Unlock()
if sess == nil {
return nil
}
_ = lastUsed
// History 交替 [user, agent, user, agent...],取末尾 limit 条,保持时间正序
start := 0
if len(hist) > limit {
start = len(hist) - limit
}
msgs := make([]map[string]interface{}, 0, len(hist)-start)
for i := start; i < len(hist); i++ {
role, text := "user", hist[i]
if after, ok := strings.CutPrefix(text, "用户: "); ok {
role, text = "user", after
} else if after, ok := strings.CutPrefix(text, "助手: "); ok {
role, text = "agent", after
}
msgs = append(msgs, map[string]interface{}{
"role": role,
"text": text,
})
}
return msgs
}
func (p *Plugin) stopServer() {
p.srvMu.Lock()
defer p.srvMu.Unlock()
@ -137,7 +230,12 @@ func (p *Plugin) startServer(addr string) error {
return fmt.Errorf("listen %s: %v", addr, err)
}
srv := &http.Server{Handler: mux}
srv := &http.Server{
Handler: mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 120 * time.Second,
IdleTimeout: 60 * time.Second,
}
addrStr := listener.Addr().String()
p.srvMu.Lock()
@ -187,6 +285,8 @@ func (p *Plugin) handleIncomingA2A(w http.ResponseWriter, r *http.Request) {
Method string `json:"method"`
Params struct {
Query string `json:"query,omitempty"`
SessionID string `json:"session_id,omitempty"`
Limit int `json:"limit,omitempty"`
Message *struct {
Role string `json:"role"`
Parts []struct {
@ -210,29 +310,96 @@ func (p *Plugin) handleIncomingA2A(w http.ResponseWriter, r *http.Request) {
}
queryText = strings.TrimSpace(queryText)
}
// Inject into agent pipeline via interrupt (preempt current processing) or direct input
if queryText != "" {
p.sdk.InjectInterruptText("a2a", "webui", fmt.Sprintf("[来自A2A Agent的查询]\n%s", queryText))
if queryText == "" {
http.Error(w, "query/message.text required", http.StatusBadRequest)
return
}
// Respond with task accepted
// 会话:调用方可指定 session_id 延续多轮上下文;不指定则新建。
sessionID := strings.TrimSpace(req.Params.SessionID)
injectText := queryText
p.sessMu.Lock()
if sessionID != "" {
sess := p.sessions[sessionID]
if sess == nil {
sess = &a2aSession{ID: sessionID, LastUsed: time.Now()}
p.sessions[sessionID] = sess
}
sess.LastUsed = time.Now()
// 有历史则把上下文拼在前面(截尾防爆量)
if len(sess.History) > 0 {
ctxText := strings.Join(sess.History, "\n")
injectText = "[对话上下文]\n" + ctxText + "\n[本轮输入]\n" + queryText
}
} else {
sessionID = fmt.Sprintf("a2a_%d", time.Now().UnixNano())
p.sessions[sessionID] = &a2aSession{ID: sessionID, LastUsed: time.Now()}
}
p.sessMu.Unlock()
// 同步注入:阻塞等待 agent 处理完成拿回复(不再抢占打断、
// 也不再回 202 让请求方永远等不到结果。HTTP 超时由调用方控制。
reply := p.sdk.InjectInputSync(p.name, p.name,
fmt.Sprintf("[来自A2A Agent的查询 session=%s]\n%s\n[注意] 请直接以文本回复本查询,不要调用 output_send__%s——你的最终文本回复会被系统自动返回给请求方。", sessionID, injectText, p.name))
// 回复写回会话历史(下一轮作为上下文)
p.sessMu.Lock()
if sess := p.sessions[sessionID]; sess != nil {
sess.History = append(sess.History, "用户: "+queryText, "助手: "+reply)
if len(sess.History) > maxSessionTurns*2 {
sess.History = sess.History[len(sess.History)-maxSessionTurns*2 :]
}
sess.LastUsed = time.Now()
}
p.sessMu.Unlock()
resp := map[string]interface{}{
"jsonrpc": "2.0",
"id": req.ID,
"result": map[string]interface{}{
"id": fmt.Sprintf("task_%d", time.Now().UnixNano()),
"status": "submitted",
"status": "completed",
"session_id": sessionID,
"message": map[string]interface{}{
"role": "agent",
"parts": []map[string]string{{"type": "text", "text": reply}},
},
},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
case "tasks.get":
case "tasks.get", "session.get":
// 按 session_id 返回会话内近 N 条消息(默认 10 条)。
sessionID := strings.TrimSpace(req.Params.SessionID)
if sessionID == "" {
sessionID = strings.TrimSpace(req.Params.Query)
}
limit := 10
if req.Params.Limit > 0 && req.Params.Limit <= 100 {
limit = req.Params.Limit
}
msgs := p.sessionMessages(sessionID, limit)
if msgs == nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"jsonrpc": "2.0", "id": req.ID,
"result": map[string]interface{}{"id": req.Params.Query, "status": "unknown"},
"result": map[string]interface{}{
"session_id": sessionID,
"status": "not_found",
"messages": []interface{}{},
},
})
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"jsonrpc": "2.0", "id": req.ID,
"result": map[string]interface{}{
"session_id": sessionID,
"status": "completed",
"messages": msgs,
},
})
default:
@ -277,6 +444,7 @@ type A2ARequest struct {
type A2AParams struct {
Query string `json:"query,omitempty"`
SessionID string `json:"session_id,omitempty"`
Message *A2AMessage `json:"message,omitempty"`
TaskID string `json:"id,omitempty"`
}
@ -291,6 +459,7 @@ type A2AResponse struct {
type A2AResult struct {
TaskID string `json:"id,omitempty"`
Status string `json:"status,omitempty"`
SessionID string `json:"session_id,omitempty"`
Message *A2AMessage `json:"message,omitempty"`
AgentCard *A2AAgentCard `json:"agent_card,omitempty"`
}
@ -356,6 +525,7 @@ func (p *Plugin) handleA2ADiscover(args map[string]interface{}) (interface{}, er
func (p *Plugin) handleA2AQuery(args map[string]interface{}) (interface{}, error) {
agentURL, _ := args["agent_url"].(string)
query, _ := args["query"].(string)
sessionID, _ := args["session_id"].(string) // 可选:延续对方会话
timeoutSec := 60
if v, ok := args["timeout"].(float64); ok && v > 0 {
timeoutSec = int(v)
@ -377,6 +547,7 @@ func (p *Plugin) handleA2AQuery(args map[string]interface{}) (interface{}, error
ID: fmt.Sprintf("a2a_%d", time.Now().UnixNano()),
Method: "tasks.send",
Params: A2AParams{
SessionID: sessionID,
Message: &A2AMessage{Role: "user", Parts: []A2APart{{Text: query, Type: "text"}}},
},
}
@ -416,10 +587,18 @@ func (p *Plugin) handleA2AQuery(args map[string]interface{}) (interface{}, error
replyText = strings.TrimSpace(replyText)
}
return map[string]interface{}{
result := map[string]interface{}{
"task_id": a2aResp.Result.TaskID, "status": a2aResp.Result.Status,
"response": replyText,
}, nil
}
if a2aResp.Result.SessionID != "" || sessionID != "" {
result["session_id"] = a2aResp.Result.SessionID
if result["session_id"] == "" {
result["session_id"] = sessionID
}
result["note"] = "延续会话:下次调用传此 session_id 可保持上下文"
}
return result, nil
}
// ---- Management Handlers ----

View File

@ -2,11 +2,15 @@
"name": "acp",
"name_zh": "ACP 代理通信",
"name_en": "ACP Agent Client Protocol",
"version": "1.0.0",
"version": "1.2.0",
"description": "Agent Client Protocol 通信插件:充当 ACP 服务端接受其他 Agent 的任务请求,同时提供客户端工具向远程 ACP Agent如 opencode发起会话并读取回复",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": ["acp", "agent", "interop"],
"tags": [
"acp",
"agent",
"interop"
],
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,

View File

@ -34,8 +34,13 @@ type Plugin struct {
type sessionState struct {
ID string
Replying []map[string]interface{}
History []string // 轮次历史 [user, agent, user, agent...],延续上下文用
LastUsed time.Time
}
// maxSessionTurns 单会话保留的最大轮次对数。
const maxSessionTurns = 10
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
@ -44,6 +49,14 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.sessions = make(map[string]*sessionState)
tp := p.name + "_"
// 注册自身为输出通道agent 回复 emit 到本通道时有落点。
// (回复主要走同步注入返回,此通道用于 agent 主动 output_send__acp
s.RegisterOutputChannel(p.name, 1, "ACP Agent 互联通道(外部 agent 会话的回复由此返回)", sdk.ChannelDef{}, func(args map[string]interface{}) (interface{}, error) {
payload, _ := args["payload"].(string)
log.Printf("[%s] channel output: %s", p.name, truncateStr(payload, 120))
return map[string]interface{}{"status": "ok"}, nil
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "listen", Default: "127.0.0.1:12001",
Type: "string", DisplayName: "监听地址",
@ -58,6 +71,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
"properties": map[string]interface{}{
"server_url": map[string]interface{}{"type": "string", "description": "目标 ACP 服务端地址(如 http://127.0.0.1:13000"},
"prompt": map[string]interface{}{"type": "string", "description": "发送给目标 Agent 的任务描述"},
"session_id": map[string]interface{}{"type": "string", "description": "可选。上次调用返回的 session_id传入可延续与该 agent 的多轮对话上下文"},
"timeout": map[string]interface{}{"type": "integer", "description": "等待回复超时(秒),默认 120"},
},
"required": []string{"server_url", "prompt"},
@ -171,6 +185,7 @@ func (p *Plugin) handleSessionPost(w http.ResponseWriter, r *http.Request) {
Text string `json:"text"`
} `json:"request,omitempty"`
SessionID string `json:"session_id,omitempty"`
Limit int `json:"limit,omitempty"`
Final bool `json:"final,omitempty"`
} `json:"params,omitempty"`
}
@ -190,21 +205,109 @@ func (p *Plugin) handleSessionPost(w http.ResponseWriter, r *http.Request) {
return
}
sid := fmt.Sprintf("session_%d", time.Now().UnixNano())
// 会话:调用方可指定 session_id 延续多轮;不指定则新建。
sid := strings.TrimSpace(req.Params.SessionID)
p.mu.Lock()
p.sessions[sid] = &sessionState{ID: sid}
if sid != "" {
if _, exists := p.sessions[sid]; !exists {
p.sessions[sid] = &sessionState{ID: sid, LastUsed: time.Now()}
}
} else {
sid = fmt.Sprintf("session_%d", time.Now().UnixNano())
p.sessions[sid] = &sessionState{ID: sid, LastUsed: time.Now()}
}
st := p.sessions[sid]
p.mu.Unlock()
if p.sdk != nil {
p.sdk.InjectInterruptText(p.name, "acp",
fmt.Sprintf("[来自ACP Agent的请求请求 session %s]\n%s", sid, text))
// 延续上下文
injectText := text
p.mu.Lock()
if len(st.History) > 0 {
ctxText := strings.Join(st.History, "\n")
injectText = "[对话上下文]\n" + ctxText + "\n[本轮输入]\n" + text
}
p.mu.Unlock()
// 同步注入等待回复:不抢占打断,完整闭环返回文本。
reply := ""
if p.sdk != nil {
reply = p.sdk.InjectInputSync(p.name, p.name,
fmt.Sprintf("[来自ACP Agent的请求 session %s]\n%s\n[注意] 请直接以文本回复本请求,不要调用 output_send__%s——你的最终文本回复会被系统自动返回给请求方。", sid, injectText, p.name))
}
// 写回历史 + 填充 Replying 供 SSE 消费
p.mu.Lock()
st.History = append(st.History, "用户: "+text, "助手: "+reply)
if len(st.History) > maxSessionTurns*2 {
st.History = st.History[len(st.History)-maxSessionTurns*2:]
}
st.LastUsed = time.Now()
if reply != "" {
st.Replying = append(st.Replying, map[string]interface{}{
"type": "reply", "text": reply,
})
}
p.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"jsonrpc": "2.0", "id": req.ID,
"result": map[string]interface{}{
"session": map[string]interface{}{"id": sid},
"reply": reply,
},
})
case "session/get":
// 按 session_id 返回会话内近 N 条消息(默认 10 条,时间正序)
sid := req.Params.SessionID
p.mu.RLock()
st := p.sessions[sid]
var hist []string
if st != nil {
hist = append([]string{}, st.History...)
}
p.mu.RUnlock()
if st == nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"jsonrpc": "2.0", "id": req.ID,
"result": map[string]interface{}{
"session_id": sid,
"status": "not_found",
"messages": []interface{}{},
},
})
return
}
limit := 10
if req.Params.Limit > 0 && req.Params.Limit <= 100 {
limit = req.Params.Limit
}
start := 0
if len(hist) > limit {
start = len(hist) - limit
}
msgs := make([]map[string]interface{}, 0, len(hist)-start)
for i := start; i < len(hist); i++ {
role, text := "user", hist[i]
if after, ok := strings.CutPrefix(text, "用户: "); ok {
role, text = "user", after
} else if after, ok := strings.CutPrefix(text, "助手: "); ok {
role, text = "agent", after
}
msgs = append(msgs, map[string]interface{}{
"role": role,
"text": text,
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"jsonrpc": "2.0", "id": req.ID,
"result": map[string]interface{}{
"session_id": sid,
"status": "completed",
"messages": msgs,
},
})
@ -212,12 +315,17 @@ func (p *Plugin) handleSessionPost(w http.ResponseWriter, r *http.Request) {
sid := req.Params.SessionID
p.mu.Lock()
st := p.sessions[sid]
if st != nil && req.Params.Final {
st.Replying = append(st.Replying, map[string]interface{}{
"type": "reply", "text": "done",
})
}
p.mu.Unlock()
if st == nil {
http.Error(w, "session not found", http.StatusNotFound)
return
}
if req.Params.Final {
// 客户端结束会话:标记并保留历史(后续可再 session/new 续)
p.mu.Lock()
st.LastUsed = time.Now()
p.mu.Unlock()
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
@ -338,6 +446,7 @@ func (p *Plugin) handleAcpQuery(args map[string]interface{}) (interface{}, error
if prompt == "" {
return map[string]interface{}{"error": "prompt 不能为空"}, nil
}
sessionID, _ := args["session_id"].(string) // 可选:延续对方会话
timeoutSec := 120
if v, ok := args["timeout"].(float64); ok && v > 0 {
timeoutSec = int(v)
@ -346,12 +455,16 @@ func (p *Plugin) handleAcpQuery(args map[string]interface{}) (interface{}, error
endpoint := serverURL + "/api/session"
client := &http.Client{Timeout: time.Duration(timeoutSec) * time.Second}
params := map[string]interface{}{
"request": map[string]interface{}{"text": prompt},
}
if sessionID != "" {
params["session_id"] = sessionID
}
newBody, _ := json.Marshal(map[string]interface{}{
"jsonrpc": "2.0", "id": "acp-" + fmt.Sprintf("%d", time.Now().UnixNano()),
"method": "session/new",
"params": map[string]interface{}{
"request": map[string]interface{}{"text": prompt},
},
"params": params,
})
req, _ := http.NewRequest("POST", endpoint, bytes.NewReader(newBody))
@ -416,6 +529,7 @@ func (p *Plugin) handleAcpQuery(args map[string]interface{}) (interface{}, error
"session_id": sid,
"status": "completed",
"reply": replyText,
"note": "延续会话:下次调用传此 session_id 可保持上下文",
}, nil
}

View File

@ -2,11 +2,16 @@
"name": "ai_image",
"name_zh": "AI绘图",
"name_en": "AI Image",
"version": "1.0.0",
"version": "1.3.0",
"description": "AI 图像生成插件,支持 OpenAI DALL·E / Stable Diffusion",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": ["ai", "image", "draw", "generate"],
"tags": [
"ai",
"image",
"draw",
"generate"
],
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,

View File

@ -5,7 +5,10 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@ -21,6 +24,8 @@ type Plugin struct {
provider string
model string
size string
baseURL string
dataDir string // <data>/ai_images生成本地图片存放目录
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
@ -111,6 +116,11 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
DisplayName: "API Key", Description: "OpenAI / Stable Diffusion API Key",
Category: "ai_image", Secret: true,
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "base_url", Default: "", Type: "string",
DisplayName: "Base URL", Description: "自定义 OpenAI 兼容网关地址(不带 /v1 尾缀,如 http://127.0.0.1:8081为空走官方 https://api.openai.com",
Category: "ai_image",
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "provider", Default: "openai", Type: "string",
DisplayName: "Provider", Description: "Image generation provider: openai / stability",
@ -131,10 +141,23 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.provider = getSetting(s.Settings(), "provider", "openai")
p.model = getSetting(s.Settings(), "model", "dall-e-3")
p.size = getSetting(s.Settings(), "size", "1024x1024")
p.baseURL = strings.TrimRight(strings.TrimSpace(getSetting(s.Settings(), "base_url", "")), "/")
// 生图本地存放目录插件专属数据目录SDK DataDir API内核保证存在
if p.sdk != nil {
if dd := s.Settings().DataDir(); dd != "" {
p.dataDir = dd
}
}
if p.dataDir == "" {
// 旧版内核无 DataDir API 时退到 /tmp
p.dataDir = filepath.Join(os.TempDir(), "homeagent_ai_images")
}
os.MkdirAll(p.dataDir, 0755)
tp := p.name + "_"
s.RegisterTool(tp+"generate", sdk.ToolDef{
Name: tp + "generate", Description: "Generate image from text prompt using AI. Returns image URL.",
Name: tp + "generate", Description: "Generate image from text prompt using AI. Downloads the result locally and returns a local file path (permanent, no expiry). To show the user, send it via output_send with type=image and payload=the returned path.",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
@ -209,6 +232,14 @@ func (p *Plugin) handleGenerate(args map[string]interface{}) (interface{}, error
}
func (p *Plugin) generateOpenAI(prompt, model, size string, n int, apiKey string) (interface{}, error) {
// 上游地址base_url 非空时走自定义网关(如本机 llmsproxy约定不带 /v1 尾缀;
// 为空保持官方直连。兼容误配了 /v1 尾缀的情况(去重)。
endpoint := "https://api.openai.com/v1/images/generations"
if p.baseURL != "" {
base := strings.TrimSuffix(p.baseURL, "/v1")
endpoint = base + "/v1/images/generations"
}
body := openAIReq{
Model: model,
Prompt: prompt,
@ -217,8 +248,9 @@ func (p *Plugin) generateOpenAI(prompt, model, size string, n int, apiKey string
ResponseFormat: "url",
}
log.Printf("[ai_image] endpoint=%s baseURL=%q model=%q", endpoint, p.baseURL, model)
b, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", "https://api.openai.com/v1/images/generations", bytes.NewReader(b))
req, _ := http.NewRequest("POST", endpoint, bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
@ -247,14 +279,74 @@ func (p *Plugin) generateOpenAI(prompt, model, size string, n int, apiKey string
urls[i] = d.URL
}
// 下载到本地 data 目录,返回本地文件路径(而非临时 S3 URL
// - S3 临时 URL 约 1 小时过期,且对无浏览器 UA 的客户端拒绝访问
// - 本地路径可经 webui /files/ 永久下发给所有客户端(含 API key 客户端)
localPaths := make([]string, len(urls))
var errs []string
for i, u := range urls {
path, err := p.downloadImage(u, fmt.Sprintf("ai_%s_%d", model, time.Now().UnixNano()))
if err != nil {
errs = append(errs, fmt.Sprintf("第%d张下载失败: %v", i+1, err))
continue
}
localPaths[i] = path
}
content := fmt.Sprintf("Generated %d image(s) with model %s:", len(urls), model)
for _, pth := range localPaths {
if pth != "" {
content += "\n" + pth
}
}
if len(errs) > 0 {
content += "\n\n" + strings.Join(errs, "\n")
}
content += "\n\n已将图片保存到本地不会过期。如需展示请用 output_send__webui(payload=本地路径, type=image)。"
return map[string]interface{}{
"content": fmt.Sprintf("Generated %d image(s) with model %s:\n%s", len(urls), model, strings.Join(urls, "\n")),
"images": urls,
"content": content,
"images": localPaths,
"prompt": prompt,
"model": model,
"local_paths": localPaths,
}, nil
}
// downloadImage 把生图返回的临时 URL 下载为本地文件,返回本地路径。
// 带浏览器 UA 以规避图床对无 UA 客户端的拦截。
func (p *Plugin) downloadImage(url, baseName string) (string, error) {
dl := &http.Client{Timeout: 60 * time.Second}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; HomeAgent/1.0)")
resp, err := dl.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))[:200])
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
ext := ".png"
if ct := resp.Header.Get("Content-Type"); strings.Contains(ct, "jpeg") || strings.Contains(ct, "jpg") {
ext = ".jpg"
} else if strings.Contains(ct, "webp") {
ext = ".webp"
}
path := filepath.Join(p.dataDir, baseName+ext)
if err := os.WriteFile(path, data, 0644); err != nil {
return "", err
}
return path, nil
}
type stabilityReq struct {
TextPrompts []stabilityPrompt `json:"text_prompts"`
Width int `json:"width"`
@ -334,7 +426,7 @@ func (p *Plugin) generateStability(prompt, model, size string, n int, apiKey str
}
return map[string]interface{}{
"content": fmt.Sprintf("Generated %d image(s) via Stability AI:\n%s", len(urls), strings.Join(urls, "\n")),
"content": fmt.Sprintf("Generated %d image(s) via Stability AI:\n%s\n\n图片已保存到本地如需展示请用 output_send(type=image)。", len(urls), strings.Join(urls, "\n")),
"images": urls,
"prompt": prompt,
"model": model,

View File

@ -2,11 +2,15 @@
"name": "bili",
"name_zh": "B站视频下载",
"name_en": "Bilibili Video Downloader",
"version": "1.1.0",
"version": "1.2.0",
"description": "B站视频下载工具基于 yt-dlp 引擎。支持查看视频清晰度列表、指定格式下载、可配置下载目录。",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": ["bili", "video", "download"],
"tags": [
"bili",
"video",
"download"
],
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,

View File

@ -107,6 +107,14 @@ func (p *Plugin) handleBiliVideo(args map[string]interface{}) (interface{}, erro
}
}
}
// 安全校验output_dir 是配置项,但避免被配成系统目录导致 yt-dlp 任意位置写。
// 禁止根/家目录本身,且规范化后必须落在明确子目录内。
outputDir = filepath.Clean(outputDir)
for _, forbidden := range []string{"/", "/etc", "/usr", "/bin", "/sbin", "/boot", "/dev", "/proc", "/sys", "/var"} {
if outputDir == forbidden {
return nil, fmt.Errorf("output_dir 不能是系统目录 %s", forbidden)
}
}
os.MkdirAll(outputDir, 0755)
var out bytes.Buffer

View File

@ -2,11 +2,17 @@
"name": "browser",
"name_zh": "浏览器",
"name_en": "Browser",
"version": "2.0.0",
"version": "2.3.0",
"description": "统一浏览器插件搜索、HTTP抓取(quick)、无头渲染(normal)、交互式浏览器(interactive/CDP)",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": ["web", "search", "fetch", "browser", "cdp"],
"tags": [
"web",
"search",
"fetch",
"browser",
"cdp"
],
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,

View File

@ -13,6 +13,7 @@ import (
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
@ -38,18 +39,44 @@ type Plugin struct {
wg sync.WaitGroup
stopCh chan struct{}
stopOnce sync.Once
profilesDir string // 持久化 profile 根目录(<data>/browser_profiles空则禁用
// 共享浏览器单例:所有 agent 共用一个 Chromium 进程(全局 UserDataDir
// 登录态/cookies 跨 agent、跨会话、跨插件重启保留每个 start 创建一个
// 新标签页CDP Target。同 source 复用自己的标签页。浏览器进程在
// 最后一个标签页关闭后保留(避免反复冷启动),仅插件 Stop 时回收。
sharedAllocCtx context.Context
sharedAllocCancel context.CancelFunc
sharedMu sync.Mutex
}
type BrowserSession struct {
id string
allocCtx context.Context
allocCtx context.Context // 共享浏览器进程上下文shared=true 时指向全局单例)
cancel context.CancelFunc
ctx context.Context
ctx context.Context // 本会话的 Target 上下文(一个标签页)
createdAt time.Time
timeout time.Duration
closed bool
mu sync.Mutex
currentURL string
shared bool // true=共享浏览器的一个标签页false=独占浏览器实例
profileDir string // 非空表示使用持久化 profile关闭时不删目录
sessionKey string // 共享模式下的复用键agent 来源标识,同 key 复用同一标签页)
}
// sanitizeProfileName 消毒 profile 名:仅保留字母数字-_防路径穿越。
func sanitizeProfileName(name string) string {
var b []byte
for _, c := range []byte(name) {
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' {
b = append(b, c)
}
}
if len(b) == 0 || string(b) == "." || string(b) == ".." {
return ""
}
return string(b)
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
@ -187,6 +214,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.proxy = readCfg(s.Settings(), "proxy", "")
p.client = newHTTPClient(p.timeout, p.proxy)
// 持久化 profile 根目录:<data>/browser_profiles
if dd, err := s.Settings().GetCore("daemon.data_dir"); err == nil {
if s2, ok := dd.(string); ok && s2 != "" {
p.profilesDir = filepath.Join(s2, "browser_profiles")
}
}
tp := p.name + "_"
cleaner := func(output string) string {
@ -242,12 +276,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
s.RegisterTool(tp+"start", sdk.ToolDef{
Name: tp + "start",
Description: "启动交互式浏览器会话(interactive 模式)。通过 CDP 连接 Chromium支持导航、截图、点击、输入等操作。返回会话 ID。",
Description: "启动交互式浏览器会话。优先连接 systemd 托管的共享浏览器后端(登录态全机共享、各 agent 独立标签页);后端未安装时返回 need_install 引导(调 browser_install无法安装时自动降级本地临时模式。同来源复用已有标签页。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"url": map[string]interface{}{"type": "string", "description": "初始导航 URL可选"},
"timeout": map[string]interface{}{"type": "string", "description": "会话超时(如 5m, 10m默认 10m)"},
"profile": map[string]interface{}{"type": "string", "description": "持久化档案名(可选,如 main。同名档案共享登录态与浏览历史不指定则为一次性临时会话"},
},
},
}, p.handleBrowserStart)
@ -337,6 +372,15 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
},
}, p.handleScroll)
s.RegisterTool(tp+"install", sdk.ToolDef{
Name: tp + "install",
Description: "安装并启动共享浏览器后端homeagent-browser.servicesystemd 托管)。前提:本机已有 chromium 二进制无则先提示用户安装apt install chromium 或等价命令)。安装后所有 agent 共享同一浏览器实例与登录态。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
}, p.handleBrowserInstall)
s.RegisterTool(tp+"close", sdk.ToolDef{
Name: tp + "close",
Description: "关闭交互式浏览器会话,释放资源。",
@ -663,6 +707,9 @@ func (p *Plugin) fetchWithChromium(rawURL string, maxChars int) (interface{}, er
}, nil
}
// handleRender 无头渲染 JS 页面并提取文本normal 模式)。
// 主路径走共享浏览器后端:开临时标签页(带全机登录态)→ 渲染 → 取 text → 关标签页;
// 后端不可用时 failback 到独立 chromium --dump-dom无登录态仅保功能
func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error) {
rawURL := readArg(args, "url", "")
if rawURL == "" {
@ -672,32 +719,70 @@ func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error)
return errResult(err.Error()), nil
}
waitSec := int64(readArg(args, "wait", float64(0)))
if waitSec > 0 {
time.Sleep(time.Duration(waitSec) * time.Second)
var title, html string
rendered := false
ok, needInstall, _ := p.ensureBackend()
if ok {
remoteCtx, remoteCancel := chromedp.NewRemoteAllocator(context.Background(), cdpEndpoint)
defer remoteCancel()
tabCtx, tabCancel := chromedp.NewContext(remoteCtx)
defer tabCancel()
actions := []chromedp.Action{
chromedp.Navigate(rawURL),
chromedp.WaitReady("body"),
}
var html string
if waitSec > 0 {
actions = append(actions, chromedp.Sleep(time.Duration(waitSec)*time.Second))
}
actions = append(actions,
chromedp.Title(&title),
chromedp.OuterHTML("html", &html),
)
// 整体限时 30s防慢页拖死工具
rctx, rcancel := context.WithTimeout(tabCtx, 30*time.Second)
defer rcancel()
if err := chromedp.Run(rctx, actions...); err == nil {
rendered = true
} else {
log.Printf("[%s] render via backend failed (%v), fallback to dump-dom", p.name, err)
}
} else if needInstall {
return map[string]interface{}{
"error": "browser backend not installed",
"need_install": true,
"guide": "调用 browser_install 安装共享后端;或重试本工具自动降级为独立 chromium 渲染(不带登录态)",
}, nil
}
if !rendered {
chromiumPath := "/usr/local/bin/chromium"
if _, err := os.Stat(chromiumPath); err == nil {
if _, err := os.Stat(chromiumPath); err != nil {
if _, e2 := exec.LookPath("chromium"); e2 == nil {
chromiumPath = "chromium"
} else {
return errResult("no chromium available"), nil
}
}
var out bytes.Buffer
cmd := exec.Command(chromiumPath, "--headless", "--disable-gpu", "--no-sandbox", "--dump-dom", rawURL)
cmd.Stdout = &out
if err := cmd.Run(); err != nil {
done := make(chan error, 1)
go func() { done <- cmd.Run() }()
select {
case err := <-done:
if err != nil {
return errResult("chromium: " + err.Error()), nil
}
case <-time.After(30 * time.Second):
cmd.Process.Kill()
<-done // 回收子进程避免僵尸
return errResult("chromium dump-dom timeout (30s)"), nil
}
html = out.String()
} else {
resp, err := http.Get(rawURL)
if err != nil {
return errResult("http get: " + err.Error()), nil
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
html = string(body)
}
title := ""
if m := regexp.MustCompile(`<title>([^<]+)</title>`).FindStringSubmatch(html); len(m) > 1 {
title = m[1]
}
text := htmlToText(html)
origLen := len(text)
truncated := origLen > 5000
@ -712,18 +797,71 @@ func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error)
if truncated {
result += fmt.Sprintf("\n\n...(仅显示前 5000 字符,共 %d 字符)", origLen)
}
return map[string]interface{}{"content": result, "title": title}, nil
mode := "backend-tab"
if !rendered {
mode = "local-dump-dom"
}
return map[string]interface{}{"content": result, "title": title, "mode": mode}, nil
}
// ── Interactive Browser Session (CDP) ─────────────────────
func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, error) {
timeoutStr := readArg(args, "timeout", "10m")
timeout, err := time.ParseDuration(timeoutStr)
func cdpReachable(endpoint string) bool {
client := &http.Client{Timeout: 2 * time.Second}
resp, err := client.Get(endpoint + "/json/version")
if err != nil {
timeout = 10 * time.Minute
return false
}
resp.Body.Close()
return resp.StatusCode == http.StatusOK
}
// systemdUnitActive 检查 homeagent-browser.service 是否已安装。
func systemdUnitInstalled() bool {
out, err := exec.Command("systemctl", "cat", "homeagent-browser.service").CombinedOutput()
return err == nil && len(out) > 0
}
// startSystemdUnit 尝试 systemctl start单元已安装但未运行时用
func startSystemdUnit() error {
return exec.Command("systemctl", "start", "homeagent-browser.service").Run()
}
// cdpEndpoint 是共享 Chromium 后端的 CDP 地址homeagent-browser.service
const cdpEndpoint = "http://127.0.0.1:9222"
// ensureBackend 确保共享浏览器后端可用:探测 → 拉起已装服务 → 报告未装。
// 返回 (ok, needInstall, err)。
func (p *Plugin) ensureBackend() (bool, bool, error) {
if cdpReachable(cdpEndpoint) {
return true, false, nil
}
if systemdUnitInstalled() {
if err := startSystemdUnit(); err == nil {
// 等待 CDP 就绪chromium 启动 ~1-3s
for i := 0; i < 10; i++ {
time.Sleep(500 * time.Millisecond)
if cdpReachable(cdpEndpoint) {
return true, false, nil
}
}
}
return false, false, fmt.Errorf("browser backend service installed but failed to start")
}
return false, true, nil // 未安装
}
// sharedTab 在共享后端上开一个新标签页RemoteAllocator + NewContext
func sharedTab(allocCtx context.Context) (context.Context, context.CancelFunc, error) {
tabCtx, tabCancel := chromedp.NewContext(allocCtx)
if err := chromedp.Run(tabCtx); err != nil {
tabCancel()
return nil, nil, err
}
return tabCtx, tabCancel, nil
}
// localSpawnFailback 本地拉起一次性 Chromium离线机器无法装 systemd 服务的兜底)。
// 用临时 profile登录态不跨会话保留——仅保证功能可用。
func (p *Plugin) localSpawnFailback() (context.Context, context.CancelFunc, context.CancelFunc, error) {
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.Flag("headless", true),
chromedp.Flag("disable-gpu", true),
@ -733,23 +871,83 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
if p.proxy != "" {
opts = append(opts, chromedp.Flag("proxy-server", p.proxy))
}
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), opts...)
ctx, _ := chromedp.NewContext(allocCtx)
// 立即分配浏览器和 Target确保后续 Run 的 timeout context 不会杀死浏览器进程
// chromedp 官方警告:首调用带 timeout 的 Run 会杀死整个浏览器
if err := chromedp.Run(ctx); err != nil {
cancel()
return errResult("browser init failed: " + err.Error()), nil
cancelAlloc()
return nil, nil, nil, err
}
return allocCtx, cancelAlloc, nil, nil
}
session := &BrowserSession{
allocCtx: allocCtx,
cancel: cancel,
ctx: ctx,
func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, error) {
timeoutStr := readArg(args, "timeout", "10m")
timeout, err := time.ParseDuration(timeoutStr)
if err != nil {
timeout = 10 * time.Minute
}
source := readArg(args, "source", "")
if source == "" {
source = "default"
}
// 同 source 复用已有标签页
p.mu.Lock()
for _, s := range p.sessions {
if s.shared && s.sessionKey == source && !s.closed {
s.mu.Lock()
id := s.id
cur := s.currentURL
s.mu.Unlock()
p.mu.Unlock()
return map[string]interface{}{
"id": id,
"status": "reused",
"url": cur,
"note": "已复用本来源的现有标签页(登录态全机共享)",
}, nil
}
}
p.mu.Unlock()
var session *BrowserSession
// 路径一systemd 托管的共享后端(主路径)
ok, needInstall, berr := p.ensureBackend()
if ok {
remoteCtx, remoteCancel := chromedp.NewRemoteAllocator(context.Background(), cdpEndpoint)
probe, _ := chromedp.NewContext(remoteCtx)
if err := chromedp.Run(probe); err != nil {
remoteCancel()
return errResult("connect to browser backend failed: " + err.Error()), nil
}
tabCtx, tabCancel := chromedp.NewContext(remoteCtx)
if err := chromedp.Run(tabCtx); err != nil {
remoteCancel()
return errResult("open tab failed: " + err.Error()), nil
}
session = &BrowserSession{
allocCtx: remoteCtx,
cancel: tabCancel,
ctx: tabCtx,
createdAt: time.Now(),
timeout: timeout,
shared: true,
sessionKey: source,
}
} else if needInstall {
guide := "浏览器后端未安装。请确认后调用 browser_install 工具完成安装:" +
"需要本机有 chromium 二进制apt install chromium 或等价命令)," +
"插件会注册 homeagent-browser.service 并启动。" +
"若本机无法联网安装 chromium可继续用本地临时模式重试 browser_start 即自动降级)。"
return map[string]interface{}{
"error": "backend not installed",
"need_install": true,
"guide": guide,
}, nil
} else {
return errResult("browser backend error: " + berr.Error()), nil
}
p.mu.Lock()
@ -761,7 +959,7 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
initURL := readArg(args, "url", "")
if initURL != "" {
if err := chromedp.Run(ctx,
if err := chromedp.Run(session.ctx,
chromedp.Navigate(initURL),
chromedp.WaitReady("body"),
); err != nil {
@ -772,13 +970,13 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
return errResult("navigate failed: " + err.Error()), nil
}
session.currentURL = initURL
p.sdk.InjectTextNoMemory(p.name, p.name, fmt.Sprintf("[浏览器 %s 已打开 %s]", id, initURL))
}
log.Printf("[%s] created browser session %s: url=%s timeout=%v", p.name, id, initURL, timeout)
log.Printf("[%s] created browser session %s: url=%s timeout=%v source=%s", p.name, id, initURL, timeout, source)
return map[string]interface{}{
"id": id,
"status": "created",
"mode": "shared-backend",
"url": initURL,
"timeout": timeout.String(),
}, nil
@ -1019,3 +1217,104 @@ func (p *Plugin) cleanupLoop() {
}
}
}
// ── browser_install安装 systemd 托管的共享浏览器后端 ──────────
// handleBrowserInstall 注册 homeagent-browser.service 并启动,验证 CDP 可达。
// 返回给 agent 的结果含全机共享使用指南(由 agent 转述给用户)。
func (p *Plugin) handleBrowserInstall(args map[string]interface{}) (interface{}, error) {
if cdpReachable(cdpEndpoint) {
return map[string]interface{}{"status": "already_running", "endpoint": cdpEndpoint}, nil
}
// 探测 chromium 二进制
chromePath := ""
for _, c := range []string{
"/usr/bin/chromium", "/usr/bin/chromium-browser",
"/usr/local/bin/chromium", "/usr/bin/google-chrome",
} {
if _, err := os.Stat(c); err == nil {
chromePath = c
break
}
}
if out, err := exec.LookPath("chromium"); err == nil && chromePath == "" {
chromePath = out
} else if out, err := exec.LookPath("google-chrome"); err == nil && chromePath == "" {
chromePath = out
}
if chromePath == "" {
return map[string]interface{}{
"error": "chromium binary not found",
"hint": "请先安装 chromiumapt install chromium 或等价命令,然后重试 browser_install",
}, nil
}
profileDir := ""
if p.profilesDir != "" {
profileDir = filepath.Join(p.profilesDir, "shared")
os.MkdirAll(profileDir, 0755)
} else {
// profilesDir 未注入(无 data_dir退到 /var/lib/homeagent-browser
profileDir = "/var/lib/homeagent-browser"
os.MkdirAll(profileDir, 0755)
}
unit := fmt.Sprintf(`[Unit]
Description=HomeAgent Shared Browser Backend (headless chromium, CDP :9222)
After=network.target
[Service]
Type=simple
ExecStart=%s --headless --no-sandbox --disable-gpu --disable-dev-shm-usage --remote-debugging-port=9222 --user-data-dir=%s --window-size=1280,800 about:blank
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
`, chromePath, profileDir)
unitPath := "/etc/systemd/system/homeagent-browser.service"
if err := os.WriteFile(unitPath, []byte(unit), 0644); err != nil {
return map[string]interface{}{
"error": "write unit failed (need root): " + err.Error(),
"hint": "插件进程无权限写 /etc/systemd/system 时,请让用户手动执行安装命令(见 manual_cmds",
"manual_cmds": []string{
"sudo tee /etc/systemd/system/homeagent-browser.service <<'EOF'\n" + unit + "EOF",
"sudo systemctl daemon-reload",
"sudo systemctl enable --now homeagent-browser.service",
},
}, nil
}
for _, cmd := range [][]string{
{"systemctl", "daemon-reload"},
{"systemctl", "enable", "--now", "homeagent-browser.service"},
} {
if out, err := exec.Command(cmd[0], cmd[1:]...).CombinedOutput(); err != nil {
return map[string]interface{}{
"error": fmt.Sprintf("%v: %s", cmd, string(out)),
}, nil
}
}
// 等待 CDP 就绪
for i := 0; i < 20; i++ {
time.Sleep(500 * time.Millisecond)
if cdpReachable(cdpEndpoint) {
guide := "共享浏览器后端已就绪CDP " + cdpEndpoint + ")。\n" +
"全机共享说明:本机所有 agentHomeAgent、pi、opencode、deepseekharness 等)都可连接此实例:" +
"登录一次全机可用;各 agent 各自占用独立标签页互不干扰;\n" +
"- HomeAgent 内部browser_start 即自动连接本后端\n" +
"- 其他 agent让其浏览器工具/MCP 连接 CDP 端点 " + cdpEndpoint + "(如 playwright connectOverCDP / puppeteer connect\n" +
"- 服务由 systemd 托管:崩溃自动重启,登录态持久保存在 " + profileDir
log.Printf("[%s] browser backend installed and running (chrome=%s profile=%s)", p.name, chromePath, profileDir)
return map[string]interface{}{
"status": "installed",
"endpoint": cdpEndpoint,
"chrome": chromePath,
"profile": profileDir,
"guide": guide,
}, nil
}
}
return map[string]interface{}{"error": "service started but CDP not reachable after 10s"}, nil
}

View File

@ -2,11 +2,16 @@
"name": "calendar",
"name_zh": "日历",
"name_en": "Calendar",
"version": "1.0.0",
"version": "1.1.0",
"description": "日历事件管理,支持提醒和重复事件",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": ["calendar", "event", "reminder", "schedule"],
"tags": [
"calendar",
"event",
"reminder",
"schedule"
],
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,

View File

@ -656,7 +656,7 @@ func (p *Plugin) saveEventsLocked() {
NextEventID: p.nextEventID,
}
b, _ := json.MarshalIndent(data, "", " ")
os.WriteFile(p.eventsFile(), b, 0644)
atomicWriteJSON(p.eventsFile(), b)
}
// --- Helper: parse remind_before ---
@ -1177,3 +1177,12 @@ func (p *Plugin) handleSearch(args map[string]interface{}) (interface{}, error)
}
return map[string]interface{}{"content": strings.Join(lines, "\n")}, nil
}
// atomicWriteJSON 原子写 JSON先写临时文件再 rename避免进程崩溃截断数据文件。
func atomicWriteJSON(path string, data []byte) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
return err
}
return os.Rename(tmp, path)
}

View File

@ -2,11 +2,15 @@
"name": "memo",
"name_zh": "备忘录",
"name_en": "Memo",
"version": "1.0.0",
"version": "1.1.0",
"description": "待办与备忘录插件。待办todo_add/todo_complete/todo_list会主动提醒备忘录memo_create/memo_list/memo_delete纯记事不提醒。",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": ["memo", "todo", "notes"],
"tags": [
"memo",
"todo",
"notes"
],
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,

View File

@ -224,7 +224,7 @@ func (p *Plugin) saveTodos() {
"next_id": p.nextTID,
}, "", " ")
p.mu.RUnlock()
os.WriteFile(p.todoPath, data, 0644)
atomicWriteJSON(p.todoPath, data)
}
func (p *Plugin) saveMemos() {
@ -234,7 +234,7 @@ func (p *Plugin) saveMemos() {
"next_id": p.nextMID,
}, "", " ")
p.mu.RUnlock()
os.WriteFile(p.memoPath, data, 0644)
atomicWriteJSON(p.memoPath, data)
}
// ── 待办:未完成计数与提醒 ──
@ -500,3 +500,12 @@ func (p *Plugin) cleanupData() {
os.Remove(p.memoPath)
}
}
// atomicWriteJSON 原子写 JSON先写临时文件再 rename避免进程崩溃截断数据文件。
func atomicWriteJSON(path string, data []byte) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
return err
}
return os.Rename(tmp, path)
}

View File

@ -2,11 +2,14 @@
"name": "qq",
"name_zh": "QQ消息",
"name_en": "qq",
"version": "1.0.0",
"version": "1.1.0",
"description": "QQ 消息收发插件,通过 NapCat 协议桥接",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": ["qq", "messaging"],
"tags": [
"qq",
"messaging"
],
"targets": "linux/amd64",
"outdir": "dist",
"bundle": false,

View File

@ -739,6 +739,11 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
return
}
// 群消息到达即记录(用于排查 napcat→webhook 链路漏报/丢弃)
if evt.MessageType == "group" {
log.Printf("[qq] webhook recv group msg id=%d from=%d in=%d raw=%.100s",
evt.MessageID, evt.UserID, evt.GroupID, evt.RawMessage)
}
rawCQ := evt.RawMessage
text := rawCQ
@ -763,6 +768,7 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
}
if evt.MessageType == "group" {
if !p.isGroupAllowed(evt.GroupID) {
log.Printf("[qq] group msg from %d rejected: policy=%s", evt.GroupID, p.groupPolicy)
w.WriteHeader(http.StatusOK)
return
}
@ -773,6 +779,9 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
return
}
if !p.isAtBot(evt.Message) {
// 诊断:@ 解析失败时打印 at 段原文与 botID定位漏报问题
log.Printf("[qq] group msg from %d/%d not @bot (botID=%d, raw=%.120s)",
evt.GroupID, evt.UserID, p.botID, rawCQ)
w.WriteHeader(http.StatusOK)
return
}
@ -810,6 +819,7 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
if evt.GroupID == rule.GroupID {
mcMsg := fmt.Sprintf("%s 说 %s", nickname, text)
go func(r ForwardRule, msg string) {
defer func() { _ = recover() }()
if err := rconSend(r.Host, r.Port, r.Password, "say "+msg); err != nil {
log.Printf("[qq] rcon forward to %s:%d: %v", r.Host, r.Port, err)
}
@ -980,6 +990,7 @@ func (p *Plugin) handleGetMessage(args map[string]interface{}) (interface{}, err
// 异步标记已读
go func() {
defer func() { _ = recover() }() // 后台任务不允许 panic 冒泡带崩进程
if d.MessageType == "group" && d.GroupID > 0 {
p.napcat("mark_group_msg_as_read", map[string]interface{}{"group_id": d.GroupID})
} else if d.UserID > 0 {
@ -1070,20 +1081,32 @@ func (p *Plugin) handleChannelOutput(args map[string]interface{}) (interface{},
}
return p.napcat("send_private_msg", msg)
case "image":
msg := map[string]interface{}{"message": fmt.Sprintf("[CQ:image,file=%s]", payload)}
if groupID != 0 {
msg["group_id"] = groupID
} else {
msg["user_id"] = userID
case "image", "file":
// 收敛到 output 通道payload 支持本地路径或 http(s) URL。
// 本地路径拷入 NapCat 共享目录转 file:// URI与 voice 分支同模式),
// 此后 agent 发本地文件不再需要单独的 upload_group_file 工具。
uri := payload
if !strings.HasPrefix(payload, "http://") && !strings.HasPrefix(payload, "https://") &&
!strings.HasPrefix(payload, "file://") {
if _, err := os.Stat(payload); err != nil {
return nil, fmt.Errorf("%s 文件不存在: %s", rawType, payload)
}
if groupID != 0 {
return p.napcat("send_group_msg", msg)
os.MkdirAll(p.remoteDir, 0755)
dest := filepath.Join(p.remoteDir, sanitizeFilename(filepath.Base(payload)))
data, err := os.ReadFile(payload)
if err != nil {
return nil, fmt.Errorf("读取文件失败: %w", err)
}
return p.napcat("send_private_msg", msg)
case "file":
msg := map[string]interface{}{"message": fmt.Sprintf("[CQ:file,file=%s]", payload)}
if err := os.WriteFile(dest, data, 0644); err != nil {
return nil, fmt.Errorf("写入共享目录失败: %w", err)
}
uri = "file:///app/files/" + filepath.Base(dest)
}
cqTag := "file"
if rawType == "image" {
cqTag = "image"
}
msg := map[string]interface{}{"message": fmt.Sprintf("[CQ:%s,file=%s]", cqTag, uri)}
if groupID != 0 {
msg["group_id"] = groupID
} else {
@ -1638,7 +1661,8 @@ func (p *Plugin) handleGetGroupFiles(args map[string]interface{}) (interface{},
return resp, nil
}
dlURL := parsed.Data.URL
httpResp, err := http.Get(dlURL)
client := &http.Client{Timeout: 120 * time.Second}
httpResp, err := client.Get(dlURL)
if err != nil {
return nil, fmt.Errorf("download: %w", err)
}
@ -1693,6 +1717,11 @@ func (p *Plugin) handleDownloadFile(args map[string]interface{}) (interface{}, e
task := p.addDownloadTask(fileID, filename)
go func(t *DownloadTask, fid, fname, furl string, gid, uid int64) {
defer func() {
if r := recover(); r != nil {
log.Printf("[qq] download task %s panic: %v", fid, r)
}
}()
savePath := ""
errMsg := ""
if furl != "" {

View File

@ -0,0 +1,153 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
var realCfg = "/home/newqqagent/config.db"
var realLog = "/home/newqqagent/log"
func TestDiagTriage(t *testing.T) {
p := &Plugin{name: "recoverydiag"}
cases := []struct {
name string
args map[string]interface{}
want string
}{
{"signal", map[string]interface{}{"exit_code": 0, "signal": "SIGSEGV"}, "process_death"},
{"oom", map[string]interface{}{"exit_code": 0, "signal": "SIGKILL", "crash_reason": "oom-kill"}, "process_starvation"},
{"nonzero", map[string]interface{}{"exit_code": 1}, "process_death"},
{"healthy", map[string]interface{}{"exit_code": 0}, "normal_stop"},
{"alive", map[string]interface{}{"still_alive": true, "signal": "SIGKILL"}, "config_unreachable"},
}
for _, c := range cases {
r, _ := p.handleTriage(c.args)
m, ok := r.(map[string]interface{})
if !ok {
t.Fatalf("%s: not a map", c.name)
}
if got, _ := m["class"].(string); got != c.want {
t.Errorf("%s: class = %q, want %q", c.name, got, c.want)
}
}
}
func TestDiagDB(t *testing.T) {
if _, err := os.Stat(realCfg); err != nil {
t.Skip("config.db not present, skipping")
}
p := &Plugin{name: "recoverydiag"}
r, err := p.handleDB(map[string]interface{}{"db_path": realCfg})
if err != nil {
t.Fatalf("handleDB: %v", err)
}
m := r.(map[string]interface{})
t.Logf("integrity=%v sources=%v verdict=%v summary=%v", m["integrity"], m["source_count"], m["verdict"], m["summary"])
if m["integrity"] != "ok" {
t.Errorf("integrity = %v, want ok", m["integrity"])
}
if m["source_count"] == 0 {
t.Errorf("source_count == 0, expected LLM sources")
}
if got, _ := m["source_failed"].(int); got != 0 {
t.Errorf("source_failed = %d, want 0 (all sources OK): %v", got, m["missing_fields"])
}
}
func TestDiagLogScan(t *testing.T) {
if _, err := os.Stat(realLog); err != nil {
t.Skip("log dir not present, skipping")
}
p := &Plugin{name: "recoverydiag"}
r, err := p.handleLogScan(map[string]interface{}{
"log_dir": realLog,
"since_minutes": 60 * 24 * 3,
})
if err != nil {
t.Fatalf("handleLogScan: %v", err)
}
m := r.(map[string]interface{})
t.Logf("matched=%v counts=%v dominant=%v conclusion=%v", m["lines_matched"], m["counts"], m["dominant"], m["conclusion"])
}
func TestDiagDelta(t *testing.T) {
base := t.TempDir()
cur := t.TempDir()
sub := filepath.Join(base, "sub")
os.MkdirAll(sub, 0755)
// modified: same path, different content
os.WriteFile(filepath.Join(base, "a.txt"), []byte("hello"), 0644)
os.WriteFile(filepath.Join(cur, "a.txt"), []byte("world!"), 0644)
// created
os.WriteFile(filepath.Join(cur, "b.txt"), []byte("new"), 0644)
// deleted
os.WriteFile(filepath.Join(base, "gone.txt"), []byte("bye"), 0644)
// unchanged
os.WriteFile(filepath.Join(base, "same.txt"), []byte("x"), 0644)
os.WriteFile(filepath.Join(cur, "same.txt"), []byte("x"), 0644)
p := &Plugin{name: "recoverydiag"}
r, err := p.handleDelta(map[string]interface{}{"baseline_dir": base, "current_dir": cur})
if err != nil {
t.Fatalf("handleDelta: %v", err)
}
m := r.(map[string]interface{})
sum := m["summary"].(map[string]int)
t.Logf("summary=%v total=%v", sum, m["total_diff"])
if sum["created"] != 1 || sum["deleted"] != 1 || sum["modified"] != 1 {
t.Errorf("summary = %v, want modified=1 created=1 deleted=1", sum)
}
}
func TestDiagLoc(t *testing.T) {
p := &Plugin{name: "recoverydiag"}
r, _ := p.handleLoc(map[string]interface{}{
"triage": map[string]interface{}{"class": "process_death", "verdict": "down"},
"db": map[string]interface{}{"verdict": "ok"},
"log_scan": map[string]interface{}{"dominant": "panic"},
"delta": map[string]interface{}{"summary": map[string]interface{}{"created": 0, "modified": 0, "deleted": 0}},
})
m := r.(map[string]interface{})
// 经 JSON 往返,模拟内核把子结论以 JSON 传给 diag_loc 的真实路径
raw, _ := json.Marshal(m)
var dec map[string]interface{}
json.Unmarshal(raw, &dec)
hs := dec["ranked_hypotheses"].([]interface{})
if len(hs) == 0 {
t.Fatal("no hypotheses")
}
top := hs[0].(map[string]interface{})
t.Logf("top cause=%v conf=%v rec=%v", top["cause"], top["confidence"], top["recommendation"])
if top["cause"] != "code_panic_loop" {
t.Errorf("expected code_panic_loop, got %v", top["cause"])
}
}
func TestDiagLocPersist(t *testing.T) {
kb := filepath.Join(t.TempDir(), "recovery_kb")
p := &Plugin{name: "recoverydiag", dataDir: filepath.Dir(kb)}
args := map[string]interface{}{
"persist": true,
"triage": map[string]interface{}{"class": "process_death", "verdict": "down"},
"db": map[string]interface{}{"verdict": "ok"},
"log_scan": map[string]interface{}{"dominant": "panic"},
"delta": map[string]interface{}{"summary": map[string]interface{}{"created": 0, "modified": 0, "deleted": 0}},
}
if _, err := p.handleLoc(args); err != nil {
t.Fatalf("handleLoc: %v", err)
}
entries, err := os.ReadDir(kb)
if err != nil || len(entries) == 0 {
t.Fatalf("expected persisted diag json, got err=%v entries=%v", err, entries)
}
data, _ := os.ReadFile(filepath.Join(kb, entries[0].Name()))
if !strings.Contains(string(data), `"cause"`) {
t.Errorf("persisted file missing cause field: %s", data)
}
}

View File

@ -0,0 +1,7 @@
module recoverydiag
go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

View File

@ -0,0 +1,11 @@
//go:build !windows || !cgo
package main
import (
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
return NewPluginFactory(name, config)
}

View File

@ -0,0 +1,21 @@
{
"name": "recoverydiag",
"name_zh": "恢复诊断",
"name_en": "Recovery Diagnostics",
"version": "0.2.0",
"description": "快速检查/崩溃取证工具集diag_triage退出码/信号/存活粗分、diag_dbconfig.db 完整性 + LLM 源解析校验、diag_log_scan日志签名命中、diag_deltalast-good 快照 vs 现状 diff、diag_loc正交综合定位。全部返回结论而非原文确定性、不消耗 LLM token供 guard / failback 恢复决策使用。",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": [
"diag",
"recovery",
"diagnostics",
"triage",
"failback"
],
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,
"replaces": {},
"source_dirs": []
}

File diff suppressed because it is too large Load Diff

View File

@ -2,11 +2,16 @@
"name": "rss",
"name_zh": "RSS订阅",
"name_en": "RSS",
"version": "1.0.0",
"version": "1.1.0",
"description": "RSS/Atom 订阅监控插件,自动检测更新并推送通知",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": ["rss", "feed", "subscription", "monitor"],
"tags": [
"rss",
"feed",
"subscription",
"monitor"
],
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,

View File

@ -467,7 +467,7 @@ func (p *Plugin) saveData() {
SeenGUIDs: p.seenGUIDs,
}
b, _ := json.MarshalIndent(data, "", " ")
os.WriteFile(p.dataFile(), b, 0644)
atomicWriteJSON(p.dataFile(), b)
}
// cleanupData 卸载时清理订阅数据目录feeds.json 等)
@ -486,3 +486,12 @@ func (p *Plugin) cleanupData() {
}
// atomicWriteJSON 原子写 JSON先写临时文件再 rename避免进程崩溃截断数据文件。
func atomicWriteJSON(path string, data []byte) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
return err
}
return os.Rename(tmp, path)
}

View File

@ -6,7 +6,7 @@ package meta
var (
// Version 是 HomeAgent SDK 版本号。
// 通过 `-ldflags="-X gitcode.com/JianFeeeee/homeagent-sdk/meta.Version=vX.Y.Z"` 注入。
Version = "0.9.1"
Version = "0.9.2"
// Commit 是构建时的 Git commit hash。
Commit = "unknown"
@ -21,7 +21,7 @@ var (
CoreModule = "gitcode.com/JianFeeeee/HomeAgent"
// CoreVersion 是此 SDK 所兼容的最低核心版本。
CoreVersion = "0.9.1"
CoreVersion = "0.9.2"
)
// FullVersion 返回完整的版本字符串。

View File

@ -114,6 +114,9 @@ type IOInjector interface {
// InjectInputSync 注入输入事件并同步等待 agent 回复,返回回复文本(无回复时返回空串)。
// 用于通道消息的完整闭环:收到入站 → agent 处理 → 回复取回 → 送回通道。
InjectInputSync(source, channel, text string) string
// SetToolBlocks 插件工具注入多模态内容块image_url/audio_url内核在下一条
// tool message 的 content 数组里带上这些块,让模型在后续轮次看到图/听到音频。
SetToolBlocks(blocks []ContentBlock)
}
// EventType identifies the kind of system event.
@ -460,3 +463,22 @@ func (s *PluginSDK) RunOnRemoveHandlers() {
handlers[i]()
}
}
// ContentBlock 是多模态内容块OpenAI 格式text/image_url/audio_url
// 插件工具返回结果时可用 PluginSDK.SetToolBlocks 注入,让下一轮 LLM
// 请求在 tool message 的 content 数组里带上图片/音频,实现"模型看图/听音频"。
type ContentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL *ImageURL `json:"image_url,omitempty"`
AudioURL *AudioURL `json:"audio_url,omitempty"`
}
type ImageURL struct {
URL string `json:"url"`
Detail string `json:"detail,omitempty"`
}
type AudioURL struct {
URL string `json:"url"`
}

View File

@ -19,6 +19,11 @@ type SettingsAPI interface {
// ListCore lists core config keys matching the prefix.
ListCore(prefix string) ([]string, error)
// DataDir returns the plugin-specific data directory (guaranteed to exist):
// <daemon data>/plugin_data/<plugin_name>. Plugins should persist any
// runtime files (generated images, caches, downloads) here.
DataDir() string
// GetPlugin reads another plugin's config table.
GetPlugin(plugin, key string) (interface{}, error)

View File

@ -647,6 +647,9 @@ func (d *dispatchSettings) Dump() map[string]interface{} {
func (d *dispatchSettings) Plugins() []string {
r, e := callString(45, "", "", "", 0, 0); if e != nil || r == "" { return nil }; var v []string; json.Unmarshal([]byte(r), &v); return v
}
func (d *dispatchSettings) DataDir() string {
r, e := callString(51, "", "", "", 0, 0); if e != nil { return "" }; return r
}
// ---- Go callbacks (called from z_entry.c via C) ----