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:
JianFeeeee
2026-08-27 09:27:15 +08:00
parent b777322b95
commit c44ec0f210
7 changed files with 1058 additions and 0 deletions

View File

@ -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 中。
## 代码结构

View File

@ -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 分隔),但不做 /authdaemon 已集中鉴权)。
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
View 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
}

View File

@ -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

View File

@ -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"

View 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 + v4l2Linux/ dshowWindows/ avfoundationmacOS。",
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-sendmacOS 用 osascriptWindows 用 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": "显示时长秒数(默认 50=永不超时)",
},
},
"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: "操控本机鼠标/键盘。依赖 xdotoolLinux/ PowerShellWindows。" +
"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 需要 xdotoolapt 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(`.*`)

View File

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