diff --git a/deps/agent-native b/deps/agent-native index e4020d2..3a110a7 160000 --- a/deps/agent-native +++ b/deps/agent-native @@ -1 +1 @@ -Subproject commit e4020d297c168347626f9fd75456a71374a0a6c8 +Subproject commit 3a110a728cf520cfbbbafc0c4c75f14bf78bec3f diff --git a/deps/agent-system-core b/deps/agent-system-core index 5dc190e..bd63223 160000 --- a/deps/agent-system-core +++ b/deps/agent-system-core @@ -1 +1 @@ -Subproject commit 5dc190ee63174be71208609309f0b36a5fe070e0 +Subproject commit bd632236c2d0a21d7f6dd9a2f67a5d2df22e57f7 diff --git a/internal/app/app.go b/internal/app/app.go index bea839e..1c4931f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -135,9 +135,25 @@ func Run(opt Options) error { if norm.DottedKeys > 0 { log.Printf("settings: normalized %d dotted key(s) in %d scope(s)", norm.DottedKeys, norm.Scopes) } + if err := setSvc.SnapshotAndMigrateSecrets(); err != nil { + return fmt.Errorf("migrate secrets: %w", err) + } + runtime.SecretLookup = func(owner, service string) (string, bool) { + if owner == "" { + owner = store.OwnerDefault + } + return st.ActiveSecret(owner, service) + } + rag.SecretLookup = func(service, fallback string) string { + return runtime.LookupSecret(store.OwnerDefault, service, fallback) + } // 网络搜索(Tavily):配置密钥池才注册(对齐 agent-native 语义:未配置不静默降级)。 eff := setSvc.Effective(store.OwnerDefault, "") - if keys := splitKeyPool(eff.GetString("tools.tavily.apiKeys", "")); len(keys) > 0 { + tavilyRaw := eff.GetString("tools.tavily.apiKeys", "") + if active, ok := st.ActiveSecret(store.OwnerDefault, "tools.tavily"); ok { + tavilyRaw = active + } + if keys := splitKeyPool(tavilyRaw); len(keys) > 0 { provider := agenttools.NewTavilyProvider(eff.GetString("tools.tavily.endpoint", ""), keys) if err := tools.RegisterSearchTools(reg, provider); err != nil { return fmt.Errorf("search tools: %w", err) @@ -201,7 +217,11 @@ func Run(opt Options) error { return nil }, } - if keys := splitKeyPool(effNow.GetString("tools.tavily.apiKeys", "")); len(keys) > 0 { + tavilyNow := effNow.GetString("tools.tavily.apiKeys", "") + if active, ok := st.ActiveSecret(store.OwnerDefault, "tools.tavily"); ok { + tavilyNow = active + } + if keys := splitKeyPool(tavilyNow); len(keys) > 0 { cfg.Search = agenttools.NewTavilyProvider(effNow.GetString("tools.tavily.endpoint", ""), keys) } if effNow.GetBool("runtime.exec", true) { diff --git a/internal/cmdbus/cmdbus.go b/internal/cmdbus/cmdbus.go index 20c6fee..e867905 100644 --- a/internal/cmdbus/cmdbus.go +++ b/internal/cmdbus/cmdbus.go @@ -8,6 +8,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "strings" "sync" @@ -105,6 +106,26 @@ func (b *Bus) RegisterBuiltin(spec agentcommand.CommandSpec, slash string, h Han return nil } +// RegisterUser 登记用户提示词命令。重复登记视为已存在。执行不走总线,发送时展开模板。 +func (b *Bus) RegisterUser(spec agentcommand.CommandSpec, slash string) error { + spec.Binding = agentcommand.Binding{Kind: agentcommand.BindMessage} + if err := b.reg.Register(spec, agentcommand.SourceUser); err != nil && !errors.Is(err, agentcommand.ErrDuplicate) { + return err + } + if slash != "" { + b.slash[strings.ToLower(slash)] = spec.ID + } + return nil +} + +// UnregisterUser 卸下用户命令。不存在时忽略。 +func (b *Bus) UnregisterUser(id, slash string) { + _ = b.reg.Unregister(id) + if slash != "" { + delete(b.slash, strings.ToLower(slash)) + } +} + // RegisterPluginCommands 插件命令 overlay(manifest provides.commands;失败 fail-closed 返回)。 func (b *Bus) RegisterPluginCommands(overlay []json.RawMessage) error { for _, raw := range overlay { diff --git a/internal/ctxasm/ctxasm.go b/internal/ctxasm/ctxasm.go index ad34f3c..e2ca562 100644 --- a/internal/ctxasm/ctxasm.go +++ b/internal/ctxasm/ctxasm.go @@ -15,9 +15,11 @@ import ( // Report 记录一次组装的实际构成(可观测:模型看到了什么、裁了什么)。 type Report struct { - Items []string `json:"items"` - Truncations []string `json:"truncations,omitempty"` - EstChars int `json:"estChars"` + Items []string `json:"items"` + Truncations []string `json:"truncations,omitempty"` + EstChars int `json:"estChars"` + Sections []Section `json:"sections,omitempty"` + PrefixHash string `json:"prefixHash,omitempty"` } // DefaultMaxAttachmentChars 单个附件注入模型的默认字符上限(settings context.maxAttachmentChars 可调)。 @@ -57,6 +59,7 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [ if maxHist <= 0 { maxHist = 40 } + history = visibleHistory(history) // 历史窗口(不含当前轮——当前轮单独注入附件与检索)。 start := 0 if len(history) > maxHist { @@ -130,9 +133,7 @@ func AssembleInput(prof core.AgentProfile, eff core.EffectiveSettings, history [ } msgs = append(msgs, agentllm.Message{Role: agentllm.RoleUser, Content: sb.String()}) - for _, m := range msgs { - rep.EstChars += len(m.Content) - } + fillEstimate(msgs, rep) return msgs, rep } diff --git a/internal/ctxasm/finish.go b/internal/ctxasm/finish.go new file mode 100644 index 0000000..ad4c17f --- /dev/null +++ b/internal/ctxasm/finish.go @@ -0,0 +1,283 @@ +package ctxasm + +import ( + "crypto/sha256" + "encoding/hex" + "math" + "regexp" + "strconv" + "strings" + "sync" + + agentllm "agent-native/runtime/llm" + "agent-native/runtime/reminders" + core "agent-system-core" +) + +// Injection 是一条在组装末尾才决定是否注入的条件条目。 +type Injection struct { + Keys []string + Secondary string + Logic string + Content string + Constant bool + ScanDepth int + BudgetChars int +} + +// Tail 是前缀稳定之后附在末尾的个人指令、会话要求和条件注入。 +type Tail struct { + Instructions string + Note string + Entries []Injection + History []core.Message + Window int +} + +var calib struct { + mu sync.Mutex + est int + actual int +} + +// NotePromptUsage 用上一请求的真实 prompt token 校准后续估算。 +func NotePromptUsage(est, actual int) { + if est <= 0 || actual <= 0 { + return + } + calib.mu.Lock() + calib.est = est + calib.actual = actual + calib.mu.Unlock() +} + +func scaleTokens(est int) int { + calib.mu.Lock() + defer calib.mu.Unlock() + if est <= 0 || calib.est <= 0 || calib.actual <= 0 { + return est + } + return est * calib.actual / calib.est +} + +// EstimateTokens 用 UTF-8 字节数 / 3.35 估算 token。 +func EstimateTokens(text string) int { + n := len([]byte(text)) + if n == 0 { + return 0 + } + return int(math.Ceil(float64(n) / 3.35)) +} + +func visibleHistory(history []core.Message) []core.Message { + best := map[string]int{} + for i, m := range history { + if m.Excluded || m.VariantGroup == "" { + continue + } + prev, ok := best[m.VariantGroup] + if !ok || m.VariantIndex >= history[prev].VariantIndex { + best[m.VariantGroup] = i + } + } + out := make([]core.Message, 0, len(history)) + for i, m := range history { + if m.Excluded { + continue + } + if m.VariantGroup != "" && best[m.VariantGroup] != i { + continue + } + out = append(out, m) + } + return out +} + +// FinishAssembly 把末尾提醒附上、按窗口丢掉最老的历史,并写分段估算与前缀哈希。 +func FinishAssembly(msgs []agentllm.Message, rep *Report, tail Tail) ([]agentllm.Message, *Report) { + if rep == nil { + rep = &Report{} + } + notes := tailNotes(tail) + reserve := 0 + for _, n := range notes { + reserve += EstimateTokens(n) + 8 + } + msgs = trimOldest(msgs, tail.Window, reserve, rep) + msgs = reminders.Attach(msgs, notes) + fillEstimate(msgs, rep) + return msgs, rep +} + +func tailNotes(tail Tail) []string { + var notes []string + if text := strings.TrimSpace(tail.Instructions); text != "" { + notes = append(notes, "个人指令:\n"+text) + } + if text := strings.TrimSpace(tail.Note); text != "" { + notes = append(notes, "本会话要求:\n"+text) + } + for _, e := range tail.Entries { + if strings.TrimSpace(e.Content) == "" { + continue + } + if !e.Constant && !entryHit(e, tail.History) { + continue + } + body := e.Content + if e.BudgetChars > 0 { + r := []rune(body) + if len(r) > e.BudgetChars { + body = string(r[:e.BudgetChars]) + } + } + notes = append(notes, body) + } + return notes +} + +func entryHit(e Injection, history []core.Message) bool { + if len(e.Keys) == 0 { + return false + } + depth := e.ScanDepth + if depth <= 0 { + depth = 4 + } + start := 0 + if len(history) > depth { + start = len(history) - depth + } + var b strings.Builder + for _, m := range history[start:] { + for _, p := range m.Parts { + if p.Type == core.PartText { + b.WriteString(p.Text) + b.WriteByte('\n') + } + } + } + text := b.String() + primary := matchAny(e.Keys, text) + sec := strings.TrimSpace(e.Secondary) + if sec == "" { + return primary + } + secondary := matchOne(sec, text) + switch e.Logic { + case "or": + return primary || secondary + case "not": + return primary && !secondary + default: + return primary && secondary + } +} + +func matchAny(keys []string, text string) bool { + for _, k := range keys { + if matchOne(k, text) { + return true + } + } + return false +} + +func matchOne(key, text string) bool { + key = strings.TrimSpace(key) + if key == "" { + return false + } + if len(key) > 2 && strings.HasPrefix(key, "/") && strings.HasSuffix(key, "/") { + re, err := regexp.Compile(key[1 : len(key)-1]) + if err == nil { + return re.MatchString(text) + } + } + return strings.Contains(strings.ToLower(text), strings.ToLower(key)) +} + +func trimOldest(msgs []agentllm.Message, window, reserve int, rep *Report) []agentllm.Message { + if window <= 0 { + return msgs + } + limit := window - reserve + if limit < 1 { + limit = 1 + } + dropped := 0 + for estimateAll(msgs) > limit { + idx := oldestDrop(msgs) + if idx < 0 { + break + } + msgs = append(msgs[:idx], msgs[idx+1:]...) + dropped++ + } + if dropped > 0 { + rep.Truncations = append(rep.Truncations, fmtDropped(dropped)) + } + return msgs +} + +func fmtDropped(n int) string { + return "按上下文窗口丢掉最早的 " + itoa(n) + " 条历史" +} + +func itoa(n int) string { return strconv.Itoa(n) } + +func oldestDrop(msgs []agentllm.Message) int { + if len(msgs) < 3 { + return -1 + } + // 保留开头的系统前缀和最后一条(当前轮)。 + start := 0 + for start < len(msgs) && msgs[start].Role == agentllm.RoleSystem { + start++ + } + if start >= len(msgs)-1 { + return -1 + } + return start +} + +func estimateAll(msgs []agentllm.Message) int { + n := 0 + for _, m := range msgs { + n += EstimateTokens(m.Content) + } + return n +} + +func fillEstimate(msgs []agentllm.Message, rep *Report) { + sys, hist, cur := 0, 0, 0 + var prefix strings.Builder + last := len(msgs) - 1 + for i, m := range msgs { + tok := EstimateTokens(m.Content) + switch { + case m.Role == agentllm.RoleSystem && i < last: + sys += tok + prefix.WriteString(m.Content) + prefix.WriteByte('\n') + case i == last: + cur += tok + default: + hist += tok + } + } + rep.Sections = []Section{ + {Name: "system", EstTokens: scaleTokens(sys)}, + {Name: "history", EstTokens: scaleTokens(hist)}, + {Name: "current", EstTokens: scaleTokens(cur)}, + } + sum := sha256.Sum256([]byte(prefix.String())) + rep.PrefixHash = hex.EncodeToString(sum[:8]) + rep.EstChars = scaleTokens(sys + hist + cur) +} + +// Section 是一段上下文的估算。 +type Section struct { + Name string `json:"name"` + EstTokens int `json:"estTokens"` +} diff --git a/internal/ctxasm/finish_test.go b/internal/ctxasm/finish_test.go new file mode 100644 index 0000000..4c8ceb1 --- /dev/null +++ b/internal/ctxasm/finish_test.go @@ -0,0 +1,64 @@ +package ctxasm + +import ( + "strings" + "testing" + + agentllm "agent-native/runtime/llm" + core "agent-system-core" +) + +func TestVisibleHistoryDropsExcludedAndUnselectedVariants(t *testing.T) { + hist := []core.Message{ + {ID: "u", Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "问"}}}, + {ID: "a1", Role: core.RoleAssistant, VariantGroup: "g", VariantIndex: 0, Excluded: true, Parts: []core.Part{{Type: core.PartText, Text: "旧"}}}, + {ID: "a2", Role: core.RoleAssistant, VariantGroup: "g", VariantIndex: 1, Parts: []core.Part{{Type: core.PartText, Text: "新"}}}, + } + msgs, _ := AssembleInput(core.AgentProfile{}, core.EffectiveSettings{}, hist, core.Message{ID: "cur", Role: core.RoleUser}, nil, nil, nil, nil, nil, nil) + blob := "" + for _, m := range msgs { + blob += m.Content + } + if strings.Contains(blob, "旧") || !strings.Contains(blob, "新") { + t.Fatalf("history = %q", blob) + } +} + +func TestFinishAssemblyAttachesNoteWithoutTouchingSystem(t *testing.T) { + msgs := []agentllm.Message{ + {Role: agentllm.RoleSystem, Content: "前缀"}, + {Role: agentllm.RoleUser, Content: "你好"}, + } + out, rep := FinishAssembly(msgs, &Report{Items: []string{"system"}}, Tail{ + Note: "用短句", + Entries: []Injection{{ + Keys: []string{"你好"}, Content: "提到问候时补充天气", ScanDepth: 2, BudgetChars: 100, + }}, + History: []core.Message{{Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "你好"}}}}, + Window: 32000, + }) + if out[0].Content != "前缀" { + t.Fatal("system prefix changed") + } + if !strings.Contains(out[len(out)-1].Content, "本会话要求") || !strings.Contains(out[len(out)-1].Content, "天气") { + t.Fatalf("tail = %q", out[len(out)-1].Content) + } + if rep.PrefixHash == "" || rep.EstChars <= 0 || len(rep.Sections) != 3 { + t.Fatalf("report = %#v", rep) + } +} + +func TestTrimDropsOldestHistory(t *testing.T) { + msgs := []agentllm.Message{ + {Role: agentllm.RoleSystem, Content: "s"}, + {Role: agentllm.RoleUser, Content: strings.Repeat("旧", 400)}, + {Role: agentllm.RoleUser, Content: "现在"}, + } + out, rep := FinishAssembly(msgs, &Report{}, Tail{Window: 30}) + if len(out) != 2 { + t.Fatalf("kept %d", len(out)) + } + if len(rep.Truncations) == 0 { + t.Fatal("expected truncation") + } +} diff --git a/internal/rag/hybrid.go b/internal/rag/hybrid.go index 287ffd4..f683186 100644 --- a/internal/rag/hybrid.go +++ b/internal/rag/hybrid.go @@ -27,6 +27,16 @@ func NewPipeline(st *store.Store, set *settings.Service, embedder core.Embedding return &Pipeline{st: st, set: set, chunker: FixedChunker{}, embedder: embedder, reranker: reranker} } +// SecretLookup 由组合根注入。service 是 models.embedding / models.rerank。 +var SecretLookup func(service, fallback string) string + +func lookupSecret(service, fallback string) string { + if SecretLookup != nil { + return SecretLookup(service, fallback) + } + return fallback +} + // SetEmbedder/SetReranker 允许组合根后注入(embedder 的路由解析依赖本管线自身)。 func (p *Pipeline) SetEmbedder(e core.EmbeddingProvider) { p.embedder = e } func (p *Pipeline) SetReranker(r core.Reranker) { p.reranker = r } @@ -41,7 +51,7 @@ func (p *Pipeline) embeddingRoute(ctx context.Context) Route { eff := p.set.Effective(store.OwnerDefault, "") return Route{ Base: eff.GetString("models.embedding.baseUrl", ""), - Key: eff.GetString("models.embedding.apiKey", ""), + Key: lookupSecret("models.embedding", eff.GetString("models.embedding.apiKey", "")), Model: eff.GetString("models.embedding.model", ""), } } @@ -50,7 +60,7 @@ func (p *Pipeline) rerankRoute(ctx context.Context) Route { eff := p.set.Effective(store.OwnerDefault, "") return Route{ Base: eff.GetString("models.rerank.baseUrl", ""), - Key: eff.GetString("models.rerank.apiKey", ""), + Key: lookupSecret("models.rerank", eff.GetString("models.rerank.apiKey", "")), Model: eff.GetString("models.rerank.model", ""), } } diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index d04a944..42a0dec 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -312,6 +312,44 @@ func (c *runCtx) SetContextObservation(o core.ContextObservation) { c.mgr.mu.Unlock() } +func (c *runCtx) ContextObservation() *core.ContextObservation { + c.mgr.mu.Lock() + defer c.mgr.mu.Unlock() + return c.h.ctxObs +} + +func (c *runCtx) SessionNote() string { + if c.mgr == nil || c.mgr.st == nil { + return "" + } + conv, err := c.mgr.st.GetConversation(c.run.Owner, c.run.ConversationID) + if err != nil { + return "" + } + return conv.SessionNote +} + +func (c *runCtx) Injections() []runtime.ContextInjection { + if c.mgr == nil || c.mgr.st == nil { + return nil + } + list, err := c.mgr.st.ListContextEntries(c.run.Owner, c.run.ProjectID, c.run.ConversationID) + if err != nil { + return nil + } + out := make([]runtime.ContextInjection, 0, len(list)) + for _, e := range list { + if !e.Enabled { + continue + } + out = append(out, runtime.ContextInjection{ + Keys: e.Keys, Secondary: e.Secondary, Logic: e.Logic, Content: e.Content, + Constant: e.Constant, ScanDepth: e.ScanDepth, BudgetChars: e.BudgetChars, + }) + } + return out +} + func (c *runCtx) Memories() []core.MemoryEntry { if c.mgr == nil || c.mgr.st == nil { return nil @@ -1019,6 +1057,10 @@ func (m *Manager) finishRun(rc *runCtx, status core.RunStatus, usage core.Usage, var msg *core.Message if len(parts) > 0 { msg = &core.Message{ConversationID: rc.run.ConversationID, RunID: rc.run.ID, Role: core.RoleAssistant, Parts: parts} + if group, index, ok := m.st.TakeRegenPending(rc.run.ConversationID); ok { + msg.VariantGroup = group + msg.VariantIndex = index + } } updated, err := m.st.SettleRun(&rc.run, msg) if err != nil { diff --git a/internal/runtime/llm.go b/internal/runtime/llm.go index e7ed564..527a715 100644 --- a/internal/runtime/llm.go +++ b/internal/runtime/llm.go @@ -231,9 +231,31 @@ func (l *LLMRunner) Execute(rc RunCtx) error { sys = ctxasm.InjectMemory(sys, ms.Memories()) } msgs, asmReport := ctxasm.AssembleInput(prof, eff, hist, currentUser, rc.Attachments(), knowledge, sys, promptObs, rc.Card, resolveArtifact) + tail := ctxasm.Tail{ + Instructions: eff.GetString("context.userInstructions", ""), + History: hist, + Window: int(eff.GetNumber("context.contextWindow", 32000)), + } + if note, ok := rc.(interface{ SessionNote() string }); ok { + tail.Note = note.SessionNote() + } + if src, ok := rc.(interface{ Injections() []ContextInjection }); ok { + for _, e := range src.Injections() { + tail.Entries = append(tail.Entries, ctxasm.Injection{ + Keys: e.Keys, Secondary: e.Secondary, Logic: e.Logic, Content: e.Content, + Constant: e.Constant, ScanDepth: e.ScanDepth, BudgetChars: e.BudgetChars, + }) + } + } + msgs, asmReport = ctxasm.FinishAssembly(msgs, asmReport, tail) if asmReport != nil { + sections := make([]core.ContextSection, len(asmReport.Sections)) + for i, sec := range asmReport.Sections { + sections[i] = core.ContextSection{Name: sec.Name, EstTokens: sec.EstTokens} + } rc.SetContextObservation(core.ContextObservation{ Items: asmReport.Items, Truncations: asmReport.Truncations, EstChars: asmReport.EstChars, + Sections: sections, PrefixHash: asmReport.PrefixHash, }) } @@ -290,7 +312,10 @@ func (l *LLMRunner) Execute(rc RunCtx) error { decls = applyToolSelection(decls, eff.GetString("runtime.toolSelection", "")) maxTurns, maxTools := loopCaps(prof.Budget) - streamOpts := agentllm.StreamOptions{MaxTokens: outputTokenCap(prof.Budget, eff, "models.chat.maxOutputTokens")} + streamOpts := agentllm.StreamOptions{ + MaxTokens: outputTokenCap(prof.Budget, eff, "models.chat.maxOutputTokens"), + MessageShape: eff.GetString("context.messageShape", "merge"), + } return l.runDirect(rc, stream, route.Model, msgs, decls, streamOpts, cardflow, eff, maxTurns, maxTools) } diff --git a/internal/runtime/llm_direct.go b/internal/runtime/llm_direct.go index c0f4384..5532a75 100644 --- a/internal/runtime/llm_direct.go +++ b/internal/runtime/llm_direct.go @@ -13,6 +13,7 @@ import ( agentllm "agent-native/runtime/llm" "agent-native/runtime/reminders" agenttools "agent-native/runtime/tools" + "agent-system-backend/internal/ctxasm" "agent-system-backend/internal/squadadapter" core "agent-system-core" ) @@ -128,6 +129,9 @@ func (l *LLMRunner) runDirect(rc RunCtx, stream agentllm.StreamFunc, model strin if streamOpts.MaxTokens > 0 { opts.MaxTokens = streamOpts.MaxTokens } + if streamOpts.MessageShape != "" { + opts.MessageShape = streamOpts.MessageShape + } in := stream(ctx, model, messages, decls, opts) out := make(chan agentllm.Event) go func() { @@ -276,6 +280,13 @@ func (st *directRun) onEvent(ev nruntime.Event) { st.turnThink.WriteString(ev.Text) _ = st.rc.Emit(core.EvThinkingDelta, core.ThinkingDeltaPayload{Index: st.thinkingIdx, Text: ev.Text}) case "message_end": + if ev.PromptTokens > 0 { + if src, ok := st.rc.(interface { + ContextObservation() *core.ContextObservation + }); ok && src.ContextObservation() != nil { + ctxasm.NotePromptUsage(src.ContextObservation().EstChars, ev.PromptTokens) + } + } st.usageTotal.PromptTokens += ev.PromptTokens st.usageTotal.CompletionTokens += ev.CompletionTokens st.usageTotal.TotalTokens += ev.TotalTokens diff --git a/internal/runtime/model.go b/internal/runtime/model.go index 7e615f1..542ec84 100644 --- a/internal/runtime/model.go +++ b/internal/runtime/model.go @@ -6,10 +6,42 @@ import ( core "agent-system-core" ) +// SecretLookup 由组合根注入。返回该服务当前生效的明文;没有独立密钥时 ok 为 false,调用方用设置里的旧字段。 +var SecretLookup func(owner, service string) (string, bool) + +// LookupSecret 给组合根以外的调用方使用。owner 为空时用 local。 +func LookupSecret(owner, service, fallback string) string { + return overlayKey(owner, service, fallback) +} + +func overlayKey(owner, service, fallback string) string { + if SecretLookup == nil { + return fallback + } + if owner == "" { + owner = "local" + } + if v, ok := SecretLookup(owner, service); ok && strings.TrimSpace(v) != "" { + return v + } + return fallback +} + // 模型路由解析(4.7 / 4.9 覆盖链):设置层 defaults → system → user → project, // 画像 ModelRoute 为会话内最高层覆盖(kind 为空回落设置层主模型)。 // 端点与密钥按 kind 独立配置,轻量档空则回落主档。 +// ContextInjection 是一条条件注入,由 runmgr 从存储读出,组装时再转换成 ctxasm 的形状。 +type ContextInjection struct { + Keys []string + Secondary string + Logic string + Content string + Constant bool + ScanDepth int + BudgetChars int +} + // ResolvedModel 是一次路由解析的结果。 type ResolvedModel struct { Kind string // chat | lightweight @@ -26,7 +58,7 @@ func ResolveChatModel(prof core.AgentProfile, eff core.EffectiveSettings) Resolv } return ResolvedModel{Kind: "chat", Model: m, Base: eff.GetString("models.chat.baseUrl", ""), - Key: eff.GetString("models.chat.apiKey", "")} + Key: overlayKey(prof.Owner, "models.chat", eff.GetString("models.chat.apiKey", ""))} } // ChatFallbacks 解析 models.chat.fallbacks。默认空,不改变现有模型选择。 @@ -52,7 +84,7 @@ func ChatFallbacks(eff core.EffectiveSettings) []string { func ResolveLightweightModel(prof core.AgentProfile, eff core.EffectiveSettings) ResolvedModel { m := eff.GetString("models.lightweight.model", "") base := eff.GetString("models.lightweight.baseUrl", "") - key := eff.GetString("models.lightweight.apiKey", "") + key := overlayKey(prof.Owner, "models.lightweight", eff.GetString("models.lightweight.apiKey", "")) if prof.Model.Lightweight != "" { m = prof.Model.Lightweight } diff --git a/internal/server/borrow.go b/internal/server/borrow.go new file mode 100644 index 0000000..e0147fb --- /dev/null +++ b/internal/server/borrow.go @@ -0,0 +1,287 @@ +package server + +import ( + "encoding/json" + "net/http" + "regexp" + "strings" + + agentcommand "agent-command" + "agent-system-backend/internal/store" + core "agent-system-core" +) + +var userCommandSlug = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) + +func (s *Server) listSecrets(w http.ResponseWriter, r *http.Request) { + list, err := s.st.ListSecrets(s.owner(r)) + writeJSONOr(w, list, err) +} + +func (s *Server) createSecret(w http.ResponseWriter, r *http.Request) { + var req struct { + Service string `json:"service"` + Label string `json:"label"` + Value string `json:"value"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + sec, err := s.st.AddSecret(s.owner(r), req.Service, req.Label, req.Value) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusOK, sec) +} + +func (s *Server) activateSecret(w http.ResponseWriter, r *http.Request) { + writeOKOr(w, s.st.ActivateSecret(s.owner(r), r.PathValue("id"))) +} + +func (s *Server) renameSecret(w http.ResponseWriter, r *http.Request) { + var req struct { + Label string `json:"label"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeOKOr(w, s.st.RenameSecret(s.owner(r), r.PathValue("id"), req.Label)) +} + +func (s *Server) deleteSecret(w http.ResponseWriter, r *http.Request) { + writeOKOr(w, s.st.DeleteSecret(s.owner(r), r.PathValue("id"))) +} + +func (s *Server) listContextEntries(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + list, err := s.st.ListContextEntries(s.owner(r), q.Get("projectId"), q.Get("conversationId")) + writeJSONOr(w, list, err) +} + +func (s *Server) saveContextEntry(w http.ResponseWriter, r *http.Request) { + var e store.ContextEntry + if err := json.NewDecoder(r.Body).Decode(&e); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + saved, err := s.st.SaveContextEntry(s.owner(r), e) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusOK, saved) +} + +func (s *Server) deleteContextEntry(w http.ResponseWriter, r *http.Request) { + writeOKOr(w, s.st.DeleteContextEntry(s.owner(r), r.PathValue("id"))) +} + +func (s *Server) listUserPromptCommands(w http.ResponseWriter, r *http.Request) { + list, err := s.st.ListUserCommands(s.owner(r)) + writeJSONOr(w, list, err) +} + +func (s *Server) saveUserPromptCommand(w http.ResponseWriter, r *http.Request) { + var c store.UserCommand + if err := json.NewDecoder(r.Body).Decode(&c); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + saved, err := s.st.SaveUserCommand(s.owner(r), c) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + s.registerUserPrompt(saved) + writeJSON(w, http.StatusOK, saved) +} + +func (s *Server) deleteUserPromptCommand(w http.ResponseWriter, r *http.Request) { + owner := s.owner(r) + id := r.PathValue("id") + list, _ := s.st.ListUserCommands(owner) + var name string + for _, c := range list { + if c.ID == id { + name = c.Name + } + } + if err := s.st.DeleteUserCommand(owner, id); err != nil { + writeStoreErr(w, err) + return + } + if s.Bus != nil && name != "" { + s.Bus.UnregisterUser(userCommandID(name), name) + } + writeJSON(w, http.StatusOK, map[string]any{"deleted": id}) +} + +func (s *Server) excludeMessage(w http.ResponseWriter, r *http.Request) { + var req struct { + Excluded *bool `json:"excluded"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + excluded := true + if req.Excluded != nil { + excluded = *req.Excluded + } + msg, err := s.st.MessageByID(r.PathValue("id")) + if err != nil { + writeStoreErr(w, err) + return + } + if _, err := s.st.GetConversation(s.owner(r), msg.ConversationID); err != nil { + writeStoreErr(w, err) + return + } + writeOKOr(w, s.st.ExcludeRunGroup(msg.ID, excluded)) +} + +func (s *Server) selectVariant(w http.ResponseWriter, r *http.Request) { + msg, err := s.st.MessageByID(r.PathValue("id")) + if err != nil { + writeStoreErr(w, err) + return + } + if _, err := s.st.GetConversation(s.owner(r), msg.ConversationID); err != nil { + writeStoreErr(w, err) + return + } + writeOKOr(w, s.st.SelectVariant(msg.ID)) +} + +func (s *Server) regenerateMessage(w http.ResponseWriter, r *http.Request) { + owner := s.owner(r) + convID := r.PathValue("id") + conv, err := s.st.GetConversation(owner, convID) + if err != nil { + writeStoreErr(w, err) + return + } + var req struct { + MessageID string `json:"messageId"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.MessageID == "" { + http.Error(w, "messageId required", http.StatusBadRequest) + return + } + target, err := s.st.MessageByID(req.MessageID) + if err != nil || target.ConversationID != convID || target.Role != core.RoleAssistant { + http.Error(w, "assistant message required", http.StatusBadRequest) + return + } + hist, err := s.st.ListMessages(convID) + if err != nil { + writeStoreErr(w, err) + return + } + var user core.Message + found := false + maxIndex := target.VariantIndex + for _, m := range hist { + if m.VariantGroup != "" && m.VariantGroup == target.VariantGroup && m.VariantIndex > maxIndex { + maxIndex = m.VariantIndex + } + if m.ID == target.ID { + found = true + } + if !found && m.Role == core.RoleUser { + user = m + } + } + if user.ID == "" { + http.Error(w, "no user turn to regenerate", http.StatusBadRequest) + return + } + group := target.VariantGroup + if group == "" { + group = "var_" + target.ID + } + if err := s.st.SetMessageFlags(target.ID, true, group, target.VariantIndex); err != nil { + writeStoreErr(w, err) + return + } + if err := s.st.ExcludeRunGroup(target.ID, true); err != nil { + writeStoreErr(w, err) + return + } + if err := s.st.SetRegenPending(convID, group, maxIndex+1); err != nil { + writeStoreErr(w, err) + return + } + if _, err := s.mgr.RewindFiles(owner, convID, user.ID); err != nil { + // 没有文件检查点时仍然重新生成回复。 + } + run, err := s.mgr.StartConversationRun(conv, user, nil, nil) + if err != nil { + _, _, _ = s.st.TakeRegenPending(convID) + writeStoreErr(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"run": run, "variantGroup": group}) +} + +func (s *Server) expandUserCommand(owner, text string) (string, bool) { + fields := strings.Fields(text) + if len(fields) == 0 || !strings.HasPrefix(fields[0], "/") { + return "", false + } + list, err := s.st.ListUserCommands(owner) + if err != nil { + return "", false + } + name := strings.ToLower(fields[0]) + rest := strings.TrimSpace(strings.TrimPrefix(text, fields[0])) + for _, c := range list { + if strings.ToLower(c.Name) != name { + continue + } + body := c.Template + if strings.Contains(body, "{{input}}") { + body = strings.ReplaceAll(body, "{{input}}", rest) + } else if rest != "" { + body = strings.TrimSpace(body + "\n" + rest) + } + return body, true + } + return "", false +} + +func userCommandID(name string) string { + slug := strings.TrimPrefix(strings.ToLower(name), "/") + return "user." + slug +} + +func (s *Server) registerUserPrompt(c store.UserCommand) { + if s.Bus == nil { + return + } + slug := strings.TrimPrefix(strings.ToLower(c.Name), "/") + if !userCommandSlug.MatchString(slug) { + return + } + _ = s.Bus.RegisterUser(agentcommand.CommandSpec{ + ID: userCommandID(c.Name), Title: c.Title, Description: "展开为提示词,不自动执行", + }, c.Name) +} + +func (s *Server) appendUserPromptCommands(owner string, out []map[string]any) []map[string]any { + list, err := s.st.ListUserCommands(owner) + if err != nil { + return out + } + for _, c := range list { + s.registerUserPrompt(c) + out = append(out, map[string]any{ + "id": userCommandID(c.Name), "name": c.Name, "title": c.Title, + "description": "展开为提示词", "source": "user", "enabled": true, + }) + } + return out +} diff --git a/internal/server/server.go b/internal/server/server.go index 72c793f..a404a64 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -167,6 +167,20 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /api/conversations/{id}/messages", s.postMessage) mux.HandleFunc("GET /api/conversations/{id}/checkpoints", s.listCheckpoints) mux.HandleFunc("POST /api/conversations/{id}/rewind", s.rewindFiles) + mux.HandleFunc("POST /api/conversations/{id}/regenerate", s.regenerateMessage) + mux.HandleFunc("POST /api/messages/{id}/exclude", s.excludeMessage) + mux.HandleFunc("POST /api/messages/{id}/variant", s.selectVariant) + mux.HandleFunc("GET /api/secrets", s.listSecrets) + mux.HandleFunc("POST /api/secrets", s.createSecret) + mux.HandleFunc("POST /api/secrets/{id}/activate", s.activateSecret) + mux.HandleFunc("PATCH /api/secrets/{id}", s.renameSecret) + mux.HandleFunc("DELETE /api/secrets/{id}", s.deleteSecret) + mux.HandleFunc("GET /api/context-entries", s.listContextEntries) + mux.HandleFunc("POST /api/context-entries", s.saveContextEntry) + mux.HandleFunc("DELETE /api/context-entries/{id}", s.deleteContextEntry) + mux.HandleFunc("GET /api/prompt-commands", s.listUserPromptCommands) + mux.HandleFunc("POST /api/prompt-commands", s.saveUserPromptCommand) + mux.HandleFunc("DELETE /api/prompt-commands/{id}", s.deleteUserPromptCommand) mux.HandleFunc("POST /api/runs/{id}/cancel", s.cancelRun) mux.HandleFunc("GET /api/runs/{id}", s.getRun) @@ -524,10 +538,12 @@ func (s *Server) patchConversation(w http.ResponseWriter, r *http.Request) { AllowCrossRead *bool `json:"allowCrossRead"` CrossPrivate *bool `json:"crossPrivate"` PermissionMode *string `json:"permissionMode"` + SessionNote *string `json:"sessionNote"` } json.NewDecoder(r.Body).Decode(&req) - onlyMode := req.PermissionMode != nil && req.Title == "" && req.ProfileID == "" && req.Pinned == nil && req.Archived == nil && req.AllowCrossRead == nil && req.CrossPrivate == nil - if !onlyMode { + onlyMode := req.PermissionMode != nil && req.Title == "" && req.ProfileID == "" && req.Pinned == nil && req.Archived == nil && req.AllowCrossRead == nil && req.CrossPrivate == nil && req.SessionNote == nil + onlyNote := req.SessionNote != nil && req.Title == "" && req.ProfileID == "" && req.Pinned == nil && req.Archived == nil && req.AllowCrossRead == nil && req.CrossPrivate == nil && req.PermissionMode == nil + if !onlyMode && !onlyNote { if err := s.st.UpdateConversation(s.owner(r), id, req.Title, req.ProfileID); err != nil { writeStoreErr(w, err) return @@ -539,6 +555,12 @@ func (s *Server) patchConversation(w http.ResponseWriter, r *http.Request) { return } } + if req.SessionNote != nil { + if err := s.st.SetSessionNote(s.owner(r), id, *req.SessionNote); err != nil { + writeStoreErr(w, err) + return + } + } writeOKOr(w, s.st.SetConversationFlags(s.owner(r), id, req.Pinned, req.Archived, req.AllowCrossRead, req.CrossPrivate)) } @@ -566,6 +588,9 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) { return } text := strings.TrimSpace(req.Text) + if expanded, ok := s.expandUserCommand(owner, text); ok { + text = expanded + } if text == "" && len(req.AttachmentIDs) == 0 { http.Error(w, "text or attachments required", http.StatusBadRequest) return @@ -1295,6 +1320,7 @@ func (s *Server) listCommands(w http.ResponseWriter, r *http.Request) { } } } + out = s.appendUserPromptCommands(s.owner(r), out) writeJSON(w, http.StatusOK, out) } diff --git a/internal/settings/secrets.go b/internal/settings/secrets.go new file mode 100644 index 0000000..712d59f --- /dev/null +++ b/internal/settings/secrets.go @@ -0,0 +1,115 @@ +package settings + +import ( + "encoding/json" + "strings" + "time" +) + +type secretSpec struct { + path string + service string +} + +var secretSpecs = []secretSpec{ + {"models.chat.apiKey", "models.chat"}, + {"models.lightweight.apiKey", "models.lightweight"}, + {"models.embedding.apiKey", "models.embedding"}, + {"models.rerank.apiKey", "models.rerank"}, + {"tools.tavily.apiKeys", "tools.tavily"}, +} + +// SnapshotAndMigrateSecrets 先为每个已存 scope 留快照,再把明文密钥迁入独立表并从表单设置里清掉。 +// 该服务已经有密钥行时不再迁入,避免重复。掩码占位符不迁。 +func (s *Service) SnapshotAndMigrateSecrets() error { + refs, err := s.st.ListSettingsScopes() + if err != nil { + return err + } + for _, ref := range refs { + cur, err := s.st.GetSettingsScope(ref.Owner, ref.Scope) + if err != nil { + return err + } + raw, err := json.Marshal(cur) + if err != nil { + return err + } + if _, err := s.st.SaveSnapshotIfChanged(ref.Owner, ref.Scope, string(raw), "startup"); err != nil { + return err + } + for _, spec := range secretSpecs { + val, ok := lookupString(cur, spec.path) + if !ok || val == MaskPlaceholder { + continue + } + has, err := s.st.HasSecret(ref.Owner, spec.service) + if err != nil { + return err + } + if !has { + if _, err := s.st.AddSecret(ref.Owner, spec.service, "迁入", val); err != nil { + return err + } + } + path := spec.path + if err := s.st.UpdateSettingsScope(ref.Owner, ref.Scope, func(cur map[string]any) (map[string]any, error) { + deletePath(cur, path) + return cur, nil + }); err != nil { + return err + } + } + } + return nil +} + +func (s *Service) snapshotThrottled(owner, scope string) { + if at, ok := s.st.LatestSnapshotAt(owner, scope); ok && time.Since(at) < 10*time.Minute { + return + } + cur, err := s.st.GetSettingsScope(owner, scope) + if err != nil { + return + } + raw, err := json.Marshal(cur) + if err != nil { + return + } + _, _ = s.st.SaveSnapshotIfChanged(owner, scope, string(raw), "save") +} + +func lookupString(m map[string]any, path string) (string, bool) { + var cur any = m + for _, p := range strings.Split(path, ".") { + obj, ok := cur.(map[string]any) + if !ok { + return "", false + } + cur, ok = obj[p] + if !ok { + return "", false + } + } + s, ok := cur.(string) + if !ok || strings.TrimSpace(s) == "" { + return "", false + } + return s, true +} + +func deletePath(m map[string]any, path string) { + parts := strings.Split(path, ".") + cur := m + for i, p := range parts { + if i == len(parts)-1 { + delete(cur, p) + return + } + next, ok := cur[p].(map[string]any) + if !ok { + return + } + cur = next + } +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go index e53c128..f3b65df 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -49,6 +49,9 @@ func SystemDefaults() map[string]any { "context": map[string]any{ "maxHistoryMessages": 40, "maxToolResultChars": 20000, + "userInstructions": "", + "contextWindow": 32000, + "messageShape": "merge", }, "crossSession": map[string]any{ "read": false, // 跨会话读取项目闸门(提案 2026-09-30-cross-session-reading):关 = 全项目禁读 @@ -93,6 +96,9 @@ func Schema() []core.SettingDesc { {Key: "runtime.composeFinal", Group: "runtime", Label: "终轮整理回复", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "用过工具的回合结束后,再用主模型写一段直接回答用户的最终回复"}, {Key: "context.maxHistoryMessages", Group: "context", Label: "历史窗口条数", Type: core.SettingNumber, Default: 40, Effect: core.EffectNewRun}, {Key: "context.maxToolResultChars", Group: "context", Label: "工具结果上限(字符)", Type: core.SettingNumber, Default: 20000, Effect: core.EffectNewRun}, + {Key: "context.userInstructions", Group: "context", Label: "个人指令", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "每次对话附在上下文末尾,不改系统前缀"}, + {Key: "context.contextWindow", Group: "context", Label: "上下文窗口(估算 token)", Type: core.SettingNumber, Default: 32000, Effect: core.EffectNewRun, Description: "超出时只丢掉最早的历史。估算为 UTF-8 字节 / 3.35,并用上一请求的真实用量校准"}, + {Key: "context.messageShape", Group: "context", Label: "消息整形", Type: core.SettingEnum, Default: "merge", Options: []string{"merge", "strict"}, Effect: core.EffectNewRun, Description: "merge 合并相邻同角色。strict 再把多余的 system 收成 user,满足严格交替"}, {Key: "crossSession.read", Group: "crossSession", Label: "跨会话读取", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "允许本项目会话读取同项目其他会话的对话记录(list_conversations / read_conversation 工具);会话可单独关闭读取或设为私有"}, {Key: "retrieval.topK", Group: "retrieval", Label: "检索命中数", Type: core.SettingNumber, Default: 5, Effect: core.EffectHot}, {Key: "ui.theme", Group: "ui", Label: "主题", Type: core.SettingEnum, Default: "dark", Options: []string{"dark", "light"}, Effect: core.EffectHot}, @@ -156,9 +162,13 @@ func (s *Service) MergeProject(owner, projectID string, values map[string]any) e // 掩码占位符视为未修改,不覆盖已存密钥。 func (s *Service) mergeScope(owner, scope string, values map[string]any) error { incoming := prepareWrite(values) - return s.st.UpdateSettingsScope(owner, scope, func(cur map[string]any) (map[string]any, error) { + if err := s.st.UpdateSettingsScope(owner, scope, func(cur map[string]any) (map[string]any, error) { return core.MergeSettings(normalizeKeys(cur), incoming), nil - }) + }); err != nil { + return err + } + s.snapshotThrottled(owner, scope) + return nil } // NormalizeStats 是启动迁移的结果。MaskPaths 只含 owner、scope 和键路径,不含值。 diff --git a/internal/store/borrow.go b/internal/store/borrow.go new file mode 100644 index 0000000..2eea5cc --- /dev/null +++ b/internal/store/borrow.go @@ -0,0 +1,455 @@ +package store + +import ( + "database/sql" + "encoding/json" + "fmt" + "strings" + "time" + + core "agent-system-core" +) + +// Secret 是一把带标签的服务密钥。列表接口不返回 Value。 +type Secret struct { + ID string `json:"id"` + Owner string `json:"owner,omitempty"` + Service string `json:"service"` + Label string `json:"label"` + Active bool `json:"active"` + Mask string `json:"mask"` + CreatedAt string `json:"createdAt"` + Value string `json:"-"` +} + +// ContextEntry 是一条条件注入。常驻或关键词命中后附在上下文末尾。 +type ContextEntry struct { + ID string `json:"id"` + Owner string `json:"owner,omitempty"` + ProjectID string `json:"projectId,omitempty"` + ConversationID string `json:"conversationId,omitempty"` + Keys []string `json:"keys"` + Secondary string `json:"secondary,omitempty"` + Logic string `json:"logic"` + Content string `json:"content"` + Constant bool `json:"constant"` + ScanDepth int `json:"scanDepth"` + SortOrder int `json:"sortOrder"` + BudgetChars int `json:"budgetChars"` + Enabled bool `json:"enabled"` +} + +// UserCommand 是用户自定义的 / 命令,展开成提示词,不自动触发。 +type UserCommand struct { + ID string `json:"id"` + Owner string `json:"owner,omitempty"` + Name string `json:"name"` + Title string `json:"title"` + Template string `json:"template"` + CreatedAt string `json:"createdAt"` +} + +func maskSecret(v string) string { + v = strings.TrimSpace(v) + if len(v) <= 4 { + return "••••••" + } + return "••••" + v[len(v)-4:] +} + +func (s *Store) ListSecrets(owner string) ([]Secret, error) { + rows, err := s.db.Query(s.q(`SELECT id,service,label,value,active,created_at FROM secret_keys WHERE owner=? ORDER BY service, created_at`), owner) + if err != nil { + return nil, err + } + defer rows.Close() + out := []Secret{} + for rows.Next() { + var sec Secret + var active int + if err := rows.Scan(&sec.ID, &sec.Service, &sec.Label, &sec.Value, &active, &sec.CreatedAt); err != nil { + return nil, err + } + sec.Active = active == 1 + sec.Mask = maskSecret(sec.Value) + sec.Value = "" + out = append(out, sec) + } + return out, rows.Err() +} + +// ActiveSecret 返回该服务当前生效的明文。没有记录时 ok 为 false。 +func (s *Store) ActiveSecret(owner, service string) (string, bool) { + var v string + err := s.db.QueryRow(s.q(`SELECT value FROM secret_keys WHERE owner=? AND service=? AND active=1 ORDER BY created_at DESC LIMIT 1`), owner, service).Scan(&v) + if err != nil || strings.TrimSpace(v) == "" { + return "", false + } + return v, true +} + +// AddSecret 追加一把密钥并把它设为该服务唯一生效项。 +func (s *Store) AddSecret(owner, service, label, value string) (Secret, error) { + service = strings.TrimSpace(service) + value = strings.TrimSpace(value) + label = strings.TrimSpace(label) + if service == "" || value == "" { + return Secret{}, fmt.Errorf("store: secret service and value are required") + } + if label == "" { + label = "未命名" + } + tx, err := s.db.Begin() + if err != nil { + return Secret{}, err + } + defer tx.Rollback() + if _, err := tx.Exec(s.q(`UPDATE secret_keys SET active=0 WHERE owner=? AND service=?`), owner, service); err != nil { + return Secret{}, err + } + sec := Secret{ID: newID("key"), Service: service, Label: label, Active: true, CreatedAt: nowISO(), Mask: maskSecret(value)} + if _, err := tx.Exec(s.q(`INSERT INTO secret_keys(id,owner,service,label,value,active,created_at) VALUES(?,?,?,?,?,?,?)`), + sec.ID, owner, service, label, value, 1, sec.CreatedAt); err != nil { + return Secret{}, err + } + return sec, tx.Commit() +} + +func (s *Store) ActivateSecret(owner, id string) error { + var service string + err := s.db.QueryRow(s.q(`SELECT service FROM secret_keys WHERE id=? AND owner=?`), id, owner).Scan(&service) + if err != nil { + return ErrNotFound + } + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.Exec(s.q(`UPDATE secret_keys SET active=0 WHERE owner=? AND service=?`), owner, service); err != nil { + return err + } + if _, err := tx.Exec(s.q(`UPDATE secret_keys SET active=1 WHERE id=? AND owner=?`), id, owner); err != nil { + return err + } + return tx.Commit() +} + +func (s *Store) RenameSecret(owner, id, label string) error { + label = strings.TrimSpace(label) + if label == "" { + return fmt.Errorf("store: label required") + } + res, err := s.db.Exec(s.q(`UPDATE secret_keys SET label=? WHERE id=? AND owner=?`), label, id, owner) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return ErrNotFound + } + return nil +} + +func (s *Store) DeleteSecret(owner, id string) error { + res, err := s.db.Exec(s.q(`DELETE FROM secret_keys WHERE id=? AND owner=?`), id, owner) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return ErrNotFound + } + return nil +} + +func (s *Store) HasSecret(owner, service string) (bool, error) { + var n int + err := s.db.QueryRow(s.q(`SELECT COUNT(1) FROM secret_keys WHERE owner=? AND service=?`), owner, service).Scan(&n) + return n > 0, err +} + +// SaveSnapshotIfChanged 在正文与最近一份不同时留档。相同则跳过。 +func (s *Store) SaveSnapshotIfChanged(owner, scope, body, reason string) (bool, error) { + var prev string + err := s.db.QueryRow(s.q(`SELECT body FROM settings_snapshots WHERE owner=? AND scope=? ORDER BY created_at DESC LIMIT 1`), owner, scope).Scan(&prev) + if err == nil && prev == body { + return false, nil + } + if err != nil && err != sql.ErrNoRows { + return false, err + } + _, err = s.db.Exec(s.q(`INSERT INTO settings_snapshots(id,owner,scope,body,reason,created_at) VALUES(?,?,?,?,?,?)`), + newID("snap"), owner, scope, body, reason, nowISO()) + return err == nil, err +} + +// LatestSnapshotAge 返回最近一份快照的时间。没有快照时 ok 为 false。 +func (s *Store) LatestSnapshotAt(owner, scope string) (time.Time, bool) { + var raw string + err := s.db.QueryRow(s.q(`SELECT created_at FROM settings_snapshots WHERE owner=? AND scope=? ORDER BY created_at DESC LIMIT 1`), owner, scope).Scan(&raw) + if err != nil { + return time.Time{}, false + } + t, err := time.Parse(time.RFC3339Nano, raw) + if err != nil { + return time.Time{}, false + } + return t, true +} + +func (s *Store) SetSessionNote(owner, id, note string) error { + res, err := s.db.Exec(s.q(`UPDATE conversations SET session_note=?, updated_at=? WHERE id=? AND owner=?`), note, nowISO(), id, owner) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return ErrNotFound + } + return nil +} + +func (s *Store) SetMessageFlags(id string, excluded bool, group string, index int) error { + ex := 0 + if excluded { + ex = 1 + } + _, err := s.db.Exec(s.q(`UPDATE messages SET excluded=?, variant_group=?, variant_index=? WHERE id=?`), ex, group, index, id) + return err +} + +// ExcludeRunGroup 把这条消息以及同一 run 的消息一起标成排除或恢复。 +// 工具调用和结果落在同一条助手消息里,同一 run 的其它落库消息一并处理。 +func (s *Store) ExcludeRunGroup(id string, excluded bool) error { + var runID string + err := s.db.QueryRow(s.q(`SELECT run_id FROM messages WHERE id=?`), id).Scan(&runID) + if err != nil { + return ErrNotFound + } + ex := 0 + if excluded { + ex = 1 + } + if _, err := s.db.Exec(s.q(`UPDATE messages SET excluded=? WHERE id=?`), ex, id); err != nil { + return err + } + if runID == "" { + return nil + } + _, err = s.db.Exec(s.q(`UPDATE messages SET excluded=? WHERE run_id=?`), ex, runID) + return err +} + +// SelectVariant 让同一组里只有这条候选进入上下文。 +func (s *Store) SelectVariant(id string) error { + var group, conv string + err := s.db.QueryRow(s.q(`SELECT variant_group, conversation_id FROM messages WHERE id=?`), id).Scan(&group, &conv) + if err != nil { + return ErrNotFound + } + if group == "" { + return s.ExcludeRunGroup(id, false) + } + if _, err := s.db.Exec(s.q(`UPDATE messages SET excluded=1 WHERE conversation_id=? AND variant_group=?`), conv, group); err != nil { + return err + } + _, err = s.db.Exec(s.q(`UPDATE messages SET excluded=0 WHERE id=?`), id) + return err +} + +func (s *Store) SetRegenPending(conversationID, group string, index int) error { + res, err := s.db.Exec(s.q(`UPDATE regen_pending SET variant_group=?, variant_index=? WHERE conversation_id=?`), group, index, conversationID) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n > 0 { + return nil + } + _, err = s.db.Exec(s.q(`INSERT INTO regen_pending(conversation_id, variant_group, variant_index) VALUES(?,?,?)`), conversationID, group, index) + return err +} + +func (s *Store) TakeRegenPending(conversationID string) (group string, index int, ok bool) { + err := s.db.QueryRow(s.q(`SELECT variant_group, variant_index FROM regen_pending WHERE conversation_id=?`), conversationID).Scan(&group, &index) + if err != nil { + return "", 0, false + } + _, _ = s.db.Exec(s.q(`DELETE FROM regen_pending WHERE conversation_id=?`), conversationID) + return group, index, true +} + +func (s *Store) ListContextEntries(owner, projectID, conversationID string) ([]ContextEntry, error) { + rows, err := s.db.Query(s.q(`SELECT id,project_id,conversation_id,keys_json,secondary,logic,content,constant,scan_depth,sort_order,budget_chars,enabled + FROM context_entries WHERE owner=? ORDER BY sort_order, created_at`), owner) + if err != nil { + return nil, err + } + defer rows.Close() + out := []ContextEntry{} + for rows.Next() { + e, err := scanEntry(rows) + if err != nil { + return nil, err + } + if e.ProjectID != "" && projectID != "" && e.ProjectID != projectID { + continue + } + if e.ConversationID != "" && conversationID != "" && e.ConversationID != conversationID { + continue + } + out = append(out, e) + } + return out, rows.Err() +} + +func scanEntry(rows interface{ Scan(...any) error }) (ContextEntry, error) { + var e ContextEntry + var keys, logic string + var constant, enabled int + err := rows.Scan(&e.ID, &e.ProjectID, &e.ConversationID, &keys, &e.Secondary, &logic, &e.Content, &constant, &e.ScanDepth, &e.SortOrder, &e.BudgetChars, &enabled) + if err != nil { + return e, err + } + _ = json.Unmarshal([]byte(keys), &e.Keys) + if e.Keys == nil { + e.Keys = []string{} + } + e.Logic = logic + if e.Logic == "" { + e.Logic = "and" + } + e.Constant = constant == 1 + e.Enabled = enabled == 1 + return e, nil +} + +func (s *Store) SaveContextEntry(owner string, e ContextEntry) (ContextEntry, error) { + if strings.TrimSpace(e.Content) == "" { + return ContextEntry{}, fmt.Errorf("store: entry content required") + } + if e.Logic != "and" && e.Logic != "or" && e.Logic != "not" { + e.Logic = "and" + } + if e.ScanDepth <= 0 { + e.ScanDepth = 4 + } + if e.BudgetChars <= 0 { + e.BudgetChars = 2000 + } + raw, _ := json.Marshal(e.Keys) + constant, enabled := 0, 0 + if e.Constant { + constant = 1 + } + if e.Enabled || e.ID == "" { + enabled = 1 + e.Enabled = true + } + if e.ID == "" { + e.ID = newID("ctx") + _, err := s.db.Exec(s.q(`INSERT INTO context_entries(id,owner,project_id,conversation_id,keys_json,secondary,logic,content,constant,scan_depth,sort_order,budget_chars,enabled,created_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`), + e.ID, owner, e.ProjectID, e.ConversationID, string(raw), e.Secondary, e.Logic, e.Content, constant, e.ScanDepth, e.SortOrder, e.BudgetChars, enabled, nowISO()) + return e, err + } + res, err := s.db.Exec(s.q(`UPDATE context_entries SET project_id=?, conversation_id=?, keys_json=?, secondary=?, logic=?, content=?, constant=?, scan_depth=?, sort_order=?, budget_chars=?, enabled=? WHERE id=? AND owner=?`), + e.ProjectID, e.ConversationID, string(raw), e.Secondary, e.Logic, e.Content, constant, e.ScanDepth, e.SortOrder, e.BudgetChars, enabled, e.ID, owner) + if err != nil { + return e, err + } + n, _ := res.RowsAffected() + if n == 0 { + return e, ErrNotFound + } + return e, nil +} + +func (s *Store) DeleteContextEntry(owner, id string) error { + res, err := s.db.Exec(s.q(`DELETE FROM context_entries WHERE id=? AND owner=?`), id, owner) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return ErrNotFound + } + return nil +} + +func (s *Store) ListUserCommands(owner string) ([]UserCommand, error) { + rows, err := s.db.Query(s.q(`SELECT id,name,title,template,created_at FROM user_commands WHERE owner=? ORDER BY name`), owner) + if err != nil { + return nil, err + } + defer rows.Close() + out := []UserCommand{} + for rows.Next() { + var c UserCommand + if err := rows.Scan(&c.ID, &c.Name, &c.Title, &c.Template, &c.CreatedAt); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +func (s *Store) SaveUserCommand(owner string, c UserCommand) (UserCommand, error) { + c.Name = strings.TrimSpace(c.Name) + if !strings.HasPrefix(c.Name, "/") { + c.Name = "/" + c.Name + } + if c.Title == "" { + c.Title = c.Name + } + if strings.TrimSpace(c.Template) == "" { + return UserCommand{}, fmt.Errorf("store: command template required") + } + if c.ID == "" { + c.ID = newID("cmd") + c.CreatedAt = nowISO() + _, err := s.db.Exec(s.q(`INSERT INTO user_commands(id,owner,name,title,template,created_at) VALUES(?,?,?,?,?,?)`), + c.ID, owner, c.Name, c.Title, c.Template, c.CreatedAt) + return c, err + } + res, err := s.db.Exec(s.q(`UPDATE user_commands SET name=?, title=?, template=? WHERE id=? AND owner=?`), c.Name, c.Title, c.Template, c.ID, owner) + if err != nil { + return c, err + } + n, _ := res.RowsAffected() + if n == 0 { + return c, ErrNotFound + } + return c, nil +} + +func (s *Store) DeleteUserCommand(owner, id string) error { + res, err := s.db.Exec(s.q(`DELETE FROM user_commands WHERE id=? AND owner=?`), id, owner) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return ErrNotFound + } + return nil +} + +// MessageByID 读取一条消息。不存在时返回 ErrNotFound。 +func (s *Store) MessageByID(id string) (core.Message, error) { + var m core.Message + var parts, ca string + var excluded, index int + err := s.db.QueryRow(s.q(`SELECT id,conversation_id,run_id,role,parts,created_at,excluded,variant_group,variant_index FROM messages WHERE id=?`), id). + Scan(&m.ID, &m.ConversationID, &m.RunID, &m.Role, &parts, &ca, &excluded, &m.VariantGroup, &index) + if err != nil { + return m, ErrNotFound + } + m.Role = core.Role(m.Role) + m.Parts, _ = unmarshalJSON[[]core.Part](parts) + m.CreatedAt, _ = parseTime(ca) + m.Excluded = excluded == 1 + m.VariantIndex = index + return m, nil +} diff --git a/internal/store/messages.go b/internal/store/messages.go index 8aaecec..205f464 100644 --- a/internal/store/messages.go +++ b/internal/store/messages.go @@ -63,7 +63,7 @@ func (s *Store) AppendMessageWithRequest(m *core.Message, requestID string) erro } func (s *Store) ListMessages(conversationID string) ([]core.Message, error) { - rows, err := s.db.Query(s.q(`SELECT id,conversation_id,run_id,role,parts,created_at FROM messages WHERE conversation_id=? ORDER BY seq`), conversationID) + rows, err := s.db.Query(s.q(`SELECT id,conversation_id,run_id,role,parts,created_at,excluded,variant_group,variant_index FROM messages WHERE conversation_id=? ORDER BY seq`), conversationID) if err != nil { return nil, err } @@ -72,12 +72,15 @@ func (s *Store) ListMessages(conversationID string) ([]core.Message, error) { for rows.Next() { var m core.Message var parts, ca string - if err := rows.Scan(&m.ID, &m.ConversationID, &m.RunID, &m.Role, &parts, &ca); err != nil { + var excluded, index int + if err := rows.Scan(&m.ID, &m.ConversationID, &m.RunID, &m.Role, &parts, &ca, &excluded, &m.VariantGroup, &index); err != nil { return nil, err } m.Role = core.Role(m.Role) m.Parts, _ = unmarshalJSON[[]core.Part](parts) m.CreatedAt, _ = parseTime(ca) + m.Excluded = excluded == 1 + m.VariantIndex = index out = append(out, m) } return out, rows.Err() diff --git a/internal/store/migrate.go b/internal/store/migrate.go index 2eb67ec..d1aa0a5 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -401,9 +401,82 @@ func init() { }, "postgres": { `ALTER TABLE conversations DROP COLUMN permission_mode`, }}}, + migration{version: 16, name: "secrets_context_and_variants", + up: map[string][]string{"sqlite": v16Up, "postgres": v16Up}, + down: map[string][]string{"sqlite": v16Down, "postgres": v16Down}}, ) } +// v16 密钥独立表、设置快照、条件注入、用户命令、会话要求、消息排除与候选回复。 +var v16Up = []string{ + `CREATE TABLE IF NOT EXISTS secret_keys ( + id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + service TEXT NOT NULL, + label TEXT NOT NULL, + value TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_secret_keys_owner_service ON secret_keys(owner, service)`, + `CREATE TABLE IF NOT EXISTS settings_snapshots ( + id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + scope TEXT NOT NULL, + body TEXT NOT NULL, + reason TEXT NOT NULL, + created_at TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_settings_snapshots_scope ON settings_snapshots(owner, scope, created_at)`, + `ALTER TABLE conversations ADD COLUMN session_note TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE messages ADD COLUMN excluded INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE messages ADD COLUMN variant_group TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE messages ADD COLUMN variant_index INTEGER NOT NULL DEFAULT 0`, + `CREATE TABLE IF NOT EXISTS regen_pending ( + conversation_id TEXT PRIMARY KEY, + variant_group TEXT NOT NULL, + variant_index INTEGER NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS context_entries ( + id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + project_id TEXT NOT NULL DEFAULT '', + conversation_id TEXT NOT NULL DEFAULT '', + keys_json TEXT NOT NULL DEFAULT '[]', + secondary TEXT NOT NULL DEFAULT '', + logic TEXT NOT NULL DEFAULT 'and', + content TEXT NOT NULL, + constant INTEGER NOT NULL DEFAULT 0, + scan_depth INTEGER NOT NULL DEFAULT 4, + sort_order INTEGER NOT NULL DEFAULT 0, + budget_chars INTEGER NOT NULL DEFAULT 2000, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS user_commands ( + id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + name TEXT NOT NULL, + title TEXT NOT NULL, + template TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE(owner, name) + )`, +} + +var v16Down = []string{ + `DROP TABLE IF EXISTS user_commands`, + `DROP TABLE IF EXISTS context_entries`, + `DROP TABLE IF EXISTS regen_pending`, + `ALTER TABLE messages DROP COLUMN variant_index`, + `ALTER TABLE messages DROP COLUMN variant_group`, + `ALTER TABLE messages DROP COLUMN excluded`, + `ALTER TABLE conversations DROP COLUMN session_note`, + `DROP TABLE IF EXISTS settings_snapshots`, + `DROP INDEX IF EXISTS idx_secret_keys_owner_service`, + `DROP TABLE IF EXISTS secret_keys`, +} + func (s *Store) migrate() error { if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL)`); err != nil { return fmt.Errorf("migrate: %w", err) diff --git a/internal/store/projects.go b/internal/store/projects.go index 9a56c51..0715496 100644 --- a/internal/store/projects.go +++ b/internal/store/projects.go @@ -138,7 +138,7 @@ func (s *Store) CreateConversation(owner, projectID, title, profileID string) (c } func (s *Store) ListConversations(owner, projectID string) ([]core.Conversation, error) { - rows, err := s.db.Query(s.q(`SELECT id,project_id,title,profile_id,pinned,archived,allow_cross_read,cross_private,permission_mode,created_at,updated_at FROM conversations WHERE project_id=? AND owner=? ORDER BY updated_at DESC`), projectID, owner) + rows, err := s.db.Query(s.q(`SELECT id,project_id,title,profile_id,pinned,archived,allow_cross_read,cross_private,permission_mode,session_note,created_at,updated_at FROM conversations WHERE project_id=? AND owner=? ORDER BY updated_at DESC`), projectID, owner) if err != nil { return nil, err } @@ -157,7 +157,7 @@ func (s *Store) ListConversations(owner, projectID string) ([]core.Conversation, // ListTasks 跨项目任务列表(方案 A 侧栏数据源):默认仅未归档;includeArchived 含归档。 func (s *Store) ListTasks(owner string, includeArchived bool) ([]core.Conversation, error) { - q := `SELECT id,project_id,title,profile_id,pinned,archived,allow_cross_read,cross_private,permission_mode,created_at,updated_at FROM conversations WHERE owner=?` + q := `SELECT id,project_id,title,profile_id,pinned,archived,allow_cross_read,cross_private,permission_mode,session_note,created_at,updated_at FROM conversations WHERE owner=?` if !includeArchived { q += ` AND archived=0` } @@ -182,7 +182,7 @@ func (s *Store) ListTasks(owner string, includeArchived bool) ([]core.Conversati func scanConversation(rows interface{ Scan(dest ...any) error }, c *core.Conversation) error { var ca, ua string var pinned, archived, allowCrossRead, crossPrivate int - if err := rows.Scan(&c.ID, &c.ProjectID, &c.Title, &c.ProfileID, &pinned, &archived, &allowCrossRead, &crossPrivate, &c.PermissionMode, &ca, &ua); err != nil { + if err := rows.Scan(&c.ID, &c.ProjectID, &c.Title, &c.ProfileID, &pinned, &archived, &allowCrossRead, &crossPrivate, &c.PermissionMode, &c.SessionNote, &ca, &ua); err != nil { return err } c.Pinned = pinned == 1 @@ -198,8 +198,8 @@ func (s *Store) GetConversation(owner, id string) (core.Conversation, error) { var c core.Conversation var ca, ua string var pinned, archived, allowCrossRead, crossPrivate int - err := s.db.QueryRow(s.q(`SELECT id,project_id,title,profile_id,pinned,archived,allow_cross_read,cross_private,permission_mode,created_at,updated_at FROM conversations WHERE id=? AND owner=?`), id, owner). - Scan(&c.ID, &c.ProjectID, &c.Title, &c.ProfileID, &pinned, &archived, &allowCrossRead, &crossPrivate, &c.PermissionMode, &ca, &ua) + err := s.db.QueryRow(s.q(`SELECT id,project_id,title,profile_id,pinned,archived,allow_cross_read,cross_private,permission_mode,session_note,created_at,updated_at FROM conversations WHERE id=? AND owner=?`), id, owner). + Scan(&c.ID, &c.ProjectID, &c.Title, &c.ProfileID, &pinned, &archived, &allowCrossRead, &crossPrivate, &c.PermissionMode, &c.SessionNote, &ca, &ua) if errors.Is(err, sql.ErrNoRows) { return c, ErrNotFound } diff --git a/internal/store/recovery.go b/internal/store/recovery.go index d892219..f1c2878 100644 --- a/internal/store/recovery.go +++ b/internal/store/recovery.go @@ -177,6 +177,15 @@ func (s *Store) settleRunOnce(r *core.Run, msg *core.Message) (bool, error) { if _, err := tx.Exec(s.q(insert), msg.ID, msg.ConversationID, msg.RunID, string(msg.Role), marshalJSON(msg.Parts), nowISO(), "", msg.ConversationID); err != nil { return false, err } + if msg.VariantGroup != "" || msg.Excluded { + ex := 0 + if msg.Excluded { + ex = 1 + } + if _, err := tx.Exec(s.q(`UPDATE messages SET excluded=?, variant_group=?, variant_index=? WHERE id=?`), ex, msg.VariantGroup, msg.VariantIndex, msg.ID); err != nil { + return false, err + } + } } if err := tx.Commit(); err != nil { return false, err diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 1729391..a8720cb 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -56,8 +56,8 @@ func TestMigrationUpgradesExistingDatabase(t *testing.T) { if err != nil { t.Fatal(err) } - if len(got) != 15 { - t.Fatalf("expected versions [1..15], got %v", got) + if len(got) != 16 { + t.Fatalf("expected versions [1..16], got %v", got) } // 老库形态(v2 前):events 全局自增、settings 单列主键。 if err := st.MigrateDown(1); err != nil {