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