From c0e92cceb1abaa6caec3d7dee1597539146c3c79 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 21 Aug 2026 16:31:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20clipboardsee/clipboardsue=20=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=20=E2=80=94=20agent=20=E8=AF=BB=E5=86=99=E8=BF=9C?= =?UTF-8?q?=E7=A8=8B=E8=AE=BE=E5=A4=87=E5=89=AA=E5=88=87=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 与 screensue/screensee 同模式配对命名: - clipboardsee: 读取设备剪切板当前内容(用户最近复制的文字) - clipboardsue: 写入文字到设备剪切板(用户可直接 Ctrl+V 粘贴) 设计要点: - 协议: homeagent-clipboardsee / homeagent-clipboardsue <文字> - clipboardsee 回执 output 字段为剪切板文字, 响应含 content/empty 字段 - clipboardsue 响应含 written 字节数 + preview 截断预览(60 rune) - 隐私提示写入工具描述: 剪切板可能含密码, 仅在用户明确要求时读取 - 典型组合写入描述: clipboardsue + computeruse keypress ctrl+v 自动粘贴 - 未授权/离线/超时完整错误路径 GUI 配套需求(已发群): executeHomeagentCmd 加 case clipboardsee/clipboardsue, Electron clipboard.readText()/writeText(text) 一行实现。 测试: 端到端读写+参数校验+未授权拒绝 全通过 --- internal/plugins/remotedevice/binary_test.go | 101 ++++++++++++++ internal/plugins/remotedevice/device.go | 131 +++++++++++++++++++ 2 files changed, 232 insertions(+) diff --git a/internal/plugins/remotedevice/binary_test.go b/internal/plugins/remotedevice/binary_test.go index 9a0b2c6..7dbc68b 100644 --- a/internal/plugins/remotedevice/binary_test.go +++ b/internal/plugins/remotedevice/binary_test.go @@ -459,3 +459,104 @@ func TestComputeruseEndToEnd(t *testing.T) { t.Fatal("type without text should error") } } + +// ===== clipboardsee / clipboardsue:剪切板读写 ===== + +func TestClipboardEndToEnd(t *testing.T) { + reg := NewRegistry() + token := "test-token-clip" + reg.SetAcceptToken(func(provided string) bool { return provided == token }) + + dev := &devicectlDevice{reg: reg} + + srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS)) + defer srv.Close() + + cli := dialTestWS(t, srv.URL, token) + defer cli.close() + + cli.sendText([]byte(`{"op":"hello","device":{"device_id":"clip-dev","name":"剪贴板机","kind":"computer","caps":["cmd"]}}`)) + if _, _, err := cli.readMsg(); err != nil { + t.Fatalf("read hello_ack: %v", err) + } + reg.SetAuthorized("clip-dev", true) + + // 设备侧响应剪贴板命令 + go func() { + for { + op, payload, err := cli.readMsg() + if err != nil { + return + } + if op != 0x1 { + continue + } + var msg map[string]interface{} + if json.Unmarshal(payload, &msg) != nil { + continue + } + if msg["op"] != "cmd" || msg["cmd_type"] != "homeagent" { + continue + } + cmd, _ := msg["command"].(string) + reqID, _ := msg["req_id"].(string) + switch { + case cmd == "clipboardsee": + cli.sendText(mustJSON(map[string]interface{}{ + "op": "cmd_result", "req_id": reqID, "device_id": "clip-dev", + "status": "ok", "output": "https://example.com/copied-link", + })) + case strings.HasPrefix(cmd, "clipboardsue "): + written := strings.TrimPrefix(cmd, "clipboardsue ") + cli.sendText(mustJSON(map[string]interface{}{ + "op": "cmd_result", "req_id": reqID, "device_id": "clip-dev", + "status": "ok", "output": "clipboard set: " + written, + })) + } + } + }() + + t.Run("clipboardsee_returns_content", func(t *testing.T) { + res, err := dev.Execute("clipboardsee", map[string]interface{}{"device_id": "clip-dev"}) + if err != nil { + t.Fatalf("clipboardsee: %v", err) + } + m := res.(map[string]interface{}) + if m["content"] != "https://example.com/copied-link" { + t.Fatalf("unexpected content: %v", m["content"]) + } + if m["empty"] == true { + t.Fatal("content should not be empty") + } + }) + + t.Run("clipboardsue_writes_text", func(t *testing.T) { + long := strings.Repeat("你好", 100) // 200 runes,验证 preview 截断 + res, err := dev.Execute("clipboardsue", map[string]interface{}{"device_id": "clip-dev", "text": long}) + if err != nil { + t.Fatalf("clipboardsue: %v", err) + } + m := res.(map[string]interface{}) + if m["written"] != len(long) { + t.Fatalf("written mismatch: %v", m["written"]) + } + preview, _ := m["preview"].(string) + if !strings.HasSuffix(preview, "...") || len([]rune(preview)) > 64 { + t.Fatalf("preview should be truncated: %q", preview) + } + }) + + t.Run("clipboardsue_requires_text", func(t *testing.T) { + if _, err := dev.Execute("clipboardsue", map[string]interface{}{"device_id": "clip-dev"}); err == nil { + t.Fatal("missing text should error") + } + }) + + t.Run("unauthorized_device_rejected", func(t *testing.T) { + reg.SetAuthorized("clip-dev", false) + defer reg.SetAuthorized("clip-dev", true) + if _, err := dev.Execute("clipboardsee", map[string]interface{}{"device_id": "clip-dev"}); err == nil { + t.Fatal("unauthorized should error") + } + }) +} diff --git a/internal/plugins/remotedevice/device.go b/internal/plugins/remotedevice/device.go index 8fe1c91..3d3e794 100644 --- a/internal/plugins/remotedevice/device.go +++ b/internal/plugins/remotedevice/device.go @@ -135,6 +135,36 @@ func (d *devicectlDevice) Tools() []agentIO.ToolDef { "required": []interface{}{"device_id", "action"}, }, }, + { + Name: "clipboardsee", + Description: "读取一台已授权设备的剪切板当前内容(用户最近复制/剪切的文字)。" + + "与 clipboardsue 配对:clipboardsee 是读,clipboardsue 是写。" + + "适用场景:用户说「看看我刚复制的东西」「把我复制的链接打开」。" + + "⚡ 隐私敏感:剪切板可能含密码/隐私,仅在用户明确要求时使用。设备必须已授权且在线。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "device_id": map[string]interface{}{"type": "string", "description": "目标设备 ID"}, + }, + "required": []interface{}{"device_id"}, + }, + }, + { + Name: "clipboardsue", + Description: "把指定文字写入一台已授权设备的剪切板(用户之后可直接 Ctrl+V 粘贴)。" + + "与 clipboardsee 配对:clipboardsee 是读,clipboardsue 是写。" + + "适用场景:帮用户准备好要粘贴的长文本/链接/代码,避免 computeruse type 逐字输入慢且易错。" + + "典型组合:clipboardsue 写入 → 提示用户 Ctrl+V,或 clipboardsue + computeruse keypress ctrl+v 自动粘贴。" + + "设备必须已授权且在线。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "device_id": map[string]interface{}{"type": "string", "description": "目标设备 ID"}, + "text": map[string]interface{}{"type": "string", "description": "要写入剪切板的内容"}, + }, + "required": []interface{}{"device_id", "text"}, + }, + }, { Name: "deviceinfo", Description: "探查一台设备接入网关时声明的详细信息与支持能力。" + @@ -165,6 +195,10 @@ func (d *devicectlDevice) Execute(tool string, args map[string]interface{}) (int return d.screensee(args) case "computeruse": return d.computeruse(args) + case "clipboardsee": + return d.clipboardsee(args) + case "clipboardsue": + return d.clipboardsue(args) case "deviceinfo": return d.info(args) default: @@ -457,3 +491,100 @@ func (d *devicectlDevice) computeruse(args map[string]interface{}) (interface{}, d.reg.SaveResult(reqID, out) return out, nil } + +// clipboardCheck 检查设备可操作性(存在/已授权/在线),返回错误或 nil。 +func (d *devicectlDevice) clipboardCheck(id, verb string) error { + if id == "" { + return fmt.Errorf("device_id required") + } + m, ok := d.reg.Get(id) + if !ok { + return fmt.Errorf("device %s 不存在", id) + } + if !m.Authorized { + return fmt.Errorf("device %s 未授权,无法%s剪切板(请先在设备管理页授权)", id, verb) + } + if !m.Online { + return fmt.Errorf("device %s 不在线", id) + } + return nil +} + +// clipboardsee 实现 clipboardsee:读取设备剪切板当前内容。 +// 协议(GUI 配套):homeagent-clipboardsee → 回执 output 字段为剪切板文字。 +func (d *devicectlDevice) clipboardsee(args map[string]interface{}) (interface{}, error) { + id, _ := args["device_id"].(string) + if err := d.clipboardCheck(id, "读取"); err != nil { + return nil, err + } + reqID := newReqID() + if err := d.reg.PushCmd(id, reqID, "clipboardsee", "homeagent"); err != nil { + return nil, fmt.Errorf("下发读取命令失败: %w", err) + } + res, err := d.reg.AwaitResult(reqID, 15*time.Second) + if err != nil { + d.reg.SaveResult(reqID, map[string]interface{}{"accepted": true, "error": err.Error(), "pending": true}) + return nil, fmt.Errorf("设备未在超时内回执: %w", err) + } + if res["status"] != "ok" { + errMsg, _ := res["error"].(string) + if errMsg == "" { + errMsg = fmt.Sprintf("status=%v", res["status"]) + } + return nil, fmt.Errorf("读取剪切板失败: %s", errMsg) + } + content, _ := res["output"].(string) + out := map[string]interface{}{ + "req_id": reqID, + "content": content, + "empty": content == "", + } + d.reg.SaveResult(reqID, out) + return out, nil +} + +// clipboardsue 实现 clipboardsue:把文字写入设备剪切板。 +// 协议(GUI 配套):homeagent-clipboardsue <文字>,回执 ok 表示已写入。 +func (d *devicectlDevice) clipboardsue(args map[string]interface{}) (interface{}, error) { + id, _ := args["device_id"].(string) + text, _ := args["text"].(string) + if text == "" { + return nil, fmt.Errorf("text required(要写入剪切板的内容)") + } + if err := d.clipboardCheck(id, "写入"); err != nil { + return nil, err + } + reqID := newReqID() + // 命令格式:clipboardsue <文字>(GUI 端取首个空格后的全部内容作为写入文本) + if err := d.reg.PushCmd(id, reqID, "clipboardsue "+text, "homeagent"); err != nil { + return nil, fmt.Errorf("下发写入命令失败: %w", err) + } + res, err := d.reg.AwaitResult(reqID, 15*time.Second) + if err != nil { + d.reg.SaveResult(reqID, map[string]interface{}{"accepted": true, "error": err.Error(), "pending": true}) + return nil, fmt.Errorf("设备未在超时内回执: %w", err) + } + if res["status"] != "ok" { + errMsg, _ := res["error"].(string) + if errMsg == "" { + errMsg = fmt.Sprintf("status=%v", res["status"]) + } + return nil, fmt.Errorf("写入剪切板失败: %s", errMsg) + } + out := map[string]interface{}{ + "req_id": reqID, + "written": len(text), + "preview": truncateForPreview(text, 60), + } + d.reg.SaveResult(reqID, out) + return out, nil +} + +// truncateForPreview 截断长文本用于回执预览。 +func truncateForPreview(s string, max int) string { + r := []rune(s) + if len(r) <= max { + return s + } + return string(r[:max]) + "..." +}