Store secrets apart from settings and assemble context at the end

Why: Masks must not be the only copy of a key, and personal instructions, lore hits, and candidate replies have to reach the model without rewriting the system prefix or deleting history.
Evidence: go test ./internal/store ./internal/ctxasm ./internal/settings ./internal/runtime ./internal/server. Migration v16 upgrades and rolls back. Startup snapshots settings before moving raw keys.
Impact: One labeled key is active per service. Excluding a message hides its whole run from the model. Regenerating appends another candidate and rewinds files to the user turn. User slash commands expand to prompt text and do not fire on their own.
Refs: docs/records/2026-10-04-sillytavern-review.md
This commit is contained in:
2026-10-04 18:20:10 +00:00
parent 8eda35f365
commit 42cca8fade
22 changed files with 1515 additions and 28 deletions
+22 -2
View File
@@ -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) {
+21
View File
@@ -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 {
+7 -6
View File
@@ -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
}
+283
View File
@@ -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"`
}
+64
View File
@@ -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")
}
}
+12 -2
View File
@@ -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", ""),
}
}
+42
View File
@@ -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 {
+26 -1
View File
@@ -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)
}
+11
View File
@@ -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
+34 -2
View File
@@ -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
}
+287
View File
@@ -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
}
+28 -2
View File
@@ -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)
}
+115
View File
@@ -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
}
}
+12 -2
View File
@@ -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 和键路径,不含值。
+455
View File
@@ -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
}
+5 -2
View File
@@ -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()
+73
View File
@@ -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)
+5 -5
View File
@@ -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
}
+9
View File
@@ -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
+2 -2
View File
@@ -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 {