fix(chat): 会话已选 LLM 时不再被「全局未激活」误拦,提示按状态细分

发送消息的前置检查只看全局激活配置(chatAgentRef),而真正执行的
runConversationSync 会优先用会话所选/Agent 绑定的配置——两处解析分叉,导致
「会话已选一个有效配置、但全局没有激活配置」时,消息在预检查阶段就被 503
「LLM 未配置」拦下,根本走不到能用所选配置的执行阶段。

- 抽出 resolveChatAgent(conversation) 作为唯一解析入口(会话/Agent 绑定优先、
  全局兜底),send 预检查与后台执行都用它,消除分叉。
- 提示按实际状态细分(chatUnavailableReason):无任何配置→提示添加;有配置但
  未激活→提示激活或为本会话指定一个;而非一律「未配置」。

相邻的 StartTriggeredRun 仍用全局 gate(触发器场景,本次不动)。

验证: 新增两个测试——三态提示区分、会话所选配置在全局无激活时仍能解析出
ChatAgent(回归防护);连本地 PG 实跑通过,server 全量无回归。
This commit is contained in:
Autumn-27 committed 2026-08-24 11:55:50 -04:00
1 parent 412a993c25
commit 7299150cfa
4 files changed
+142 -9

No files matched your search

+7
View File
@@ -14,6 +14,13 @@
- 新建任务的「任务分类」由下拉选择改为可搜索输入框:输入即过滤已有分类,库里没有的名称回车(或点下拉里的「创建」)即时新建并选中,选中项以可移除的 tag 展示;仍限单个分类。
### 对话
#### 修复的问题
- 修复会话已选定 LLM 配置、但全局无激活配置时,发送消息被「LLM 未配置,无法对话」拦截的问题。发送前的检查原本只看全局激活配置,而实际执行会优先用会话所选配置,两处解析逻辑不一致;现统一为同一套(会话/Agent 绑定优先,全局兜底)。
- 无法对话时的提示按状态细分:完全没有配置提示「添加配置」,有配置但未激活提示「激活或为本会话指定一个配置」,不再一律显示「未配置」,便于定位。
### Agent
#### 修改的功能
+97
View File
@@ -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")
}
}
+7 -9
View File
@@ -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
}
+31
View File
@@ -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.