返回 DeepSeek-Reasonix
session_ai_title.go
根目录 / desktop / session_ai_title.go
1 package main
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "strings"
8
9 "reasonix/internal/agent"
10 "reasonix/internal/boot"
11 "reasonix/internal/config"
12 "reasonix/internal/control"
13 "reasonix/internal/provider"
14 "reasonix/internal/sessioncatalog"
15 )
16
17 const (
18 aiSessionTitleMaxTurns = 3
19 aiSessionTitleMaxTurnRunes = 500
20 )
21
22 type aiSessionTitleOperation struct {
23 ID string
24 Cancel context.CancelCauseFunc
25 }
26
27 // AIRenameSession generates a title from the explicitly targeted durable
28 // conversation. The argument also accepts a canonical session route or legacy
29 // path, which takes precedence over the compatibility topic lookup.
30 func (a *App) AIRenameSession(topicID string) (string, error) {
31 topicID = strings.TrimSpace(topicID)
32 if topicID == "" {
33 return "", newSessionOperationError(sessionOperationTargetNotFound, "The session no longer exists.")
34 }
35 selector := sessionTargetSelector{TopicID: topicID}
36 if _, ok := parseSessionRoute(topicID); ok || strings.ContainsAny(topicID, `/\`) {
37 selector = sessionTargetSelector{SessionPath: topicID}
38 }
39 result, err := a.AIRenameSessionTarget(selector)
40 return result.Title, err
41 }
42
43 // AIRenameSessionTarget generates and commits a title for one explicit durable
44 // target without selecting it or constructing a conversation controller.
45 func (a *App) AIRenameSessionTarget(selector SessionSelector) (SessionMutationResult, error) {
46 target, err := a.resolveSessionMutationTarget(selector)
47 if err != nil {
48 return SessionMutationResult{}, err
49 }
50 key := target.key()
51 operationID := "title-ai-" + strings.TrimPrefix(newTabID(), "tab_")
52 if strings.TrimSpace(target.SessionRef.SessionID) == "" && strings.TrimSpace(target.SessionPath) == "" {
53 return SessionMutationResult{}, sessionOperationErrorForTarget(
54 newSessionOperationError(sessionOperationNoMessages, "This session has no user messages to analyze."),
55 key, operationID,
56 )
57 }
58 operationCtx, operationCancel := context.WithCancelCause(a.bootContext())
59 a.aiSessionTitleMu.Lock()
60 if a.aiSessionTitleInFlight == nil {
61 a.aiSessionTitleInFlight = map[string]aiSessionTitleOperation{}
62 }
63 if _, busy := a.aiSessionTitleInFlight[key]; busy {
64 a.aiSessionTitleMu.Unlock()
65 operationCancel(context.Canceled)
66 return SessionMutationResult{}, sessionOperationErrorForTarget(
67 newSessionOperationError(sessionOperationBusy, "AI rename is already running for this session."),
68 key, operationID,
69 )
70 }
71 a.aiSessionTitleInFlight[key] = aiSessionTitleOperation{ID: operationID, Cancel: operationCancel}
72 a.aiSessionTitleMu.Unlock()
73 defer func() {
74 operationCancel(context.Canceled)
75 a.finishAISessionTitle(key, operationID)
76 }()
77
78 var title string
79 if strings.TrimSpace(target.SessionRef.SessionID) != "" {
80 title, err = a.aiRenameCanonicalSession(operationCtx, target)
81 } else {
82 title, err = a.aiRenameLegacySession(operationCtx, target)
83 }
84 if err != nil {
85 if cause := context.Cause(operationCtx); cause != nil && !errors.Is(cause, context.Canceled) {
86 err = cause
87 }
88 return SessionMutationResult{}, sessionOperationErrorForTarget(err, key, operationID)
89 }
90 result := SessionMutationResult{
91 TargetKey: key, OperationID: operationID, Committed: true, Title: title,
92 LifecycleGeneration: target.LifecycleGeneration,
93 }
94 if target.SessionRef.SessionID != "" {
95 if info, statErr := a.desktopSessionService("").Query().Stat(a.bootContext(), target.SessionRef); statErr == nil {
96 result.TitleVersion = titleSequenceVersion(info.TitleSequence)
97 } else {
98 result.ProjectionPending = true
99 }
100 } else if _, revision, revisionErr := agent.SessionTitleSnapshot(target.SessionPath); revisionErr == nil {
101 result.TitleVersion = revision
102 } else {
103 result.ProjectionPending = true
104 }
105 a.emitSessionTargetChange("session_metadata_changed", SessionTargetChangeEvent{
106 TargetKey: key, OperationID: operationID, LifecycleGeneration: target.LifecycleGeneration,
107 Title: title, WorkspaceID: target.WorkspaceID,
108 })
109 return result, nil
110 }
111
112 func (a *App) aiRenameLegacySession(ctx context.Context, target SessionTarget) (string, error) {
113 sessionDir, validated, err := a.sessionDirForPath(target.SessionPath)
114 if err != nil {
115 return "", newSessionOperationError(sessionOperationTargetNotFound, "The session no longer exists.")
116 }
117 expectedRevision := ""
118 modelRef := ""
119 if meta, ok, loadErr := agent.LoadBranchMeta(validated); loadErr != nil {
120 return "", fmt.Errorf("AI rename session: read current title: %w", loadErr)
121 } else if ok {
122 modelRef = meta.Model
123 }
124 _, expectedRevision, err = agent.SessionTitleSnapshot(validated)
125 if err != nil {
126 return "", fmt.Errorf("AI rename session: read title revision: %w", err)
127 }
128 users, err := loadTopicTitleUserTurnsFromSession(validated)
129 if err != nil {
130 return "", fmt.Errorf("AI rename session: read conversation: %w", err)
131 }
132 if len(users) == 0 {
133 return "", newSessionOperationError(sessionOperationNoMessages, "This session has no user messages to analyze.")
134 }
135 title, err := a.generateTargetSessionTitle(ctx, target, modelRef, sessionTitleTranscript(users))
136 if err != nil {
137 return "", err
138 }
139 a.lifecycleCheckpoint("session-title-before-commit")
140 a.sessionRemovalMu.Lock()
141 defer a.sessionRemovalMu.Unlock()
142 a.topicTitleMutationMu.Lock()
143 defer a.topicTitleMutationMu.Unlock()
144 if err := ctx.Err(); err != nil {
145 return "", err
146 }
147 if a.sessionTargetRuntimeRebound(target) {
148 return "", newSessionOperationError("target_changed", "The session moved or changed state. Try again.")
149 }
150 // A legacy source may have been imported and archived while generation
151 // was in flight. Original files remain on disk after canonical archive.
152 if _, found, err := a.legacyCanonicalRef(ctx, validated); err != nil {
153 return "", err
154 } else if found {
155 return "", newSessionOperationError("target_changed", "The session moved or changed state. Try again.")
156 }
157 if err := a.renameSessionInDirIfTitleUnchanged(sessionDir, validated, expectedRevision, title); err != nil {
158 if errors.Is(err, agent.ErrSessionTitleChanged) {
159 return "", newSessionOperationError(sessionOperationTitleConflict, "The session title changed while AI rename was running. Try again.")
160 }
161 return "", err
162 }
163 return title, nil
164 }
165
166 // The model round trip is already bounded where it is made: control's session
167 // title call gives the provider its own budget. This operation therefore runs
168 // on the caller's cancellation only, like aiRenameLegacySession. A second
169 // host-level wall clock here would also bound the durable snapshot, the flush,
170 // the history projection TitleMessages builds and the conditional commit —
171 // work whose cost scales with the conversation, not with provider health — so
172 // a long session on a slow host would lose an already generated title to a
173 // deadline that belongs to the provider.
174 func (a *App) aiRenameCanonicalSession(ctx context.Context, target SessionTarget) (string, error) {
175 service := a.desktopSessionService("")
176 ref := target.SessionRef
177 snapshot, err := service.Query().Snapshot(ctx, ref)
178 if err != nil {
179 return "", fmt.Errorf("AI rename session: read current title: %w", err)
180 }
181 // Publish accepted user turns before querying durable history when the
182 // target has a live runtime. Cold sessions are already fully durable.
183 if runtime, ok := service.Runtime(ref); ok {
184 if _, err := runtime.Session().Flush(ctx); err != nil {
185 return "", fmt.Errorf("AI rename session: flush conversation: %w", err)
186 }
187 }
188 messages, err := service.Query().TitleMessages(ctx, ref, aiSessionTitleMaxTurns)
189 if err != nil {
190 return "", fmt.Errorf("AI rename session: read conversation: %w", err)
191 }
192 var users []string
193 for _, message := range messages {
194 if content := topicTitleUserText(message); content != "" {
195 users = append(users, content)
196 }
197 }
198 if len(users) == 0 {
199 return "", newSessionOperationError(sessionOperationNoMessages, "This session has no user messages to analyze.")
200 }
201 title, err := a.generateTargetSessionTitle(ctx, target, snapshot.Projection.ModelRef, sessionTitleTranscript(users))
202 if err != nil {
203 return "", err
204 }
205 a.lifecycleCheckpoint("session-title-before-commit")
206 // Serialize against manual writes; the session title sequence is the
207 // authority even if another branch is created during provider generation.
208 a.sessionRemovalMu.Lock()
209 defer a.sessionRemovalMu.Unlock()
210 a.topicTitleMutationMu.Lock()
211 defer a.topicTitleMutationMu.Unlock()
212 if err := ctx.Err(); err != nil {
213 return "", err
214 }
215 if a.sessionTargetRuntimeRebound(target) {
216 return "", newSessionOperationError("target_changed", "The session moved or changed state. Try again.")
217 }
218 if err := a.workspaceRegistry().WithSessionUnchanged(ctx, ref.SessionID, target.WorkspaceID, target.LifecycleGeneration, func() error {
219 return service.SetTitleIfSequence(ctx, ref, snapshot.Projection.TitleSequence, title)
220 }); err != nil {
221 return "", sessionOperationConflict(err)
222 }
223 a.publishCanonicalSessionTitle(ref, title)
224 return title, nil
225 }
226
227 func (a *App) cancelAISessionTitle(targetKey string) {
228 targetKey = strings.TrimSpace(targetKey)
229 if targetKey == "" {
230 return
231 }
232 a.aiSessionTitleMu.Lock()
233 operation := a.aiSessionTitleInFlight[targetKey]
234 if operation.ID != "" {
235 delete(a.aiSessionTitleInFlight, targetKey)
236 }
237 a.aiSessionTitleMu.Unlock()
238 if operation.Cancel != nil {
239 operation.Cancel(newSessionOperationError(sessionOperationTitleConflict, "The session title changed while AI rename was running. Try again."))
240 }
241 }
242
243 func (a *App) invalidateAuxiliaryProviderOperations() {
244 if a == nil {
245 return
246 }
247 a.auxiliaryProviderGeneration.Add(1)
248 a.aiSessionTitleMu.Lock()
249 operations := make([]aiSessionTitleOperation, 0, len(a.aiSessionTitleInFlight))
250 for key, operation := range a.aiSessionTitleInFlight {
251 operations = append(operations, operation)
252 delete(a.aiSessionTitleInFlight, key)
253 }
254 a.aiSessionTitleMu.Unlock()
255 for _, operation := range operations {
256 if operation.Cancel != nil {
257 operation.Cancel(newSessionOperationError(
258 "provider_unavailable",
259 "The session's model provider changed while AI rename was running. Try again.",
260 ))
261 }
262 }
263 }
264
265 func (a *App) finishAISessionTitle(targetKey, operationID string) {
266 a.aiSessionTitleMu.Lock()
267 defer a.aiSessionTitleMu.Unlock()
268 // A manual rename can cancel an operation and a later request may already
269 // own the same target. A stale completion must not clear that newer
270 // request's deduplication entry.
271 if current, ok := a.aiSessionTitleInFlight[targetKey]; ok && current.ID == operationID {
272 delete(a.aiSessionTitleInFlight, targetKey)
273 }
274 }
275
276 func (a *App) generateTargetSessionTitle(ctx context.Context, target SessionTarget, modelRef, transcript string) (string, error) {
277 providerGeneration := a.auxiliaryProviderGeneration.Load()
278 if cause := context.Cause(ctx); cause != nil {
279 return "", cause
280 }
281 var (
282 title string
283 err error
284 )
285 if target.Controller != nil {
286 title, err = target.Controller.GenerateSessionTitleForModel(ctx, modelRef, transcript)
287 } else {
288 root := target.WorkspaceRoot
289 if target.Scope == "global" || root == "" {
290 root = globalWorkspaceRoot()
291 }
292 cfg, loadErr := config.LoadModelRuntimeSnapshot(root, modelRef)
293 if loadErr != nil {
294 return "", newSessionOperationError("provider_unavailable", "Unable to load model settings. Check the session's provider configuration.")
295 }
296 if modelRef == "" {
297 modelRef = cfg.DefaultModel
298 }
299 sessionID := strings.TrimSpace(target.SessionRef.SessionID)
300 if sessionID == "" {
301 sessionID = strings.TrimSpace(target.TopicID)
302 }
303 if sessionID == "" {
304 sessionID = sessionRuntimeKey(target.SessionPath)
305 }
306 handle, acquireErr := boot.AcquireAuxiliaryProvider(ctx, boot.AuxiliaryProviderRequest{
307 Config: cfg, SessionID: sessionID, WorkspaceRoot: root, ModelRef: modelRef,
308 })
309 if acquireErr != nil {
310 return "", newSessionOperationError("provider_unavailable", "The session's model provider is unavailable. Check its model or extension configuration.")
311 }
312 defer func() { _ = handle.Close() }()
313 title, err = control.GenerateSessionTitleWithResolver(ctx, handle.Resolver, modelRef, transcript)
314 }
315 if err != nil {
316 return "", err
317 }
318 if cause := context.Cause(ctx); cause != nil {
319 return "", cause
320 }
321 if a.auxiliaryProviderGeneration.Load() != providerGeneration {
322 return "", newSessionOperationError(
323 "provider_unavailable",
324 "The session's model provider changed while AI rename was running. Try again.",
325 )
326 }
327 return title, nil
328 }
329
330 func (a *App) controllerForTopic(topicID string) *control.Controller {
331 a.mu.RLock()
332 defer a.mu.RUnlock()
333 var found *control.Controller
334 for _, tab := range a.runtimeTabsLocked() {
335 if tab == nil || (strings.TrimSpace(tab.TopicID) != topicID && tab.SessionID != topicID && sessionRoute(tab.SessionID) != topicID) || tab.Ctrl == nil {
336 continue
337 }
338 if ctrl, ok := tab.Ctrl.(*control.Controller); ok {
339 if tab.ID == a.activeTabID {
340 return ctrl
341 }
342 if found != nil && found != ctrl {
343 return nil
344 }
345 found = ctrl
346 }
347 }
348 return found
349 }
350
351 func topicTitleUserText(message provider.Message) string {
352 if !agent.IsUserAuthoredTurnMessage(message) {
353 return ""
354 }
355 content := control.StripComposePrefixes(agent.UserPreviewText(agent.UserMessageText(message)))
356 return strings.TrimSpace(control.StripReferencedContextPrefix(content))
357 }
358
359 func sessionTitleTranscript(users []string) string {
360 parts := make([]string, 0, aiSessionTitleMaxTurns)
361 for _, user := range users {
362 if len(parts) >= aiSessionTitleMaxTurns {
363 break
364 }
365 user = strings.TrimSpace(user)
366 if runes := []rune(user); len(runes) > aiSessionTitleMaxTurnRunes {
367 user = string(runes[:aiSessionTitleMaxTurnRunes])
368 }
369 if user != "" {
370 parts = append(parts, user)
371 }
372 }
373 return strings.Join(parts, "\n\n")
374 }
375
376 func sessionPreviewForPath(path string) string {
377 path = strings.TrimSpace(path)
378 if path == "" {
379 return ""
380 }
381 if meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok {
382 if preview := strings.TrimSpace(meta.Preview); preview != "" {
383 return preview
384 }
385 }
386 preview, ok, err := agent.LoadSessionPreviewFromDisplayIndex(path)
387 if err != nil || !ok {
388 return ""
389 }
390 return preview
391 }
392
393 func topicSessionPreview(sessions []sessioncatalog.SessionRecord, path string) string {
394 for _, session := range sessions {
395 if sessionRuntimeKey(session.Path) == sessionRuntimeKey(path) {
396 return strings.TrimSpace(session.Preview)
397 }
398 }
399 return ""
400 }
401
401 lines GO