mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-29 22:12:52 +00:00
feat: screensee 工具 — agent 查看远程设备屏幕内容
与 screensue(向用户屏幕显示)配对: screensue 是给用户看, screensee 是 agent 看。 服务端实现: 1. remotedevice 新增 screensee 工具: - 下发 homeagent-screensee 命令 → 设备截屏回传 jpeg base64 - seeHandler 回调(agent 核心注入)用视觉模型自动描述屏幕内容 - 未授权/离线/超时完整错误路径; 结果留档 cmdresult 2. SDK LLMMessage 扩展多模态 Blocks(text/image_url): - llm_impl 转换为 agentAPI.ContentBlock, 视觉模型可看图 3. describeScreen: 默认提示词描述窗口/文字/界面状态; provider 参数可指定视觉源(临时切换后恢复) GUI 端需配套(已发群): onDeviceMsg 加 case "screensee", desktopCapturer 截屏 → jpeg base64 data URL 回执(同 camerasue 抓拍模式)。 测试: 端到端模拟设备截屏回传+视觉回调验证; 全项目 go test 通过
This commit is contained in:
@ -295,3 +295,76 @@ func TestPushDataOfflineDevice(t *testing.T) {
|
|||||||
t.Fatalf("expected not online error, got %v", err)
|
t.Fatalf("expected not online error, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== screensee:截屏回传 + 视觉描述回调 =====
|
||||||
|
|
||||||
|
func TestScreenseeEndToEnd(t *testing.T) {
|
||||||
|
reg := NewRegistry()
|
||||||
|
token := "test-token-see"
|
||||||
|
reg.SetAcceptToken(func(provided string) bool { return provided == token })
|
||||||
|
|
||||||
|
dev := &devicectlDevice{reg: reg}
|
||||||
|
var gotDataURL string
|
||||||
|
dev.SetSeeHandler(func(dataURL string, provider string) string {
|
||||||
|
gotDataURL = dataURL
|
||||||
|
return "屏幕上显示的是测试画面"
|
||||||
|
})
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(reg.ServeWS))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
cli := dialTestWS(t, srv.URL, token)
|
||||||
|
defer cli.close()
|
||||||
|
|
||||||
|
// 设备 hello + bind(bind 需 token 才能被授权流程识别,这里直接手动授权)
|
||||||
|
cli.sendText([]byte(`{"op":"hello","device":{"device_id":"see-dev","name":"屏幕机","kind":"computer","caps":["cmd"]}}`))
|
||||||
|
if _, _, err := cli.readMsg(); err != nil {
|
||||||
|
t.Fatalf("read hello_ack: %v", err)
|
||||||
|
}
|
||||||
|
reg.SetAuthorized("see-dev", true)
|
||||||
|
|
||||||
|
// 设备侧循环收命令并回执(模拟 GUI screensee 实现)
|
||||||
|
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["command"] == "screensee" {
|
||||||
|
reqID, _ := msg["req_id"].(string)
|
||||||
|
fakeJPEG := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10} // JPEG magic
|
||||||
|
b64 := base64.StdEncoding.EncodeToString(fakeJPEG)
|
||||||
|
cli.sendText(mustJSON(map[string]interface{}{
|
||||||
|
"op": "cmd_result", "req_id": reqID, "device_id": "see-dev",
|
||||||
|
"status": "ok", "output": "data:image/jpeg;base64," + b64,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// agent 调用 screensee
|
||||||
|
res, err := dev.Execute("screensee", map[string]interface{}{"device_id": "see-dev"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("screensee: %v", err)
|
||||||
|
}
|
||||||
|
m := res.(map[string]interface{})
|
||||||
|
if m["description"] != "屏幕上显示的是测试画面" {
|
||||||
|
t.Fatalf("unexpected description: %v", m["description"])
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(gotDataURL, "data:image/jpeg;base64,") {
|
||||||
|
t.Fatalf("handler received bad dataURL: %s", gotDataURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未授权设备应拒绝
|
||||||
|
reg.SetAuthorized("see-dev", false)
|
||||||
|
if _, err := dev.Execute("screensee", map[string]interface{}{"device_id": "see-dev"}); err == nil {
|
||||||
|
t.Fatal("expected unauthorized error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -10,11 +10,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// devicectlDevice 把设备网关暴露为 IOManager 的一个 Device:
|
// devicectlDevice 把设备网关暴露为 IOManager 的一个 Device:
|
||||||
// Tools() 提供 devicedetect / device_ctl_status / device_ctl_cmdrun / device_ctl_cmdresult,
|
// Tools() 提供 devicedetect / device_ctl_status / device_ctl_cmdrun / device_ctl_cmdresult / screensee,
|
||||||
// Execute() 检查授权并路由到 WS 在线设备。
|
// Execute() 检查授权并路由到 WS 在线设备。
|
||||||
type devicectlDevice struct {
|
type devicectlDevice struct {
|
||||||
reg *Registry
|
reg *Registry
|
||||||
persist func() // 授权变更后持久化
|
persist func() // 授权变更后持久化
|
||||||
|
|
||||||
|
// screensee 回调:设备截屏回传后由 agent 核心消费(视觉描述)。
|
||||||
|
// 由插件 Start 注入;nil 时退化为仅返回 base64 数据。
|
||||||
|
seeHandler func(dataURL string, provider string) string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *devicectlDevice) Name() string { return "devicectl" }
|
func (d *devicectlDevice) Name() string { return "devicectl" }
|
||||||
@ -89,6 +93,21 @@ func (d *devicectlDevice) Tools() []agentIO.ToolDef {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "screensee",
|
||||||
|
Description: "查看一台已授权设备的屏幕当前画面(截屏回传)。" +
|
||||||
|
"与 screensue(向用户屏幕显示内容)配对:screensue 是给用户看,screensee 是你看。" +
|
||||||
|
"返回屏幕截图的自动视觉描述;如需读取屏上文字可接着用 ocr_image。" +
|
||||||
|
"需要 device_id(来自 devicedetect)。设备必须已授权且在线。",
|
||||||
|
Parameters: map[string]interface{}{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]interface{}{
|
||||||
|
"device_id": map[string]interface{}{"type": "string", "description": "目标设备 ID"},
|
||||||
|
"provider": map[string]interface{}{"type": "string", "description": "可选:用于视觉描述的 LLM 源名称,不填则使用默认模型"},
|
||||||
|
},
|
||||||
|
"required": []interface{}{"device_id"},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "deviceinfo",
|
Name: "deviceinfo",
|
||||||
Description: "探查一台设备接入网关时声明的详细信息与支持能力。" +
|
Description: "探查一台设备接入网关时声明的详细信息与支持能力。" +
|
||||||
@ -115,6 +134,8 @@ func (d *devicectlDevice) Execute(tool string, args map[string]interface{}) (int
|
|||||||
return d.cmdrun(args)
|
return d.cmdrun(args)
|
||||||
case "device_ctl_cmdresult":
|
case "device_ctl_cmdresult":
|
||||||
return d.cmdresult(args)
|
return d.cmdresult(args)
|
||||||
|
case "screensee":
|
||||||
|
return d.screensee(args)
|
||||||
case "deviceinfo":
|
case "deviceinfo":
|
||||||
return d.info(args)
|
return d.info(args)
|
||||||
default:
|
default:
|
||||||
@ -283,3 +304,55 @@ func (d *devicectlDevice) info(args map[string]interface{}) (interface{}, error)
|
|||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSeeHandler 注入 screensee 的视觉描述回调(agent 核心提供)。
|
||||||
|
func (d *devicectlDevice) SetSeeHandler(fn func(dataURL string, provider string) string) {
|
||||||
|
d.seeHandler = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// screensee 实现 screensee:向设备下发 homeagent-screensee 截屏命令,
|
||||||
|
// 等待回传 jpeg base64,交给 seeHandler(agent 核心)做视觉描述。
|
||||||
|
func (d *devicectlDevice) screensee(args map[string]interface{}) (interface{}, error) {
|
||||||
|
id, _ := args["device_id"].(string)
|
||||||
|
provider, _ := args["provider"].(string)
|
||||||
|
if id == "" {
|
||||||
|
return nil, fmt.Errorf("device_id required")
|
||||||
|
}
|
||||||
|
m, ok := d.reg.Get(id)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("device %s 不存在", id)
|
||||||
|
}
|
||||||
|
if !m.Authorized {
|
||||||
|
return nil, fmt.Errorf("device %s 未授权,无法查看屏幕(请先在设备管理页授权)", id)
|
||||||
|
}
|
||||||
|
if !m.Online {
|
||||||
|
return nil, fmt.Errorf("device %s 不在线", id)
|
||||||
|
}
|
||||||
|
reqID := newReqID()
|
||||||
|
if err := d.reg.PushCmd(id, reqID, "screensee", "homeagent"); err != nil {
|
||||||
|
return nil, fmt.Errorf("下发截屏命令失败: %w", err)
|
||||||
|
}
|
||||||
|
res, err := d.reg.AwaitResult(reqID, 30*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)
|
||||||
|
}
|
||||||
|
output, _ := res["output"].(string)
|
||||||
|
// 设备端回传 data URL(data:image/jpeg;base64,...)或裸 base64
|
||||||
|
if !strings.HasPrefix(output, "data:") {
|
||||||
|
output = "data:image/jpeg;base64," + output
|
||||||
|
}
|
||||||
|
d.reg.SaveResult(reqID, res)
|
||||||
|
if d.seeHandler == nil {
|
||||||
|
return map[string]interface{}{"image_data_url": output, "note": "无视觉描述处理器,仅返回原始图像数据"}, nil
|
||||||
|
}
|
||||||
|
desc := d.seeHandler(output, provider)
|
||||||
|
return map[string]interface{}{"description": desc}, nil
|
||||||
|
}
|
||||||
|
|||||||
@ -109,6 +109,8 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
|||||||
|
|
||||||
// ---- devicectl Device(agent 工具) ----------------
|
// ---- devicectl Device(agent 工具) ----------------
|
||||||
p.dev = &devicectlDevice{reg: p.registry, persist: p.persistAuthorized}
|
p.dev = &devicectlDevice{reg: p.registry, persist: p.persistAuthorized}
|
||||||
|
// screensee 视觉描述回调:截屏回传后用视觉模型描述屏幕内容
|
||||||
|
p.dev.SetSeeHandler(p.describeScreen)
|
||||||
if err := s.RegisterChannel("devicectl", p.dev); err != nil {
|
if err := s.RegisterChannel("devicectl", p.dev); err != nil {
|
||||||
log.Printf("[remotedevice] register devicectl channel: %v", err)
|
log.Printf("[remotedevice] register devicectl channel: %v", err)
|
||||||
}
|
}
|
||||||
@ -250,6 +252,41 @@ func (p *Plugin) handleDeviceAuth(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]interface{}{"device_id": req.DeviceID, "authorized": req.Authorize})
|
writeJSON(w, http.StatusOK, map[string]interface{}{"device_id": req.DeviceID, "authorized": req.Authorize})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// describeScreen 用视觉模型描述设备屏幕截图(screensee 回调)。
|
||||||
|
// provider 为空时使用默认 LLM 源;模型不支持视觉时返回友好错误。
|
||||||
|
func (p *Plugin) describeScreen(dataURL string, provider string) string {
|
||||||
|
if p.sdk == nil || p.sdk.LLM() == nil {
|
||||||
|
return "LLM 不可用,无法描述屏幕内容"
|
||||||
|
}
|
||||||
|
llm := p.sdk.LLM()
|
||||||
|
req := &sdk.LLMCompletionRequest{
|
||||||
|
MaxTokens: 2048,
|
||||||
|
Messages: []sdk.LLMMessage{{
|
||||||
|
Role: "user",
|
||||||
|
Blocks: []sdk.LLMContentBlock{
|
||||||
|
{Type: "text", Text: "这是用户设备的屏幕截图。请详细描述屏幕上显示的内容:正在运行的窗口/应用、可见的文字内容、界面状态等。如果是代码编辑器或终端,尽量转述关键文字信息。"},
|
||||||
|
{Type: "image_url", ImageURL: dataURL},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
// 指定源:临时切换(低频操作,用完恢复原源)
|
||||||
|
if provider != "" {
|
||||||
|
prev := llm.CurrentSource()
|
||||||
|
if err := llm.SetSource(provider); err != nil {
|
||||||
|
log.Printf("[remotedevice] screensee set source %s: %v", provider, err)
|
||||||
|
} else if prev != "" {
|
||||||
|
defer func() { _ = llm.SetSource(prev) }()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
resp, err := llm.Chat(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("屏幕截图视觉描述失败: %v(当前模型可能不支持图像输入)", err)
|
||||||
|
}
|
||||||
|
return resp.Content
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Plugin) Stop() error {
|
func (p *Plugin) Stop() error {
|
||||||
if p.server != nil {
|
if p.server != nil {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
|||||||
@ -23,6 +23,16 @@ type LLMMessage struct {
|
|||||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||||
ToolCalls []LLMToolCall `json:"tool_calls,omitempty"`
|
ToolCalls []LLMToolCall `json:"tool_calls,omitempty"`
|
||||||
|
// Blocks 多模态内容块(与 Content 二选一;非空时优先)。
|
||||||
|
// 支持 text 与 image_url 两类,用于视觉模型看图(如 screensee 截屏描述)。
|
||||||
|
Blocks []LLMContentBlock `json:"blocks,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LLMContentBlock 是多模态消息中的单个内容块。
|
||||||
|
type LLMContentBlock struct {
|
||||||
|
Type string `json:"type"` // "text" | "image_url"
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
ImageURL string `json:"image_url,omitempty"` // data URL 或 http(s) URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// LLMToolCall 是中立的工具调用请求。
|
// LLMToolCall 是中立的工具调用请求。
|
||||||
|
|||||||
@ -71,6 +71,18 @@ func (l *llmImpl) Chat(ctx context.Context, req *LLMCompletionRequest) (*LLMComp
|
|||||||
ReasoningContent: m.ReasoningContent,
|
ReasoningContent: m.ReasoningContent,
|
||||||
ToolCallID: m.ToolCallID,
|
ToolCallID: m.ToolCallID,
|
||||||
}
|
}
|
||||||
|
// 多模态 Blocks:text/image_url → agentAPI.ContentBlock
|
||||||
|
for _, b := range m.Blocks {
|
||||||
|
switch b.Type {
|
||||||
|
case "text":
|
||||||
|
msg.Blocks = append(msg.Blocks, agentAPI.ContentBlock{Type: "text", Text: b.Text})
|
||||||
|
case "image_url":
|
||||||
|
msg.Blocks = append(msg.Blocks, agentAPI.ContentBlock{
|
||||||
|
Type: "image_url",
|
||||||
|
ImageURL: &agentAPI.ImageURL{URL: b.ImageURL, Detail: "high"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
if len(m.ToolCalls) > 0 {
|
if len(m.ToolCalls) > 0 {
|
||||||
msg.ToolCalls = make([]agentAPI.ToolCall, len(m.ToolCalls))
|
msg.ToolCalls = make([]agentAPI.ToolCall, len(m.ToolCalls))
|
||||||
for j, tc := range m.ToolCalls {
|
for j, tc := range m.ToolCalls {
|
||||||
|
|||||||
Reference in New Issue
Block a user