feat: Agent 任务名与对话标题双能力自动生成

新增 task_auto_title_enabled / chat_auto_title_enabled(默认关)、
共享 GenerateAutoTitle、spawn_task 同步写 name、对话首条先截断再异步 AI,
设置页「自动标题」双 Switch。closes #55
This commit is contained in:
artex-bot committed 2026-10-27 12:33:53 +08:00
1 parent e740334b90
commit 6c781f21f4
11 files changed
+310 -1

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
package server
import "testing"
func TestAutoTitleMayReplace(t *testing.T) {
for _, current := range []string{"", "新对话", "首条消息预览"} {
if !autoTitleMayReplace(current, "首条消息预览") {
t.Fatalf("expected %q to be replaceable", current)
}
}
if autoTitleMayReplace("用户手动改的标题", "首条消息预览") {
t.Fatal("manual title must not be overwritten")
}
}
+59
View File
@@ -446,6 +446,59 @@ func (s *Server) pgSendConversationMessage(w http.ResponseWriter, r *http.Reques
title = "附件消息"
}
_ = pg.RenameConversation(c.ID, title)
if s.m.pg.GetBool(db.SettingChatAutoTitle, false) {
conversationID := c.ID
agentKey := c.AgentKey
pinnedProfile := c.LLMProfileID
promptContent := msg
if len(req.Attachments) > 0 {
var names []string
for _, attachment := range req.Attachments {
if name := strings.TrimSpace(attachment.Name); name != "" {
names = append(names, name)
}
}
if len(names) > 0 {
if promptContent != "" {
promptContent += "\n"
}
promptContent += "附件:" + strings.Join(names, "、")
}
}
go func() {
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 {
profileID = &active.ID
}
}
if profileID == 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 {
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)
}
}
}()
}
}
_ = pg.TouchConversation(c.ID)
@@ -458,6 +511,12 @@ func (s *Server) pgSendConversationMessage(w http.ResponseWriter, r *http.Reques
writeJSON(w, 202, map[string]any{"status": "accepted"})
}
// autoTitleMayReplace protects a user's later rename while allowing the
// asynchronous title completion to replace the initial placeholder/preview.
func autoTitleMayReplace(current, initial string) bool {
return current == "" || current == "新对话" || current == initial
}
// 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.
+11
View File
@@ -18,6 +18,17 @@ func TestIntelligenceHTTPSettingsAndScope(t *testing.T) {
if s.settingsPayload()[db.SettingTaskIntelligence] != false {
t.Fatal("default must be off")
}
if s.settingsPayload()[db.SettingTaskAutoTitle] != false || s.settingsPayload()[db.SettingChatAutoTitle] != false {
t.Fatal("auto title settings must default to off")
}
if r := request("PUT", "/api/settings", `{"task_auto_title_enabled":true,"chat_auto_title_enabled":true}`); r.Code != 200 {
t.Fatal(r.Code, r.Body)
}
if !s.m.pg.GetBool(db.SettingTaskAutoTitle, false) || !s.m.pg.GetBool(db.SettingChatAutoTitle, false) {
t.Fatal("auto title settings were not persisted")
}
defer s.m.pg.SetBool(db.SettingTaskAutoTitle, false)
defer s.m.pg.SetBool(db.SettingChatAutoTitle, false)
if r := request("PUT", "/api/settings", `{"task_intelligence_enabled":true}`); r.Code != 200 {
t.Fatal(r.Code, r.Body)
}
+16 -1
View File
@@ -251,7 +251,7 @@ func (s *Server) toolSpawnTask() actool.CoreTool {
"plan_heartbeat_seconds": map[string]any{"type": "integer", "description": "可选:planner 心跳触发间隔(秒)。距上轮规划结束/任务开始满该值且期间无触发 → 触发一轮规划(兜底死锁 + 唤醒去监督飞行中的 worker)。留空或 0 = 默认 600(10min);"},
"seed_first_intent": map[string]any{"type": "boolean", "description": "可选:对于简单任务可开启,创建时直接下发一条种子意图(内容=描述+目标)让 worker 免等首轮 planner 直接开跑测试;默认 false(走标准先规划再执行)。"},
}, "description", "goal"),
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
func(ctx context.Context, in json.RawMessage) (actool.Result, error) {
var a struct {
Description string `json:"description"`
Goal string `json:"goal"`
@@ -305,7 +305,22 @@ func (s *Server) toolSpawnTask() actool.CoreTool {
if pin != nil {
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)
}
}
}
t, err := s.m.CreateTaskWithOptions(a.Description, a.Goal, db.TaskCreateOptions{
Name: name,
SourceTaskIDs: sourceIDs,
LLMProfileIDs: llmIDs,
TimeoutSeconds: a.TimeoutSeconds,
+16
View File
@@ -3443,6 +3443,8 @@ func (s *Server) settingsPayload() map[string]any {
"traffic_capture": s.m.TrafficEnabled(),
"agent_traffic_binding": s.m.pg.GetBool(settingAgentTrafficBinding, false),
"task_intelligence_enabled": s.m.pg.GetBool(db.SettingTaskIntelligence, false),
db.SettingTaskAutoTitle: s.m.pg.GetBool(db.SettingTaskAutoTitle, false),
db.SettingChatAutoTitle: s.m.pg.GetBool(db.SettingChatAutoTitle, false),
db.SettingCredentialAgent: s.m.pg.GetBool(db.SettingCredentialAgent, false),
db.SettingCredentialTraffic: s.m.pg.GetBool(db.SettingCredentialAgent, false) && s.m.pg.GetBool(db.SettingCredentialTraffic, false),
"llm_record": s.m.LLMRecordEnabled(),
@@ -3517,6 +3519,8 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
TrafficCapture *bool `json:"traffic_capture"`
AgentTrafficBinding *bool `json:"agent_traffic_binding"`
TaskIntelligence *bool `json:"task_intelligence_enabled"`
TaskAutoTitle *bool `json:"task_auto_title_enabled"`
ChatAutoTitle *bool `json:"chat_auto_title_enabled"`
CredentialAgent *bool `json:"credential_agent_enabled"`
CredentialTraffic *bool `json:"credential_traffic_binding"`
LLMRecord *bool `json:"llm_record"` // LLM 录制开关(默认关);即时生效,无需重建 agent
@@ -3689,6 +3693,18 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request) {
return
}
}
if req.TaskAutoTitle != nil {
if err := s.m.pg.SetBool(db.SettingTaskAutoTitle, *req.TaskAutoTitle); err != nil {
writeErr(w, 500, err.Error())
return
}
}
if req.ChatAutoTitle != nil {
if err := s.m.pg.SetBool(db.SettingChatAutoTitle, *req.ChatAutoTitle); err != nil {
writeErr(w, 500, err.Error())
return
}
}
if req.AgentTrafficBinding != nil {
if err := s.m.pg.SetBool(settingAgentTrafficBinding, *req.AgentTrafficBinding); err != nil {
writeErr(w, 500, err.Error())