mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
feat: clipboardsee/clipboardsue 工具 — agent 读写远程设备剪切板
与 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) 一行实现。 测试: 端到端读写+参数校验+未授权拒绝 全通过
This commit is contained in:
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -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]) + "..."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user