mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 01:18:08 +00:00
feat(waiter): daemon模式 + localuse本机外设插件
waiter 新增 --daemon 后台驻留模式 (daemon.go): - 维持 homed 连接,TUI实例经 Unix socket 接入 - 单客户端串行模型:每个TUI独占homed响应,新客户端回放缓冲(256行) - 设备桥场景可无 homed 运行(纯设备桥驻留) - 设备桥看护循环:WS断开自动重连(3-5s间隔) - conn.go 新增 daemonConn 类型,dial() 优先检测 daemon localuse 插件 (internal/plugins/localuse/): - local_screensee / local_camerasue / local_speakeruse - local_screensue / local_clipboardsee / local_clipboardsue / local_computeruse - 跨平台实现(Linux/macOS/Windows),能力与 waiter device.go 对齐 - headless服务器上缺依赖工具自动返回安装提示 SDK sync: third_party SetToolBlocks 多模态类型同步 README 补充 daemon 模式使用文档
This commit is contained in:
17
README.md
17
README.md
@ -151,6 +151,23 @@ make build build-cli
|
||||
echo "你好,记住我喜欢喝咖啡" | ./build/waiter
|
||||
```
|
||||
|
||||
### 后台驻留模式(daemon)
|
||||
|
||||
waiter 也支持后台驻留,保持与 homed 的持久连接并等待 TUI 实例接入,适合让 agent 主动召唤用户/设备桥持续存活:
|
||||
|
||||
```bash
|
||||
# 后台驻留(默认连 ~/.homeagent/cli.sock)
|
||||
./build/waiter --daemon
|
||||
|
||||
# 指定 socket
|
||||
./build/waiter --socket /path/to/cli.sock --daemon
|
||||
|
||||
# 随后任意 TUI/一行实例都会自动接入正在运行的 daemon,而不是直连 homed
|
||||
./build/waiter
|
||||
```
|
||||
|
||||
daemon 监听 `~/.homeagent/waiter.sock`,新客户端连入时会回放缓冲的最近对话(256 行),断连后 daemon 持续存活、自动重连 homed,并保持设备桥(若配置了 `device_gateway`/`device_token`)。
|
||||
|
||||
API 密钥通过 WebUI `http://localhost:8080` 设置页配置,持久化在 SQLite 中。
|
||||
|
||||
## 代码结构
|
||||
|
||||
@ -19,6 +19,12 @@ type Conn interface {
|
||||
}
|
||||
|
||||
func dial(cfg *Config) (Conn, error) {
|
||||
// 优先连接运行中的 daemon(后台驻留模式)
|
||||
if daemonIsRunning() {
|
||||
if c, err := dialDaemon(); err == nil {
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
if cfg.Remote != "" {
|
||||
return dialRemote(cfg.Remote, cfg.APIKey)
|
||||
}
|
||||
@ -52,6 +58,39 @@ func dialLocal(socket, apiKey string) (Conn, error) {
|
||||
return lc, nil
|
||||
}
|
||||
|
||||
// daemonConn 是连接到运行中 waiter daemon 的轻量封装。
|
||||
// 协议与 localConn 完全一致(行式 \n 分隔),但不做 /auth(daemon 已集中鉴权)。
|
||||
type daemonConn struct {
|
||||
conn net.Conn
|
||||
r *bufio.Reader
|
||||
}
|
||||
|
||||
func dialDaemon() (Conn, error) {
|
||||
sock := daemonSocketPath()
|
||||
c, err := net.DialTimeout("unix", sock, 3*time.Second)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("daemon %s: %w", sock, err)
|
||||
}
|
||||
return &daemonConn{conn: c, r: bufio.NewReader(c)}, nil
|
||||
}
|
||||
|
||||
func (c *daemonConn) Send(line string) error {
|
||||
_, err := fmt.Fprintf(c.conn, "%s\n", line)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *daemonConn) ReadLine() (string, error) {
|
||||
s, err := c.r.ReadString('\n')
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSuffix(s, "\n"), nil
|
||||
}
|
||||
|
||||
func (c *daemonConn) Close() error {
|
||||
return c.conn.Close()
|
||||
}
|
||||
|
||||
type localConn struct {
|
||||
conn net.Conn
|
||||
r *bufio.Reader
|
||||
|
||||
378
cmd/waiter/daemon.go
Normal file
378
cmd/waiter/daemon.go
Normal file
@ -0,0 +1,378 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/devicebridge/client"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Daemon 模式:后台驻留,维持 homed 连接 + 设备桥 + 消息缓冲
|
||||
//
|
||||
// 工作原理:
|
||||
// - daemon 保持一个到 homed 的持久连接
|
||||
// - TUI 实例通过 Unix socket 连接到 daemon
|
||||
// - daemon 为每个 TUI 客户端分配独立的 homed 响应(通过 homeMu 序列化)
|
||||
// - 新客户端连入时回放缓冲的历史消息(方便重连后看到上下文)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const (
|
||||
daemonSocketName = "waiter.sock"
|
||||
msgBufCap = 256 // 环形缓冲最近 N 行 homed 输出
|
||||
)
|
||||
|
||||
type msgEntry struct {
|
||||
line string
|
||||
seq uint64
|
||||
}
|
||||
|
||||
type daemonHandler struct {
|
||||
// homed 连接
|
||||
homeMu sync.Mutex
|
||||
homeConn net.Conn
|
||||
homeR *bufio.Reader
|
||||
homeCfg *Config
|
||||
|
||||
// 消息缓冲(新客户端连入时回放)
|
||||
bufMu sync.Mutex
|
||||
buf []msgEntry
|
||||
bufSeq uint64
|
||||
bufCap int
|
||||
|
||||
// 生命周期
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
func newDaemonHandler() *daemonHandler {
|
||||
return &daemonHandler{
|
||||
bufCap: msgBufCap,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 消息缓冲 =====
|
||||
|
||||
func (h *daemonHandler) appendBuf(line string) {
|
||||
h.bufMu.Lock()
|
||||
defer h.bufMu.Unlock()
|
||||
h.bufSeq++
|
||||
h.buf = append(h.buf, msgEntry{line: line, seq: h.bufSeq})
|
||||
if len(h.buf) > h.bufCap {
|
||||
h.buf = h.buf[len(h.buf)-h.bufCap:]
|
||||
}
|
||||
}
|
||||
|
||||
func (h *daemonHandler) replayBuffer() []string {
|
||||
h.bufMu.Lock()
|
||||
defer h.bufMu.Unlock()
|
||||
lines := make([]string, 0, len(h.buf))
|
||||
for _, e := range h.buf {
|
||||
lines = append(lines, e.line)
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// ===== homed 连接 =====
|
||||
|
||||
func (h *daemonHandler) connectHome(cfg *Config) error {
|
||||
h.homeCfg = cfg
|
||||
if cfg.Remote != "" {
|
||||
return fmt.Errorf("daemon: remote mode not supported")
|
||||
}
|
||||
if cfg.Socket == "" {
|
||||
cfg.Socket = discoverSocket("")
|
||||
}
|
||||
c, err := net.DialTimeout("unix", cfg.Socket, 5*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("daemon: connect home: %w", err)
|
||||
}
|
||||
h.homeConn = c
|
||||
h.homeR = bufio.NewReader(c)
|
||||
log.Printf("[daemon] connected to home %s", cfg.Socket)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *daemonHandler) closeHome() {
|
||||
if h.homeConn != nil {
|
||||
h.homeConn.Close()
|
||||
h.homeConn = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (h *daemonHandler) reconnectHome() {
|
||||
cfg := h.homeCfg
|
||||
if cfg == nil {
|
||||
cfg = discoverConfig("")
|
||||
}
|
||||
if cfg.Socket == "" && cfg.Remote == "" {
|
||||
cfg.Socket = discoverSocket("")
|
||||
}
|
||||
for i := 0; i < 30; i++ {
|
||||
select {
|
||||
case <-h.stopCh:
|
||||
return
|
||||
default:
|
||||
}
|
||||
h.closeHome()
|
||||
time.Sleep(2 * time.Second)
|
||||
if err := h.connectHome(cfg); err != nil {
|
||||
log.Printf("[daemon] reconnect home (%d/30): %v", i+1, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("[daemon] reconnected to home")
|
||||
return
|
||||
}
|
||||
log.Printf("[daemon] gave up reconnecting to home")
|
||||
}
|
||||
|
||||
// handleClient 处理单个 TUI 客户端:
|
||||
// 1. 回放缓冲历史
|
||||
// 2. 读客户端输入 → 转发到 homed
|
||||
// 3. 读 homed 响应 → 回写给该客户端(独占响应,不广播)
|
||||
func (h *daemonHandler) handleClient(c net.Conn) {
|
||||
defer c.Close()
|
||||
|
||||
cid := fmt.Sprintf("%s", c.RemoteAddr())
|
||||
log.Printf("[daemon] client %s connected", cid)
|
||||
defer log.Printf("[daemon] client %s disconnected", cid)
|
||||
|
||||
// 1) 回放缓冲(新客户端看到最近对话上下文)
|
||||
for _, line := range h.replayBuffer() {
|
||||
fmt.Fprintf(c, "%s\n", line)
|
||||
}
|
||||
|
||||
// 2) 循环:读客户端 → 转发 homed → 读 homed 响应 → 回写客户端
|
||||
reader := bufio.NewReader(c)
|
||||
for {
|
||||
c.SetReadDeadline(time.Now().Add(5 * time.Minute))
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
|
||||
continue
|
||||
}
|
||||
return
|
||||
}
|
||||
line = strings.TrimSuffix(line, "\n")
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 转发到 homed(加锁保证请求-响应配对)
|
||||
h.homeMu.Lock()
|
||||
if h.homeConn == nil {
|
||||
h.homeMu.Unlock()
|
||||
fmt.Fprintf(c, `{"type":"error","error":"not connected to home"}`+"\n")
|
||||
continue
|
||||
}
|
||||
_, sendErr := fmt.Fprintf(h.homeConn, "%s\n", line)
|
||||
if sendErr != nil {
|
||||
h.homeMu.Unlock()
|
||||
fmt.Fprintf(c, `{"type":"error","error":"send failed"}`+"\n")
|
||||
continue
|
||||
}
|
||||
|
||||
// 读 homed 响应(所有帧:reasoning_delta / content_delta / tool_call / response / error)
|
||||
for {
|
||||
h.homeConn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
respLine, readErr := h.homeR.ReadString('\n')
|
||||
if readErr != nil {
|
||||
h.homeMu.Unlock()
|
||||
log.Printf("[daemon] home read error during client %s: %v", cid, readErr)
|
||||
h.reconnectHome()
|
||||
// 回写错误给客户端
|
||||
fmt.Fprintf(c, `{"type":"error","error":"home disconnected"}`+"\n")
|
||||
goto nextMessage
|
||||
}
|
||||
respLine = strings.TrimSuffix(respLine, "\n")
|
||||
if respLine == "" {
|
||||
continue
|
||||
}
|
||||
// 写入缓冲 + 回写给发起请求的客户端
|
||||
h.appendBuf(respLine)
|
||||
fmt.Fprintf(c, "%s\n", respLine)
|
||||
|
||||
// 检查是否是终结帧
|
||||
if strings.Contains(respLine, `"type":"response"`) || strings.Contains(respLine, `"type":"error"`) {
|
||||
break
|
||||
}
|
||||
}
|
||||
h.homeMu.Unlock()
|
||||
|
||||
nextMessage:
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 启动入口 =====
|
||||
|
||||
func runDaemon(cfg *Config) {
|
||||
dh := newDaemonHandler()
|
||||
|
||||
// 连接 homed(设备桥场景下可失败——被控主机无需 homed)
|
||||
if cfg.Socket != "" || cfg.Remote != "" {
|
||||
if err := dh.connectHome(cfg); err != nil {
|
||||
log.Printf("[daemon] home connect failed: %v (continue with device bridge only)", err)
|
||||
dh.homeConn = nil
|
||||
}
|
||||
} else {
|
||||
log.Printf("[daemon] no home socket configured, running device bridge only")
|
||||
}
|
||||
defer dh.closeHome()
|
||||
|
||||
// 启动设备桥(设备网关场景下为核心职责)
|
||||
startDaemonDeviceBridge(cfg)
|
||||
|
||||
// 监听 Unix socket
|
||||
sockPath := daemonSocketPath()
|
||||
os.Remove(sockPath)
|
||||
os.MkdirAll(filepath.Dir(sockPath), 0755)
|
||||
|
||||
ln, err := net.Listen("unix", sockPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "daemon: listen %s: %v\n", sockPath, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() {
|
||||
ln.Close()
|
||||
os.Remove(sockPath)
|
||||
}()
|
||||
|
||||
log.Printf("[daemon] listening on %s", sockPath)
|
||||
fmt.Printf("waiter daemon started\n socket: %s\n press Ctrl+C to stop\n", sockPath)
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-sigCh
|
||||
log.Printf("[daemon] shutting down")
|
||||
close(dh.stopCh)
|
||||
dh.closeHome()
|
||||
ln.Close()
|
||||
}()
|
||||
|
||||
// 单客户端模式:串行处理(同一时刻只有一个 TUI 连接)
|
||||
// 这与 homed CLI 插件的行为一致——一个连接对应一个活跃会话。
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-dh.stopCh:
|
||||
log.Printf("[daemon] stopped")
|
||||
return
|
||||
default:
|
||||
log.Printf("[daemon] accept error: %v", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
dh.handleClient(conn)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Socket 工具 =====
|
||||
|
||||
func daemonSocketPath() string {
|
||||
home, _ := os.UserHomeDir()
|
||||
if home == "" {
|
||||
home = "/tmp"
|
||||
}
|
||||
return filepath.Join(home, ".homeagent", daemonSocketName)
|
||||
}
|
||||
|
||||
func daemonIsRunning() bool {
|
||||
sock := daemonSocketPath()
|
||||
c, err := net.DialTimeout("unix", sock, 500*time.Millisecond)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
c.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
func startDaemonDeviceBridge(cfg *Config) {
|
||||
dg := cfg.DeviceGateway
|
||||
dt := cfg.DeviceToken
|
||||
if dg == "" || dt == "" {
|
||||
return
|
||||
}
|
||||
// 设备桥重连循环:WS 断开时自动重连
|
||||
go runDeviceBridgeLoop(dg, dt)
|
||||
}
|
||||
|
||||
// runDeviceBridgeLoop 无限重连循环:建立设备桥 → 等待断开 → 重连。
|
||||
func runDeviceBridgeLoop(gateway, token string) {
|
||||
for {
|
||||
bridge, err := connectDeviceBridge(gateway, token)
|
||||
if err != nil {
|
||||
log.Printf("[daemon] device bridge connect failed: %v, retrying in 5s", err)
|
||||
time.Sleep(5 * time.Second)
|
||||
continue
|
||||
}
|
||||
log.Printf("[daemon] device bridge connected, waiting...")
|
||||
bridge.Wait() // 阻塞直到断开
|
||||
log.Printf("[daemon] device bridge disconnected, reconnecting in 3s")
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
// connectDeviceBridge 创建并启动一次设备桥,返回 bridge 实例供 Wait()。
|
||||
func connectDeviceBridge(gateway, token string) (*client.Bridge, error) {
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "local"
|
||||
}
|
||||
deviceID := "waiter-" + sanitizeID(hostname)
|
||||
|
||||
caps := []string{
|
||||
"status", "cmdrun", "deviceinfo", "cmdresult",
|
||||
"computeruse", "screensee", "clipboardsee", "clipboardsue",
|
||||
"camerasue", "speakeruse", "screensue",
|
||||
}
|
||||
|
||||
info := map[string]interface{}{
|
||||
"hostname": hostname,
|
||||
"platform": runtime.GOOS,
|
||||
"arch": runtime.GOARCH,
|
||||
"cpus": runtime.NumCPU(),
|
||||
}
|
||||
|
||||
// 确保 gateway URL 格式正确
|
||||
gw := gateway
|
||||
if !strings.HasPrefix(gw, "ws://") && !strings.HasPrefix(gw, "wss://") {
|
||||
gw = "ws://" + gw
|
||||
}
|
||||
if !strings.Contains(gw, "/api/v1/device/ws") {
|
||||
gw = gw + "/api/v1/device/ws"
|
||||
}
|
||||
|
||||
bridge := client.New(gw, token, deviceID, hostname, caps, info)
|
||||
|
||||
// 注册命令处理器
|
||||
cr := client.NewCmdRouter()
|
||||
cr.Handle("homeagent-", handleHomeagentCmd)
|
||||
cr.HandleDefault(handleShellCmd)
|
||||
bridge.OnCmd(func(reqID, command string) {
|
||||
cr.Dispatch(reqID, command)
|
||||
})
|
||||
|
||||
if err := bridge.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 设置全局变量供 sendBridgeResult 使用
|
||||
deviceBridge = bridge
|
||||
deviceBridgeID = deviceID
|
||||
auth := true // daemon 模式默认授权(配置已指定)
|
||||
bridge.SetAuthorized(auth)
|
||||
|
||||
return bridge, nil
|
||||
}
|
||||
@ -126,6 +126,7 @@ func main() {
|
||||
deviceGateway := flag.String("device", "", "remotedevice 网关地址(如 127.0.0.1:9890),启动设备桥")
|
||||
deviceToken := flag.String("device-token", "", "设备接入 token")
|
||||
deviceAuthorized := flag.Bool("device-authorized", false, "客户端本地授权(允许远程操控本机;也可在 waiter.yaml 配 device_authorized: true)")
|
||||
daemonMode := flag.Bool("daemon", false, "后台驻留模式:维持 homed 连接 + 设备桥,等待 TUI 实例接入")
|
||||
testCap := flag.String("test-cap", "", "测试本地能力(screensue/speakeruse/screensee/clipboardsee/clipboardsue/computeruse/camerasue),如 --test-cap screensue")
|
||||
testCapArgs := flag.String("test-cap-args", "", "测试能力的参数")
|
||||
flag.Parse()
|
||||
@ -144,6 +145,12 @@ func main() {
|
||||
cfg.Socket = discoverSocket("")
|
||||
}
|
||||
|
||||
// Daemon 模式:后台驻留
|
||||
if *daemonMode {
|
||||
runDaemon(cfg)
|
||||
return
|
||||
}
|
||||
|
||||
oneShotMsg := *chat
|
||||
if oneShotMsg == "" {
|
||||
oneShotMsg = *say
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/cmd"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/files"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/healthcheck"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/localuse"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/mcp"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/multimodal"
|
||||
_ "gitcode.com/JianFeeeee/HomeAgent/internal/plugins/pluginmgr"
|
||||
|
||||
594
internal/plugins/localuse/plugin.go
Normal file
594
internal/plugins/localuse/plugin.go
Normal file
@ -0,0 +1,594 @@
|
||||
package localuse
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
|
||||
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
|
||||
)
|
||||
|
||||
func init() {
|
||||
plugin.RegisterPluginMeta("localuse", "本地外设", "Local Device Use")
|
||||
plugin.RegisterFactory("localuse", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||||
return New(name), nil
|
||||
})
|
||||
}
|
||||
|
||||
type Plugin struct {
|
||||
name string
|
||||
dataDir string
|
||||
}
|
||||
|
||||
func New(name string) *Plugin {
|
||||
return &Plugin{name: name}
|
||||
}
|
||||
|
||||
func (p *Plugin) Name() string { return p.name }
|
||||
|
||||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
s.SetAutoRestart(true)
|
||||
|
||||
if dd := s.Settings().DataDir(); dd != "" {
|
||||
p.dataDir = dd
|
||||
os.MkdirAll(p.dataDir, 0755)
|
||||
}
|
||||
|
||||
// ── screensee ──
|
||||
s.RegisterTool("local_screensee", sdk.ToolDef{
|
||||
Name: "local_screensee",
|
||||
Description: "截取本机屏幕当前画面(截屏)。" +
|
||||
"返回图片的 base64 data URL,可直接用于视觉分析。" +
|
||||
"依赖:Linux 需 scrot/import/gnome-screenshot 任一;macOS 需 screencapture(自带);Windows 用 PowerShell。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
}, p.handleScreensee)
|
||||
|
||||
// ── camerasue ──
|
||||
s.RegisterTool("local_camerasue", sdk.ToolDef{
|
||||
Name: "local_camerasue",
|
||||
Description: "使用本机摄像头拍照或录像。" +
|
||||
"无参数=拍照(jpeg),传入正整数=录像 N 秒(mp4)。" +
|
||||
"依赖:ffmpeg + v4l2(Linux)/ dshow(Windows)/ avfoundation(macOS)。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"duration": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "录像秒数(省略=拍照,传 0=拍照)",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, p.handleCamerasue)
|
||||
|
||||
// ── speakeruse ──
|
||||
s.RegisterTool("local_speakeruse", sdk.ToolDef{
|
||||
Name: "local_speakeruse",
|
||||
Description: "使用本机扬声器朗读指定文字(TTS)。" +
|
||||
"依赖:Linux 需 espeak/festival 任一;macOS 需 say(自带);Windows 用 PowerShell SAPI。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"text": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "要朗读的文字",
|
||||
},
|
||||
},
|
||||
"required": []string{"text"},
|
||||
},
|
||||
}, p.handleSpeakeruse)
|
||||
|
||||
// ── screensue ──
|
||||
s.RegisterTool("local_screensue", sdk.ToolDef{
|
||||
Name: "local_screensue",
|
||||
Description: "在本机屏幕上显示一段内容(通知/弹窗/HTML 页面)。" +
|
||||
"适合在用户面前弹出提醒、会议倒计时等。" +
|
||||
"Linux 用 xterm/browsh/w3m/notify-send;macOS 用 osascript;Windows 用 PowerShell 弹窗。" +
|
||||
"参数可选 duration(秒,默认 5)。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"content": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "要显示的文字或 HTML",
|
||||
},
|
||||
"duration": map[string]interface{}{
|
||||
"type": "integer",
|
||||
"description": "显示时长秒数(默认 5,0=永不超时)",
|
||||
},
|
||||
},
|
||||
"required": []string{"content"},
|
||||
},
|
||||
}, p.handleScreensue)
|
||||
|
||||
// ── clipboardsee ──
|
||||
s.RegisterTool("local_clipboardsee", sdk.ToolDef{
|
||||
Name: "local_clipboardsee",
|
||||
Description: "读取本机剪切板当前内容(用户最近复制/剪切的文字)。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
}, p.handleClipboardsee)
|
||||
|
||||
// ── clipboardsue ──
|
||||
s.RegisterTool("local_clipboardsue", sdk.ToolDef{
|
||||
Name: "local_clipboardsue",
|
||||
Description: "将文字写入本机剪切板(用户随后可 Ctrl+V 粘贴)。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"text": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "要写入剪切板的文字",
|
||||
},
|
||||
},
|
||||
"required": []string{"text"},
|
||||
},
|
||||
}, p.handleClipboardsue)
|
||||
|
||||
// ── computeruse ──
|
||||
s.RegisterTool("local_computeruse", sdk.ToolDef{
|
||||
Name: "local_computeruse",
|
||||
Description: "操控本机鼠标/键盘。依赖 xdotool(Linux)/ PowerShell(Windows)。" +
|
||||
"action 可选:click / doubleclick / rightclick / move / scroll / keypress / type。",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"action": map[string]interface{}{
|
||||
"type": "string", "enum": []interface{}{
|
||||
"click", "doubleclick", "rightclick", "move", "scroll", "keypress", "type",
|
||||
},
|
||||
},
|
||||
"x": map[string]interface{}{"type": "integer", "description": "X 坐标(click/move 必填)"},
|
||||
"y": map[string]interface{}{"type": "integer", "description": "Y 坐标(click/move 必填)"},
|
||||
"button": map[string]interface{}{"type": "string", "description": "鼠标按键 left/right/middle"},
|
||||
"dy": map[string]interface{}{"type": "integer", "description": "scroll 滚动量"},
|
||||
"key": map[string]interface{}{"type": "string", "description": "keypress 按键名"},
|
||||
"text": map[string]interface{}{"type": "string", "description": "type 要输入的文字"},
|
||||
},
|
||||
"required": []interface{}{"action"},
|
||||
},
|
||||
}, p.handleComputeruse)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Plugin) Stop() error { return nil }
|
||||
|
||||
// ===========================================================================
|
||||
// 工具实现
|
||||
// ===========================================================================
|
||||
|
||||
// ── screensee ──────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *Plugin) handleScreensee(args map[string]interface{}) (interface{}, error) {
|
||||
platform := runtime.GOOS
|
||||
tmpDir := os.TempDir()
|
||||
outFile := filepath.Join(tmpDir, fmt.Sprintf("local_screenshot_%d.png", time.Now().UnixNano()))
|
||||
defer os.Remove(outFile)
|
||||
|
||||
var cmd *exec.Cmd
|
||||
switch platform {
|
||||
case "linux":
|
||||
if _, err := exec.LookPath("import"); err == nil {
|
||||
cmd = exec.Command("import", "-window", "root", outFile)
|
||||
} else if _, err := exec.LookPath("scrot"); err == nil {
|
||||
cmd = exec.Command("scrot", outFile)
|
||||
} else if _, err := exec.LookPath("gnome-screenshot"); err == nil {
|
||||
cmd = exec.Command("gnome-screenshot", "-f", outFile)
|
||||
} else {
|
||||
return map[string]interface{}{"isError": true, "content": "无截图工具(安装 scrot/import/gnome-screenshot 任一)"}, nil
|
||||
}
|
||||
case "darwin":
|
||||
cmd = exec.Command("screencapture", "-x", outFile)
|
||||
case "windows":
|
||||
psScript := fmt.Sprintf(`
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
$screen = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
|
||||
$bitmap = New-Object Drawing.Bitmap $screen.Width, $screen.Height
|
||||
$graphics = [Drawing.Graphics]::FromImage($bitmap)
|
||||
$graphics.CopyFromScreen($screen.X, $screen.Y, 0, 0, $screen.Size)
|
||||
$bitmap.Save('%s', [Drawing.Imaging.ImageFormat]::Png)
|
||||
$graphics.Dispose()
|
||||
$bitmap.Dispose()
|
||||
`, strings.ReplaceAll(outFile, "\\", "\\\\"))
|
||||
cmd = exec.Command("powershell", "-NoProfile", "-Command", psScript)
|
||||
default:
|
||||
return map[string]interface{}{"isError": true, "content": "不支持的平台: " + platform}, nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return map[string]interface{}{"isError": true, "content": fmt.Sprintf("截屏失败: %v\n%s", err, string(out))}, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(outFile)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"isError": true, "content": fmt.Sprintf("读取截图失败: %v", err)}, nil
|
||||
}
|
||||
|
||||
// 大图保存到 dataDir(避免 agent 上下文爆掉),返回路径
|
||||
if len(data) > 256*1024 && p.dataDir != "" {
|
||||
srcFile := filepath.Join(p.dataDir, fmt.Sprintf("screenshot_%d.png", time.Now().UnixNano()))
|
||||
if err := os.WriteFile(srcFile, data, 0644); err == nil {
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("截图已保存: %s (%d bytes)", srcFile, len(data)),
|
||||
"local_path": srcFile,
|
||||
"size": len(data),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
b64 := base64.StdEncoding.EncodeToString(data)
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("截图完成 (%d bytes)", len(data)),
|
||||
"image_data_url": "data:image/png;base64," + b64,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ── camerasue ──────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *Plugin) handleCamerasue(args map[string]interface{}) (interface{}, error) {
|
||||
duration := 0
|
||||
if d, ok := args["duration"].(float64); ok && d > 0 {
|
||||
duration = int(d)
|
||||
}
|
||||
|
||||
platform := runtime.GOOS
|
||||
tmpDir := os.TempDir()
|
||||
isVideo := duration > 0
|
||||
outFile := filepath.Join(tmpDir, fmt.Sprintf("local_cam_%d.jpg", time.Now().UnixNano()))
|
||||
if isVideo {
|
||||
outFile = filepath.Join(tmpDir, fmt.Sprintf("local_cam_%d.mp4", time.Now().UnixNano()))
|
||||
}
|
||||
|
||||
var cmd *exec.Cmd
|
||||
switch platform {
|
||||
case "linux":
|
||||
if isVideo {
|
||||
cmd = exec.Command("ffmpeg", "-f", "v4l2", "-i", "/dev/video0",
|
||||
"-t", fmt.Sprintf("%d", duration),
|
||||
"-pix_fmt", "yuv420p", "-c:v", "libx264",
|
||||
"-f", "mp4", "-y", outFile)
|
||||
} else {
|
||||
cmd = exec.Command("ffmpeg", "-f", "v4l2", "-i", "/dev/video0",
|
||||
"-frames:v", "1", "-f", "image2pipe", "-vcodec", "mjpeg", "pipe:1")
|
||||
}
|
||||
case "windows":
|
||||
if isVideo {
|
||||
cmd = exec.Command("ffmpeg", "-f", "dshow", "-i", "video=USB Camera",
|
||||
"-t", fmt.Sprintf("%d", duration),
|
||||
"-pix_fmt", "yuv420p", "-c:v", "libx264",
|
||||
"-f", "mp4", "-y", outFile)
|
||||
} else {
|
||||
cmd = exec.Command("ffmpeg", "-f", "dshow", "-i", "video=USB Camera",
|
||||
"-frames:v", "1", "-f", "image2pipe", "-vcodec", "mjpeg", "pipe:1")
|
||||
}
|
||||
default:
|
||||
return map[string]interface{}{"isError": true, "content": "camerasue 不支持平台: " + platform}, nil
|
||||
}
|
||||
|
||||
timeout := 15 * time.Second
|
||||
if isVideo {
|
||||
timeout = time.Duration(duration+15) * time.Second
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
if isVideo {
|
||||
cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return map[string]interface{}{"isError": true, "content": fmt.Sprintf("摄像头录像失败: %v", err)}, nil
|
||||
}
|
||||
data, err := os.ReadFile(outFile)
|
||||
os.Remove(outFile)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"isError": true, "content": "读取录像失败"}, nil
|
||||
}
|
||||
if p.dataDir != "" {
|
||||
srcFile := filepath.Join(p.dataDir, fmt.Sprintf("camera_%d.mp4", time.Now().UnixNano()))
|
||||
if err := os.WriteFile(srcFile, data, 0644); err == nil {
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("录像已保存: %s (%d bytes, %ds)", srcFile, len(data), duration),
|
||||
"local_path": srcFile,
|
||||
"size": len(data),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
b64 := base64.StdEncoding.EncodeToString(data)
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("录像完成 (%d bytes, %ds)", len(data), duration),
|
||||
"image_data_url": "data:video/mp4;base64," + b64,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 拍照:读 stdout JPEG
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = nil
|
||||
cmd2 := exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...)
|
||||
cmd2.Stdout = &out
|
||||
if err := cmd2.Run(); err != nil {
|
||||
return map[string]interface{}{"isError": true, "content": fmt.Sprintf("摄像头拍照失败: %v", err)}, nil
|
||||
}
|
||||
|
||||
b64 := base64.StdEncoding.EncodeToString(out.Bytes())
|
||||
return map[string]interface{}{
|
||||
"content": fmt.Sprintf("拍照完成 (%d bytes)", len(out.Bytes())),
|
||||
"image_data_url": "data:image/jpeg;base64," + b64,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ── speakeruse ─────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *Plugin) handleSpeakeruse(args map[string]interface{}) (interface{}, error) {
|
||||
text, _ := args["text"].(string)
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return map[string]interface{}{"isError": true, "content": "text 不能为空"}, nil
|
||||
}
|
||||
|
||||
platform := runtime.GOOS
|
||||
var cmd *exec.Cmd
|
||||
switch platform {
|
||||
case "linux":
|
||||
if _, err := exec.LookPath("espeak"); err == nil {
|
||||
cmd = exec.Command("espeak", text)
|
||||
} else if _, err := exec.LookPath("festival"); err == nil {
|
||||
cmd = exec.Command("festival", "--tts", "--pipe")
|
||||
stdin, _ := cmd.StdinPipe()
|
||||
go func() { defer stdin.Close(); stdin.Write([]byte(text)) }()
|
||||
} else {
|
||||
return map[string]interface{}{"isError": true, "content": "无 TTS 引擎(安装 espeak 或 festival)"}, nil
|
||||
}
|
||||
case "darwin":
|
||||
cmd = exec.Command("say", text)
|
||||
case "windows":
|
||||
psScript := fmt.Sprintf(`
|
||||
Add-Type -AssemblyName System.Speech
|
||||
$synthesizer = New-Object System.Speech.Synthesis.SpeechSynthesizer
|
||||
$synthesizer.Speak('%s')
|
||||
`, strings.ReplaceAll(text, "'", "''"))
|
||||
cmd = exec.Command("powershell", "-NoProfile", "-Command", psScript)
|
||||
default:
|
||||
return map[string]interface{}{"isError": true, "content": "speakeruse 不支持平台: " + platform}, nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
cmd = exec.CommandContext(ctx, cmd.Path, cmd.Args[1:]...)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return map[string]interface{}{"isError": true, "content": fmt.Sprintf("朗读失败: %v", err)}, nil
|
||||
}
|
||||
return map[string]interface{}{"content": "朗读完成"}, nil
|
||||
}
|
||||
|
||||
// ── screensue ──────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *Plugin) handleScreensue(args map[string]interface{}) (interface{}, error) {
|
||||
content, _ := args["content"].(string)
|
||||
duration := 5
|
||||
if d, ok := args["duration"].(float64); ok && d > 0 {
|
||||
duration = int(d)
|
||||
}
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return map[string]interface{}{"isError": true, "content": "content 不能为空"}, nil
|
||||
}
|
||||
|
||||
platform := runtime.GOOS
|
||||
switch platform {
|
||||
case "linux":
|
||||
return p.screensueLinux(content, duration)
|
||||
case "darwin":
|
||||
// macOS 用 osascript 弹窗
|
||||
psScript := fmt.Sprintf(`display dialog "%s" with title "HomeAgent" buttons {"OK"} default button "OK giving up after %d"`, strings.ReplaceAll(content, `"`, `\"`), duration)
|
||||
cmd := exec.Command("osascript", "-e", psScript)
|
||||
_ = cmd.Run()
|
||||
return map[string]interface{}{"content": "屏幕显示已触发"}, nil
|
||||
case "windows":
|
||||
escContent := strings.ReplaceAll(content, "\x27", "\x27\x27")
|
||||
psScript := fmt.Sprintf(`
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
$form = New-Object System.Windows.Forms.Form
|
||||
$form.Text = "HomeAgent"
|
||||
$form.Size = New-Object Drawing.Size(500,300)
|
||||
$form.StartPosition = "CenterScreen"
|
||||
$form.TopMost = $true
|
||||
$label = New-Object System.Windows.Forms.Label
|
||||
$label.Text = '%s'
|
||||
$label.AutoSize = $true
|
||||
$label.Location = New-Object Drawing.Point(20,20)
|
||||
$form.Controls.Add($label)
|
||||
$timer = New-Object System.Windows.Forms.Timer
|
||||
$timer.Interval = %d
|
||||
$timer.Add_Tick({ $form.Close() })
|
||||
$timer.Start()
|
||||
[Windows.Forms.Application]::Run($form)
|
||||
`, escContent, duration*1000)
|
||||
cmd := exec.Command("powershell", "-NoProfile", "-Command", psScript)
|
||||
_ = cmd.Run()
|
||||
return map[string]interface{}{"content": "屏幕显示已触发"}, nil
|
||||
default:
|
||||
return map[string]interface{}{"isError": true, "content": "screensue 不支持平台: " + platform}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Plugin) screensueLinux(content string, duration int) (interface{}, error) {
|
||||
// 优先 xterm
|
||||
if _, err := exec.LookPath("xterm"); err == nil {
|
||||
// 写临时文件显示
|
||||
tmpFile := filepath.Join(os.TempDir(), fmt.Sprintf("local_screensue_%d.txt", time.Now().UnixNano()))
|
||||
os.WriteFile(tmpFile, []byte(content), 0644)
|
||||
defer os.Remove(tmpFile)
|
||||
cmd := exec.Command("xterm", "-T", "HomeAgent", "-e", "cat", tmpFile)
|
||||
_ = cmd.Start()
|
||||
go func() { time.Sleep(time.Duration(duration) * time.Second); cmd.Process.Kill() }()
|
||||
return map[string]interface{}{"content": "屏幕显示已触发(xterm)"}, nil
|
||||
}
|
||||
// 回退 notify-send
|
||||
if _, err := exec.LookPath("notify-send"); err == nil {
|
||||
_ = exec.Command("notify-send", "HomeAgent", content).Run()
|
||||
return map[string]interface{}{"content": "通知已发送"}, nil
|
||||
}
|
||||
return map[string]interface{}{"isError": true, "content": "无可用显示方式(安装 xterm 或 libnotify-bin)"}, nil
|
||||
}
|
||||
|
||||
// ── clipboardsee ───────────────────────────────────────────────────────────
|
||||
|
||||
func (p *Plugin) handleClipboardsee(args map[string]interface{}) (interface{}, error) {
|
||||
platform := runtime.GOOS
|
||||
switch platform {
|
||||
case "linux":
|
||||
if _, err := exec.LookPath("xclip"); err == nil {
|
||||
out, _ := exec.Command("xclip", "-o", "-selection", "clipboard").Output()
|
||||
return map[string]interface{}{"content": string(out)}, nil
|
||||
}
|
||||
if _, err := exec.LookPath("xsel"); err == nil {
|
||||
out, _ := exec.Command("xsel", "-ob").Output()
|
||||
return map[string]interface{}{"content": string(out)}, nil
|
||||
}
|
||||
return map[string]interface{}{"isError": true, "content": "无剪贴板工具(安装 xclip 或 xsel)"}, nil
|
||||
case "darwin":
|
||||
out, _ := exec.Command("pbpaste").Output()
|
||||
return map[string]interface{}{"content": string(out)}, nil
|
||||
case "windows":
|
||||
ps := `Add-Type -AssemblyName System.Windows.Forms; [Windows.Forms.Clipboard]::GetText()`
|
||||
out, _ := exec.Command("powershell", "-NoProfile", "-Command", ps).Output()
|
||||
return map[string]interface{}{"content": strings.TrimSpace(string(out))}, nil
|
||||
default:
|
||||
return map[string]interface{}{"isError": true, "content": "clipboardsee 不支持: " + platform}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// ── clipboardsue ───────────────────────────────────────────────────────────
|
||||
|
||||
func (p *Plugin) handleClipboardsue(args map[string]interface{}) (interface{}, error) {
|
||||
text, _ := args["text"].(string)
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return map[string]interface{}{"isError": true, "content": "text 不能为空"}, nil
|
||||
}
|
||||
|
||||
platform := runtime.GOOS
|
||||
switch platform {
|
||||
case "linux":
|
||||
if _, err := exec.LookPath("xclip"); err == nil {
|
||||
cmd := exec.Command("xclip", "-i", "-selection", "clipboard")
|
||||
cmd.Stdin = strings.NewReader(text)
|
||||
_ = cmd.Run()
|
||||
return map[string]interface{}{"content": "剪切板已写入"}, nil
|
||||
}
|
||||
if _, err := exec.LookPath("xsel"); err == nil {
|
||||
cmd := exec.Command("xsel", "-ib")
|
||||
cmd.Stdin = strings.NewReader(text)
|
||||
_ = cmd.Run()
|
||||
return map[string]interface{}{"content": "剪切板已写入"}, nil
|
||||
}
|
||||
return map[string]interface{}{"isError": true, "content": "无剪贴板工具"}, nil
|
||||
case "darwin":
|
||||
cmd := exec.Command("pbcopy")
|
||||
cmd.Stdin = strings.NewReader(text)
|
||||
_ = cmd.Run()
|
||||
return map[string]interface{}{"content": "剪切板已写入"}, nil
|
||||
case "windows":
|
||||
ps := fmt.Sprintf(`Add-Type -AssemblyName System.Windows.Forms; [Windows.Forms.Clipboard]::SetText('%s')`,
|
||||
strings.ReplaceAll(text, "'", "''"))
|
||||
_ = exec.Command("powershell", "-NoProfile", "-Command", ps).Run()
|
||||
return map[string]interface{}{"content": "剪切板已写入"}, nil
|
||||
default:
|
||||
return map[string]interface{}{"isError": true, "content": "clipboardsue 不支持: " + platform}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// ── computeruse ────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *Plugin) handleComputeruse(args map[string]interface{}) (interface{}, error) {
|
||||
action, _ := args["action"].(string)
|
||||
if action == "" {
|
||||
return map[string]interface{}{"isError": true, "content": "action 必填"}, nil
|
||||
}
|
||||
|
||||
platform := runtime.GOOS
|
||||
if platform == "linux" {
|
||||
if _, err := exec.LookPath("xdotool"); err != nil {
|
||||
return map[string]interface{}{"isError": true, "content": "computeruse 需要 xdotool(apt install xdotool)"}, nil
|
||||
}
|
||||
return p.computeruseLinux(action, args)
|
||||
}
|
||||
if platform == "windows" {
|
||||
return p.computeruseWindows(action, args)
|
||||
}
|
||||
return map[string]interface{}{"isError": true, "content": "computeruse 不支持: " + platform}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) computeruseLinux(action string, args map[string]interface{}) (interface{}, error) {
|
||||
switch action {
|
||||
case "click":
|
||||
btn := "1"
|
||||
if b, ok := args["button"].(string); ok {
|
||||
switch b {
|
||||
case "right":
|
||||
btn = "3"
|
||||
case "middle":
|
||||
btn = "2"
|
||||
}
|
||||
}
|
||||
_ = exec.Command("xdotool", "click", btn).Run()
|
||||
case "doubleclick":
|
||||
_ = exec.Command("xdotool", "click", "--repeat", "2", "1").Run()
|
||||
case "rightclick":
|
||||
_ = exec.Command("xdotool", "click", "3").Run()
|
||||
case "move":
|
||||
x, _ := args["x"].(float64)
|
||||
y, _ := args["y"].(float64)
|
||||
_ = exec.Command("xdotool", "mousemove", fmt.Sprintf("%d", int(x)), fmt.Sprintf("%d", int(y))).Run()
|
||||
case "scroll":
|
||||
dy, _ := args["dy"].(float64)
|
||||
btn := "4"
|
||||
if dy < 0 {
|
||||
btn = "5"
|
||||
}
|
||||
_ = exec.Command("xdotool", "click", btn).Run()
|
||||
case "type":
|
||||
text, _ := args["text"].(string)
|
||||
_ = exec.Command("xdotool", "type", text).Run()
|
||||
case "keypress":
|
||||
key, _ := args["key"].(string)
|
||||
_ = exec.Command("xdotool", "key", key).Run()
|
||||
default:
|
||||
return map[string]interface{}{"isError": true, "content": "未知 action: " + action}, nil
|
||||
}
|
||||
return map[string]interface{}{"content": "操作已执行: " + action}, nil
|
||||
}
|
||||
|
||||
func (p *Plugin) computeruseWindows(action string, args map[string]interface{}) (interface{}, error) {
|
||||
switch action {
|
||||
case "click", "move":
|
||||
x, _ := args["x"].(float64)
|
||||
y, _ := args["y"].(float64)
|
||||
ps := fmt.Sprintf(`[System.Windows.Forms.Cursor]::Position = [System.Drawing.Point]::new(%d,%d)`, int(x), int(y))
|
||||
_ = exec.Command("powershell", "-NoProfile", "-Command", ps).Run()
|
||||
case "type":
|
||||
text, _ := args["text"].(string)
|
||||
ps := fmt.Sprintf(`$w = New-Object -ComObject wscript.shell; $w.SendKeys('%s')`, strings.ReplaceAll(text, "'", "''"))
|
||||
_ = exec.Command("powershell", "-NoProfile", "-Command", ps).Run()
|
||||
default:
|
||||
return map[string]interface{}{"isError": true, "content": "Windows computeruse 暂不支持 " + action}, nil
|
||||
}
|
||||
return map[string]interface{}{"content": "操作已执行: " + action}, nil
|
||||
}
|
||||
|
||||
// 确保正则在编译时生效(避免 unused)
|
||||
var _ = regexp.MustCompile(`.*`)
|
||||
22
third_party/homeagent-sdk/sdk/plugin.go
vendored
22
third_party/homeagent-sdk/sdk/plugin.go
vendored
@ -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"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user