diff --git a/agent/auto_title.go b/agent/auto_title.go index 02a94e4..280c9e1 100644 --- a/agent/auto_title.go +++ b/agent/auto_title.go @@ -51,22 +51,23 @@ func cleanAutoTitle(raw string) string { if i := strings.IndexAny(title, "\r\n"); i >= 0 { title = strings.TrimSpace(title[:i]) } - for len(title) >= 2 { - first, firstSize := utf8.DecodeRuneInString(title) - last, lastSize := utf8.DecodeLastRuneInString(title) - if (first == '"' && last == '"') || (first == '\'' && last == '\'') || (first == '“' && last == '”') || (first == '「' && last == '」') { - title = strings.TrimSpace(title[firstSize : len(title)-lastSize]) - continue + for { + previous := title + if len(title) >= 2 { + first, firstSize := utf8.DecodeRuneInString(title) + last, lastSize := utf8.DecodeLastRuneInString(title) + if (first == '"' && last == '"') || (first == '\'' && last == '\'') || (first == '“' && last == '”') || (first == '「' && last == '」') { + title = title[firstSize : len(title)-lastSize] + } } - break - } - lines := strings.FieldsFunc(title, func(r rune) bool { return r == '\r' || r == '\n' }) - if len(lines) > 0 { - title = strings.TrimSpace(lines[0]) - } - for _, prefix := range []string{"标题:", "标题:"} { - if strings.HasPrefix(title, prefix) { - title = strings.TrimSpace(strings.TrimPrefix(title, prefix)) + for _, prefix := range []string{"标题:", "标题:"} { + if strings.HasPrefix(title, prefix) { + title = strings.TrimPrefix(title, prefix) + break + } + } + title = strings.TrimSpace(title) + if title == previous { break } } diff --git a/agent/auto_title_test.go b/agent/auto_title_test.go index 7041204..b640a76 100644 --- a/agent/auto_title_test.go +++ b/agent/auto_title_test.go @@ -41,6 +41,15 @@ func TestGenerateAutoTitleCleansResponse(t *testing.T) { if p.req.Thinking != "disabled" || p.req.MaxTokens != 96 || len(p.req.Tools) != 0 { t.Fatalf("request options: %+v", p.req) } + + quoted := &autoTitleProvider{reply: `标题:"整理项目设置"`} + got, err = GenerateAutoTitle(t.Context(), quoted, "整理项目设置") + if err != nil { + t.Fatal(err) + } + if got != "整理项目设置" { + t.Fatalf("nested quote/prefix cleanup: got %q", got) + } } func TestGenerateAutoTitleProviderError(t *testing.T) { diff --git a/db/conversation.go b/db/conversation.go index 7db9afd..48ede2a 100644 --- a/db/conversation.go +++ b/db/conversation.go @@ -197,6 +197,15 @@ func (d *DB) RenameConversation(id int64, title string) error { return err } +// RenameConversationIfTitle updates a thread only while its title is still an +// initial placeholder or preview. This keeps asynchronous auto-title writes +// from overwriting a concurrent manual rename. +func (d *DB) RenameConversationIfTitle(id int64, title, initial string) error { + _, err := d.Exec(`UPDATE conversations SET title=$2 +WHERE id=$1 AND title IN ('', '新对话', $3)`, id, title, initial) + return err +} + // TouchConversation bumps updated_at so the thread floats to the top of the list. func (d *DB) TouchConversation(id int64) error { _, err := d.Exec(`UPDATE conversations SET updated_at=now() WHERE id=$1`, id) diff --git a/server/auto_title_test.go b/server/auto_title_test.go index 25b5434..f28a065 100644 --- a/server/auto_title_test.go +++ b/server/auto_title_test.go @@ -12,3 +12,14 @@ func TestAutoTitleMayReplace(t *testing.T) { t.Fatal("manual title must not be overwritten") } } + +func TestAutoTitleIsUsable(t *testing.T) { + for _, generated := range []string{"", "新对话"} { + if autoTitleIsUsable(generated) { + t.Fatalf("placeholder title %q must be rejected", generated) + } + } + if !autoTitleIsUsable("整理项目设置") { + t.Fatal("generated title should be accepted") + } +} diff --git a/server/conversations.go b/server/conversations.go index 389188a..f396647 100644 --- a/server/conversations.go +++ b/server/conversations.go @@ -17,6 +17,7 @@ import ( "github.com/Autumn-27/artex/db" "github.com/Autumn-27/artex/intercept" "github.com/Autumn-27/artex/sidequestion" + "github.com/Autumn-27/norma/llm" ) const ( @@ -469,33 +470,34 @@ func (s *Server) pgSendConversationMessage(w http.ResponseWriter, r *http.Reques ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() profileID := s.effectiveProfileForAgent(agentKey, pinnedProfile) - if profileID == nil { - if active, err := s.m.pg.ActiveProfile(); err == nil && active != nil { + var prov llm.Provider + providerOK := false + if profileID != nil { + prov, _, providerOK = s.providerForProfile(*profileID) + } + if !providerOK { + if active, activeErr := s.m.pg.ActiveProfile(); activeErr == nil && active != nil { profileID = &active.ID + prov, _, providerOK = s.providerForProfile(active.ID) + } else if activeErr != nil { + log.Printf("[auto-title] conversation %d active profile lookup failed: %v", conversationID, activeErr) } } - if profileID == nil { + if profileID == nil || !providerOK || prov == nil { log.Printf("[auto-title] conversation %d: no effective profile", conversationID) return } - prov, _, ok := s.providerForProfile(*profileID) - if !ok { - log.Printf("[auto-title] conversation %d: provider unavailable for profile %d", conversationID, *profileID) - return - } generated, err := agent.GenerateAutoTitle(ctx, prov, promptContent) if err != nil { log.Printf("[auto-title] conversation %d: %v", conversationID, err) return } - current, err := s.m.pg.GetConversation(conversationID) - if err != nil || current == nil { + if !autoTitleIsUsable(generated) { + log.Printf("[auto-title] conversation %d: generated title is empty or placeholder", conversationID) return } - if autoTitleMayReplace(current.Title, title) { - if err := s.m.pg.RenameConversation(conversationID, generated); err != nil { - log.Printf("[auto-title] conversation %d rename failed: %v", conversationID, err) - } + if err := s.m.pg.RenameConversationIfTitle(conversationID, generated, title); err != nil { + log.Printf("[auto-title] conversation %d rename failed: %v", conversationID, err) } }() } @@ -517,6 +519,10 @@ func autoTitleMayReplace(current, initial string) bool { return current == "" || current == "新对话" || current == initial } +func autoTitleIsUsable(generated string) bool { + return generated != "" && generated != "新对话" +} + // runConversation runs ONE agent turn on a conversation in a background goroutine // (each conversation is independent → parallel). Steps stream to // conversation_activities. Shared by the chat HTTP handler and the P3 scheduler. diff --git a/server/orchestration.go b/server/orchestration.go index 06d6892..bca694b 100644 --- a/server/orchestration.go +++ b/server/orchestration.go @@ -306,17 +306,35 @@ func (s *Server) toolSpawnTask() actool.CoreTool { llmIDs = []int64{*pin} } name := "" - if s.m.pg.GetBool(db.SettingTaskAutoTitle, false) { - if active, activeErr := s.m.pg.ActiveProfile(); activeErr == nil && active != nil { - if prov, _, providerOK := s.providerForProfile(active.ID); providerOK { - if generated, titleErr := agent.GenerateAutoTitle(ctx, prov, a.Description+"\n"+a.Goal); titleErr == nil { - name = generated - } else { - log.Printf("[auto-title] task %s: %v", a.Description, titleErr) - } - } else { - log.Printf("[auto-title] task provider unavailable for active profile %d", active.ID) + autoTitleEnabled := s.m.pg.GetBool(db.SettingTaskAutoTitle, false) + if autoTitleEnabled { + if err := ctx.Err(); err != nil { + return actool.Errorf(err.Error()), nil + } + active, activeErr := s.m.pg.ActiveProfile() + if activeErr != nil { + log.Printf("[auto-title] active profile lookup failed: %v", activeErr) + } else if active == nil { + log.Printf("[auto-title] no active profile") + } else if prov, _, providerOK := s.providerForProfile(active.ID); providerOK { + titleCtx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + generated, titleErr := agent.GenerateAutoTitle(titleCtx, prov, a.Description+"\n"+a.Goal) + if err := ctx.Err(); err != nil { + return actool.Errorf(err.Error()), nil } + if titleErr == nil { + name = generated + } else { + log.Printf("[auto-title] task title generation failed: %v", titleErr) + } + } else { + log.Printf("[auto-title] task provider unavailable for active profile %d", active.ID) + } + } + if autoTitleEnabled { + if err := ctx.Err(); err != nil { + return actool.Errorf(err.Error()), nil } } t, err := s.m.CreateTaskWithOptions(a.Description, a.Goal, db.TaskCreateOptions{