返回 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 if a.sessionTargetRuntimeRebound(target) {
140 return "", newSessionOperationError("target_changed", "The session moved or changed state. Try again.")
141 }
142 a.sessionRemovalMu.Lock()
143 defer a.sessionRemovalMu.Unlock()
144 a.topicTitleMutationMu.Lock()
145 defer a.topicTitleMutationMu.Unlock()
146 if err := a.renameSessionInDirIfTitleUnchanged(sessionDir, validated, expectedRevision, title); err != nil {
147 if errors.Is(err, agent.ErrSessionTitleChanged) {
148 return "", newSessionOperationError(sessionOperationTitleConflict, "The session title changed while AI rename was running. Try again.")
149 }
150 return "", err
151 }
152 return title, nil
153 }
154
155 // The model round trip is already bounded where it is made: control's session
156 // title call gives the provider its own budget. This operation therefore runs
157 // on the caller's cancellation only, like aiRenameLegacySession. A second
158 // host-level wall clock here would also bound the durable snapshot, the flush,
159 // the history projection TitleMessages builds and the conditional commit —
160 // work whose cost scales with the conversation, not with provider health — so
161 // a long session on a slow host would lose an already generated title to a
162 // deadline that belongs to the provider.
163 func (a *App) aiRenameCanonicalSession(ctx context.Context, target SessionTarget) (string, error) {
164 service := a.desktopSessionService("")
165 ref := target.SessionRef
166 snapshot, err := service.Query().Snapshot(ctx, ref)
167 if err != nil {
168 return "", fmt.Errorf("AI rename session: read current title: %w", err)
169 }
170 // Publish accepted user turns before querying durable history when the
171 // target has a live runtime. Cold sessions are already fully durable.
172 if runtime, ok := service.Runtime(ref); ok {
173 if _, err := runtime.Session().Flush(ctx); err != nil {
174 return "", fmt.Errorf("AI rename session: flush conversation: %w", err)
175 }
176 }
177 messages, err := service.Query().TitleMessages(ctx, ref, aiSessionTitleMaxTurns)
178 if err != nil {
179 return "", fmt.Errorf("AI rename session: read conversation: %w", err)
180 }
181 var users []string
182 for _, message := range messages {
183 if content := topicTitleUserText(message); content != "" {
184 users = append(users, content)
185 }
186 }
187 if len(users) == 0 {
188 return "", newSessionOperationError(sessionOperationNoMessages, "This session has no user messages to analyze.")
189 }
190 title, err := a.generateTargetSessionTitle(ctx, target, snapshot.Projection.ModelRef, sessionTitleTranscript(users))
191 if err != nil {
192 return "", err
193 }
194 if a.sessionTargetRuntimeRebound(target) {
195 return "", newSessionOperationError("target_changed", "The session moved or changed state. Try again.")
196 }
197 // Serialize against manual writes; the session title sequence is the
198 // authority even if another branch is created during provider generation.
199 a.sessionRemovalMu.Lock()
200 defer a.sessionRemovalMu.Unlock()
201 a.topicTitleMutationMu.Lock()
202 defer a.topicTitleMutationMu.Unlock()
203 if err := a.workspaceRegistry().WithSessionUnchanged(ctx, ref.SessionID, target.WorkspaceID, target.LifecycleGeneration, func() error {
204 return service.SetTitleIfSequence(ctx, ref, snapshot.Projection.TitleSequence, title)
205 }); err != nil {
206 return "", sessionOperationConflict(err)
207 }
208 a.publishCanonicalSessionTitle(ref, title)
209 return title, nil
210 }
211
212 func (a *App) cancelAISessionTitle(targetKey string) {
213 targetKey = strings.TrimSpace(targetKey)
214 if targetKey == "" {
215 return
216 }
217 a.aiSessionTitleMu.Lock()
218 operation := a.aiSessionTitleInFlight[targetKey]
219 if operation.ID != "" {
220 delete(a.aiSessionTitleInFlight, targetKey)
221 }
222 a.aiSessionTitleMu.Unlock()
223 if operation.Cancel != nil {
224 operation.Cancel(newSessionOperationError(sessionOperationTitleConflict, "The session title changed while AI rename was running. Try again."))
225 }
226 }
227
228 func (a *App) invalidateAuxiliaryProviderOperations() {
229 if a == nil {
230 return
231 }
232 a.auxiliaryProviderGeneration.Add(1)
233 a.aiSessionTitleMu.Lock()
234 operations := make([]aiSessionTitleOperation, 0, len(a.aiSessionTitleInFlight))
235 for key, operation := range a.aiSessionTitleInFlight {
236 operations = append(operations, operation)
237 delete(a.aiSessionTitleInFlight, key)
238 }
239 a.aiSessionTitleMu.Unlock()
240 for _, operation := range operations {
241 if operation.Cancel != nil {
242 operation.Cancel(newSessionOperationError(
243 "provider_unavailable",
244 "The session's model provider changed while AI rename was running. Try again.",
245 ))
246 }
247 }
248 }
249
250 func (a *App) finishAISessionTitle(targetKey, operationID string) {
251 a.aiSessionTitleMu.Lock()
252 defer a.aiSessionTitleMu.Unlock()
253 // A manual rename can cancel an operation and a later request may already
254 // own the same target. A stale completion must not clear that newer
255 // request's deduplication entry.
256 if current, ok := a.aiSessionTitleInFlight[targetKey]; ok && current.ID == operationID {
257 delete(a.aiSessionTitleInFlight, targetKey)
258 }
259 }
260
261 func (a *App) generateTargetSessionTitle(ctx context.Context, target SessionTarget, modelRef, transcript string) (string, error) {
262 providerGeneration := a.auxiliaryProviderGeneration.Load()
263 if cause := context.Cause(ctx); cause != nil {
264 return "", cause
265 }
266 var (
267 title string
268 err error
269 )
270 if target.Controller != nil {
271 title, err = target.Controller.GenerateSessionTitleForModel(ctx, modelRef, transcript)
272 } else {
273 root := target.WorkspaceRoot
274 if target.Scope == "global" || root == "" {
275 root = globalWorkspaceRoot()
276 }
277 cfg, loadErr := config.LoadModelRuntimeSnapshot(root, modelRef)
278 if loadErr != nil {
279 return "", newSessionOperationError("provider_unavailable", "Unable to load model settings. Check the session's provider configuration.")
280 }
281 if modelRef == "" {
282 modelRef = cfg.DefaultModel
283 }
284 sessionID := strings.TrimSpace(target.SessionRef.SessionID)
285 if sessionID == "" {
286 sessionID = strings.TrimSpace(target.TopicID)
287 }
288 if sessionID == "" {
289 sessionID = sessionRuntimeKey(target.SessionPath)
290 }
291 handle, acquireErr := boot.AcquireAuxiliaryProvider(ctx, boot.AuxiliaryProviderRequest{
292 Config: cfg, SessionID: sessionID, WorkspaceRoot: root, ModelRef: modelRef,
293 })
294 if acquireErr != nil {
295 return "", newSessionOperationError("provider_unavailable", "The session's model provider is unavailable. Check its model or extension configuration.")
296 }
297 defer func() { _ = handle.Close() }()
298 title, err = control.GenerateSessionTitleWithResolver(ctx, handle.Resolver, modelRef, transcript)
299 }
300 if err != nil {
301 return "", err
302 }
303 if cause := context.Cause(ctx); cause != nil {
304 return "", cause
305 }
306 if a.auxiliaryProviderGeneration.Load() != providerGeneration {
307 return "", newSessionOperationError(
308 "provider_unavailable",
309 "The session's model provider changed while AI rename was running. Try again.",
310 )
311 }
312 return title, nil
313 }
314
315 func (a *App) controllerForTopic(topicID string) *control.Controller {
316 a.mu.RLock()
317 defer a.mu.RUnlock()
318 var found *control.Controller
319 for _, tab := range a.runtimeTabsLocked() {
320 if tab == nil || (strings.TrimSpace(tab.TopicID) != topicID && tab.SessionID != topicID && sessionRoute(tab.SessionID) != topicID) || tab.Ctrl == nil {
321 continue
322 }
323 if ctrl, ok := tab.Ctrl.(*control.Controller); ok {
324 if tab.ID == a.activeTabID {
325 return ctrl
326 }
327 if found != nil && found != ctrl {
328 return nil
329 }
330 found = ctrl
331 }
332 }
333 return found
334 }
335
336 func topicTitleUserText(message provider.Message) string {
337 if !agent.IsUserAuthoredTurnMessage(message) {
338 return ""
339 }
340 content := control.StripComposePrefixes(agent.UserPreviewText(agent.UserMessageText(message)))
341 return strings.TrimSpace(control.StripReferencedContextPrefix(content))
342 }
343
344 func sessionTitleTranscript(users []string) string {
345 parts := make([]string, 0, aiSessionTitleMaxTurns)
346 for _, user := range users {
347 if len(parts) >= aiSessionTitleMaxTurns {
348 break
349 }
350 user = strings.TrimSpace(user)
351 if runes := []rune(user); len(runes) > aiSessionTitleMaxTurnRunes {
352 user = string(runes[:aiSessionTitleMaxTurnRunes])
353 }
354 if user != "" {
355 parts = append(parts, user)
356 }
357 }
358 return strings.Join(parts, "\n\n")
359 }
360
361 func sessionPreviewForPath(path string) string {
362 path = strings.TrimSpace(path)
363 if path == "" {
364 return ""
365 }
366 if meta, ok, err := agent.LoadBranchMeta(path); err == nil && ok {
367 if preview := strings.TrimSpace(meta.Preview); preview != "" {
368 return preview
369 }
370 }
371 preview, ok, err := agent.LoadSessionPreviewFromDisplayIndex(path)
372 if err != nil || !ok {
373 return ""
374 }
375 return preview
376 }
377
378 func topicSessionPreview(sessions []sessioncatalog.SessionRecord, path string) string {
379 for _, session := range sessions {
380 if sessionRuntimeKey(session.Path) == sessionRuntimeKey(path) {
381 return strings.TrimSpace(session.Preview)
382 }
383 }
384 return ""
385 }
386
386 lines GO