返回 DeepSeek-Reasonix
file_browser_preview.go
根目录 / desktop / file_browser_preview.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "path/filepath"
8 "strings"
9 "time"
10
11 "reasonix/internal/browser"
12 )
13
14 const fileBrowserPreviewTimeout = 60 * time.Second
15
16 // FileBrowserPreviewRequest is the additive desktop RPC contract used by file
17 // links, present cards, automatic delivery, and the browser_preview tool.
18 // ExpectedSessionGeneration may be zero for an older caller; current callers
19 // send it whenever they already hold tab metadata.
20 type FileBrowserPreviewRequest struct {
21 Source string `json:"source"`
22 Path string `json:"path"`
23 ToolCallID string `json:"toolCallId,omitempty"`
24 OperationID string `json:"operationId"`
25 ExpectedSessionGeneration uint64 `json:"expectedSessionGeneration,omitempty"`
26 // UserInitiated is accepted only by the renderer RPC. It lets an explicit
27 // refresh replace the file URL while the user owns the tab without granting
28 // the agent control of that page. Agent tool requests never set this field.
29 UserInitiated bool `json:"userInitiated,omitempty"`
30 }
31
32 type FileBrowserPreviewResult struct {
33 TabID string `json:"tabId"`
34 URL string `json:"url"`
35 Status string `json:"status"`
36 Error string `json:"error,omitempty"`
37 SessionGeneration uint64 `json:"sessionGeneration"`
38 }
39
40 type fileBrowserPreviewBinding struct {
41 BrowserTabID string
42 URL string
43 SessionGeneration uint64
44 release func() // revokes the exact minted resource without App.mu
45 }
46
47 type preparedFileBrowserPreview struct {
48 url string
49 identity string
50 release func()
51 }
52
53 // OpenFileBrowserPreviewForTab validates the resource and opens it through the
54 // same task grant the agent browser tools use. That makes the visible page and
55 // the page the agent can inspect one and the same Electron WebContentsView.
56 func (a *App) OpenFileBrowserPreviewForTab(tabID string, request FileBrowserPreviewRequest) (FileBrowserPreviewResult, error) {
57 ctx, cancel := context.WithTimeout(a.bootContext(), fileBrowserPreviewTimeout)
58 defer cancel()
59 return a.openFileBrowserPreview(ctx, tabID, request, nil)
60 }
61
62 func normalizeFileBrowserPreviewRequest(request FileBrowserPreviewRequest) (FileBrowserPreviewRequest, error) {
63 request.Source = strings.TrimSpace(request.Source)
64 request.Path = strings.TrimSpace(request.Path)
65 request.OperationID = strings.TrimSpace(request.OperationID)
66 if request.Source == "" {
67 request.Source = "workspace"
68 }
69 if request.Source != "workspace" && request.Source != "presented" && request.Source != "reference" {
70 return FileBrowserPreviewRequest{}, fmt.Errorf("unsupported file preview source %q", request.Source)
71 }
72 if request.Path == "" || request.OperationID == "" {
73 return FileBrowserPreviewRequest{}, errors.New("path and operationId are required")
74 }
75 if request.Source == "presented" && strings.TrimSpace(request.ToolCallID) == "" {
76 return FileBrowserPreviewRequest{}, errors.New("toolCallId is required for a presented file")
77 }
78 return request, nil
79 }
80
81 func (a *App) openFileBrowserPreview(ctx context.Context, tabID string, request FileBrowserPreviewRequest, supplied browser.Executor) (FileBrowserPreviewResult, error) {
82 request, err := normalizeFileBrowserPreviewRequest(request)
83 if err != nil {
84 return FileBrowserPreviewResult{}, err
85 }
86 // Serialize publications without making lifecycle cleanup wait on host RPC.
87 a.filePreviews.operations.Lock()
88 defer a.filePreviews.operations.Unlock()
89 tab, generation, err := a.fileBrowserPreviewTab(tabID, request.ExpectedSessionGeneration)
90 if err != nil {
91 return FileBrowserPreviewResult{}, err
92 }
93 exec := supplied
94 if exec == nil {
95 exec = a.hostBrowserExecutorForTab(tab.ID)
96 }
97 if exec == nil {
98 return FileBrowserPreviewResult{}, errors.New("the built-in browser is unavailable")
99 }
100 prepared, err := a.prepareFileBrowserPreview(tabID, request)
101 if err != nil {
102 return FileBrowserPreviewResult{}, err
103 }
104 key := strings.Join([]string{tabID, fmt.Sprint(generation), request.Source, strings.TrimSpace(request.ToolCallID), filepath.Clean(prepared.identity)}, "\x00")
105
106 op, binding, reusable := a.filePreviews.begin(key, prepared)
107 defer a.filePreviews.end(op)
108 published := false
109 defer func() {
110 if !published {
111 prepared.release()
112 }
113 }()
114 if reusable {
115 tabs, listErr := exec.Tabs(ctx)
116 if listErr != nil {
117 return FileBrowserPreviewResult{}, listErr
118 }
119 found := false
120 for _, candidate := range tabs {
121 if candidate.ID != binding.BrowserTabID {
122 continue
123 }
124 found = true
125 // Navigating away explicitly releases the file binding. A later
126 // delivery opens a fresh tab instead of overwriting the user's page.
127 if candidate.URL != binding.URL {
128 a.filePreviews.retire(key, binding.URL)
129 reusable = false
130 }
131 break
132 }
133 if !found {
134 a.filePreviews.retire(key, binding.URL)
135 reusable = false
136 }
137 }
138
139 var browserTab browser.Tab
140 if reusable {
141 navigateRequest := browser.NavigateRequest{
142 OperationID: request.OperationID, TabID: binding.BrowserTabID,
143 URL: prepared.url, Action: browser.NavigateURL,
144 }
145 if request.UserInitiated {
146 if renderer, ok := exec.(interface {
147 navigateFilePreview(context.Context, browser.NavigateRequest) (browser.Tab, error)
148 }); ok {
149 browserTab, err = renderer.navigateFilePreview(ctx, navigateRequest)
150 } else {
151 browserTab, err = exec.Navigate(ctx, navigateRequest)
152 }
153 } else {
154 browserTab, err = exec.Navigate(ctx, navigateRequest)
155 }
156 } else {
157 browserTab, err = exec.Open(ctx, browser.OpenRequest{OperationID: request.OperationID, URL: prepared.url})
158 }
159 if err != nil {
160 return FileBrowserPreviewResult{}, err
161 }
162 err = a.publishFileBrowserPreview(tab, generation, key, op, fileBrowserPreviewBinding{
163 BrowserTabID: browserTab.ID, URL: prepared.url, SessionGeneration: generation, release: prepared.release,
164 })
165 if reusable && binding.URL != prepared.url {
166 binding.release()
167 }
168 if err != nil {
169 if reusable {
170 a.filePreviews.retire(key, binding.URL)
171 }
172 // The request can be cancelled after the host has created the tab.
173 // Cleanup needs its own bounded context or Close would fail immediately.
174 cleanupCtx, cancelCleanup := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
175 defer cancelCleanup()
176 _ = exec.Close(cleanupCtx, browser.CloseRequest{OperationID: request.OperationID + "-stale-close", TabID: browserTab.ID})
177 return FileBrowserPreviewResult{}, err
178 }
179 published = true
180 if recovery, ok := exec.(interface {
181 rememberFilePreview(context.Context, string, FileBrowserPreviewRequest)
182 }); ok {
183 recovery.rememberFilePreview(ctx, browserTab.ID, request)
184 }
185 status := "opened"
186 errorText := browserTab.Error
187 if browserTab.Loading {
188 status = "loading"
189 }
190 if errorText != "" {
191 status = "failed"
192 }
193 return FileBrowserPreviewResult{TabID: browserTab.ID, URL: prepared.url, Status: status, Error: errorText, SessionGeneration: generation}, nil
194 }
195
196 func (a *App) fileBrowserPreviewTab(tabID string, expected uint64) (*WorkspaceTab, uint64, error) {
197 a.mu.RLock()
198 tab := a.tabs[tabID]
199 if tab == nil {
200 a.mu.RUnlock()
201 return nil, 0, errors.New("workspace tab is no longer available")
202 }
203 generation := tab.SessionGeneration
204 a.mu.RUnlock()
205 if expected != 0 && generation != expected {
206 return nil, generation, errors.New("the session changed before the preview opened")
207 }
208 return tab, generation, nil
209 }
210
211 func (a *App) prepareFileBrowserPreview(tabID string, request FileBrowserPreviewRequest) (preparedFileBrowserPreview, error) {
212 var preview FilePreview
213 var identity string
214 var err error
215 switch request.Source {
216 case "presented":
217 preview = a.ReadPresentedFileForTab(tabID, request.ToolCallID, request.Path)
218 identity, err = a.ResolvePresentedPathForTab(tabID, request.ToolCallID, request.Path)
219 case "reference":
220 preview = a.ReadReferenceFileForTab(tabID, request.Path)
221 identity, err = a.ResolveReferencePathForTab(tabID, request.Path)
222 default:
223 preview = a.ReadFileForTab(tabID, request.Path)
224 identity, err = a.ResolveWorkspacePathForTab(tabID, request.Path)
225 }
226 if err != nil {
227 if preview.URL != "" {
228 a.revokeWorkspaceMediaPath(preview.URL)
229 }
230 return preparedFileBrowserPreview{}, err
231 }
232 if preview.Err != "" {
233 if preview.URL != "" {
234 a.revokeWorkspaceMediaPath(preview.URL)
235 }
236 return preparedFileBrowserPreview{}, errors.New(preview.Err)
237 }
238 if preview.URL == "" {
239 return preparedFileBrowserPreview{}, errors.New("this file type cannot be opened in the built-in browser")
240 }
241 origin, err := a.ensureWorkspacePreviewOrigin()
242 if err != nil {
243 a.revokeWorkspaceMediaPath(preview.URL)
244 return preparedFileBrowserPreview{}, err
245 }
246 a.extendWorkspaceBrowserPreviewToken(preview.URL)
247 store := a.ensureMediaTokenStore()
248 token := strings.SplitN(strings.TrimPrefix(preview.URL, "/__reasonix_workspace_media/"), "/", 2)[0]
249 return preparedFileBrowserPreview{url: origin + preview.URL, identity: identity, release: func() { store.revoke(token) }}, nil
250 }
251
252 func (a *App) releaseFileBrowserPreviewTab(browserTabID string) {
253 if browserTabID == "" {
254 return
255 }
256 a.filePreviews.releaseMatching(func(_ string, binding fileBrowserPreviewBinding) bool { return binding.BrowserTabID == browserTabID })
257 }
258
259 func (a *App) releaseFileBrowserPreviewURL(rawURL string) {
260 if rawURL == "" {
261 return
262 }
263 a.filePreviews.releaseMatching(func(_ string, binding fileBrowserPreviewBinding) bool { return binding.URL == rawURL })
264 }
265
266 func (a *App) releaseFileBrowserPreviewsForTask(tabID string) {
267 if tabID == "" {
268 return
269 }
270 prefix := tabID + "\x00"
271 a.filePreviews.releaseMatching(func(key string, _ fileBrowserPreviewBinding) bool { return strings.HasPrefix(key, prefix) })
272 }
273
273 lines GO