返回 DeepSeek-Reasonix
attachment_targets.go
根目录 / desktop / attachment_targets.go
1 package main
2
3 import (
4 "context"
5 "crypto/rand"
6 "crypto/sha256"
7 "encoding/hex"
8 "fmt"
9 "strings"
10 "sync"
11
12 "reasonix/internal/attachment"
13 "reasonix/internal/control"
14 "reasonix/internal/event"
15 "reasonix/internal/session"
16 "reasonix/internal/sessioninbox"
17 )
18
19 type ComposerTarget struct {
20 Kind string `json:"kind"`
21 DraftID string `json:"draftId,omitempty"`
22 TabID string `json:"tabId,omitempty"`
23 Session *session.SessionRef `json:"session,omitempty"`
24 Generation uint64 `json:"generation,omitempty"`
25 }
26
27 type attachmentTargetState struct {
28 // Test hook after I/O and before target revalidation; set before use.
29 attachmentIOHook func()
30 attachmentJoinHook func()
31 attachmentTargetsMu sync.Mutex
32 attachmentTargets map[string]attachmentTarget
33 attachmentStageOps map[string]*attachmentStageOperation
34 }
35
36 type attachmentStageOperation struct {
37 fingerprint string
38 done chan struct{}
39 view DraftImageView
40 err error
41 }
42
43 func (a *App) releaseAttachmentStageOperations(ownerPrefix string) {
44 a.attachmentTargetsMu.Lock()
45 defer a.attachmentTargetsMu.Unlock()
46 for key, op := range a.attachmentStageOps {
47 if strings.HasPrefix(key, ownerPrefix) {
48 // In-flight operations retain their result until completion so existing
49 // waiters can observe the owner cancellation. Completed receipts can be
50 // dropped immediately with their credential family.
51 select {
52 case <-op.done:
53 delete(a.attachmentStageOps, key)
54 default:
55 }
56 }
57 }
58 }
59
60 type AttachmentTargetView struct {
61 Token string `json:"token"`
62 Capabilities []string `json:"capabilities"`
63 }
64
65 func (a *App) attachmentContext() context.Context {
66 if a.ctx != nil {
67 return a.ctx
68 }
69 return context.Background()
70 }
71
72 func (a *App) attachmentOperationContext(target attachmentTarget) context.Context {
73 if target.ctx != nil {
74 return target.ctx
75 }
76 return a.attachmentContext()
77 }
78
79 // CaptureAttachmentTarget must run before browser file reads or hashing.
80 // The opaque token carries no client-selected filesystem authority.
81 func (a *App) CaptureAttachmentTarget(composer ComposerTarget) (AttachmentTargetView, error) {
82 empty := AttachmentTargetView{Capabilities: []string{}}
83 target, err := a.attachmentTargetForComposerTarget(composer)
84 if err != nil {
85 return empty, err
86 }
87 if target.draftID == "" {
88 if _, ok := target.ctrl.(*control.Controller); !ok {
89 return empty, fmt.Errorf("unsupported: attachments-v2")
90 }
91 }
92 var id [24]byte
93 if _, err := rand.Read(id[:]); err != nil {
94 return empty, err
95 }
96 token := hex.EncodeToString(id[:])
97 a.attachmentTargetsMu.Lock()
98 defer a.attachmentTargetsMu.Unlock()
99 if a.attachmentTargets == nil {
100 a.attachmentTargets = make(map[string]attachmentTarget)
101 }
102 for key, previous := range a.attachmentTargets {
103 if !a.attachmentTargetCurrent(previous) {
104 if previous.cancel != nil {
105 previous.cancel()
106 }
107 delete(a.attachmentTargets, key)
108 }
109 }
110 if len(a.attachmentTargets) >= 256 {
111 return empty, fmt.Errorf("too many outstanding attachment operations")
112 }
113 if controller, ok := target.ctrl.(*control.Controller); ok {
114 target.ctx, target.cancel = controller.NewAttachmentOperationContext(a.attachmentContext())
115 } else {
116 target.ctx, target.cancel = context.WithCancel(a.attachmentContext())
117 }
118 a.attachmentTargets[token] = target
119 return AttachmentTargetView{Token: token, Capabilities: []string{"attachments-v2"}}, nil
120 }
121
122 func (a *App) attachmentTargetToken(token string) (attachmentTarget, error) {
123 a.attachmentTargetsMu.Lock()
124 target, ok := a.attachmentTargets[token]
125 a.attachmentTargetsMu.Unlock()
126 if !ok || !a.attachmentTargetCurrent(target) {
127 return attachmentTarget{}, fmt.Errorf("attachment target changed; please retry")
128 }
129 return target, nil
130 }
131
132 func (a *App) ReleaseAttachmentTarget(token string) {
133 a.attachmentTargetsMu.Lock()
134 if target, ok := a.attachmentTargets[token]; ok && target.cancel != nil {
135 target.cancel()
136 }
137 delete(a.attachmentTargets, token)
138 a.attachmentTargetsMu.Unlock()
139 }
140
141 func (a *App) StageImageForTarget(token, operationID, displayName, mime, dataURL string) (DraftImageView, error) {
142 target, err := a.attachmentTargetToken(token)
143 if err != nil {
144 return DraftImageView{}, err
145 }
146 operationID = strings.TrimSpace(operationID)
147 if operationID == "" {
148 return DraftImageView{}, fmt.Errorf("attachment operationId is required")
149 }
150 fingerprint, err := stageImageFingerprint(displayName, mime, dataURL)
151 if err != nil {
152 return DraftImageView{}, err
153 }
154 key := target.ownerIdentity + "\x00" + operationID
155 a.attachmentTargetsMu.Lock()
156 if a.attachmentStageOps == nil {
157 a.attachmentStageOps = make(map[string]*attachmentStageOperation)
158 }
159 if existing := a.attachmentStageOps[key]; existing != nil {
160 if existing.fingerprint != fingerprint {
161 a.attachmentTargetsMu.Unlock()
162 return DraftImageView{}, fmt.Errorf("attachment operation conflicts with different input")
163 }
164 done := existing.done
165 a.attachmentTargetsMu.Unlock()
166 if a.attachmentJoinHook != nil {
167 a.attachmentJoinHook()
168 }
169 select {
170 case <-done:
171 return existing.view, existing.err
172 case <-a.attachmentOperationContext(target).Done():
173 return DraftImageView{}, a.attachmentOperationContext(target).Err()
174 }
175 }
176 count := 0
177 for existingKey := range a.attachmentStageOps {
178 if strings.HasPrefix(existingKey, target.ownerIdentity+"\x00") {
179 count++
180 }
181 }
182 if count >= 256 {
183 a.attachmentTargetsMu.Unlock()
184 return DraftImageView{}, fmt.Errorf("too many outstanding attachment operations")
185 }
186 op := &attachmentStageOperation{fingerprint: fingerprint, done: make(chan struct{})}
187 a.attachmentStageOps[key] = op
188 a.attachmentTargetsMu.Unlock()
189
190 if target.draftID != "" {
191 rel, stageErr := control.SaveImageDataURLInRoot(target.root, dataURL)
192 if stageErr == nil {
193 op.view = DraftImageView{Path: rel, DisplayName: attachment.NormalizeDisplayName(displayName), MIME: strings.ToLower(strings.TrimSpace(mime))}
194 }
195 op.err = stageErr
196 } else {
197 op.view, op.err = a.stageImageForTarget(target, displayName, mime, dataURL)
198 }
199 invalidReason := a.attachmentTargetInvalidReason(target)
200 ownerCurrent := invalidReason == ""
201 if op.err == nil && !ownerCurrent {
202 op.view = DraftImageView{}
203 op.err = fmt.Errorf("attachment target changed (%s); please retry", invalidReason)
204 }
205 a.attachmentTargetsMu.Lock()
206 close(op.done)
207 if !ownerCurrent {
208 delete(a.attachmentStageOps, key)
209 }
210 a.attachmentTargetsMu.Unlock()
211 return op.view, op.err
212 }
213
214 func stageImageFingerprint(displayName, mime, dataURL string) (string, error) {
215 const marker = ";base64,"
216 before, encoded, ok := strings.Cut(dataURL, marker)
217 if !ok || !strings.HasPrefix(before, "data:") {
218 return "", fmt.Errorf("unsupported pasted image")
219 }
220 raw, err := decodeBase64(encoded)
221 if err != nil {
222 return "", err
223 }
224 digest := sha256.New()
225 _, _ = digest.Write(raw)
226 _, _ = digest.Write([]byte{0})
227 _, _ = digest.Write([]byte(strings.ToLower(strings.TrimSpace(mime))))
228 _, _ = digest.Write([]byte{0})
229 _, _ = digest.Write([]byte(attachment.NormalizeDisplayName(displayName)))
230 return hex.EncodeToString(digest.Sum(nil)), nil
231 }
232
233 func (a *App) ReadDraftImageForTarget(token, draftID string) (string, error) {
234 target, err := a.attachmentTargetToken(token)
235 if err != nil {
236 return "", err
237 }
238 if target.draftID != "" {
239 return control.ImageDataURLInRoot(target.root, draftID)
240 }
241 return a.readDraftImageForTarget(target, draftID)
242 }
243
244 func (a *App) SavePastedFileForTarget(token, name, dataURL string) (string, error) {
245 target, err := a.attachmentTargetToken(token)
246 if err != nil {
247 return "", err
248 }
249 rel, err := control.SaveAttachmentDataURLInRoot(target.root, name, dataURL)
250 return a.finishAttachmentWrite(target, rel, err)
251 }
252
253 func (a *App) SaveClipboardImageForTarget(token string) (string, error) {
254 target, err := a.attachmentTargetToken(token)
255 if err != nil {
256 return "", err
257 }
258 rel, err := control.SaveClipboardImageInRoot(target.root)
259 return a.finishAttachmentWrite(target, rel, err)
260 }
261
262 func (a *App) AttachmentDataURLForTarget(token, path string) (string, error) {
263 target, err := a.attachmentTargetToken(token)
264 if err != nil {
265 return "", err
266 }
267 return a.attachmentDataURLForTarget(target, path)
268 }
269
270 func (a *App) AttachDroppedForTarget(token, path string) (DroppedItem, error) {
271 target, err := a.attachmentTargetToken(token)
272 if err != nil {
273 return DroppedItem{}, err
274 }
275 return a.attachDroppedForTarget(target, path)
276 }
277
278 func (a *App) RebindDraftImageForTarget(token, draftID string) (DraftImageView, error) {
279 target, err := a.attachmentTargetToken(token)
280 if err != nil {
281 return DraftImageView{}, err
282 }
283 c, ok := target.ctrl.(*control.Controller)
284 if !ok {
285 return DraftImageView{}, fmt.Errorf("unsupported: draft attachment rebind")
286 }
287 draft, err := c.RebindDraftImage(a.attachmentOperationContext(target), draftID)
288 if err != nil {
289 return DraftImageView{}, err
290 }
291 if !a.attachmentTargetCurrent(target) {
292 return DraftImageView{}, fmt.Errorf("attachment target changed; please retry")
293 }
294 return draftImageView(draft), nil
295 }
296
297 func (a *App) ReleaseDraftImageForTarget(token, draftID string) error {
298 target, err := a.attachmentTargetToken(token)
299 if err != nil {
300 return err
301 }
302 if c, ok := target.ctrl.(*control.Controller); ok {
303 c.ReleaseDraftImage(draftID)
304 }
305 a.attachmentTargetsMu.Lock()
306 for key, op := range a.attachmentStageOps {
307 if strings.HasPrefix(key, target.ownerIdentity+"\x00") && op.view.DraftID == draftID {
308 delete(a.attachmentStageOps, key)
309 }
310 }
311 a.attachmentTargetsMu.Unlock()
312 return nil
313 }
314
315 // StartTurnForAttachmentTarget shares controller admission for all structured
316 // sources. beginTabTurn holds the runtime replacement barrier during admission.
317 func (a *App) StartTurnForAttachmentTarget(token, submissionID string, req control.SubmissionRequest) (TurnStartView, error) {
318 target, err := a.attachmentTargetToken(token)
319 if err != nil {
320 return TurnStartView{}, err
321 }
322 if submissionID == "" {
323 return TurnStartView{}, fmt.Errorf("submissionId is required")
324 }
325 req.ID = submissionID
326 c, ok := target.ctrl.(*control.Controller)
327 if !ok {
328 return TurnStartView{}, fmt.Errorf("attachment target is not a session")
329 }
330 if receipt, found, err := c.LookupSubmissionContext(a.attachmentOperationContext(target), req); found || err != nil {
331 return TurnStartView{TurnID: receipt.TurnID, SubmissionID: submissionID, Status: event.TurnQueued, Disposition: control.SubmitTurnStarted}, err
332 }
333 prepared, err := c.PrepareSubmission(a.attachmentOperationContext(target), req)
334 if err != nil {
335 return TurnStartView{}, err
336 }
337 admission, ctrl, err := a.beginTabTurn(target.tabID, true, submissionID)
338 if err != nil {
339 return TurnStartView{}, err
340 }
341 defer admission.abort()
342 if ctrl != target.ctrl || !a.attachmentTargetCurrent(target) {
343 return TurnStartView{}, fmt.Errorf("attachment target changed; please retry")
344 }
345 setup := func() error {
346 if req.Goal != "" {
347 if err := syncTabGoalToController(ctrl, req.Goal); err != nil {
348 return err
349 }
350 a.mu.Lock()
351 target.tab.goal = req.Goal
352 target.tab.toolApprovalMode = normalizeToolApprovalMode(req.ToolApprovalMode)
353 target.tab.mode = tabModeFromAxes(false, target.tab.toolApprovalMode == control.ToolApprovalYolo)
354 a.saveTabsLocked()
355 a.mu.Unlock()
356 ctrl.SetPlanMode(false)
357 applyTabToolApprovalModeToController(ctrl, normalizeToolApprovalMode(req.ToolApprovalMode))
358 }
359 return a.ensureTabTopicIndexedForUserTurn(target.tab)
360 }
361 receipt, err := c.SubmitPreparedWithSetup(a.attachmentOperationContext(target), prepared, setup)
362 if err != nil {
363 return TurnStartView{}, err
364 }
365 admission.finish(ctrl)
366 return TurnStartView{TurnID: receipt.TurnID, SubmissionID: submissionID, Status: event.TurnQueued, Disposition: control.SubmitTurnStarted}, nil
367 }
368
369 func (a *App) EnqueueForAttachmentTarget(token, submissionID, input, display string, invocations []control.InvocationRequest, attachments []control.SubmissionAttachment) (InboxReceiptView, error) {
370 target, err := a.attachmentTargetToken(token)
371 if err != nil {
372 return InboxReceiptView{}, err
373 }
374 a.runtimeAdmissionMu.RLock()
375 defer a.runtimeAdmissionMu.RUnlock()
376 if !a.attachmentTargetCurrent(target) {
377 return InboxReceiptView{}, fmt.Errorf("attachment target changed; please retry")
378 }
379 c, ok := target.ctrl.(*control.Controller)
380 if !ok {
381 return InboxReceiptView{}, fmt.Errorf("attachment target is not a session")
382 }
383 receipt, err := c.TryEnqueueFollowupContext(a.attachmentOperationContext(target), control.InboxRequest{Intent: sessioninbox.IntentFollowup, Display: display, Raw: input, Submit: input, Source: "desktop", Idempotency: submissionID, Invocations: invocations, Attachments: attachments})
384 if err != nil {
385 return InboxReceiptView{}, err
386 }
387 a.emitInboxChanged(target.tabID)
388 return InboxReceiptView{ItemID: receipt.ItemID, Paused: receipt.Paused}, nil
389 }
390
390 lines GO