返回 DeepSeek-Reasonix
remote_transcript_api.go
根目录 / desktop / remote_transcript_api.go
1 package main
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "io"
8 "net/http"
9 "net/url"
10 "strings"
11
12 "reasonix/internal/control"
13 "reasonix/internal/servecontract"
14 "reasonix/internal/session"
15 "reasonix/internal/sessioncontent"
16 "reasonix/internal/transcript"
17 )
18
19 type RemoteTranscriptSnapshot struct {
20 Supported bool `json:"supported"`
21 Snapshot *transcript.Snapshot `json:"snapshot,omitempty"`
22 }
23
24 func (a *App) remoteTranscriptRead(tabID, route string, request any, destination any) (bool, error) {
25 return a.remoteTranscriptReadAttempt(tabID, route, request, destination, true)
26 }
27
28 // remoteTranscriptReadAttempt retries one 409 after refreshing the remote
29 // identity: the serve may rotate its foreground while the desktop is reading
30 // (model switch, resume, takeover), and retrying the stale route would only
31 // repeat the conflict while a status refresh re-points the tab at the live
32 // session.
33 func (a *App) remoteTranscriptReadAttempt(tabID, route string, request, destination any, refreshOnConflict bool) (bool, error) {
34 client, base, err := a.remoteTabCommandClient(tabID)
35 if err != nil {
36 return false, err
37 }
38 a.remoteTabMu.Lock()
39 tab := a.remoteTabs[tabID]
40 if tab == nil || tab.client != client {
41 a.remoteTabMu.Unlock()
42 return false, fmt.Errorf("remote transcript runtime changed")
43 }
44 gen, sessionPath := tab.gen, tab.routing.currentPath
45 a.remoteTabMu.Unlock()
46 encoded, err := json.Marshal(request)
47 if err != nil {
48 return false, err
49 }
50 query := url.Values{"request": []string{string(encoded)}}
51 if sessionPath != "" {
52 query.Set("session", sessionPath)
53 }
54 ctx, cancel := commandContext(a)
55 requestClient := client
56 if route == "/transcript/follow" {
57 cancel()
58 ctx = a.bootContext()
59 if ctx == nil {
60 ctx = context.Background()
61 }
62 ctx, cancel = context.WithCancel(ctx)
63 // Keep credentials and transport, but let the subscription context
64 // own cancellation instead of the command client's total deadline.
65 streamClient := *client
66 streamClient.Timeout = 0
67 requestClient = &streamClient
68 }
69 defer cancel()
70 req, err := http.NewRequestWithContext(ctx, http.MethodGet, serveURL(base, route)+"?"+query.Encode(), nil)
71 if err != nil {
72 return false, err
73 }
74 response, err := requestClient.Do(req)
75 if err != nil {
76 return false, err
77 }
78 defer response.Body.Close()
79 const maxResponseBytes = transcript.MaxResponseBytes
80 body, err := io.ReadAll(io.LimitReader(response.Body, maxResponseBytes+1))
81 if err != nil {
82 return false, err
83 }
84 if len(body) > maxResponseBytes {
85 return false, fmt.Errorf("remote transcript response exceeds limit")
86 }
87 a.remoteTabMu.Lock()
88 current := a.remoteTabs[tabID]
89 valid := current == tab && current.gen == gen && current.client == client && current.routing.currentPath == sessionPath
90 a.remoteTabMu.Unlock()
91 if !valid {
92 return false, fmt.Errorf("remote transcript response belongs to a replaced session")
93 }
94 switch response.StatusCode {
95 case http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusNotImplemented:
96 return false, nil
97 case http.StatusConflict:
98 if refreshOnConflict {
99 if _, refreshErr := a.RemoteTabStatus(tabID); refreshErr == nil {
100 return a.remoteTranscriptReadAttempt(tabID, route, request, destination, false)
101 }
102 }
103 return false, fmt.Errorf("remote transcript read failed (HTTP %d)", response.StatusCode)
104 case http.StatusOK:
105 default:
106 return false, fmt.Errorf("remote transcript read failed (HTTP %d)", response.StatusCode)
107 }
108 // Old Serve builds may route an unknown GET to their HTML index. Only
109 // explicit protocol data enables the new projection; versions are not guessed.
110 var header struct {
111 ProtocolVersion int `json:"protocolVersion"`
112 Stale bool `json:"stale"`
113 }
114 if route != "/transcript/content" {
115 expected := transcript.ProtocolVersion
116 if route == "/transcript/follow" {
117 expected = transcript.FollowProtocolVersion
118 }
119 if json.Unmarshal(body, &header) != nil || header.ProtocolVersion != expected {
120 return false, nil
121 }
122 }
123 if err := json.Unmarshal(body, destination); err != nil {
124 return false, fmt.Errorf("invalid remote transcript response: %w", err)
125 }
126 return true, nil
127 }
128
129 func (a *App) RemoteTranscriptFollowForTab(tabID string, req transcript.FollowRequest) (control.TranscriptFollowResponse, error) {
130 var result control.TranscriptFollowResponse
131 a.remoteTabMu.Lock()
132 tab := a.remoteTabs[tabID]
133 compatible := tab != nil && tab.capabilities[servecontract.TranscriptV2]
134 a.remoteTabMu.Unlock()
135 if !compatible {
136 return result, fmt.Errorf("transcript v2 is required; upgrade Serve and Desktop together")
137 }
138 supported, err := a.remoteTranscriptRead(tabID, "/transcript/follow", req, &result)
139 if err == nil && !supported {
140 err = fmt.Errorf("transcript v2 is required; upgrade Serve and Desktop together")
141 }
142 return result, err
143 }
144
145 func (a *App) RemoteTranscriptSnapshotForTab(tabID string, req transcript.PageRequest) (RemoteTranscriptSnapshot, error) {
146 var snap transcript.Snapshot
147 supported, err := a.remoteTranscriptRead(tabID, "/transcript/snapshot", req, &snap)
148 if err != nil || !supported {
149 return RemoteTranscriptSnapshot{Supported: false}, err
150 }
151 return RemoteTranscriptSnapshot{Supported: true, Snapshot: &snap}, nil
152 }
153
154 func (a *App) RemoteTranscriptPageForTab(tabID string, req transcript.PageRequest) (transcript.Snapshot, error) {
155 var snap transcript.Snapshot
156 supported, err := a.remoteTranscriptRead(tabID, "/transcript/page", req, &snap)
157 if err == nil && !supported {
158 err = control.ErrTranscriptProjectionUnavailable
159 }
160 return snap, err
161 }
162
163 // RemoteTranscriptOutlineForTab reads the turn index a Serve advertises through
164 // the transcript-outline capability. An absent token means the route is not
165 // served at all, so the client keeps its loaded-turn rail instead of spending a
166 // round trip to learn that. Errors from an advertised capability are reported
167 // rather than downgraded to "unsupported".
168 func (a *App) RemoteTranscriptOutlineForTab(tabID string, req transcript.OutlineRequest) (transcript.OutlinePage, error) {
169 a.remoteTabMu.Lock()
170 tab := a.remoteTabs[tabID]
171 advertised := tab != nil && tab.capabilities[servecontract.TranscriptOutlineV1]
172 a.remoteTabMu.Unlock()
173 if !advertised {
174 return transcript.OutlinePage{}, control.ErrTranscriptProjectionUnavailable
175 }
176 var page transcript.OutlinePage
177 supported, err := a.remoteTranscriptRead(tabID, "/transcript/outline", req, &page)
178 if err == nil && !supported {
179 err = control.ErrTranscriptProjectionUnavailable
180 }
181 return page, err
182 }
183
184 func (a *App) RemoteTranscriptContentForTab(tabID string, req transcript.ContentRequest) (transcript.ContentChunk, error) {
185 var chunk transcript.ContentChunk
186 supported, err := a.remoteTranscriptRead(tabID, "/transcript/content", req, &chunk)
187 if err == nil && !supported {
188 err = control.ErrTranscriptProjectionUnavailable
189 }
190 return chunk, err
191 }
192
193 func (a *App) RemoteTranscriptReplayForTab(tabID string, req control.TranscriptReplayRequest) (control.TranscriptReplay, error) {
194 var replay control.TranscriptReplay
195 supported, err := a.remoteTranscriptRead(tabID, "/transcript/replay", req, &replay)
196 if err == nil && !supported {
197 err = control.ErrTranscriptProjectionUnavailable
198 }
199 return replay, err
200 }
201
202 // remoteSessionHistoryRead uses the capability negotiated during the
203 // authenticated Serve handshake. Unlike the compatibility transcript API,
204 // canonical history is session-ID based and does not accept arbitrary paths.
205 func (a *App) remoteSessionHistoryRead(tabID, route string, query url.Values, destination any, maxResponseBytes int64) (bool, error) {
206 client, base, err := a.remoteTabCommandClient(tabID)
207 if err != nil {
208 return false, err
209 }
210 a.remoteTabMu.Lock()
211 tab := a.remoteTabs[tabID]
212 if tab == nil || tab.client != client {
213 a.remoteTabMu.Unlock()
214 return false, fmt.Errorf("remote session history runtime changed")
215 }
216 requiredCapability := serveCapabilitySessions
217 switch route {
218 case "/session-history/content":
219 requiredCapability = serveCapabilitySessionContentV1
220 case "/session/open":
221 requiredCapability = serveCapabilitySessionReadV2
222 case "/session-history/window", "/session-message-field":
223 requiredCapability = serveCapabilityHistoryWindowV1
224 }
225 if !tab.capabilities[requiredCapability] {
226 a.remoteTabMu.Unlock()
227 return false, nil
228 }
229 gen, sessionID, sessionPath := tab.gen, tab.session.sessionID, tab.routing.currentPath
230 a.remoteTabMu.Unlock()
231 if query == nil {
232 query = make(url.Values)
233 }
234 if sessionID != "" {
235 query.Set("sessionId", sessionID)
236 }
237 ctx, cancel := commandContext(a)
238 defer cancel()
239 req, err := http.NewRequestWithContext(ctx, http.MethodGet, serveURL(base, route)+"?"+query.Encode(), nil)
240 if err != nil {
241 return false, err
242 }
243 response, err := client.Do(req)
244 if err != nil {
245 return false, err
246 }
247 defer response.Body.Close()
248 body, err := io.ReadAll(io.LimitReader(response.Body, maxResponseBytes+1))
249 if err != nil {
250 return false, err
251 }
252 if int64(len(body)) > maxResponseBytes {
253 return false, fmt.Errorf("remote session history response exceeds limit")
254 }
255 a.remoteTabMu.Lock()
256 current := a.remoteTabs[tabID]
257 valid := current == tab && current.gen == gen && current.client == client &&
258 current.session.sessionID == sessionID && current.routing.currentPath == sessionPath
259 a.remoteTabMu.Unlock()
260 if !valid {
261 return false, fmt.Errorf("remote session history response belongs to a replaced session")
262 }
263 switch response.StatusCode {
264 case http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusNotImplemented:
265 return false, nil
266 case http.StatusOK:
267 default:
268 return false, fmt.Errorf("remote session history read failed (HTTP %d): %s", response.StatusCode, strings.TrimSpace(string(body)))
269 }
270 if err := json.Unmarshal(body, destination); err != nil {
271 return false, fmt.Errorf("invalid remote session history response: %w", err)
272 }
273 return true, nil
274 }
275
276 // RemoteSessionHistoryPageForTab reads one fixed-snapshot canonical history
277 // page. The Serve enforces the 500-message and 2 MiB page budgets.
278 func (a *App) RemoteSessionOpenForTab(tabID string) (session.SessionOpenView, error) {
279 a.remoteTabMu.Lock()
280 tab := a.remoteTabs[tabID]
281 supportedRead := tab != nil && tab.capabilities[serveCapabilitySessionReadV2]
282 a.remoteTabMu.Unlock()
283 if !supportedRead {
284 return session.SessionOpenView{}, fmt.Errorf("remote Reasonix Serve does not support %s; upgrade the remote service", serveCapabilitySessionReadV2)
285 }
286 var view session.SessionOpenView
287 supported, err := a.remoteSessionHistoryRead(tabID, "/session/open", nil, &view, session.HistoryPageMaxBytes+(64<<10))
288 if err == nil && !supported {
289 err = control.ErrTranscriptProjectionUnavailable
290 }
291 return view, err
292 }
293
294 func (a *App) RemoteSessionHistoryPageForTab(tabID, cursor string, limit int) (session.MessageHistoryPage, error) {
295 query := make(url.Values)
296 if cursor != "" {
297 query.Set("cursor", cursor)
298 }
299 if limit > 0 {
300 query.Set("limit", fmt.Sprint(limit))
301 }
302 var page session.MessageHistoryPage
303 supported, err := a.remoteSessionHistoryRead(tabID, "/session-history/page", query, &page, session.HistoryPageMaxBytes+(64<<10))
304 if err == nil && !supported {
305 err = control.ErrTranscriptProjectionUnavailable
306 }
307 return page, err
308 }
309
310 // RemoteSessionHistoryContentForTab reads at most one MiB after Serve proves
311 // that the content reference belongs to the selected session.
312 func (a *App) RemoteSessionHistoryContentForTab(tabID string, ref sessioncontent.Ref, offset int64) (SessionHistoryContentChunk, error) {
313 if offset < 0 || offset > ref.Bytes {
314 return SessionHistoryContentChunk{}, fmt.Errorf("invalid session history content offset")
315 }
316 if offset == ref.Bytes {
317 return SessionHistoryContentChunk{NextOffset: offset, Done: true}, nil
318 }
319 length := min(int64(sessionHistoryContentChunkBytes), ref.Bytes-offset)
320 request, err := json.Marshal(map[string]any{"ref": ref, "offset": offset, "length": length})
321 if err != nil {
322 return SessionHistoryContentChunk{}, err
323 }
324 query := url.Values{"request": []string{string(request)}}
325 var chunk SessionHistoryContentChunk
326 supported, err := a.remoteSessionHistoryRead(tabID, "/session-history/content", query, &chunk, 2<<20)
327 if err == nil && !supported {
328 err = control.ErrTranscriptProjectionUnavailable
329 }
330 return chunk, err
331 }
332
333 // RemoteSessionHistoryWindowForTab pages a bounded window around an anchor
334 // through Serve. The history-window-v1 capability is required; an older
335 // remote service answers with an upgrade hint instead of simulating the
336 // window through full downloads.
337 func (a *App) RemoteSessionHistoryWindowForTab(tabID string, req session.HistoryWindowRequest) (session.HistoryWindowPage, error) {
338 a.remoteTabMu.Lock()
339 tab := a.remoteTabs[tabID]
340 supportedWindow := tab != nil && tab.capabilities[serveCapabilityHistoryWindowV1]
341 a.remoteTabMu.Unlock()
342 if !supportedWindow {
343 // A typed status, not an error: an older Serve is a capability answer
344 // the reader keeps working against (protocol-7 pages) rather than a
345 // failure, and the string carries the upgrade hint to the surface.
346 return session.HistoryWindowPage{Status: session.HistoryWindowUnsupported, Messages: []session.PersistentMessage{}}, nil
347 }
348 query := make(url.Values)
349 query.Set("anchor", req.Anchor)
350 if req.MessageID != "" {
351 query.Set("messageId", req.MessageID)
352 }
353 if req.Turn > 0 {
354 query.Set("turn", fmt.Sprint(req.Turn))
355 }
356 if req.Cursor != "" {
357 query.Set("cursor", req.Cursor)
358 }
359 if req.Direction != "" {
360 query.Set("direction", req.Direction)
361 }
362 if req.Limit > 0 {
363 query.Set("limit", fmt.Sprint(req.Limit))
364 }
365 var page session.HistoryWindowPage
366 supported, err := a.remoteSessionHistoryRead(tabID, "/session-history/window", query, &page, session.HistoryPageMaxBytes+(64<<10))
367 if err == nil && !supported {
368 err = control.ErrTranscriptProjectionUnavailable
369 }
370 return page, err
371 }
372
373 // RemoteSessionMessageFieldForTab reads one bounded fragment of one top-level
374 // message field through Serve.
375 func (a *App) RemoteSessionMessageFieldForTab(tabID, messageID string, version int, field string, offset, length int64) (session.MessageFieldPage, error) {
376 a.remoteTabMu.Lock()
377 tab := a.remoteTabs[tabID]
378 supportedWindow := tab != nil && tab.capabilities[serveCapabilityHistoryWindowV1]
379 a.remoteTabMu.Unlock()
380 if !supportedWindow {
381 return session.MessageFieldPage{Status: session.HistoryWindowUnsupported, MessageID: messageID, Field: field}, nil
382 }
383 query := url.Values{
384 "messageId": []string{messageID},
385 "field": []string{field},
386 "version": []string{fmt.Sprint(version)},
387 "offset": []string{fmt.Sprint(offset)},
388 "length": []string{fmt.Sprint(length)},
389 }
390 var page session.MessageFieldPage
391 supported, err := a.remoteSessionHistoryRead(tabID, "/session-message-field", query, &page, 512<<10)
392 if err == nil && !supported {
393 err = control.ErrTranscriptProjectionUnavailable
394 }
395 return page, err
396 }
397
398 func (a *App) RemoteSearchSessionHistoryForTab(tabID, textQuery, cursor string, limit int) (session.SearchHistoryPage, error) {
399 query := url.Values{"q": []string{textQuery}}
400 if cursor != "" {
401 query.Set("cursor", cursor)
402 }
403 if limit > 0 {
404 query.Set("limit", fmt.Sprint(limit))
405 }
406 var page session.SearchHistoryPage
407 supported, err := a.remoteSessionHistoryRead(tabID, "/session-history/search", query, &page, session.HistoryPageMaxBytes+(64<<10))
408 if err == nil && !supported {
409 err = control.ErrTranscriptProjectionUnavailable
410 }
411 return page, err
412 }
413
414 func (a *App) RemoteLocateSessionMessageForTab(tabID, messageID string, snapshot uint64) (session.MessageLocation, error) {
415 query := url.Values{"messageId": []string{messageID}}
416 if snapshot > 0 {
417 query.Set("snapshot", fmt.Sprint(snapshot))
418 }
419 var location session.MessageLocation
420 supported, err := a.remoteSessionHistoryRead(tabID, "/session-history/locate", query, &location, 64<<10)
421 if err == nil && !supported {
422 err = control.ErrTranscriptProjectionUnavailable
423 }
424 return location, err
425 }
426
426 lines GO