返回 DeepSeek-Reasonix
attachments_app.go
根目录 / desktop / attachments_app.go
1 package main
2
3 import (
4 "context"
5 "encoding/base64"
6 "fmt"
7 "os"
8 "path/filepath"
9 "strings"
10
11 "reasonix/internal/attachment"
12 "reasonix/internal/control"
13 )
14
15 type attachmentTarget struct {
16 tab *WorkspaceTab
17 tabID string
18 draftID string
19 draftGeneration uint64
20 root string
21 generation uint64
22 ctrl control.SessionAPI
23 ctx context.Context
24 cancel context.CancelFunc
25 ownerIdentity string
26 ownerBound bool
27 runtimeEpoch string
28 }
29
30 func (a *App) attachmentTargetForComposerTarget(composer ComposerTarget) (attachmentTarget, error) {
31 if composer.Kind != "draft" {
32 return a.attachmentTargetForTab(composer.TabID)
33 }
34 record, err := a.draftStore().Get(a.bootContext(), strings.TrimSpace(composer.DraftID))
35 if err != nil {
36 return attachmentTarget{}, err
37 }
38 root := record.WorkspaceRoot
39 if record.Scope != "project" {
40 root = globalWorkspaceRoot()
41 }
42 absRoot, err := filepath.Abs(root)
43 if err != nil {
44 return attachmentTarget{}, err
45 }
46 return attachmentTarget{
47 draftID: record.ID, draftGeneration: composer.Generation, root: absRoot,
48 ownerIdentity: fmt.Sprintf("draft:%s:%d", record.ID, composer.Generation),
49 }, nil
50 }
51
52 func attachmentWorkspaceRoot(tab *WorkspaceTab) (string, error) {
53 root := strings.TrimSpace(tab.WorkspaceRoot)
54 if root == "" {
55 if tab.Scope == "project" {
56 return "", fmt.Errorf("attachment workspace is not ready")
57 }
58 root = globalWorkspaceRoot()
59 }
60 absRoot, err := filepath.Abs(root)
61 if err != nil {
62 return "", err
63 }
64 return absRoot, nil
65 }
66
67 func (a *App) attachmentTargetForTab(tabID string) (attachmentTarget, error) {
68 tab, _ := a.tabAndCtrlByID(tabID)
69 if tab == nil {
70 return attachmentTarget{}, fmt.Errorf("attachment workspace is no longer available")
71 }
72 a.reconcileTabWithPinnedSessionMeta(tab)
73 a.mu.RLock()
74 defer a.mu.RUnlock()
75 if a.tabs[tab.ID] != tab || tab.removed {
76 return attachmentTarget{}, fmt.Errorf("attachment workspace is no longer available")
77 }
78 root, err := attachmentWorkspaceRoot(tab)
79 if err != nil {
80 return attachmentTarget{}, err
81 }
82 target := attachmentTarget{
83 tab: tab, tabID: tab.ID, root: root, generation: tab.SessionGeneration, ctrl: tab.Ctrl,
84 runtimeEpoch: a.runtimeEpochForTabLocked(tab),
85 }
86 if c, ok := tab.Ctrl.(*control.Controller); ok {
87 target.ownerIdentity = c.AttachmentOwnerIdentity()
88 target.ownerBound = strings.Trim(target.ownerIdentity, "\x00") != ""
89 }
90 if !target.ownerBound {
91 target.ownerIdentity = fmt.Sprintf("tab:%s:%d:%s", tab.ID, tab.SessionGeneration, target.runtimeEpoch)
92 }
93 return target, nil
94 }
95
96 func (a *App) attachmentTargetCurrent(target attachmentTarget) bool {
97 return a.attachmentTargetInvalidReason(target) == ""
98 }
99
100 func (a *App) attachmentTargetInvalidReason(target attachmentTarget) string {
101 if target.tab == nil {
102 if target.draftID == "" {
103 return "missing owner"
104 }
105 record, err := a.draftStore().Get(a.bootContext(), target.draftID)
106 if err != nil || record.Status != "active" {
107 return "draft is no longer active"
108 }
109 root := record.WorkspaceRoot
110 if record.Scope != "project" {
111 root = globalWorkspaceRoot()
112 }
113 absRoot, err := filepath.Abs(root)
114 if err != nil || !sameProjectRoot(absRoot, target.root) {
115 return "workspace changed"
116 }
117 return ""
118 }
119 a.mu.RLock()
120 defer a.mu.RUnlock()
121 tab := a.tabs[target.tabID]
122 if tab != target.tab || tab.removed {
123 return "tab closed"
124 }
125 if tab.SessionGeneration != target.generation {
126 return "session generation changed"
127 }
128 if tab.Ctrl != target.ctrl {
129 return "runtime changed"
130 }
131 if a.runtimeEpochForTabLocked(tab) != target.runtimeEpoch {
132 return "runtime epoch changed"
133 }
134 if c, ok := tab.Ctrl.(*control.Controller); ok && target.ownerBound && c.AttachmentOwnerIdentity() != target.ownerIdentity {
135 return "attachment owner changed"
136 }
137 root, err := attachmentWorkspaceRoot(tab)
138 if err != nil || !sameProjectRoot(root, target.root) {
139 return "workspace changed"
140 }
141 return ""
142 }
143
144 func (a *App) finishAttachmentWrite(target attachmentTarget, rel string, err error) (string, error) {
145 if err != nil {
146 return "", err
147 }
148 if a.attachmentIOHook != nil {
149 a.attachmentIOHook()
150 }
151 if a.attachmentTargetCurrent(target) {
152 return rel, nil
153 }
154 if isAttachmentRefPath(rel) {
155 _ = os.Remove(filepath.Join(target.root, filepath.FromSlash(rel)))
156 }
157 return "", fmt.Errorf("attachment workspace changed while saving; please retry")
158 }
159
160 func isAttachmentRefPath(path string) bool {
161 path = filepath.ToSlash(filepath.Clean(filepath.FromSlash(path)))
162 return strings.HasPrefix(path, ".reasonix/attachments/")
163 }
164
165 // SavePastedImage stores a browser clipboard image data URL under the active
166 // tab's workspace .reasonix/attachments and returns the relative @-reference path.
167 func (a *App) SavePastedImage(dataURL string) (string, error) {
168 target, err := a.attachmentTargetForTab("")
169 if err != nil {
170 return "", err
171 }
172 rel, saveErr := control.SaveImageDataURLInRoot(target.root, dataURL)
173 return a.finishAttachmentWrite(target, rel, saveErr)
174 }
175
176 func (a *App) SavePastedImageForTab(tabID, dataURL string) (string, error) {
177 target, err := a.attachmentTargetForTab(tabID)
178 if err != nil {
179 return "", err
180 }
181 rel, saveErr := control.SaveImageDataURLInRoot(target.root, dataURL)
182 return a.finishAttachmentWrite(target, rel, saveErr)
183 }
184
185 // SaveClipboardImage reads the native OS clipboard image under the active tab's
186 // workspace .reasonix/attachments and returns the relative @-reference path.
187 func (a *App) SaveClipboardImage() (string, error) {
188 target, err := a.attachmentTargetForTab("")
189 if err != nil {
190 return "", err
191 }
192 rel, saveErr := control.SaveClipboardImageInRoot(target.root)
193 return a.finishAttachmentWrite(target, rel, saveErr)
194 }
195
196 func (a *App) SaveClipboardImageForTab(tabID string) (string, error) {
197 target, err := a.attachmentTargetForTab(tabID)
198 if err != nil {
199 return "", err
200 }
201 rel, saveErr := control.SaveClipboardImageInRoot(target.root)
202 return a.finishAttachmentWrite(target, rel, saveErr)
203 }
204
205 // SavePastedFile stores a dropped non-image file (the browser exposes its bytes
206 // as a data URL but not a real path) under the active tab's workspace
207 // .reasonix/attachments and returns the relative @-reference path.
208 func (a *App) SavePastedFile(name, dataURL string) (string, error) {
209 target, err := a.attachmentTargetForTab("")
210 if err != nil {
211 return "", err
212 }
213 rel, saveErr := control.SaveAttachmentDataURLInRoot(target.root, name, dataURL)
214 return a.finishAttachmentWrite(target, rel, saveErr)
215 }
216
217 func (a *App) SavePastedFileForTab(tabID, name, dataURL string) (string, error) {
218 target, err := a.attachmentTargetForTab(tabID)
219 if err != nil {
220 return "", err
221 }
222 rel, saveErr := control.SaveAttachmentDataURLInRoot(target.root, name, dataURL)
223 return a.finishAttachmentWrite(target, rel, saveErr)
224 }
225
226 // AttachmentDataURL returns a safe data URL for a stored image attachment.
227 func (a *App) AttachmentDataURL(path string) (string, error) {
228 target, err := a.attachmentTargetForTab("")
229 if err != nil {
230 return "", err
231 }
232 return a.attachmentDataURLForTarget(target, path)
233 }
234
235 func (a *App) AttachmentDataURLForTab(tabID, path string) (string, error) {
236 target, err := a.attachmentTargetForTab(tabID)
237 if err != nil {
238 return "", err
239 }
240 return a.attachmentDataURLForTarget(target, path)
241 }
242
243 func (a *App) attachmentDataURLForTarget(target attachmentTarget, path string) (string, error) {
244 dataURL, readErr := control.ImageDataURLInRoot(target.root, path)
245 if readErr != nil {
246 return "", readErr
247 }
248 if a.attachmentIOHook != nil {
249 a.attachmentIOHook()
250 }
251 if !a.attachmentTargetCurrent(target) {
252 return "", fmt.Errorf("attachment workspace changed while reading; please retry")
253 }
254 return dataURL, nil
255 }
256
257 // DroppedItem is one OS-dropped file resolved into a composer context entry.
258 type DroppedItem struct {
259 Kind string `json:"kind"`
260 Path string `json:"path"`
261 IsDir bool `json:"isDir,omitempty"`
262 DisplayPath string `json:"displayPath,omitempty"`
263 PreviewURL string `json:"previewUrl,omitempty"`
264 }
265
266 const attachmentsCapabilityV1 = "attachments-v1"
267
268 type DraftImageView struct {
269 DraftID string `json:"draftId"`
270 Path string `json:"path,omitempty"`
271 DisplayName string `json:"displayName"`
272 MIME string `json:"mime"`
273 Width int `json:"width"`
274 Height int `json:"height"`
275 Bytes int64 `json:"bytes"`
276 }
277
278 func (a *App) StageImageForTab(tabID, operationID, displayName, mime, dataURL string) (DraftImageView, error) {
279 target, err := a.attachmentTargetForTab(tabID)
280 if err != nil {
281 return DraftImageView{}, err
282 }
283 return a.stageImageForTarget(target, displayName, mime, dataURL)
284 }
285
286 func (a *App) stageImageForTarget(target attachmentTarget, displayName, mime, dataURL string) (DraftImageView, error) {
287 identified, ok := target.ctrl.(*control.Controller)
288 if !ok {
289 return DraftImageView{}, fmt.Errorf("unsupported: %s", attachmentsCapabilityV1)
290 }
291 draft, err := identified.StageImage(a.attachmentOperationContext(target), displayName, mime, dataURL)
292 if a.attachmentIOHook != nil {
293 a.attachmentIOHook()
294 }
295 if !a.attachmentTargetCurrent(target) {
296 identified.ReleaseDraftImage(draft.ID)
297 return DraftImageView{}, fmt.Errorf("attachment workspace changed while saving; please retry")
298 }
299 if err != nil {
300 return DraftImageView{}, err
301 }
302 return draftImageView(draft), nil
303 }
304
305 func (a *App) ReadDraftImageForTab(tabID, draftID string) (string, error) {
306 target, err := a.attachmentTargetForTab(tabID)
307 if err != nil {
308 return "", err
309 }
310 return a.readDraftImageForTarget(target, draftID)
311 }
312
313 func (a *App) readDraftImageForTarget(target attachmentTarget, draftID string) (string, error) {
314 identified, ok := target.ctrl.(*control.Controller)
315 if !ok {
316 return "", fmt.Errorf("unsupported: %s", attachmentsCapabilityV1)
317 }
318 draft, raw, err := identified.ReadDraftImage(a.attachmentOperationContext(target), draftID)
319 if a.attachmentIOHook != nil {
320 a.attachmentIOHook()
321 }
322 if !a.attachmentTargetCurrent(target) {
323 return "", fmt.Errorf("attachment workspace changed while reading; please retry")
324 }
325 if err != nil {
326 return "", err
327 }
328 return attachment.DataURL(draft.MIME, raw), nil
329 }
330
331 func (a *App) ReadSessionAttachmentForTab(tabID, digest string, offset int64) (SessionHistoryContentChunk, error) {
332 target, err := a.attachmentTargetForTab(tabID)
333 if err != nil {
334 return SessionHistoryContentChunk{}, err
335 }
336 ctx := a.attachmentContext()
337 var cancel context.CancelFunc
338 if controller, ok := target.ctrl.(*control.Controller); ok {
339 ctx, cancel = controller.NewAttachmentOperationContext(ctx)
340 } else {
341 ctx, cancel = context.WithCancel(ctx)
342 }
343 defer cancel()
344 target.ctx = ctx
345 if err := ctx.Err(); err != nil {
346 return SessionHistoryContentChunk{}, err
347 }
348 query, sessionRef, err := a.canonicalSessionQuery(tabID)
349 if err != nil {
350 return SessionHistoryContentChunk{}, err
351 }
352 if !a.attachmentTargetCurrent(target) {
353 return SessionHistoryContentChunk{}, fmt.Errorf("attachment workspace changed while reading; please retry")
354 }
355 data, total, err := query.ReadSessionAttachment(ctx, sessionRef, digest, offset, sessionHistoryContentChunkBytes)
356 if a.attachmentIOHook != nil {
357 a.attachmentIOHook()
358 }
359 if !a.attachmentTargetCurrent(target) {
360 return SessionHistoryContentChunk{}, fmt.Errorf("attachment workspace changed while reading; please retry")
361 }
362 if err != nil {
363 return SessionHistoryContentChunk{}, err
364 }
365 next := offset + int64(len(data))
366 return SessionHistoryContentChunk{
367 Data: base64.StdEncoding.EncodeToString(data),
368 NextOffset: next,
369 Done: next >= total,
370 }, nil
371 }
372
373 func (a *App) ReleaseDraftImageForTab(tabID, draftID string) error {
374 target, err := a.attachmentTargetForTab(tabID)
375 if err != nil {
376 return err
377 }
378 identified, ok := target.ctrl.(*control.Controller)
379 if !ok {
380 return fmt.Errorf("unsupported: %s", attachmentsCapabilityV1)
381 }
382 identified.ReleaseDraftImage(draftID)
383 return nil
384 }
385
386 func draftImageView(draft attachment.DraftCredential) DraftImageView {
387 return DraftImageView{
388 DraftID: draft.ID,
389 DisplayName: draft.DisplayName,
390 MIME: draft.MIME,
391 Width: draft.Width,
392 Height: draft.Height,
393 Bytes: draft.Bytes,
394 }
395 }
396
397 func (a *App) AttachDropped(path string) (DroppedItem, error) {
398 return a.attachDroppedForTab("", path)
399 }
400
401 func (a *App) AttachDroppedForTab(tabID, path string) (DroppedItem, error) {
402 return a.attachDroppedForTab(tabID, path)
403 }
404
405 func (a *App) attachDroppedForTab(tabID, path string) (DroppedItem, error) {
406 target, err := a.attachmentTargetForTab(tabID)
407 if err != nil {
408 return DroppedItem{}, err
409 }
410 return a.attachDroppedForTarget(target, path)
411 }
412
413 func (a *App) attachDroppedForTarget(target attachmentTarget, path string) (DroppedItem, error) {
414 info, err := os.Lstat(path)
415 if err != nil {
416 return DroppedItem{}, err
417 }
418 if isImageExt(path) {
419 if rel, saveErr := control.SaveImageFileInRoot(target.root, path); saveErr == nil {
420 rel, saveErr = a.finishAttachmentWrite(target, rel, nil)
421 if saveErr != nil {
422 return DroppedItem{}, saveErr
423 }
424 preview, previewErr := a.attachmentDataURLForTarget(target, rel)
425 if previewErr != nil {
426 _ = os.Remove(filepath.Join(target.root, filepath.FromSlash(rel)))
427 return DroppedItem{}, previewErr
428 }
429 return DroppedItem{Kind: "attachment", Path: rel, PreviewURL: preview}, nil
430 } else {
431 return DroppedItem{}, saveErr
432 }
433 }
434 if rel, ok := workspaceRelativeIn(path, target.root); ok {
435 if !a.attachmentTargetCurrent(target) {
436 return DroppedItem{}, fmt.Errorf("attachment workspace changed while reading; please retry")
437 }
438 return DroppedItem{Kind: "workspace", Path: rel, IsDir: info.IsDir()}, nil
439 }
440 if info.IsDir() {
441 if target.tab == nil || target.ctrl == nil {
442 return DroppedItem{}, fmt.Errorf("workspace is not ready")
443 }
444 if err := a.ensureTabControllerWorkspace(target.tab); err != nil {
445 return DroppedItem{}, err
446 }
447 refreshed, err := a.attachmentTargetForTab(target.tabID)
448 if err != nil || refreshed.tab != target.tab || refreshed.generation != target.generation ||
449 !sameProjectRoot(refreshed.root, target.root) {
450 return DroppedItem{}, fmt.Errorf("attachment workspace is no longer available")
451 }
452 target = refreshed
453 ctrl := target.ctrl
454 if ctrl == nil || !a.attachmentTargetCurrent(target) {
455 return DroppedItem{}, fmt.Errorf("attachment workspace is no longer available")
456 }
457 token, displayPath, err := ctrl.RegisterExternalFolderRef(path)
458 if err != nil {
459 return DroppedItem{}, err
460 }
461 return DroppedItem{Kind: "workspace", Path: token, IsDir: true, DisplayPath: displayPath}, nil
462 }
463 rel, err := control.SaveAttachmentFileInRoot(target.root, path)
464 if err != nil {
465 return DroppedItem{}, err
466 }
467 rel, err = a.finishAttachmentWrite(target, rel, nil)
468 if err != nil {
469 return DroppedItem{}, err
470 }
471 return DroppedItem{Kind: "attachment", Path: rel}, nil
472 }
473
474 func isImageExt(path string) bool {
475 switch strings.ToLower(filepath.Ext(path)) {
476 case ".png", ".jpg", ".jpeg", ".gif", ".webp":
477 return true
478 }
479 return false
480 }
481
482 func workspaceRelativeIn(path, workspaceRoot string) (string, bool) {
483 root := workspaceRoot
484 if !filepath.IsAbs(root) {
485 abs, err := filepath.Abs(root)
486 if err != nil {
487 return "", false
488 }
489 root = abs
490 }
491 rel, err := filepath.Rel(root, path)
492 if err != nil {
493 return "", false
494 }
495 if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
496 return "", false
497 }
498 return filepath.ToSlash(rel), true
499 }
500
500 lines GO