mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
fix: 自动标题取消语义、清洗循环与拒绝占位回写
spawn_task 标题 20s 超时且父 ctx 取消不建任务;清洗循环剥前缀内外引号; 异步标题拒绝「新对话」并用条件更新防手改竞态;会话 profile 失败回退全局。
This commit is contained in:
1 parent
6c781f21f4
commit
3d3ee8827e
6 files changed
+93
-39
No files matched your search
+16
-15
@@ -51,22 +51,23 @@ func cleanAutoTitle(raw string) string {
|
|||||||
if i := strings.IndexAny(title, "\r\n"); i >= 0 {
|
if i := strings.IndexAny(title, "\r\n"); i >= 0 {
|
||||||
title = strings.TrimSpace(title[:i])
|
title = strings.TrimSpace(title[:i])
|
||||||
}
|
}
|
||||||
for len(title) >= 2 {
|
for {
|
||||||
first, firstSize := utf8.DecodeRuneInString(title)
|
previous := title
|
||||||
last, lastSize := utf8.DecodeLastRuneInString(title)
|
if len(title) >= 2 {
|
||||||
if (first == '"' && last == '"') || (first == '\'' && last == '\'') || (first == '“' && last == '”') || (first == '「' && last == '」') {
|
first, firstSize := utf8.DecodeRuneInString(title)
|
||||||
title = strings.TrimSpace(title[firstSize : len(title)-lastSize])
|
last, lastSize := utf8.DecodeLastRuneInString(title)
|
||||||
continue
|
if (first == '"' && last == '"') || (first == '\'' && last == '\'') || (first == '“' && last == '”') || (first == '「' && last == '」') {
|
||||||
|
title = title[firstSize : len(title)-lastSize]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
break
|
for _, prefix := range []string{"标题:", "标题:"} {
|
||||||
}
|
if strings.HasPrefix(title, prefix) {
|
||||||
lines := strings.FieldsFunc(title, func(r rune) bool { return r == '\r' || r == '\n' })
|
title = strings.TrimPrefix(title, prefix)
|
||||||
if len(lines) > 0 {
|
break
|
||||||
title = strings.TrimSpace(lines[0])
|
}
|
||||||
}
|
}
|
||||||
for _, prefix := range []string{"标题:", "标题:"} {
|
title = strings.TrimSpace(title)
|
||||||
if strings.HasPrefix(title, prefix) {
|
if title == previous {
|
||||||
title = strings.TrimSpace(strings.TrimPrefix(title, prefix))
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,15 @@ func TestGenerateAutoTitleCleansResponse(t *testing.T) {
|
|||||||
if p.req.Thinking != "disabled" || p.req.MaxTokens != 96 || len(p.req.Tools) != 0 {
|
if p.req.Thinking != "disabled" || p.req.MaxTokens != 96 || len(p.req.Tools) != 0 {
|
||||||
t.Fatalf("request options: %+v", p.req)
|
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) {
|
func TestGenerateAutoTitleProviderError(t *testing.T) {
|
||||||
|
|||||||
@@ -197,6 +197,15 @@ func (d *DB) RenameConversation(id int64, title string) error {
|
|||||||
return err
|
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.
|
// TouchConversation bumps updated_at so the thread floats to the top of the list.
|
||||||
func (d *DB) TouchConversation(id int64) error {
|
func (d *DB) TouchConversation(id int64) error {
|
||||||
_, err := d.Exec(`UPDATE conversations SET updated_at=now() WHERE id=$1`, id)
|
_, err := d.Exec(`UPDATE conversations SET updated_at=now() WHERE id=$1`, id)
|
||||||
|
|||||||
@@ -12,3 +12,14 @@ func TestAutoTitleMayReplace(t *testing.T) {
|
|||||||
t.Fatal("manual title must not be overwritten")
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-14
@@ -17,6 +17,7 @@ import (
|
|||||||
"github.com/Autumn-27/artex/db"
|
"github.com/Autumn-27/artex/db"
|
||||||
"github.com/Autumn-27/artex/intercept"
|
"github.com/Autumn-27/artex/intercept"
|
||||||
"github.com/Autumn-27/artex/sidequestion"
|
"github.com/Autumn-27/artex/sidequestion"
|
||||||
|
"github.com/Autumn-27/norma/llm"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -469,33 +470,34 @@ func (s *Server) pgSendConversationMessage(w http.ResponseWriter, r *http.Reques
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
profileID := s.effectiveProfileForAgent(agentKey, pinnedProfile)
|
profileID := s.effectiveProfileForAgent(agentKey, pinnedProfile)
|
||||||
if profileID == nil {
|
var prov llm.Provider
|
||||||
if active, err := s.m.pg.ActiveProfile(); err == nil && active != nil {
|
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
|
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)
|
log.Printf("[auto-title] conversation %d: no effective profile", conversationID)
|
||||||
return
|
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)
|
generated, err := agent.GenerateAutoTitle(ctx, prov, promptContent)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[auto-title] conversation %d: %v", conversationID, err)
|
log.Printf("[auto-title] conversation %d: %v", conversationID, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
current, err := s.m.pg.GetConversation(conversationID)
|
if !autoTitleIsUsable(generated) {
|
||||||
if err != nil || current == nil {
|
log.Printf("[auto-title] conversation %d: generated title is empty or placeholder", conversationID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if autoTitleMayReplace(current.Title, title) {
|
if err := s.m.pg.RenameConversationIfTitle(conversationID, generated, title); err != nil {
|
||||||
if err := s.m.pg.RenameConversation(conversationID, generated); err != nil {
|
log.Printf("[auto-title] conversation %d rename failed: %v", conversationID, err)
|
||||||
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
|
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
|
// runConversation runs ONE agent turn on a conversation in a background goroutine
|
||||||
// (each conversation is independent → parallel). Steps stream to
|
// (each conversation is independent → parallel). Steps stream to
|
||||||
// conversation_activities. Shared by the chat HTTP handler and the P3 scheduler.
|
// conversation_activities. Shared by the chat HTTP handler and the P3 scheduler.
|
||||||
|
|||||||
+28
-10
@@ -306,17 +306,35 @@ func (s *Server) toolSpawnTask() actool.CoreTool {
|
|||||||
llmIDs = []int64{*pin}
|
llmIDs = []int64{*pin}
|
||||||
}
|
}
|
||||||
name := ""
|
name := ""
|
||||||
if s.m.pg.GetBool(db.SettingTaskAutoTitle, false) {
|
autoTitleEnabled := s.m.pg.GetBool(db.SettingTaskAutoTitle, false)
|
||||||
if active, activeErr := s.m.pg.ActiveProfile(); activeErr == nil && active != nil {
|
if autoTitleEnabled {
|
||||||
if prov, _, providerOK := s.providerForProfile(active.ID); providerOK {
|
if err := ctx.Err(); err != nil {
|
||||||
if generated, titleErr := agent.GenerateAutoTitle(ctx, prov, a.Description+"\n"+a.Goal); titleErr == nil {
|
return actool.Errorf(err.Error()), nil
|
||||||
name = generated
|
}
|
||||||
} else {
|
active, activeErr := s.m.pg.ActiveProfile()
|
||||||
log.Printf("[auto-title] task %s: %v", a.Description, titleErr)
|
if activeErr != nil {
|
||||||
}
|
log.Printf("[auto-title] active profile lookup failed: %v", activeErr)
|
||||||
} else {
|
} else if active == nil {
|
||||||
log.Printf("[auto-title] task provider unavailable for active profile %d", active.ID)
|
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{
|
t, err := s.m.CreateTaskWithOptions(a.Description, a.Goal, db.TaskCreateOptions{
|
||||||
|
|||||||
Reference in new issue
Block a user