Store secrets apart from settings and guard open listeners #63
+2
-1
@@ -14,7 +14,8 @@
|
||||
|
||||
## 配置(启动层)
|
||||
|
||||
- 来源优先级:内置默认 → 显式配置文件(`--config <json>`,无自动发现)→ 环境变量(`AGENT_SYSTEM_ADDR/DATA/PLUGINS/DSN/ARTIFACTS/AUTH/MOCK`)→ 显式 CLI(未传入的 flag 不覆盖)。
|
||||
- 来源优先级:内置默认 → 显式配置文件(`--config <json>`,无自动发现)→ 环境变量(`AGENT_SYSTEM_ADDR/DATA/PLUGINS/DSN/ARTIFACTS/AUTH/MOCK/INSTANCE/INSECURE_LISTEN/ALLOWED_HOSTS`)→ 显式 CLI(未传入的 flag 不覆盖)。
|
||||
- 监听守卫:非回环地址且未开认证时拒绝启动(在打开数据库之前)。出路是改用 `127.0.0.1`、`--auth on`,或显式 `--insecure-listen`(每次启动警告)。`config explain` 会打印 `listen guard`。
|
||||
- 只读诊断:`agent-system config explain [--config …] [--<flag> …]`——逐字段打印解析值与来源,DSN 脱敏;不建库、不写目录。
|
||||
- 多实例:`envs/{dev,test,prod}.json` 为隔离示例(data 根/DB 不得重叠);实例约定详见 Documents 仓 `docs/proposals/2026-09-30-configuration-surface.md`。
|
||||
- 运行时业务设置(`/api/settings` user/project scope、profiles 路由)是独立的业务策略层,不参与启动合并。
|
||||
|
||||
@@ -42,13 +42,15 @@ func main() {
|
||||
artifacts := fs.String("artifacts", "", "工件对象目录(默认 <data>/artifacts;PG 档多实例时指向共享卷)")
|
||||
auth := fs.String("auth", "auto", "API key 认证:auto(PG 档开、嵌入式关)| on | off")
|
||||
instance := fs.String("instance", "", "本进程标识(空 = 主机名:监听地址;启动对账只收口这个实例的 run)")
|
||||
insecure := fs.Bool("insecure-listen", false, "允许在非回环地址上不开启认证(每次启动都会警告)")
|
||||
allowedHosts := fs.String("allowed-hosts", "", "逗号分隔的额外主机名,供反向代理的 Host 与 Origin 校验")
|
||||
fs.Parse(os.Args[1:])
|
||||
cliSet := map[string]bool{}
|
||||
fs.Visit(func(f *flag.Flag) { cliSet[f.Name] = true })
|
||||
cliSet := visited(fs)
|
||||
|
||||
resolved, err := config.Load(*cfgFile, os.LookupEnv, config.Config{
|
||||
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
|
||||
StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth, Instance: *instance,
|
||||
InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts,
|
||||
}, cliSet)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system:", err)
|
||||
@@ -57,13 +59,29 @@ func main() {
|
||||
if err := app.Run(app.Options{
|
||||
Addr: resolved.Addr, DataDir: resolved.DataDir, PluginsDir: resolved.PluginsDir, ForceMock: resolved.Mock,
|
||||
StorageDSN: resolved.StorageDSN, ArtifactsDir: resolved.ArtifactsDir, AuthMode: resolved.AuthMode,
|
||||
InstanceID: resolved.Instance,
|
||||
InstanceID: resolved.Instance, InsecureListen: resolved.InsecureListen, AllowedHosts: resolved.AllowedHosts(),
|
||||
}); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// visited 把连字符 flag 收成配置字段名,供 Load 的 cliSet 使用。
|
||||
func visited(fs *flag.FlagSet) map[string]bool {
|
||||
set := map[string]bool{}
|
||||
fs.Visit(func(f *flag.Flag) {
|
||||
name := f.Name
|
||||
switch name {
|
||||
case "insecure-listen":
|
||||
name = "insecureListen"
|
||||
case "allowed-hosts":
|
||||
name = "allowedHosts"
|
||||
}
|
||||
set[name] = true
|
||||
})
|
||||
return set
|
||||
}
|
||||
|
||||
// runConfig:config explain——只读解析诊断(不建库、不写业务目录;DSN 脱敏)。
|
||||
func runConfig(args []string) int {
|
||||
if len(args) == 0 || args[0] != "explain" {
|
||||
@@ -80,13 +98,14 @@ func runConfig(args []string) int {
|
||||
artifacts := fs.String("artifacts", "", "")
|
||||
auth := fs.String("auth", "auto", "")
|
||||
instance := fs.String("instance", "", "")
|
||||
insecure := fs.Bool("insecure-listen", false, "")
|
||||
allowedHosts := fs.String("allowed-hosts", "", "")
|
||||
fs.Parse(args[1:])
|
||||
cliSet := map[string]bool{}
|
||||
fs.Visit(func(f *flag.Flag) { cliSet[f.Name] = true })
|
||||
resolved, err := config.Load(*cfgFile, os.LookupEnv, config.Config{
|
||||
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
|
||||
StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth, Instance: *instance,
|
||||
}, cliSet)
|
||||
InsecureListen: *insecure, AllowedHostsRaw: *allowedHosts,
|
||||
}, visited(fs))
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system:", err)
|
||||
return 1
|
||||
|
||||
Vendored
+1
-1
Submodule deps/agent-native updated: e4020d297c...3a110a728c
Vendored
+1
-1
Submodule deps/agent-squad updated: dcd523ea05...5a57636cc0
Vendored
+1
-1
Submodule deps/agent-system-core updated: 5dc190ee63...0b5c7747a3
+46
-12
@@ -33,6 +33,7 @@ import (
|
||||
"agent-system-backend/internal/bus"
|
||||
"agent-system-backend/internal/cardsvc"
|
||||
"agent-system-backend/internal/cmdbus"
|
||||
"agent-system-backend/internal/config"
|
||||
"agent-system-backend/internal/hostapi"
|
||||
"agent-system-backend/internal/intentgate"
|
||||
"agent-system-backend/internal/pgbus"
|
||||
@@ -54,14 +55,16 @@ import (
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Addr string
|
||||
DataDir string
|
||||
PluginsDir string
|
||||
ForceMock bool
|
||||
StorageDSN string // 空 = 嵌入式 SQLite 档;非空 = PostgreSQL 档(多用户/多实例)
|
||||
ArtifactsDir string // PG 档工件对象目录(空 = <DataDir>/artifacts)
|
||||
AuthMode string // auto(默认:PG 档开、嵌入式关)| on | off
|
||||
InstanceID string // 空 = 主机名:监听地址;写入 runs.instance_id,启动对账只收口本实例
|
||||
Addr string
|
||||
DataDir string
|
||||
PluginsDir string
|
||||
ForceMock bool
|
||||
StorageDSN string // 空 = 嵌入式 SQLite 档;非空 = PostgreSQL 档(多用户/多实例)
|
||||
ArtifactsDir string // PG 档工件对象目录(空 = <DataDir>/artifacts)
|
||||
AuthMode string // auto(默认:PG 档开、嵌入式关)| on | off
|
||||
InstanceID string // 空 = 主机名:监听地址;写入 runs.instance_id,启动对账只收口本实例
|
||||
InsecureListen bool
|
||||
AllowedHosts []string
|
||||
}
|
||||
|
||||
// Run 装配并阻塞服务直至 SIGINT/SIGTERM(优雅停机:中断在途 run、冲净事件缓冲)。
|
||||
@@ -71,6 +74,15 @@ func Run(opt Options) error {
|
||||
Sinks: []logsys.Sink{&sink.Console{Out: os.Stderr, Format: "json"}},
|
||||
})
|
||||
logger.Info("agent-system starting", "addr", opt.Addr, "data", opt.DataDir)
|
||||
resolved := config.Resolved{Config: config.Config{
|
||||
Addr: opt.Addr, AuthMode: opt.AuthMode, StorageDSN: opt.StorageDSN, InsecureListen: opt.InsecureListen,
|
||||
}}
|
||||
if msg := resolved.ListenRefusal(); msg != "" {
|
||||
return fmt.Errorf("listen guard: %s", msg)
|
||||
}
|
||||
if opt.InsecureListen && !config.IsLoopbackListen(opt.Addr) && !resolved.AuthEnabled() {
|
||||
log.Printf("warning: listening on %s without authentication because insecureListen is set", opt.Addr)
|
||||
}
|
||||
traceFn := telemetry.StartFunc(logger)
|
||||
var st *store.Store
|
||||
var err error
|
||||
@@ -123,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)
|
||||
@@ -189,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) {
|
||||
@@ -370,7 +402,7 @@ func Run(opt Options) error {
|
||||
return int(setSvc.Effective(store.OwnerDefault, "").GetNumber("runtime.rateLimitPerMin", 0))
|
||||
})
|
||||
// 认证策略(auto):PG 档默认开启(多用户面不能裸奔);嵌入式档默认关闭(本地单用户)。
|
||||
authOn := opt.AuthMode == "on" || (opt.AuthMode != "off" && opt.StorageDSN != "")
|
||||
authOn := resolved.AuthEnabled()
|
||||
srv.Auth = server.NewAuthenticator(st, authOn)
|
||||
if authOn {
|
||||
log.Printf("auth: API key required (create with: agent-system apikey create --owner <name>)")
|
||||
@@ -379,7 +411,9 @@ func Run(opt Options) error {
|
||||
return b.Subscribe(conversationID)
|
||||
}
|
||||
|
||||
httpSrv := &http.Server{Addr: opt.Addr, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second}
|
||||
httpSrv := &http.Server{Addr: opt.Addr, ReadHeaderTimeout: 10 * time.Second, Handler: server.RequestGuard{
|
||||
AuthOn: authOn, AllowedHosts: opt.AllowedHosts, Next: srv.Handler(),
|
||||
}}
|
||||
|
||||
go func() {
|
||||
log.Printf("agent-system serving on http://%s (data: %s, plugins: %s)", opt.Addr, opt.DataDir, opt.PluginsDir)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+111
-27
@@ -9,20 +9,23 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config 启动部署连接类字段(与 app.Options 一一对应)。
|
||||
type Config struct {
|
||||
Addr string `json:"addr"`
|
||||
DataDir string `json:"data"`
|
||||
PluginsDir string `json:"plugins"`
|
||||
StorageDSN string `json:"dsn"`
|
||||
ArtifactsDir string `json:"artifacts"`
|
||||
AuthMode string `json:"auth"`
|
||||
Mock bool `json:"mock"`
|
||||
Instance string `json:"instance"`
|
||||
Addr string `json:"addr"`
|
||||
DataDir string `json:"data"`
|
||||
PluginsDir string `json:"plugins"`
|
||||
StorageDSN string `json:"dsn"`
|
||||
ArtifactsDir string `json:"artifacts"`
|
||||
AuthMode string `json:"auth"`
|
||||
Mock bool `json:"mock"`
|
||||
Instance string `json:"instance"`
|
||||
InsecureListen bool `json:"insecureListen"`
|
||||
AllowedHostsRaw string `json:"allowedHosts"`
|
||||
}
|
||||
|
||||
var defaults = Config{
|
||||
@@ -34,14 +37,16 @@ var defaults = Config{
|
||||
|
||||
// envKey 字段 → 环境变量名(AGENT_SYSTEM_ 前缀)。
|
||||
var envKey = map[string]string{
|
||||
"addr": "AGENT_SYSTEM_ADDR",
|
||||
"data": "AGENT_SYSTEM_DATA",
|
||||
"plugins": "AGENT_SYSTEM_PLUGINS",
|
||||
"dsn": "AGENT_SYSTEM_DSN",
|
||||
"artifacts": "AGENT_SYSTEM_ARTIFACTS",
|
||||
"auth": "AGENT_SYSTEM_AUTH",
|
||||
"mock": "AGENT_SYSTEM_MOCK",
|
||||
"instance": "AGENT_SYSTEM_INSTANCE",
|
||||
"addr": "AGENT_SYSTEM_ADDR",
|
||||
"data": "AGENT_SYSTEM_DATA",
|
||||
"plugins": "AGENT_SYSTEM_PLUGINS",
|
||||
"dsn": "AGENT_SYSTEM_DSN",
|
||||
"artifacts": "AGENT_SYSTEM_ARTIFACTS",
|
||||
"auth": "AGENT_SYSTEM_AUTH",
|
||||
"mock": "AGENT_SYSTEM_MOCK",
|
||||
"instance": "AGENT_SYSTEM_INSTANCE",
|
||||
"insecureListen": "AGENT_SYSTEM_INSECURE_LISTEN",
|
||||
"allowedHosts": "AGENT_SYSTEM_ALLOWED_HOSTS",
|
||||
}
|
||||
|
||||
// Source 配置来源层级(数值越大优先级越高)。
|
||||
@@ -91,8 +96,8 @@ func Load(file string, envLookup func(string) (string, bool), cli Config, cliSet
|
||||
}
|
||||
// 文件层按"键存在"覆盖(区分未出现与显式 false/空串;空值有效性由字段语义判定)。
|
||||
for f := range present {
|
||||
if f == "mock" {
|
||||
cfg.Mock = fc.Mock
|
||||
if isBoolField(f) {
|
||||
setBool(&cfg, f, boolField(fc, f))
|
||||
} else {
|
||||
setString(&cfg, f, stringField(fc, f))
|
||||
}
|
||||
@@ -102,12 +107,12 @@ func Load(file string, envLookup func(string) (string, bool), cli Config, cliSet
|
||||
// 环境层:变量存在即参与覆盖(空串按显式空值处理,字段 schema 自行判定)。
|
||||
for f, key := range envKey {
|
||||
if v, ok := envLookup(key); ok {
|
||||
if f == "mock" {
|
||||
if isBoolField(f) {
|
||||
b, err := parseBool(key, v)
|
||||
if err != nil {
|
||||
return Resolved{}, err
|
||||
}
|
||||
cfg.Mock = b
|
||||
setBool(&cfg, f, b)
|
||||
} else {
|
||||
setString(&cfg, f, v)
|
||||
}
|
||||
@@ -117,8 +122,8 @@ func Load(file string, envLookup func(string) (string, bool), cli Config, cliSet
|
||||
// CLI 层:仅显式传入。
|
||||
for f := range cliSet {
|
||||
src[f] = SrcCLI
|
||||
if f == "mock" {
|
||||
cfg.Mock = cli.Mock
|
||||
if isBoolField(f) {
|
||||
setBool(&cfg, f, boolField(cli, f))
|
||||
} else {
|
||||
setString(&cfg, f, stringField(cli, f))
|
||||
}
|
||||
@@ -156,6 +161,10 @@ func loadFile(path string) (Config, map[string]bool, error) {
|
||||
dst = &c.Mock
|
||||
case "instance":
|
||||
dst = &c.Instance
|
||||
case "insecureListen":
|
||||
dst = &c.InsecureListen
|
||||
case "allowedHosts":
|
||||
dst = &c.AllowedHostsRaw
|
||||
default:
|
||||
return Config{}, nil, fmt.Errorf("config file %s: unknown key %q", path, k)
|
||||
}
|
||||
@@ -183,10 +192,34 @@ func stringField(c Config, f string) string {
|
||||
return c.AuthMode
|
||||
case "instance":
|
||||
return c.Instance
|
||||
case "allowedHosts":
|
||||
return c.AllowedHostsRaw
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isBoolField(f string) bool {
|
||||
return f == "mock" || f == "insecureListen"
|
||||
}
|
||||
|
||||
func boolField(c Config, f string) bool {
|
||||
switch f {
|
||||
case "insecureListen":
|
||||
return c.InsecureListen
|
||||
default:
|
||||
return c.Mock
|
||||
}
|
||||
}
|
||||
|
||||
func setBool(c *Config, f string, v bool) {
|
||||
switch f {
|
||||
case "insecureListen":
|
||||
c.InsecureListen = v
|
||||
default:
|
||||
c.Mock = v
|
||||
}
|
||||
}
|
||||
|
||||
func setString(c *Config, f, v string) {
|
||||
switch f {
|
||||
case "addr":
|
||||
@@ -203,6 +236,8 @@ func setString(c *Config, f, v string) {
|
||||
c.AuthMode = v
|
||||
case "instance":
|
||||
c.Instance = v
|
||||
case "allowedHosts":
|
||||
c.AllowedHostsRaw = v
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,17 +258,66 @@ func (r Resolved) Explain(w io.Writer, file string) {
|
||||
} else {
|
||||
fmt.Fprintln(w, "config file: (none)")
|
||||
}
|
||||
fmt.Fprintln(w, "field value source")
|
||||
for _, f := range []string{"addr", "data", "plugins", "dsn", "artifacts", "auth", "mock", "instance"} {
|
||||
fmt.Fprintln(w, "field value source")
|
||||
for _, f := range []string{"addr", "data", "plugins", "dsn", "artifacts", "auth", "mock", "instance", "insecureListen", "allowedHosts"} {
|
||||
v := stringField(r.Config, f)
|
||||
if f == "mock" {
|
||||
v = fmt.Sprintf("%t", r.Mock)
|
||||
if isBoolField(f) {
|
||||
v = fmt.Sprintf("%t", boolField(r.Config, f))
|
||||
}
|
||||
if f == "dsn" {
|
||||
v = redact(v)
|
||||
}
|
||||
fmt.Fprintf(w, "%-10s %-56s %s\n", f, v, r.sources[f])
|
||||
fmt.Fprintf(w, "%-16s %-56s %s\n", f, v, r.sources[f])
|
||||
}
|
||||
if msg := r.ListenRefusal(); msg != "" {
|
||||
fmt.Fprintf(w, "listen guard: 拒绝启动(%s)\n", msg)
|
||||
} else if r.InsecureListen && !IsLoopbackListen(r.Addr) && !r.AuthEnabled() {
|
||||
fmt.Fprintln(w, "listen guard: 放行(已显式 insecureListen,无认证监听非回环地址)")
|
||||
} else {
|
||||
fmt.Fprintln(w, "listen guard: 放行")
|
||||
}
|
||||
}
|
||||
|
||||
// AuthEnabled 是认证开关的唯一判定:显式 on,或 auto 且配置了 PostgreSQL。
|
||||
func (r Resolved) AuthEnabled() bool {
|
||||
return r.AuthMode == "on" || (r.AuthMode != "off" && r.StorageDSN != "")
|
||||
}
|
||||
|
||||
// AllowedHosts 把逗号分隔的主机名拆开,忽略空段。
|
||||
func (r Resolved) AllowedHosts() []string {
|
||||
var out []string
|
||||
for _, part := range strings.Split(r.AllowedHostsRaw, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ListenRefusal 在非回环、未开认证、且没有显式放行时返回拒绝原因。空串表示可以启动。
|
||||
func (r Resolved) ListenRefusal() string {
|
||||
if r.AuthEnabled() || r.InsecureListen || IsLoopbackListen(r.Addr) {
|
||||
return ""
|
||||
}
|
||||
return "非回环且未开认证。改用 127.0.0.1、加上 --auth on,或显式 --insecure-listen"
|
||||
}
|
||||
|
||||
// IsLoopbackListen 判断监听地址是否只绑回环。空主机、0.0.0.0、:: 视为所有网卡。
|
||||
func IsLoopbackListen(addr string) bool {
|
||||
host := addr
|
||||
if h, _, err := net.SplitHostPort(addr); err == nil {
|
||||
host = h
|
||||
}
|
||||
host = strings.Trim(strings.ToLower(host), "[]")
|
||||
switch host {
|
||||
case "", "0.0.0.0", "::":
|
||||
return false
|
||||
case "localhost":
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
// redact 脱敏 DSN:scheme://user:password@… → scheme://user:***@…。
|
||||
|
||||
@@ -148,3 +148,43 @@ func TestExplainRedactsDSN(t *testing.T) {
|
||||
t.Fatalf("source column missing: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenGuard(t *testing.T) {
|
||||
cases := []struct {
|
||||
addr string
|
||||
ok bool
|
||||
}{
|
||||
{"127.0.0.1:8787", true},
|
||||
{"localhost:8787", true},
|
||||
{"[::1]:8787", true},
|
||||
{":8787", false},
|
||||
{"0.0.0.0:8787", false},
|
||||
{"[::]:8787", false},
|
||||
{"192.168.1.9:8787", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := IsLoopbackListen(tc.addr); got != tc.ok {
|
||||
t.Errorf("%s loopback=%v want %v", tc.addr, got, tc.ok)
|
||||
}
|
||||
}
|
||||
refused, err := Load("", noEnv, Config{Addr: "0.0.0.0:8788"}, map[string]bool{"addr": true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if refused.ListenRefusal() == "" {
|
||||
t.Fatal("non-loopback without auth must refuse")
|
||||
}
|
||||
var b bytes.Buffer
|
||||
refused.Explain(&b, "")
|
||||
if !bytes.Contains(b.Bytes(), []byte("拒绝启动")) {
|
||||
t.Fatalf("explain missing refusal: %s", b.String())
|
||||
}
|
||||
allowed, err := Load("", noEnv, Config{Addr: "0.0.0.0:8788", InsecureListen: true}, map[string]bool{"addr": true, "insecureListen": true})
|
||||
if err != nil || allowed.ListenRefusal() != "" {
|
||||
t.Fatalf("explicit insecure listen should pass: %v %q", err, allowed.ListenRefusal())
|
||||
}
|
||||
authed, err := Load("", noEnv, Config{Addr: "0.0.0.0:8788", AuthMode: "on"}, map[string]bool{"addr": true, "auth": true})
|
||||
if err != nil || !authed.AuthEnabled() || authed.ListenRefusal() != "" {
|
||||
t.Fatalf("auth on should pass: %+v %q", authed.Config, authed.ListenRefusal())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
@@ -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", ""),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RequestGuard 包在组合根的 Handler 外面。
|
||||
// Handler 本身不改,测试里的 httptest 请求仍直接打 Handler。
|
||||
type RequestGuard struct {
|
||||
AuthOn bool
|
||||
AllowedHosts []string
|
||||
Next http.Handler
|
||||
}
|
||||
|
||||
func (g RequestGuard) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
if isWriteMethod(r.Method) {
|
||||
if msg := rejectWriteOrigin(r, g.AllowedHosts); msg != "" {
|
||||
http.Error(w, msg, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
if !g.AuthOn {
|
||||
if msg := rejectHost(r, g.AllowedHosts); msg != "" {
|
||||
http.Error(w, msg, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
g.Next.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func isWriteMethod(method string) bool {
|
||||
switch method {
|
||||
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func rejectWriteOrigin(r *http.Request, allowed []string) string {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "null" {
|
||||
return "拒绝来源为 null 的写请求。经反向代理访问时,把对外域名加入 allowedHosts。"
|
||||
}
|
||||
if origin != "" {
|
||||
parsed, err := url.Parse(origin)
|
||||
if err != nil || parsed.Host == "" {
|
||||
return "拒绝无法识别来源的写请求。经反向代理访问时,把对外域名加入 allowedHosts。"
|
||||
}
|
||||
if hostsMatch(parsed.Host, r.Host) || hostListed(parsed.Hostname(), allowed) {
|
||||
return ""
|
||||
}
|
||||
return "写请求的来源与访问地址不一致。经反向代理访问时,把对外域名加入 allowedHosts。"
|
||||
}
|
||||
site := r.Header.Get("Sec-Fetch-Site")
|
||||
if site != "" && site != "same-origin" && site != "none" {
|
||||
return "拒绝跨站写请求。经反向代理访问时,把对外域名加入 allowedHosts。"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func rejectHost(r *http.Request, allowed []string) string {
|
||||
host := requestHostname(r.Host)
|
||||
if host == "" {
|
||||
return "拒绝没有 Host 的请求。经反向代理访问时,把对外域名加入 allowedHosts。"
|
||||
}
|
||||
if strings.EqualFold(host, "localhost") || net.ParseIP(host) != nil || hostListed(host, allowed) {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("拒绝主机名 %s。经反向代理访问时,把对外域名加入 allowedHosts。", host)
|
||||
}
|
||||
|
||||
func requestHostname(hostport string) string {
|
||||
if h, _, err := net.SplitHostPort(hostport); err == nil {
|
||||
return strings.Trim(h, "[]")
|
||||
}
|
||||
return strings.Trim(hostport, "[]")
|
||||
}
|
||||
|
||||
func hostsMatch(a, b string) bool {
|
||||
return strings.EqualFold(requestHostname(a), requestHostname(b))
|
||||
}
|
||||
|
||||
func hostListed(host string, allowed []string) bool {
|
||||
for _, item := range allowed {
|
||||
if strings.EqualFold(requestHostname(item), host) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const pageCSP = "script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'; " +
|
||||
"img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; worker-src 'self' blob:; frame-src 'self' https:"
|
||||
|
||||
const activeCSP = "sandbox allow-scripts allow-popups"
|
||||
|
||||
func setPageHeaders(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Security-Policy", pageCSP)
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Referrer-Policy", "same-origin")
|
||||
}
|
||||
|
||||
func setArtifactHeaders(w http.ResponseWriter, contentType string) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
if isActiveContent(contentType) {
|
||||
w.Header().Set("Content-Security-Policy", activeCSP)
|
||||
}
|
||||
}
|
||||
|
||||
func isActiveContent(contentType string) bool {
|
||||
ct := strings.ToLower(contentType)
|
||||
return strings.Contains(ct, "text/html") ||
|
||||
strings.Contains(ct, "application/xhtml") ||
|
||||
strings.Contains(ct, "image/svg") ||
|
||||
strings.Contains(ct, "xml")
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func guard(auth bool, hosts ...string) RequestGuard {
|
||||
return RequestGuard{
|
||||
AuthOn: auth, AllowedHosts: hosts,
|
||||
Next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestGuardWrites(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
auth bool
|
||||
host string
|
||||
origin string
|
||||
site string
|
||||
want int
|
||||
}{
|
||||
{"same origin", false, "127.0.0.1:8788", "http://127.0.0.1:8788", "", http.StatusNoContent},
|
||||
{"cross origin", false, "127.0.0.1:8788", "https://evil.example", "", http.StatusForbidden},
|
||||
{"null origin", false, "127.0.0.1:8788", "null", "", http.StatusForbidden},
|
||||
{"sec-fetch cross", false, "127.0.0.1:8788", "", "cross-site", http.StatusForbidden},
|
||||
{"sec-fetch same", false, "127.0.0.1:8788", "", "same-origin", http.StatusNoContent},
|
||||
{"no browser headers", false, "127.0.0.1:8788", "", "", http.StatusNoContent},
|
||||
{"allowed host origin", false, "127.0.0.1:8788", "https://app.example", "", http.StatusNoContent},
|
||||
{"evil host without auth", false, "evil.example", "", "", http.StatusForbidden},
|
||||
{"evil host with auth", true, "evil.example", "", "", http.StatusNoContent},
|
||||
{"ip host", false, "192.168.1.9:8788", "", "", http.StatusNoContent},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/memory", strings.NewReader(`{}`))
|
||||
req.Host = tc.host
|
||||
if tc.origin != "" {
|
||||
req.Header.Set("Origin", tc.origin)
|
||||
}
|
||||
if tc.site != "" {
|
||||
req.Header.Set("Sec-Fetch-Site", tc.site)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
hosts := []string{}
|
||||
if tc.name == "allowed host origin" {
|
||||
hosts = []string{"app.example"}
|
||||
}
|
||||
guard(tc.auth, hosts...).ServeHTTP(rec, req)
|
||||
if rec.Code != tc.want {
|
||||
t.Fatalf("status %d want %d body %s", rec.Code, tc.want, rec.Body.String())
|
||||
}
|
||||
if rec.Code == http.StatusForbidden && !strings.Contains(rec.Body.String(), "allowedHosts") {
|
||||
t.Fatalf("refusal should mention allowedHosts: %s", rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageAndArtifactHeaders(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
setPageHeaders(rec)
|
||||
if rec.Header().Get("Content-Security-Policy") == "" || rec.Header().Get("X-Content-Type-Options") != "nosniff" || rec.Header().Get("Referrer-Policy") != "same-origin" {
|
||||
t.Fatalf("page headers: %v", rec.Header())
|
||||
}
|
||||
art := httptest.NewRecorder()
|
||||
setArtifactHeaders(art, "text/html; charset=utf-8")
|
||||
if !strings.Contains(art.Header().Get("Content-Security-Policy"), "sandbox") || art.Header().Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Fatalf("artifact headers: %v", art.Header())
|
||||
}
|
||||
plain := httptest.NewRecorder()
|
||||
setArtifactHeaders(plain, "text/plain")
|
||||
if plain.Header().Get("Content-Security-Policy") != "" {
|
||||
t.Fatalf("plain artifact should not be sandboxed: %v", plain.Header())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -379,9 +393,11 @@ func (s *Server) serveStatic(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
path = "index.html"
|
||||
}
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
switch {
|
||||
case strings.HasSuffix(path, ".html"):
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
setPageHeaders(w)
|
||||
case strings.HasSuffix(path, ".js"):
|
||||
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
|
||||
case strings.HasSuffix(path, ".css"):
|
||||
@@ -522,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
|
||||
@@ -537,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))
|
||||
}
|
||||
|
||||
@@ -564,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
|
||||
@@ -1042,7 +1069,9 @@ func (s *Server) artifactContent(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", contentTypeOf(a))
|
||||
ct := contentTypeOf(a)
|
||||
w.Header().Set("Content-Type", ct)
|
||||
setArtifactHeaders(w, ct)
|
||||
w.Header().Set("Content-Disposition", contentDisposition(a.Name, r.URL.Query().Get("download") == "1"))
|
||||
w.Write(content)
|
||||
}
|
||||
@@ -1291,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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 和键路径,不含值。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user