diff --git a/CHANGELOG.md b/CHANGELOG.md index 076db0d..9f54bee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,13 @@ - 新建任务的「任务分类」由下拉选择改为可搜索输入框:输入即过滤已有分类,库里没有的名称回车(或点下拉里的「创建」)即时新建并选中,选中项以可移除的 tag 展示;仍限单个分类。 +### 对话 + +#### 修复的问题 + +- 修复会话已选定 LLM 配置、但全局无激活配置时,发送消息被「LLM 未配置,无法对话」拦截的问题。发送前的检查原本只看全局激活配置,而实际执行会优先用会话所选配置,两处解析逻辑不一致;现统一为同一套(会话/Agent 绑定优先,全局兜底)。 +- 无法对话时的提示按状态细分:完全没有配置提示「添加配置」,有配置但未激活提示「激活或为本会话指定一个配置」,不再一律显示「未配置」,便于定位。 + ### Agent #### 修改的功能 diff --git a/server/chat_llm_resolve_test.go b/server/chat_llm_resolve_test.go new file mode 100644 index 0000000..5108988 --- /dev/null +++ b/server/chat_llm_resolve_test.go @@ -0,0 +1,97 @@ +package server + +import ( + "context" + "strings" + "testing" + + "github.com/Autumn-27/artex/db" +) + +// TestChatUnavailableReasonDistinguishesStates pins the operator-facing message +// to the actual configuration state: nothing configured vs. configured-but-not- +// activated must read differently so the user knows what to do. +func TestChatUnavailableReasonDistinguishesStates(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Skipf("postgres unavailable (%v) — skipping", err) + } + defer m.Close() + td := t.TempDir() + s := New(context.Background(), m, td, td, td) + + // Start from a clean profile table; other tests in the shared DB may have left rows. + existing, _ := m.pg.ListProfiles() + for _, p := range existing { + if p.IsDefault { + // The active profile cannot be deleted directly; clear the flag first. + _, _ = m.pg.Exec(`UPDATE llm_profiles SET is_default=false WHERE id=$1`, p.ID) + } + _ = m.pg.DeleteProfile(p.ID) + } + + if reason := s.chatUnavailableReason(); !strings.Contains(reason, "尚未配置") { + t.Fatalf("no-profile reason=%q, want 尚未配置", reason) + } + + id, err := m.pg.SaveProfile(&db.LLMProfile{Name: "p1", Format: "anthropic", Model: "claude-x", APIKey: "sk-test"}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = m.pg.Exec(`UPDATE llm_profiles SET is_default=false WHERE id=$1`, id) + _ = m.pg.DeleteProfile(id) + }) + + // Profile exists but is not activated. + if reason := s.chatUnavailableReason(); !strings.Contains(reason, "没有已激活") { + t.Fatalf("inactive reason=%q, want 没有已激活", reason) + } + + // Once activated, the reason no longer claims a missing/inactive config. + if err := m.pg.SetActiveProfile(id); err != nil { + t.Fatal(err) + } + if reason := s.chatUnavailableReason(); strings.Contains(reason, "尚未配置") || strings.Contains(reason, "没有已激活") { + t.Fatalf("active reason=%q should not report missing/inactive", reason) + } +} + +// TestResolveChatAgentHonoursConversationProfile is the regression guard for the +// reported bug: a conversation that picked a valid profile must resolve a chat +// agent even when NO global profile is active, so the send precheck stops +// rejecting it with "LLM 未配置". +func TestResolveChatAgentHonoursConversationProfile(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Skipf("postgres unavailable (%v) — skipping", err) + } + defer m.Close() + td := t.TempDir() + s := New(context.Background(), m, td, td, td) + + // Force the global fallback to nil so a non-nil result can ONLY come from the + // conversation's own profile — this is exactly the situation the user hit + // (no usable global config) and isolates the fix from the test host's env. + s.cfgMu.Lock() + s.chatAgent = nil + s.cfgMu.Unlock() + + id, err := m.pg.SaveProfile(&db.LLMProfile{Name: "conv-pick", Format: "anthropic", Model: "claude-x", APIKey: "sk-test"}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = m.pg.DeleteProfile(id) }) + + // A conversation that pinned this profile resolves a chat agent despite the + // nil global fallback; without the fix the send precheck rejected it outright. + conv := &db.Conversation{AgentKey: "mainagent", LLMProfileID: &id} + if s.resolveChatAgent(conv) == nil { + t.Fatalf("resolveChatAgent(conversation with valid profile) = nil; global fallback wrongly gates the pick") + } + + // With neither a pick nor a global fallback there is genuinely nothing to run. + if s.resolveChatAgent(&db.Conversation{AgentKey: "mainagent"}) != nil { + t.Fatalf("resolveChatAgent with neither pick nor global config should be nil") + } +} diff --git a/server/conversations.go b/server/conversations.go index de81e4c..e11b78c 100644 --- a/server/conversations.go +++ b/server/conversations.go @@ -315,9 +315,12 @@ func (s *Server) pgSendConversationMessage(w http.ResponseWriter, r *http.Reques writeErr(w, 400, "消息不能为空") return } - ca := s.chatAgentRef() - if ca == nil { - writeErr(w, 503, "LLM 未配置,无法对话") + // Same resolution as the background runner: honour the agent binding / this + // conversation's chosen profile before falling back to the global config, so a + // conversation that picked a valid LLM is not rejected just because no global + // config is active. + if s.resolveChatAgent(c) == nil { + writeErr(w, 503, s.chatUnavailableReason()) return } @@ -390,12 +393,7 @@ func (s *Server) runConversationSync(c *db.Conversation, msg, busyKey string) { s.chatMu.Unlock() }() // precedence: the conversation agent's own binding → this conversation's pin → global. - ca := s.chatAgentRef() - if eff := s.effectiveProfileForAgent(c.AgentKey, c.LLMProfileID); eff != nil { - if pa := s.chatAgentForProfile(*eff); pa != nil { - ca = pa - } - } + ca := s.resolveChatAgent(c) if ca == nil { return } diff --git a/server/server.go b/server/server.go index b7ad774..0e5ffb7 100644 --- a/server/server.go +++ b/server/server.go @@ -492,6 +492,37 @@ func (s *Server) effectiveProfileForAgent(agentKey string, pinID *int64) *int64 return pinID } +// resolveChatAgent picks the ChatAgent for one conversation: the agent's own +// binding or this conversation's chosen profile first, the global active config +// only as a fallback. Both the send precheck and the background runner MUST use +// this — resolving differently in the two paths is how a conversation that had +// picked a valid profile still got rejected with "LLM 未配置" when no global +// config was active. +func (s *Server) resolveChatAgent(c *db.Conversation) *agent.ChatAgent { + ca := s.chatAgentRef() + if eff := s.effectiveProfileForAgent(c.AgentKey, c.LLMProfileID); eff != nil { + if pa := s.chatAgentForProfile(*eff); pa != nil { + ca = pa + } + } + return ca +} + +// chatUnavailableReason explains why no ChatAgent could be resolved, so the user +// knows whether to add a config, activate one, or pick one for this conversation +// — rather than a flat "not configured" that hides which of those it is. +func (s *Server) chatUnavailableReason() string { + if s.m.pg != nil { + if profiles, err := s.m.pg.ListProfiles(); err == nil && len(profiles) == 0 { + return "尚未配置 LLM:请到 系统 → LLM 配置 添加一个配置" + } + if active, err := s.m.pg.ActiveProfile(); err == nil && active == nil { + return "没有已激活的 LLM 配置:请到 系统 → LLM 配置 激活一个,或在本对话为该会话指定一个配置" + } + } + return "LLM 未就绪,无法对话:请检查 系统 → LLM 配置是否有可用且已激活的配置" +} + // providerForProfile returns a cached provider+cfg for a profile id, so every agent // bound/pinned to the same profile shares one provider instance (one rate limiter). // ok=false when the profile is missing/invalid → caller falls back to the global pair.