返回 DeepSeek-Reasonix
tools_read.go
根目录 / internal / browser / tools_read.go
1 package browser
2
3 import (
4 "context"
5 "encoding/base64"
6 "encoding/json"
7 "fmt"
8 "io"
9 "os"
10 "strings"
11 "time"
12
13 "reasonix/internal/tool"
14 )
15
16 const screenshotMaxBytes = 8 << 20
17
18 func tabsTool(exec Executor) tool.Tool {
19 return readTool{base: base{exec: exec, name: "browser_tabs",
20 description: "List this task's browser tabs with ID, URL, title, and loading state. Start here to find a tabId, or call browser_open when the task has no tab yet.",
21 schema: objectSchema(nil),
22 snip: listSnip,
23 }, run: runTabs}
24 }
25
26 func runTabs(ctx context.Context, exec Executor, args json.RawMessage) (string, error) {
27 var p struct{}
28 if err := decode(args, &p); err != nil {
29 return "", err
30 }
31 tabs, err := exec.Tabs(ctx)
32 if err != nil {
33 return "", translate(err, "browser_tabs")
34 }
35 if len(tabs) == 0 {
36 return "no tabs are open for this task; call browser_open to create one", nil
37 }
38 lines := make([]string, 0, len(tabs)+1)
39 lines = append(lines, fmt.Sprintf("%d tab(s)", len(tabs)))
40 for _, t := range tabs {
41 lines = append(lines, formatTab(t))
42 }
43 return strings.Join(lines, "\n"), nil
44 }
45
46 func formatTab(t Tab) string {
47 s := fmt.Sprintf("tab %s: %s", t.ID, t.URL)
48 if t.Title != "" {
49 s += fmt.Sprintf(" %q", t.Title)
50 }
51 var flags []string
52 if t.Loading {
53 flags = append(flags, "loading")
54 }
55 if t.Temporary {
56 flags = append(flags, "temporary")
57 }
58 if len(flags) > 0 {
59 s += " [" + strings.Join(flags, ", ") + "]"
60 }
61 return s
62 }
63
64 func snapshotTool(exec Executor) tool.Tool {
65 return readTool{base: base{exec: exec, name: "browser_snapshot",
66 description: "Capture the structural snapshot of a tab: an accessibility-style tree where each interactive element carries a ref such as ref=e12, plus the documentToken that binds those refs to the current document. Take a snapshot before browser_click, browser_type, browser_press, browser_scroll, browser_select, or browser_upload, and again after anything changes the page: refs and the token expire on navigation, page replacement, or user take-over. Pass selector to scope the tree to one subtree.",
67 schema: objectSchema([]string{"tabId"}, tabIDProp(), str("selector", "Optional CSS selector; only the matching subtree is captured.")),
68 snip: treeSnip,
69 }, run: runSnapshot}
70 }
71
72 func runSnapshot(ctx context.Context, exec Executor, args json.RawMessage) (string, error) {
73 var p struct {
74 TabID string `json:"tabId"`
75 Selector string `json:"selector"`
76 }
77 if err := decode(args, &p); err != nil {
78 return "", err
79 }
80 if err := requireTab(p.TabID); err != nil {
81 return "", err
82 }
83 snap, err := exec.Snapshot(ctx, SnapshotRequest{TabID: p.TabID, Selector: p.Selector})
84 if err != nil {
85 return "", translate(err, "browser_snapshot")
86 }
87 return fmt.Sprintf("documentToken: %s\nurl: %s\ntitle: %s\nrefs: %d\n\n%s", snap.DocumentToken, snap.URL, snap.Title, snap.Refs, snap.Tree), nil
88 }
89
90 func downloadTool(exec Executor) tool.Tool {
91 return readTool{base: base{exec: exec, name: "browser_download",
92 description: "List the downloads of a tab, or wait up to waitSeconds for an in-progress one to finish. Each entry reports ID, URL, saved path, state, and size; the path is a task-owned file the file tools can read.",
93 schema: objectSchema([]string{"tabId"}, tabIDProp(), bounded(integer("waitSeconds", "Seconds to wait for an in-progress download to complete; 0 lists immediately."), 0, 300)),
94 snip: listSnip,
95 }, run: runDownloads}
96 }
97
98 func runDownloads(ctx context.Context, exec Executor, args json.RawMessage) (string, error) {
99 var p struct {
100 TabID string `json:"tabId"`
101 WaitSeconds int `json:"waitSeconds"`
102 }
103 if err := decode(args, &p); err != nil {
104 return "", err
105 }
106 if err := requireTab(p.TabID); err != nil {
107 return "", err
108 }
109 if p.WaitSeconds < 0 || p.WaitSeconds > 300 {
110 return "", fmt.Errorf("waitSeconds must be between 0 and 300")
111 }
112 downloads, err := exec.Downloads(ctx, DownloadsRequest{TabID: p.TabID, WaitFor: time.Duration(p.WaitSeconds) * time.Second})
113 if err != nil {
114 return "", translate(err, "browser_download")
115 }
116 if len(downloads) == 0 {
117 return "no downloads for tab " + p.TabID, nil
118 }
119 lines := make([]string, 0, len(downloads)+1)
120 lines = append(lines, fmt.Sprintf("%d download(s) for tab %s", len(downloads), p.TabID))
121 for _, d := range downloads {
122 lines = append(lines, fmt.Sprintf("download %s: %s %s -> %s (%d bytes)", d.ID, d.State, d.URL, d.Path, d.Bytes))
123 }
124 return strings.Join(lines, "\n"), nil
125 }
126
127 type screenshot struct{ base }
128
129 func screenshotTool(exec Executor) tool.Tool {
130 return screenshot{base{exec: exec, name: "browser_screenshot",
131 description: "Capture a PNG of a tab's viewport, of one element by ref, or of the full page, and return it as an image. Use browser_snapshot for structure and refs; use this to see layout, images, or rendering. Images over 8 MiB are not returned; capture an element or the viewport instead.",
132 schema: objectSchema([]string{"tabId"}, tabIDProp(), refProp("Optional element ref from browser_snapshot; captures only that element."), boolean("fullPage", "Capture the whole scrollable page instead of the viewport.")),
133 snip: shortSnip,
134 }}
135 }
136
137 func (screenshot) ReadOnly() bool { return true }
138 func (screenshot) PlanModeSafe() bool { return true }
139
140 func (t screenshot) Execute(ctx context.Context, args json.RawMessage) (string, error) {
141 text, images, err := t.ExecuteWithImages(ctx, args)
142 if err == nil && len(images) > 0 {
143 text += "\nVisual content requires a structured image channel and an image-capable model."
144 }
145 return text, err
146 }
147
148 func (t screenshot) ExecuteWithImages(ctx context.Context, args json.RawMessage) (string, []string, error) {
149 if err := t.ready(ctx); err != nil {
150 return "", nil, err
151 }
152 var p struct {
153 TabID string `json:"tabId"`
154 Ref string `json:"ref"`
155 FullPage bool `json:"fullPage"`
156 }
157 if err := decode(args, &p); err != nil {
158 return "", nil, err
159 }
160 if err := requireTab(p.TabID); err != nil {
161 return "", nil, err
162 }
163 shot, err := t.exec.Screenshot(ctx, ScreenshotRequest{TabID: p.TabID, Ref: p.Ref, FullPage: p.FullPage})
164 if err != nil {
165 return "", nil, translate(err, "browser_screenshot")
166 }
167 return encodeScreenshot(p.TabID, shot)
168 }
169
170 func encodeScreenshot(tabID string, shot Screenshot) (string, []string, error) {
171 info, err := os.Stat(shot.Path)
172 if err != nil {
173 return "", nil, fmt.Errorf("read screenshot %s: %w", shot.Path, err)
174 }
175 if !info.Mode().IsRegular() {
176 return "", nil, fmt.Errorf("screenshot %s is not a regular file", shot.Path)
177 }
178 if info.Size() > screenshotMaxBytes {
179 return oversizeText(shot.Path, info.Size()), nil, nil
180 }
181 f, err := os.Open(shot.Path)
182 if err != nil {
183 return "", nil, fmt.Errorf("read screenshot %s: %w", shot.Path, err)
184 }
185 defer f.Close()
186 data, err := io.ReadAll(io.LimitReader(f, screenshotMaxBytes+1))
187 if err != nil {
188 return "", nil, fmt.Errorf("read screenshot %s: %w", shot.Path, err)
189 }
190 if len(data) > screenshotMaxBytes {
191 return oversizeText(shot.Path, int64(len(data))), nil, nil
192 }
193 mime := shot.MIME
194 if mime == "" {
195 mime = "image/png"
196 }
197 text := fmt.Sprintf("[image: %s, %dx%d] screenshot of tab %s saved at %s", mime, shot.Width, shot.Height, tabID, shot.Path)
198 return text, []string{"data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data)}, nil
199 }
200
201 func oversizeText(path string, size int64) string {
202 return fmt.Sprintf("screenshot %s is %d bytes, over the %d MiB limit for an inline image, so it was not returned. Capture one element with ref, or the viewport without fullPage, to get a smaller image.", path, size, screenshotMaxBytes>>20)
203 }
204
204 lines GO