mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-10-03 23:54:12 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c1574be25 | |||
| 68497b4092 | |||
| 130f805b6e | |||
| cd1984e26e | |||
| 6184736fd4 | |||
| 81bfdfce1d | |||
| e3f93e254b | |||
| d57c5eaf3e |
1
.gitignore
vendored
1
.gitignore
vendored
@ -30,4 +30,3 @@ z_entry.c
|
|||||||
|
|
||||||
# plugindev binary in tools/
|
# plugindev binary in tools/
|
||||||
tools/plugindev/plugindev
|
tools/plugindev/plugindev
|
||||||
example/recoverydiag/
|
|
||||||
|
|||||||
@ -2,14 +2,18 @@
|
|||||||
"name": "a2a",
|
"name": "a2a",
|
||||||
"name_zh": "A2A 代理通信",
|
"name_zh": "A2A 代理通信",
|
||||||
"name_en": "A2A Agent Communication",
|
"name_en": "A2A Agent Communication",
|
||||||
"version": "1.0.0",
|
"version": "1.3.0",
|
||||||
"description": "Agent-to-Agent 协议通信插件,支持双向 A2A 通信:可查询其他 Agent 并回复其请求。提供 HTTP 服务端暴露本 Agent 能力。",
|
"description": "Agent-to-Agent 协议通信插件,支持双向 A2A 通信:可查询其他 Agent 并回复其请求。提供 HTTP 服务端暴露本 Agent 能力。",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
"tags": ["a2a", "agent", "interop"],
|
"tags": [
|
||||||
|
"a2a",
|
||||||
|
"agent",
|
||||||
|
"interop"
|
||||||
|
],
|
||||||
"targets": "linux/amd64",
|
"targets": "linux/amd64",
|
||||||
"outdir": "dist",
|
"outdir": "dist",
|
||||||
"bundle": true,
|
"bundle": true,
|
||||||
"replaces": {},
|
"replaces": {},
|
||||||
"source_dirs": []
|
"source_dirs": []
|
||||||
}
|
}
|
||||||
@ -21,15 +21,47 @@ type Plugin struct {
|
|||||||
srvMu sync.Mutex
|
srvMu sync.Mutex
|
||||||
server *http.Server
|
server *http.Server
|
||||||
serverAddr string
|
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) Name() string { return p.name }
|
||||||
|
|
||||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||||
s.SetAutoRestart(true)
|
s.SetAutoRestart(true)
|
||||||
p.sdk = s
|
p.sdk = s
|
||||||
|
p.sessions = make(map[string]*a2aSession)
|
||||||
tp := p.name + "_"
|
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{
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
Key: "listen", Default: "127.0.0.1:12000",
|
Key: "listen", Default: "127.0.0.1:12000",
|
||||||
Type: "string", DisplayName: "监听地址",
|
Type: "string", DisplayName: "监听地址",
|
||||||
@ -45,6 +77,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
"agent_url": map[string]interface{}{"type": "string", "description": "目标 Agent 的 A2A 端点 URL"},
|
"agent_url": map[string]interface{}{"type": "string", "description": "目标 Agent 的 A2A 端点 URL"},
|
||||||
"query": map[string]interface{}{"type": "string", "description": "发送给目标 Agent 的文本查询"},
|
"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"},
|
"timeout": map[string]interface{}{"type": "integer", "description": "超时时间(秒),默认 60"},
|
||||||
},
|
},
|
||||||
"required": []string{"agent_url", "query"},
|
"required": []string{"agent_url", "query"},
|
||||||
@ -114,6 +147,66 @@ func (p *Plugin) Stop() error {
|
|||||||
return nil
|
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() {
|
func (p *Plugin) stopServer() {
|
||||||
p.srvMu.Lock()
|
p.srvMu.Lock()
|
||||||
defer p.srvMu.Unlock()
|
defer p.srvMu.Unlock()
|
||||||
@ -137,7 +230,12 @@ func (p *Plugin) startServer(addr string) error {
|
|||||||
return fmt.Errorf("listen %s: %v", addr, err)
|
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()
|
addrStr := listener.Addr().String()
|
||||||
|
|
||||||
p.srvMu.Lock()
|
p.srvMu.Lock()
|
||||||
@ -186,7 +284,9 @@ func (p *Plugin) handleIncomingA2A(w http.ResponseWriter, r *http.Request) {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Method string `json:"method"`
|
Method string `json:"method"`
|
||||||
Params struct {
|
Params struct {
|
||||||
Query string `json:"query,omitempty"`
|
Query string `json:"query,omitempty"`
|
||||||
|
SessionID string `json:"session_id,omitempty"`
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
Message *struct {
|
Message *struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Parts []struct {
|
Parts []struct {
|
||||||
@ -210,29 +310,96 @@ func (p *Plugin) handleIncomingA2A(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
queryText = strings.TrimSpace(queryText)
|
queryText = strings.TrimSpace(queryText)
|
||||||
}
|
}
|
||||||
|
if queryText == "" {
|
||||||
// Inject into agent pipeline via interrupt (preempt current processing) or direct input
|
http.Error(w, "query/message.text required", http.StatusBadRequest)
|
||||||
if queryText != "" {
|
return
|
||||||
p.sdk.InjectInterruptText("a2a", "webui", fmt.Sprintf("[来自A2A Agent的查询]\n%s", queryText))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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{}{
|
resp := map[string]interface{}{
|
||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
"id": req.ID,
|
"id": req.ID,
|
||||||
"result": map[string]interface{}{
|
"result": map[string]interface{}{
|
||||||
"id": fmt.Sprintf("task_%d", time.Now().UnixNano()),
|
"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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(resp)
|
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{}{
|
||||||
|
"session_id": sessionID,
|
||||||
|
"status": "not_found",
|
||||||
|
"messages": []interface{}{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
"jsonrpc": "2.0", "id": req.ID,
|
"jsonrpc": "2.0", "id": req.ID,
|
||||||
"result": map[string]interface{}{"id": req.Params.Query, "status": "unknown"},
|
"result": map[string]interface{}{
|
||||||
|
"session_id": sessionID,
|
||||||
|
"status": "completed",
|
||||||
|
"messages": msgs,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@ -276,9 +443,10 @@ type A2ARequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type A2AParams struct {
|
type A2AParams struct {
|
||||||
Query string `json:"query,omitempty"`
|
Query string `json:"query,omitempty"`
|
||||||
Message *A2AMessage `json:"message,omitempty"`
|
SessionID string `json:"session_id,omitempty"`
|
||||||
TaskID string `json:"id,omitempty"`
|
Message *A2AMessage `json:"message,omitempty"`
|
||||||
|
TaskID string `json:"id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type A2AResponse struct {
|
type A2AResponse struct {
|
||||||
@ -291,6 +459,7 @@ type A2AResponse struct {
|
|||||||
type A2AResult struct {
|
type A2AResult struct {
|
||||||
TaskID string `json:"id,omitempty"`
|
TaskID string `json:"id,omitempty"`
|
||||||
Status string `json:"status,omitempty"`
|
Status string `json:"status,omitempty"`
|
||||||
|
SessionID string `json:"session_id,omitempty"`
|
||||||
Message *A2AMessage `json:"message,omitempty"`
|
Message *A2AMessage `json:"message,omitempty"`
|
||||||
AgentCard *A2AAgentCard `json:"agent_card,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) {
|
func (p *Plugin) handleA2AQuery(args map[string]interface{}) (interface{}, error) {
|
||||||
agentURL, _ := args["agent_url"].(string)
|
agentURL, _ := args["agent_url"].(string)
|
||||||
query, _ := args["query"].(string)
|
query, _ := args["query"].(string)
|
||||||
|
sessionID, _ := args["session_id"].(string) // 可选:延续对方会话
|
||||||
timeoutSec := 60
|
timeoutSec := 60
|
||||||
if v, ok := args["timeout"].(float64); ok && v > 0 {
|
if v, ok := args["timeout"].(float64); ok && v > 0 {
|
||||||
timeoutSec = int(v)
|
timeoutSec = int(v)
|
||||||
@ -377,7 +547,8 @@ func (p *Plugin) handleA2AQuery(args map[string]interface{}) (interface{}, error
|
|||||||
ID: fmt.Sprintf("a2a_%d", time.Now().UnixNano()),
|
ID: fmt.Sprintf("a2a_%d", time.Now().UnixNano()),
|
||||||
Method: "tasks.send",
|
Method: "tasks.send",
|
||||||
Params: A2AParams{
|
Params: A2AParams{
|
||||||
Message: &A2AMessage{Role: "user", Parts: []A2APart{{Text: query, Type: "text"}}},
|
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)
|
replyText = strings.TrimSpace(replyText)
|
||||||
}
|
}
|
||||||
|
|
||||||
return map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
"task_id": a2aResp.Result.TaskID, "status": a2aResp.Result.Status,
|
"task_id": a2aResp.Result.TaskID, "status": a2aResp.Result.Status,
|
||||||
"response": replyText,
|
"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 ----
|
// ---- Management Handlers ----
|
||||||
|
|||||||
@ -2,11 +2,15 @@
|
|||||||
"name": "acp",
|
"name": "acp",
|
||||||
"name_zh": "ACP 代理通信",
|
"name_zh": "ACP 代理通信",
|
||||||
"name_en": "ACP Agent Client Protocol",
|
"name_en": "ACP Agent Client Protocol",
|
||||||
"version": "1.0.0",
|
"version": "1.2.0",
|
||||||
"description": "Agent Client Protocol 通信插件:充当 ACP 服务端接受其他 Agent 的任务请求,同时提供客户端工具向远程 ACP Agent(如 opencode)发起会话并读取回复",
|
"description": "Agent Client Protocol 通信插件:充当 ACP 服务端接受其他 Agent 的任务请求,同时提供客户端工具向远程 ACP Agent(如 opencode)发起会话并读取回复",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
"tags": ["acp", "agent", "interop"],
|
"tags": [
|
||||||
|
"acp",
|
||||||
|
"agent",
|
||||||
|
"interop"
|
||||||
|
],
|
||||||
"targets": "linux/amd64",
|
"targets": "linux/amd64",
|
||||||
"outdir": "dist",
|
"outdir": "dist",
|
||||||
"bundle": true,
|
"bundle": true,
|
||||||
|
|||||||
@ -34,8 +34,13 @@ type Plugin struct {
|
|||||||
type sessionState struct {
|
type sessionState struct {
|
||||||
ID string
|
ID string
|
||||||
Replying []map[string]interface{}
|
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) Name() string { return p.name }
|
||||||
|
|
||||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
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)
|
p.sessions = make(map[string]*sessionState)
|
||||||
tp := p.name + "_"
|
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{
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
Key: "listen", Default: "127.0.0.1:12001",
|
Key: "listen", Default: "127.0.0.1:12001",
|
||||||
Type: "string", DisplayName: "监听地址",
|
Type: "string", DisplayName: "监听地址",
|
||||||
@ -58,6 +71,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
"server_url": map[string]interface{}{"type": "string", "description": "目标 ACP 服务端地址(如 http://127.0.0.1:13000)"},
|
"server_url": map[string]interface{}{"type": "string", "description": "目标 ACP 服务端地址(如 http://127.0.0.1:13000)"},
|
||||||
"prompt": map[string]interface{}{"type": "string", "description": "发送给目标 Agent 的任务描述"},
|
"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"},
|
"timeout": map[string]interface{}{"type": "integer", "description": "等待回复超时(秒),默认 120"},
|
||||||
},
|
},
|
||||||
"required": []string{"server_url", "prompt"},
|
"required": []string{"server_url", "prompt"},
|
||||||
@ -171,6 +185,7 @@ func (p *Plugin) handleSessionPost(w http.ResponseWriter, r *http.Request) {
|
|||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
} `json:"request,omitempty"`
|
} `json:"request,omitempty"`
|
||||||
SessionID string `json:"session_id,omitempty"`
|
SessionID string `json:"session_id,omitempty"`
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
Final bool `json:"final,omitempty"`
|
Final bool `json:"final,omitempty"`
|
||||||
} `json:"params,omitempty"`
|
} `json:"params,omitempty"`
|
||||||
}
|
}
|
||||||
@ -190,21 +205,109 @@ func (p *Plugin) handleSessionPost(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sid := fmt.Sprintf("session_%d", time.Now().UnixNano())
|
// 会话:调用方可指定 session_id 延续多轮;不指定则新建。
|
||||||
|
sid := strings.TrimSpace(req.Params.SessionID)
|
||||||
p.mu.Lock()
|
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()
|
p.mu.Unlock()
|
||||||
|
|
||||||
if p.sdk != nil {
|
// 延续上下文
|
||||||
p.sdk.InjectInterruptText(p.name, "acp",
|
injectText := text
|
||||||
fmt.Sprintf("[来自ACP Agent的请求请求 session %s]\n%s", sid, 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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
"jsonrpc": "2.0", "id": req.ID,
|
"jsonrpc": "2.0", "id": req.ID,
|
||||||
"result": map[string]interface{}{
|
"result": map[string]interface{}{
|
||||||
"session": map[string]interface{}{"id": sid},
|
"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
|
sid := req.Params.SessionID
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
st := p.sessions[sid]
|
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()
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
@ -338,6 +446,7 @@ func (p *Plugin) handleAcpQuery(args map[string]interface{}) (interface{}, error
|
|||||||
if prompt == "" {
|
if prompt == "" {
|
||||||
return map[string]interface{}{"error": "prompt 不能为空"}, nil
|
return map[string]interface{}{"error": "prompt 不能为空"}, nil
|
||||||
}
|
}
|
||||||
|
sessionID, _ := args["session_id"].(string) // 可选:延续对方会话
|
||||||
timeoutSec := 120
|
timeoutSec := 120
|
||||||
if v, ok := args["timeout"].(float64); ok && v > 0 {
|
if v, ok := args["timeout"].(float64); ok && v > 0 {
|
||||||
timeoutSec = int(v)
|
timeoutSec = int(v)
|
||||||
@ -346,12 +455,16 @@ func (p *Plugin) handleAcpQuery(args map[string]interface{}) (interface{}, error
|
|||||||
endpoint := serverURL + "/api/session"
|
endpoint := serverURL + "/api/session"
|
||||||
client := &http.Client{Timeout: time.Duration(timeoutSec) * time.Second}
|
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{}{
|
newBody, _ := json.Marshal(map[string]interface{}{
|
||||||
"jsonrpc": "2.0", "id": "acp-" + fmt.Sprintf("%d", time.Now().UnixNano()),
|
"jsonrpc": "2.0", "id": "acp-" + fmt.Sprintf("%d", time.Now().UnixNano()),
|
||||||
"method": "session/new",
|
"method": "session/new",
|
||||||
"params": map[string]interface{}{
|
"params": params,
|
||||||
"request": map[string]interface{}{"text": prompt},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
req, _ := http.NewRequest("POST", endpoint, bytes.NewReader(newBody))
|
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,
|
"session_id": sid,
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"reply": replyText,
|
"reply": replyText,
|
||||||
|
"note": "延续会话:下次调用传此 session_id 可保持上下文",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,14 +2,19 @@
|
|||||||
"name": "ai_image",
|
"name": "ai_image",
|
||||||
"name_zh": "AI绘图",
|
"name_zh": "AI绘图",
|
||||||
"name_en": "AI Image",
|
"name_en": "AI Image",
|
||||||
"version": "1.0.0",
|
"version": "1.3.0",
|
||||||
"description": "AI 图像生成插件,支持 OpenAI DALL·E / Stable Diffusion",
|
"description": "AI 图像生成插件,支持 OpenAI DALL·E / Stable Diffusion",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
"tags": ["ai", "image", "draw", "generate"],
|
"tags": [
|
||||||
|
"ai",
|
||||||
|
"image",
|
||||||
|
"draw",
|
||||||
|
"generate"
|
||||||
|
],
|
||||||
"targets": "linux/amd64",
|
"targets": "linux/amd64",
|
||||||
"outdir": "dist",
|
"outdir": "dist",
|
||||||
"bundle": true,
|
"bundle": true,
|
||||||
"replaces": {},
|
"replaces": {},
|
||||||
"source_dirs": []
|
"source_dirs": []
|
||||||
}
|
}
|
||||||
@ -5,7 +5,10 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -21,6 +24,8 @@ type Plugin struct {
|
|||||||
provider string
|
provider string
|
||||||
model string
|
model string
|
||||||
size string
|
size string
|
||||||
|
baseURL string
|
||||||
|
dataDir string // <data>/ai_images:生成本地图片存放目录
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||||
@ -111,10 +116,15 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
DisplayName: "API Key", Description: "OpenAI / Stable Diffusion API Key",
|
DisplayName: "API Key", Description: "OpenAI / Stable Diffusion API Key",
|
||||||
Category: "ai_image", Secret: true,
|
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{
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
Key: "provider", Default: "openai", Type: "string",
|
Key: "provider", Default: "openai", Type: "string",
|
||||||
DisplayName: "Provider", Description: "Image generation provider: openai / stability",
|
DisplayName: "Provider", Description: "Image generation provider: openai / stability",
|
||||||
Category: "ai_image",
|
Category: "ai_image",
|
||||||
})
|
})
|
||||||
s.Settings().RegisterDef(sdk.ConfigDef{
|
s.Settings().RegisterDef(sdk.ConfigDef{
|
||||||
Key: "model", Default: "dall-e-3", Type: "string",
|
Key: "model", Default: "dall-e-3", Type: "string",
|
||||||
@ -131,10 +141,23 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
p.provider = getSetting(s.Settings(), "provider", "openai")
|
p.provider = getSetting(s.Settings(), "provider", "openai")
|
||||||
p.model = getSetting(s.Settings(), "model", "dall-e-3")
|
p.model = getSetting(s.Settings(), "model", "dall-e-3")
|
||||||
p.size = getSetting(s.Settings(), "size", "1024x1024")
|
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 + "_"
|
tp := p.name + "_"
|
||||||
s.RegisterTool(tp+"generate", sdk.ToolDef{
|
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{}{
|
Parameters: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{
|
"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) {
|
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{
|
body := openAIReq{
|
||||||
Model: model,
|
Model: model,
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
@ -217,8 +248,9 @@ func (p *Plugin) generateOpenAI(prompt, model, size string, n int, apiKey string
|
|||||||
ResponseFormat: "url",
|
ResponseFormat: "url",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("[ai_image] endpoint=%s baseURL=%q model=%q", endpoint, p.baseURL, model)
|
||||||
b, _ := json.Marshal(body)
|
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("Content-Type", "application/json")
|
||||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
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
|
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{}{
|
return map[string]interface{}{
|
||||||
"content": fmt.Sprintf("Generated %d image(s) with model %s:\n%s", len(urls), model, strings.Join(urls, "\n")),
|
"content": content,
|
||||||
"images": urls,
|
"images": localPaths,
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"model": model,
|
"model": model,
|
||||||
|
"local_paths": localPaths,
|
||||||
}, nil
|
}, 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 {
|
type stabilityReq struct {
|
||||||
TextPrompts []stabilityPrompt `json:"text_prompts"`
|
TextPrompts []stabilityPrompt `json:"text_prompts"`
|
||||||
Width int `json:"width"`
|
Width int `json:"width"`
|
||||||
@ -334,7 +426,7 @@ func (p *Plugin) generateStability(prompt, model, size string, n int, apiKey str
|
|||||||
}
|
}
|
||||||
|
|
||||||
return map[string]interface{}{
|
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,
|
"images": urls,
|
||||||
"prompt": prompt,
|
"prompt": prompt,
|
||||||
"model": model,
|
"model": model,
|
||||||
|
|||||||
@ -2,14 +2,18 @@
|
|||||||
"name": "bili",
|
"name": "bili",
|
||||||
"name_zh": "B站视频下载",
|
"name_zh": "B站视频下载",
|
||||||
"name_en": "Bilibili Video Downloader",
|
"name_en": "Bilibili Video Downloader",
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"description": "B站视频下载工具,基于 yt-dlp 引擎。支持查看视频清晰度列表、指定格式下载、可配置下载目录。",
|
"description": "B站视频下载工具,基于 yt-dlp 引擎。支持查看视频清晰度列表、指定格式下载、可配置下载目录。",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
"tags": ["bili", "video", "download"],
|
"tags": [
|
||||||
|
"bili",
|
||||||
|
"video",
|
||||||
|
"download"
|
||||||
|
],
|
||||||
"targets": "linux/amd64",
|
"targets": "linux/amd64",
|
||||||
"outdir": "dist",
|
"outdir": "dist",
|
||||||
"bundle": true,
|
"bundle": true,
|
||||||
"replaces": {},
|
"replaces": {},
|
||||||
"source_dirs": []
|
"source_dirs": []
|
||||||
}
|
}
|
||||||
@ -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)
|
os.MkdirAll(outputDir, 0755)
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
|
|||||||
@ -2,14 +2,20 @@
|
|||||||
"name": "browser",
|
"name": "browser",
|
||||||
"name_zh": "浏览器",
|
"name_zh": "浏览器",
|
||||||
"name_en": "Browser",
|
"name_en": "Browser",
|
||||||
"version": "2.0.0",
|
"version": "2.3.0",
|
||||||
"description": "统一浏览器插件:搜索、HTTP抓取(quick)、无头渲染(normal)、交互式浏览器(interactive/CDP)",
|
"description": "统一浏览器插件:搜索、HTTP抓取(quick)、无头渲染(normal)、交互式浏览器(interactive/CDP)",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
"tags": ["web", "search", "fetch", "browser", "cdp"],
|
"tags": [
|
||||||
|
"web",
|
||||||
|
"search",
|
||||||
|
"fetch",
|
||||||
|
"browser",
|
||||||
|
"cdp"
|
||||||
|
],
|
||||||
"targets": "linux/amd64",
|
"targets": "linux/amd64",
|
||||||
"outdir": "dist",
|
"outdir": "dist",
|
||||||
"bundle": true,
|
"bundle": true,
|
||||||
"replaces": {},
|
"replaces": {},
|
||||||
"source_dirs": []
|
"source_dirs": []
|
||||||
}
|
}
|
||||||
@ -13,6 +13,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@ -33,23 +34,49 @@ type Plugin struct {
|
|||||||
proxy string
|
proxy string
|
||||||
client *http.Client
|
client *http.Client
|
||||||
|
|
||||||
sessions map[string]*BrowserSession
|
sessions map[string]*BrowserSession
|
||||||
nextID int
|
nextID int
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
stopCh chan struct{}
|
stopCh chan struct{}
|
||||||
stopOnce sync.Once
|
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 {
|
type BrowserSession struct {
|
||||||
id string
|
id string
|
||||||
allocCtx context.Context
|
allocCtx context.Context // 共享浏览器进程上下文(shared=true 时指向全局单例)
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
ctx context.Context
|
ctx context.Context // 本会话的 Target 上下文(一个标签页)
|
||||||
createdAt time.Time
|
createdAt time.Time
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
closed bool
|
closed bool
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
currentURL string
|
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) {
|
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.proxy = readCfg(s.Settings(), "proxy", "")
|
||||||
p.client = newHTTPClient(p.timeout, p.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 + "_"
|
tp := p.name + "_"
|
||||||
|
|
||||||
cleaner := func(output string) string {
|
cleaner := func(output string) string {
|
||||||
@ -242,12 +276,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
|
|
||||||
s.RegisterTool(tp+"start", sdk.ToolDef{
|
s.RegisterTool(tp+"start", sdk.ToolDef{
|
||||||
Name: tp + "start",
|
Name: tp + "start",
|
||||||
Description: "启动交互式浏览器会话(interactive 模式)。通过 CDP 连接 Chromium,支持导航、截图、点击、输入等操作。返回会话 ID。",
|
Description: "启动交互式浏览器会话。优先连接 systemd 托管的共享浏览器后端(登录态全机共享、各 agent 独立标签页);后端未安装时返回 need_install 引导(调 browser_install);无法安装时自动降级本地临时模式。同来源复用已有标签页。",
|
||||||
Parameters: map[string]interface{}{
|
Parameters: map[string]interface{}{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]interface{}{
|
"properties": map[string]interface{}{
|
||||||
"url": map[string]interface{}{"type": "string", "description": "初始导航 URL(可选)"},
|
"url": map[string]interface{}{"type": "string", "description": "初始导航 URL(可选)"},
|
||||||
"timeout": map[string]interface{}{"type": "string", "description": "会话超时(如 5m, 10m,默认 10m)"},
|
"timeout": map[string]interface{}{"type": "string", "description": "会话超时(如 5m, 10m,默认 10m)"},
|
||||||
|
"profile": map[string]interface{}{"type": "string", "description": "持久化档案名(可选,如 main)。同名档案共享登录态与浏览历史;不指定则为一次性临时会话"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, p.handleBrowserStart)
|
}, p.handleBrowserStart)
|
||||||
@ -337,6 +372,15 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
},
|
},
|
||||||
}, p.handleScroll)
|
}, p.handleScroll)
|
||||||
|
|
||||||
|
s.RegisterTool(tp+"install", sdk.ToolDef{
|
||||||
|
Name: tp + "install",
|
||||||
|
Description: "安装并启动共享浏览器后端(homeagent-browser.service,systemd 托管)。前提:本机已有 chromium 二进制(无则先提示用户安装:apt install chromium 或等价命令)。安装后所有 agent 共享同一浏览器实例与登录态。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
}, p.handleBrowserInstall)
|
||||||
|
|
||||||
s.RegisterTool(tp+"close", sdk.ToolDef{
|
s.RegisterTool(tp+"close", sdk.ToolDef{
|
||||||
Name: tp + "close",
|
Name: tp + "close",
|
||||||
Description: "关闭交互式浏览器会话,释放资源。",
|
Description: "关闭交互式浏览器会话,释放资源。",
|
||||||
@ -663,6 +707,9 @@ func (p *Plugin) fetchWithChromium(rawURL string, maxChars int) (interface{}, er
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleRender 无头渲染 JS 页面并提取文本(normal 模式)。
|
||||||
|
// 主路径走共享浏览器后端:开临时标签页(带全机登录态)→ 渲染 → 取 text → 关标签页;
|
||||||
|
// 后端不可用时 failback 到独立 chromium --dump-dom(无登录态,仅保功能)。
|
||||||
func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error) {
|
func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error) {
|
||||||
rawURL := readArg(args, "url", "")
|
rawURL := readArg(args, "url", "")
|
||||||
if rawURL == "" {
|
if rawURL == "" {
|
||||||
@ -672,32 +719,70 @@ func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error)
|
|||||||
return errResult(err.Error()), nil
|
return errResult(err.Error()), nil
|
||||||
}
|
}
|
||||||
waitSec := int64(readArg(args, "wait", float64(0)))
|
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"),
|
||||||
|
}
|
||||||
|
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
|
||||||
}
|
}
|
||||||
var html string
|
|
||||||
chromiumPath := "/usr/local/bin/chromium"
|
if !rendered {
|
||||||
if _, err := os.Stat(chromiumPath); err == nil {
|
chromiumPath := "/usr/local/bin/chromium"
|
||||||
|
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
|
var out bytes.Buffer
|
||||||
cmd := exec.Command(chromiumPath, "--headless", "--disable-gpu", "--no-sandbox", "--dump-dom", rawURL)
|
cmd := exec.Command(chromiumPath, "--headless", "--disable-gpu", "--no-sandbox", "--dump-dom", rawURL)
|
||||||
cmd.Stdout = &out
|
cmd.Stdout = &out
|
||||||
if err := cmd.Run(); err != nil {
|
done := make(chan error, 1)
|
||||||
return errResult("chromium: " + err.Error()), nil
|
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()
|
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)
|
text := htmlToText(html)
|
||||||
origLen := len(text)
|
origLen := len(text)
|
||||||
truncated := origLen > 5000
|
truncated := origLen > 5000
|
||||||
@ -712,18 +797,71 @@ func (p *Plugin) handleRender(args map[string]interface{}) (interface{}, error)
|
|||||||
if truncated {
|
if truncated {
|
||||||
result += fmt.Sprintf("\n\n...(仅显示前 5000 字符,共 %d 字符)", origLen)
|
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 cdpReachable(endpoint string) bool {
|
||||||
|
client := &http.Client{Timeout: 2 * time.Second}
|
||||||
func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, error) {
|
resp, err := client.Get(endpoint + "/json/version")
|
||||||
timeoutStr := readArg(args, "timeout", "10m")
|
|
||||||
timeout, err := time.ParseDuration(timeoutStr)
|
|
||||||
if err != nil {
|
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[:],
|
opts := append(chromedp.DefaultExecAllocatorOptions[:],
|
||||||
chromedp.Flag("headless", true),
|
chromedp.Flag("headless", true),
|
||||||
chromedp.Flag("disable-gpu", true),
|
chromedp.Flag("disable-gpu", true),
|
||||||
@ -733,23 +871,83 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
|
|||||||
if p.proxy != "" {
|
if p.proxy != "" {
|
||||||
opts = append(opts, chromedp.Flag("proxy-server", p.proxy))
|
opts = append(opts, chromedp.Flag("proxy-server", p.proxy))
|
||||||
}
|
}
|
||||||
|
allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), opts...)
|
||||||
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
|
|
||||||
ctx, _ := chromedp.NewContext(allocCtx)
|
ctx, _ := chromedp.NewContext(allocCtx)
|
||||||
|
|
||||||
// 立即分配浏览器和 Target,确保后续 Run 的 timeout context 不会杀死浏览器进程
|
|
||||||
// chromedp 官方警告:首调用带 timeout 的 Run 会杀死整个浏览器
|
|
||||||
if err := chromedp.Run(ctx); err != nil {
|
if err := chromedp.Run(ctx); err != nil {
|
||||||
cancel()
|
cancelAlloc()
|
||||||
return errResult("browser init failed: " + err.Error()), nil
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
return allocCtx, cancelAlloc, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
session := &BrowserSession{
|
source := readArg(args, "source", "")
|
||||||
allocCtx: allocCtx,
|
if source == "" {
|
||||||
cancel: cancel,
|
source = "default"
|
||||||
ctx: ctx,
|
}
|
||||||
createdAt: time.Now(),
|
|
||||||
timeout: timeout,
|
// 同 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()
|
p.mu.Lock()
|
||||||
@ -761,7 +959,7 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
|
|||||||
|
|
||||||
initURL := readArg(args, "url", "")
|
initURL := readArg(args, "url", "")
|
||||||
if initURL != "" {
|
if initURL != "" {
|
||||||
if err := chromedp.Run(ctx,
|
if err := chromedp.Run(session.ctx,
|
||||||
chromedp.Navigate(initURL),
|
chromedp.Navigate(initURL),
|
||||||
chromedp.WaitReady("body"),
|
chromedp.WaitReady("body"),
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@ -772,13 +970,13 @@ func (p *Plugin) handleBrowserStart(args map[string]interface{}) (interface{}, e
|
|||||||
return errResult("navigate failed: " + err.Error()), nil
|
return errResult("navigate failed: " + err.Error()), nil
|
||||||
}
|
}
|
||||||
session.currentURL = initURL
|
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{}{
|
return map[string]interface{}{
|
||||||
"id": id,
|
"id": id,
|
||||||
"status": "created",
|
"status": "created",
|
||||||
|
"mode": "shared-backend",
|
||||||
"url": initURL,
|
"url": initURL,
|
||||||
"timeout": timeout.String(),
|
"timeout": timeout.String(),
|
||||||
}, nil
|
}, 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": "请先安装 chromium:apt 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" +
|
||||||
|
"全机共享说明:本机所有 agent(HomeAgent、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
|
||||||
|
}
|
||||||
|
|||||||
@ -2,14 +2,19 @@
|
|||||||
"name": "calendar",
|
"name": "calendar",
|
||||||
"name_zh": "日历",
|
"name_zh": "日历",
|
||||||
"name_en": "Calendar",
|
"name_en": "Calendar",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "日历事件管理,支持提醒和重复事件",
|
"description": "日历事件管理,支持提醒和重复事件",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
"tags": ["calendar", "event", "reminder", "schedule"],
|
"tags": [
|
||||||
|
"calendar",
|
||||||
|
"event",
|
||||||
|
"reminder",
|
||||||
|
"schedule"
|
||||||
|
],
|
||||||
"targets": "linux/amd64",
|
"targets": "linux/amd64",
|
||||||
"outdir": "dist",
|
"outdir": "dist",
|
||||||
"bundle": true,
|
"bundle": true,
|
||||||
"replaces": {},
|
"replaces": {},
|
||||||
"source_dirs": []
|
"source_dirs": []
|
||||||
}
|
}
|
||||||
@ -656,7 +656,7 @@ func (p *Plugin) saveEventsLocked() {
|
|||||||
NextEventID: p.nextEventID,
|
NextEventID: p.nextEventID,
|
||||||
}
|
}
|
||||||
b, _ := json.MarshalIndent(data, "", " ")
|
b, _ := json.MarshalIndent(data, "", " ")
|
||||||
os.WriteFile(p.eventsFile(), b, 0644)
|
atomicWriteJSON(p.eventsFile(), b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Helper: parse remind_before ---
|
// --- 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
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@ -2,14 +2,18 @@
|
|||||||
"name": "memo",
|
"name": "memo",
|
||||||
"name_zh": "备忘录",
|
"name_zh": "备忘录",
|
||||||
"name_en": "Memo",
|
"name_en": "Memo",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "待办与备忘录插件。待办(todo_add/todo_complete/todo_list)会主动提醒;备忘录(memo_create/memo_list/memo_delete)纯记事不提醒。",
|
"description": "待办与备忘录插件。待办(todo_add/todo_complete/todo_list)会主动提醒;备忘录(memo_create/memo_list/memo_delete)纯记事不提醒。",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
"tags": ["memo", "todo", "notes"],
|
"tags": [
|
||||||
|
"memo",
|
||||||
|
"todo",
|
||||||
|
"notes"
|
||||||
|
],
|
||||||
"targets": "linux/amd64",
|
"targets": "linux/amd64",
|
||||||
"outdir": "dist",
|
"outdir": "dist",
|
||||||
"bundle": true,
|
"bundle": true,
|
||||||
"replaces": {},
|
"replaces": {},
|
||||||
"source_dirs": []
|
"source_dirs": []
|
||||||
}
|
}
|
||||||
@ -224,7 +224,7 @@ func (p *Plugin) saveTodos() {
|
|||||||
"next_id": p.nextTID,
|
"next_id": p.nextTID,
|
||||||
}, "", " ")
|
}, "", " ")
|
||||||
p.mu.RUnlock()
|
p.mu.RUnlock()
|
||||||
os.WriteFile(p.todoPath, data, 0644)
|
atomicWriteJSON(p.todoPath, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Plugin) saveMemos() {
|
func (p *Plugin) saveMemos() {
|
||||||
@ -234,7 +234,7 @@ func (p *Plugin) saveMemos() {
|
|||||||
"next_id": p.nextMID,
|
"next_id": p.nextMID,
|
||||||
}, "", " ")
|
}, "", " ")
|
||||||
p.mu.RUnlock()
|
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)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@ -2,14 +2,17 @@
|
|||||||
"name": "qq",
|
"name": "qq",
|
||||||
"name_zh": "QQ消息",
|
"name_zh": "QQ消息",
|
||||||
"name_en": "qq",
|
"name_en": "qq",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "QQ 消息收发插件,通过 NapCat 协议桥接",
|
"description": "QQ 消息收发插件,通过 NapCat 协议桥接",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
"tags": ["qq", "messaging"],
|
"tags": [
|
||||||
|
"qq",
|
||||||
|
"messaging"
|
||||||
|
],
|
||||||
"targets": "linux/amd64",
|
"targets": "linux/amd64",
|
||||||
"outdir": "dist",
|
"outdir": "dist",
|
||||||
"bundle": false,
|
"bundle": false,
|
||||||
"replaces": {},
|
"replaces": {},
|
||||||
"source_dirs": []
|
"source_dirs": []
|
||||||
}
|
}
|
||||||
@ -739,6 +739,11 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
return
|
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
|
rawCQ := evt.RawMessage
|
||||||
text := rawCQ
|
text := rawCQ
|
||||||
@ -763,6 +768,7 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
if evt.MessageType == "group" {
|
if evt.MessageType == "group" {
|
||||||
if !p.isGroupAllowed(evt.GroupID) {
|
if !p.isGroupAllowed(evt.GroupID) {
|
||||||
|
log.Printf("[qq] group msg from %d rejected: policy=%s", evt.GroupID, p.groupPolicy)
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -773,6 +779,9 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !p.isAtBot(evt.Message) {
|
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)
|
w.WriteHeader(http.StatusOK)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -810,6 +819,7 @@ func (p *Plugin) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
if evt.GroupID == rule.GroupID {
|
if evt.GroupID == rule.GroupID {
|
||||||
mcMsg := fmt.Sprintf("%s 说 %s", nickname, text)
|
mcMsg := fmt.Sprintf("%s 说 %s", nickname, text)
|
||||||
go func(r ForwardRule, msg string) {
|
go func(r ForwardRule, msg string) {
|
||||||
|
defer func() { _ = recover() }()
|
||||||
if err := rconSend(r.Host, r.Port, r.Password, "say "+msg); err != nil {
|
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)
|
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() {
|
go func() {
|
||||||
|
defer func() { _ = recover() }() // 后台任务不允许 panic 冒泡带崩进程
|
||||||
if d.MessageType == "group" && d.GroupID > 0 {
|
if d.MessageType == "group" && d.GroupID > 0 {
|
||||||
p.napcat("mark_group_msg_as_read", map[string]interface{}{"group_id": d.GroupID})
|
p.napcat("mark_group_msg_as_read", map[string]interface{}{"group_id": d.GroupID})
|
||||||
} else if d.UserID > 0 {
|
} 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)
|
return p.napcat("send_private_msg", msg)
|
||||||
|
|
||||||
case "image":
|
case "image", "file":
|
||||||
msg := map[string]interface{}{"message": fmt.Sprintf("[CQ:image,file=%s]", payload)}
|
// 收敛到 output 通道:payload 支持本地路径或 http(s) URL。
|
||||||
if groupID != 0 {
|
// 本地路径拷入 NapCat 共享目录转 file:// URI(与 voice 分支同模式),
|
||||||
msg["group_id"] = groupID
|
// 此后 agent 发本地文件不再需要单独的 upload_group_file 工具。
|
||||||
} else {
|
uri := payload
|
||||||
msg["user_id"] = userID
|
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)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(dest, data, 0644); err != nil {
|
||||||
|
return nil, fmt.Errorf("写入共享目录失败: %w", err)
|
||||||
|
}
|
||||||
|
uri = "file:///app/files/" + filepath.Base(dest)
|
||||||
}
|
}
|
||||||
if groupID != 0 {
|
cqTag := "file"
|
||||||
return p.napcat("send_group_msg", msg)
|
if rawType == "image" {
|
||||||
|
cqTag = "image"
|
||||||
}
|
}
|
||||||
return p.napcat("send_private_msg", msg)
|
msg := map[string]interface{}{"message": fmt.Sprintf("[CQ:%s,file=%s]", cqTag, uri)}
|
||||||
|
|
||||||
case "file":
|
|
||||||
msg := map[string]interface{}{"message": fmt.Sprintf("[CQ:file,file=%s]", payload)}
|
|
||||||
if groupID != 0 {
|
if groupID != 0 {
|
||||||
msg["group_id"] = groupID
|
msg["group_id"] = groupID
|
||||||
} else {
|
} else {
|
||||||
@ -1638,7 +1661,8 @@ func (p *Plugin) handleGetGroupFiles(args map[string]interface{}) (interface{},
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
dlURL := parsed.Data.URL
|
dlURL := parsed.Data.URL
|
||||||
httpResp, err := http.Get(dlURL)
|
client := &http.Client{Timeout: 120 * time.Second}
|
||||||
|
httpResp, err := client.Get(dlURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("download: %w", err)
|
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)
|
task := p.addDownloadTask(fileID, filename)
|
||||||
|
|
||||||
go func(t *DownloadTask, fid, fname, furl string, gid, uid int64) {
|
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 := ""
|
savePath := ""
|
||||||
errMsg := ""
|
errMsg := ""
|
||||||
if furl != "" {
|
if furl != "" {
|
||||||
|
|||||||
153
example/recoverydiag/diag_test.go
Normal file
153
example/recoverydiag/diag_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
7
example/recoverydiag/go.mod
Normal file
7
example/recoverydiag/go.mod
Normal 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 => ../../
|
||||||
11
example/recoverydiag/main.go
Normal file
11
example/recoverydiag/main.go
Normal 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)
|
||||||
|
}
|
||||||
21
example/recoverydiag/plg.json
Normal file
21
example/recoverydiag/plg.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "recoverydiag",
|
||||||
|
"name_zh": "恢复诊断",
|
||||||
|
"name_en": "Recovery Diagnostics",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"description": "快速检查/崩溃取证工具集:diag_triage(退出码/信号/存活粗分)、diag_db(config.db 完整性 + LLM 源解析校验)、diag_log_scan(日志签名命中)、diag_delta(last-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": []
|
||||||
|
}
|
||||||
1023
example/recoverydiag/plugin.go
Normal file
1023
example/recoverydiag/plugin.go
Normal file
File diff suppressed because it is too large
Load Diff
@ -2,14 +2,19 @@
|
|||||||
"name": "rss",
|
"name": "rss",
|
||||||
"name_zh": "RSS订阅",
|
"name_zh": "RSS订阅",
|
||||||
"name_en": "RSS",
|
"name_en": "RSS",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "RSS/Atom 订阅监控插件,自动检测更新并推送通知",
|
"description": "RSS/Atom 订阅监控插件,自动检测更新并推送通知",
|
||||||
"author": "HomeAgent",
|
"author": "HomeAgent",
|
||||||
"entry": "plugin.so",
|
"entry": "plugin.so",
|
||||||
"tags": ["rss", "feed", "subscription", "monitor"],
|
"tags": [
|
||||||
|
"rss",
|
||||||
|
"feed",
|
||||||
|
"subscription",
|
||||||
|
"monitor"
|
||||||
|
],
|
||||||
"targets": "linux/amd64",
|
"targets": "linux/amd64",
|
||||||
"outdir": "dist",
|
"outdir": "dist",
|
||||||
"bundle": true,
|
"bundle": true,
|
||||||
"replaces": {},
|
"replaces": {},
|
||||||
"source_dirs": []
|
"source_dirs": []
|
||||||
}
|
}
|
||||||
@ -467,7 +467,7 @@ func (p *Plugin) saveData() {
|
|||||||
SeenGUIDs: p.seenGUIDs,
|
SeenGUIDs: p.seenGUIDs,
|
||||||
}
|
}
|
||||||
b, _ := json.MarshalIndent(data, "", " ")
|
b, _ := json.MarshalIndent(data, "", " ")
|
||||||
os.WriteFile(p.dataFile(), b, 0644)
|
atomicWriteJSON(p.dataFile(), b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanupData 卸载时清理订阅数据目录(feeds.json 等)
|
// 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)
|
||||||
|
}
|
||||||
|
|||||||
@ -6,7 +6,7 @@ package meta
|
|||||||
var (
|
var (
|
||||||
// Version 是 HomeAgent SDK 版本号。
|
// Version 是 HomeAgent SDK 版本号。
|
||||||
// 通过 `-ldflags="-X gitcode.com/JianFeeeee/homeagent-sdk/meta.Version=vX.Y.Z"` 注入。
|
// 通过 `-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 是构建时的 Git commit hash。
|
||||||
Commit = "unknown"
|
Commit = "unknown"
|
||||||
@ -21,7 +21,7 @@ var (
|
|||||||
CoreModule = "gitcode.com/JianFeeeee/HomeAgent"
|
CoreModule = "gitcode.com/JianFeeeee/HomeAgent"
|
||||||
|
|
||||||
// CoreVersion 是此 SDK 所兼容的最低核心版本。
|
// CoreVersion 是此 SDK 所兼容的最低核心版本。
|
||||||
CoreVersion = "0.9.1"
|
CoreVersion = "0.9.2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FullVersion 返回完整的版本字符串。
|
// FullVersion 返回完整的版本字符串。
|
||||||
|
|||||||
@ -114,6 +114,9 @@ type IOInjector interface {
|
|||||||
// InjectInputSync 注入输入事件并同步等待 agent 回复,返回回复文本(无回复时返回空串)。
|
// InjectInputSync 注入输入事件并同步等待 agent 回复,返回回复文本(无回复时返回空串)。
|
||||||
// 用于通道消息的完整闭环:收到入站 → agent 处理 → 回复取回 → 送回通道。
|
// 用于通道消息的完整闭环:收到入站 → agent 处理 → 回复取回 → 送回通道。
|
||||||
InjectInputSync(source, channel, text string) string
|
InjectInputSync(source, channel, text string) string
|
||||||
|
// SetToolBlocks 插件工具注入多模态内容块(image_url/audio_url),内核在下一条
|
||||||
|
// tool message 的 content 数组里带上这些块,让模型在后续轮次看到图/听到音频。
|
||||||
|
SetToolBlocks(blocks []ContentBlock)
|
||||||
}
|
}
|
||||||
|
|
||||||
// EventType identifies the kind of system event.
|
// EventType identifies the kind of system event.
|
||||||
@ -460,3 +463,22 @@ func (s *PluginSDK) RunOnRemoveHandlers() {
|
|||||||
handlers[i]()
|
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"`
|
||||||
|
}
|
||||||
|
|||||||
@ -19,6 +19,11 @@ type SettingsAPI interface {
|
|||||||
// ListCore lists core config keys matching the prefix.
|
// ListCore lists core config keys matching the prefix.
|
||||||
ListCore(prefix string) ([]string, error)
|
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 reads another plugin's config table.
|
||||||
GetPlugin(plugin, key string) (interface{}, error)
|
GetPlugin(plugin, key string) (interface{}, error)
|
||||||
|
|
||||||
|
|||||||
@ -647,6 +647,9 @@ func (d *dispatchSettings) Dump() map[string]interface{} {
|
|||||||
func (d *dispatchSettings) Plugins() []string {
|
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
|
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) ----
|
// ---- Go callbacks (called from z_entry.c via C) ----
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user