Merge remote-tracking branch 'origin/fix/gemini-mcp-schema-sanitization' into fix/gemini-mcp-schema-sanitization
This commit is contained in:
commit
87ee76b117
24 changed files with 1380 additions and 101 deletions
64
.github/workflows/stale.yml
vendored
Normal file
64
.github/workflows/stale.yml
vendored
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
name: Close stale issues and PRs
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# Run daily at 03:00 JST (18:00 UTC)
|
||||||
|
- cron: "0 18 * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
stale:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Mark and close stale issues and PRs
|
||||||
|
uses: actions/stale@v10
|
||||||
|
with:
|
||||||
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
# ── Issue: 7 days inactive → stale; 7 more days → close ──
|
||||||
|
days-before-issue-stale: 7
|
||||||
|
days-before-issue-close: 7
|
||||||
|
stale-issue-label: "stale"
|
||||||
|
stale-issue-message: >
|
||||||
|
This issue has had no activity for 7 days and has been marked as stale.
|
||||||
|
If it is still relevant, please reply or update; otherwise it will be
|
||||||
|
closed automatically in 7 days.
|
||||||
|
close-issue-message: >
|
||||||
|
This issue has been closed after 14 days of inactivity.
|
||||||
|
If it is still needed, feel free to reopen it anytime.
|
||||||
|
close-issue-reason: "not_planned"
|
||||||
|
|
||||||
|
# ── PR: 7 days inactive → stale; 7 more days → close ──
|
||||||
|
days-before-pr-stale: 7
|
||||||
|
days-before-pr-close: 7
|
||||||
|
stale-pr-label: "stale"
|
||||||
|
stale-pr-message: >
|
||||||
|
This PR has had no activity for 7 days and has been marked as stale.
|
||||||
|
If you are still working on it, please push an update or leave a comment;
|
||||||
|
otherwise it will be closed automatically in 7 days.
|
||||||
|
close-pr-message: >
|
||||||
|
This PR has been closed after 14 days of inactivity.
|
||||||
|
If you would like to continue, feel free to reopen it or submit a new PR.
|
||||||
|
|
||||||
|
# ── Protected labels (exempt from stale processing) ──
|
||||||
|
exempt-issue-labels: "pinned,keep-open,wip,do-not-close,type: roadmap"
|
||||||
|
exempt-pr-labels: "pinned,keep-open,wip,do-not-close,type: roadmap"
|
||||||
|
|
||||||
|
# ── Exempt draft PRs ──
|
||||||
|
exempt-draft-pr: true
|
||||||
|
|
||||||
|
# ── Remove stale label when activity resumes ──
|
||||||
|
remove-stale-when-updated: true
|
||||||
|
remove-issue-stale-when-updated: true
|
||||||
|
remove-pr-stale-when-updated: true
|
||||||
|
|
||||||
|
# ── Scan oldest items first so old stale items are not starved ──
|
||||||
|
ascending: true
|
||||||
|
|
||||||
|
# ── Throttle: max operations per run ──
|
||||||
|
operations-per-run: 500
|
||||||
|
|
@ -1001,7 +1001,9 @@ func LoadConfig(path string) (*Config, error) {
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
}
|
}
|
||||||
if e := json.Unmarshal(data, &versionInfo); e != nil {
|
if e := json.Unmarshal(data, &versionInfo); e != nil {
|
||||||
return nil, fmt.Errorf("failed to detect config version: %w", e)
|
e = wrapJSONError(data, e, "config.json")
|
||||||
|
logger.ErrorCF("config", formatDiagnosticLogMessage("Malformed config file", e), map[string]any{"path": path})
|
||||||
|
return nil, e
|
||||||
}
|
}
|
||||||
if len(data) <= 10 {
|
if len(data) <= 10 {
|
||||||
logger.Warn(fmt.Sprintf("content is [%s]", string(data)))
|
logger.Warn(fmt.Sprintf("content is [%s]", string(data)))
|
||||||
|
|
@ -1016,10 +1018,23 @@ func LoadConfig(path string) (*Config, error) {
|
||||||
"config migrate start",
|
"config migrate start",
|
||||||
map[string]any{"from": versionInfo.Version, "to": CurrentVersion},
|
map[string]any{"from": versionInfo.Version, "to": CurrentVersion},
|
||||||
)
|
)
|
||||||
|
if err = validateLegacyConfigDiagnostics(data); err != nil {
|
||||||
|
logger.ErrorCF(
|
||||||
|
"config",
|
||||||
|
formatDiagnosticLogMessage("Failed to load config", err),
|
||||||
|
map[string]any{"path": path},
|
||||||
|
)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
var m map[string]any
|
var m map[string]any
|
||||||
m, err = loadConfigMap(path)
|
m, err = loadConfigMap(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logger.ErrorCF(
|
||||||
|
"config",
|
||||||
|
formatDiagnosticLogMessage("Failed to load config", err),
|
||||||
|
map[string]any{"path": path},
|
||||||
|
)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1061,10 +1076,23 @@ func LoadConfig(path string) (*Config, error) {
|
||||||
"config migrate start",
|
"config migrate start",
|
||||||
map[string]any{"from": versionInfo.Version, "to": CurrentVersion},
|
map[string]any{"from": versionInfo.Version, "to": CurrentVersion},
|
||||||
)
|
)
|
||||||
|
if err = validateLegacyConfigDiagnostics(data); err != nil {
|
||||||
|
logger.ErrorCF(
|
||||||
|
"config",
|
||||||
|
formatDiagnosticLogMessage("Failed to load config", err),
|
||||||
|
map[string]any{"path": path},
|
||||||
|
)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
var m map[string]any
|
var m map[string]any
|
||||||
m, err = loadConfigMap(path)
|
m, err = loadConfigMap(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logger.ErrorCF(
|
||||||
|
"config",
|
||||||
|
formatDiagnosticLogMessage("Failed to load config", err),
|
||||||
|
map[string]any{"path": path},
|
||||||
|
)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1106,9 +1134,22 @@ func LoadConfig(path string) (*Config, error) {
|
||||||
"config migrate start",
|
"config migrate start",
|
||||||
map[string]any{"from": versionInfo.Version, "to": CurrentVersion},
|
map[string]any{"from": versionInfo.Version, "to": CurrentVersion},
|
||||||
)
|
)
|
||||||
|
if err = validateLegacyConfigDiagnostics(data); err != nil {
|
||||||
|
logger.ErrorCF(
|
||||||
|
"config",
|
||||||
|
formatDiagnosticLogMessage("Failed to load config", err),
|
||||||
|
map[string]any{"path": path},
|
||||||
|
)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
var m map[string]any
|
var m map[string]any
|
||||||
m, err = loadConfigMap(path)
|
m, err = loadConfigMap(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logger.ErrorCF(
|
||||||
|
"config",
|
||||||
|
formatDiagnosticLogMessage("Failed to load config", err),
|
||||||
|
map[string]any{"path": path},
|
||||||
|
)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
migrateErr := migrateV2ToV3(m)
|
migrateErr := migrateV2ToV3(m)
|
||||||
|
|
@ -1143,6 +1184,11 @@ func LoadConfig(path string) (*Config, error) {
|
||||||
// Current version
|
// Current version
|
||||||
cfg, err = loadConfig(data)
|
cfg, err = loadConfig(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
logger.ErrorCF(
|
||||||
|
"config",
|
||||||
|
formatDiagnosticLogMessage("Failed to load config", err),
|
||||||
|
map[string]any{"path": path},
|
||||||
|
)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// Load security configuration
|
// Load security configuration
|
||||||
|
|
|
||||||
|
|
@ -847,6 +847,72 @@ func TestLoadConfig_WebPreferNativeCanBeDisabled(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadConfig_SyntaxErrorReportsLineAndColumn(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
configPath := filepath.Join(dir, "config.json")
|
||||||
|
raw := "{\n \"version\": 2,\n \"tools\": {\n \"web\": {\n \"enabled\": true,,\n \"format\": \"markdown\"\n }\n }\n}\n"
|
||||||
|
if err := os.WriteFile(configPath, []byte(raw), 0o600); err != nil {
|
||||||
|
t.Fatalf("WriteFile() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := LoadConfig(configPath)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected syntax error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "syntax error at line 5, column 23") {
|
||||||
|
t.Fatalf("expected line/column diagnostic, got %q", err.Error())
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "\"enabled\": true,,") {
|
||||||
|
t.Fatalf("expected source snippet in diagnostic, got %q", err.Error())
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "^") {
|
||||||
|
t.Fatalf("expected caret marker in diagnostic, got %q", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfig_TypeErrorReportsFieldPath(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
configPath := filepath.Join(dir, "config.json")
|
||||||
|
raw := "{\n \"version\": 2,\n \"tools\": {\n \"web\": {\n \"fetch_limit_bytes\": \"oops\"\n }\n }\n}\n"
|
||||||
|
if err := os.WriteFile(configPath, []byte(raw), 0o600); err != nil {
|
||||||
|
t.Fatalf("WriteFile() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := LoadConfig(configPath)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected type error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "type error at line 5, column 33") {
|
||||||
|
t.Fatalf("expected line/column diagnostic, got %q", err.Error())
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "fetch_limit_bytes") {
|
||||||
|
t.Fatalf("expected field name in diagnostic, got %q", err.Error())
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "\"fetch_limit_bytes\": \"oops\"") {
|
||||||
|
t.Fatalf("expected source snippet in diagnostic, got %q", err.Error())
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "^") {
|
||||||
|
t.Fatalf("expected caret marker in diagnostic, got %q", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadConfig_UnknownFieldsReportsExactPaths(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
configPath := filepath.Join(dir, "config.json")
|
||||||
|
raw := "{\n \"version\": 2,\n \"tools\": {\n \"weeb\": {\n \"enabled\": true\n },\n \"web\": {\n \"fatch_limit_bytes\": 123\n }\n }\n}\n"
|
||||||
|
if err := os.WriteFile(configPath, []byte(raw), 0o600); err != nil {
|
||||||
|
t.Fatalf("WriteFile() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := LoadConfig(configPath)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected unknown field error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "tools.weeb") || !strings.Contains(err.Error(), "tools.web.fatch_limit_bytes") {
|
||||||
|
t.Fatalf("expected exact unknown field paths, got %q", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDefaultConfig_ExecAllowRemoteEnabled(t *testing.T) {
|
func TestDefaultConfig_ExecAllowRemoteEnabled(t *testing.T) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
if !cfg.Tools.Exec.AllowRemote {
|
if !cfg.Tools.Exec.AllowRemote {
|
||||||
|
|
@ -1355,25 +1421,12 @@ func TestLoadConfig_TelegramPlaceholderTextAcceptsSingleString(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestLoadConfig_WarnsForPlaintextAPIKey verifies that LoadConfig resolves a plaintext
|
// TestLoadConfig_WarnsForPlaintextAPIKey verifies that LoadConfig resolves a plaintext
|
||||||
// api_key into memory but does NOT rewrite the config file. File writes are the sole
|
// api_keys entry into memory but does NOT rewrite the config file. File writes are the sole
|
||||||
// responsibility of SaveConfig.
|
// responsibility of SaveConfig.
|
||||||
func TestLoadConfig_WarnsForPlaintextAPIKey(t *testing.T) {
|
func TestLoadConfig_WarnsForPlaintextAPIKey(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
cfgPath := filepath.Join(dir, "config.json")
|
cfgPath := filepath.Join(dir, "config.json")
|
||||||
const original = `{"version":1,"model_list":[{"model_name":"test","model":"openai/gpt-4","api_key":"sk-plaintext"}]}`
|
const original = `{"version":2,"model_list":[{"model_name":"test","model":"openai/gpt-4","api_keys":["sk-plaintext"]}]}`
|
||||||
if err := os.WriteFile(cfgPath, []byte(original), 0o600); err != nil {
|
|
||||||
t.Fatalf("setup: %v", err)
|
|
||||||
}
|
|
||||||
secPath := filepath.Join(dir, SecurityConfigFile)
|
|
||||||
const securityConfig = `
|
|
||||||
model_list:
|
|
||||||
test:0:
|
|
||||||
api_keys:
|
|
||||||
- "sk-plaintext"
|
|
||||||
`
|
|
||||||
if err := os.WriteFile(secPath, []byte(securityConfig), 0o600); err != nil {
|
|
||||||
t.Fatalf("setup: %v", err)
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(cfgPath, []byte(original), 0o600); err != nil {
|
if err := os.WriteFile(cfgPath, []byte(original), 0o600); err != nil {
|
||||||
t.Fatalf("setup: %v", err)
|
t.Fatalf("setup: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
441
pkg/config/diagnostics.go
Normal file
441
pkg/config/diagnostics.go
Normal file
|
|
@ -0,0 +1,441 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
func decodeJSONWithDiagnostics(data []byte, target any, label string) error {
|
||||||
|
var raw any
|
||||||
|
if err := json.Unmarshal(data, &raw); err != nil {
|
||||||
|
return wrapJSONError(data, err, label)
|
||||||
|
}
|
||||||
|
|
||||||
|
unknownFields := collectUnknownJSONFields(raw, reflect.TypeOf(target), "")
|
||||||
|
if len(unknownFields) > 0 {
|
||||||
|
sort.Strings(unknownFields)
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%s contains unknown field(s): %s",
|
||||||
|
label,
|
||||||
|
strings.Join(unknownFields, ", "),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(data, target); err != nil {
|
||||||
|
return wrapJSONError(data, err, label)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DiagnosticSummary(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
summary, _ := splitDiagnosticError(err.Error())
|
||||||
|
return stripANSISequences(summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDiagnosticLogMessage(prefix string, err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, preview := splitDiagnosticError(err.Error())
|
||||||
|
summary = stripANSISequences(summary)
|
||||||
|
if preview == "" {
|
||||||
|
if summary == "" {
|
||||||
|
return prefix
|
||||||
|
}
|
||||||
|
return prefix + ": " + summary
|
||||||
|
}
|
||||||
|
if summary == "" {
|
||||||
|
return prefix + "\n" + preview
|
||||||
|
}
|
||||||
|
return prefix + ": " + summary + "\n" + preview
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapJSONError(data []byte, err error, label string) error {
|
||||||
|
switch e := err.(type) {
|
||||||
|
case *json.SyntaxError:
|
||||||
|
line, column := lineAndColumnForOffset(data, e.Offset)
|
||||||
|
preview := diagnosticPreviewForOffset(data, e.Offset)
|
||||||
|
if preview != "" {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%s syntax error at line %d, column %d: %w\n%s",
|
||||||
|
label,
|
||||||
|
line,
|
||||||
|
column,
|
||||||
|
err,
|
||||||
|
preview,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s syntax error at line %d, column %d: %w", label, line, column, err)
|
||||||
|
case *json.UnmarshalTypeError:
|
||||||
|
line, column := lineAndColumnForOffset(data, e.Offset)
|
||||||
|
preview := diagnosticPreviewForOffset(data, e.Offset)
|
||||||
|
field := strings.TrimSpace(e.Field)
|
||||||
|
if field != "" {
|
||||||
|
if preview != "" {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%s type error at line %d, column %d for field %q: expected %s but got %s\n%s",
|
||||||
|
label,
|
||||||
|
line,
|
||||||
|
column,
|
||||||
|
field,
|
||||||
|
e.Type.String(),
|
||||||
|
e.Value,
|
||||||
|
preview,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%s type error at line %d, column %d for field %q: expected %s but got %s",
|
||||||
|
label,
|
||||||
|
line,
|
||||||
|
column,
|
||||||
|
field,
|
||||||
|
e.Type.String(),
|
||||||
|
e.Value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if preview != "" {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%s type error at line %d, column %d: expected %s but got %s\n%s",
|
||||||
|
label,
|
||||||
|
line,
|
||||||
|
column,
|
||||||
|
e.Type.String(),
|
||||||
|
e.Value,
|
||||||
|
preview,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%s type error at line %d, column %d: expected %s but got %s",
|
||||||
|
label,
|
||||||
|
line,
|
||||||
|
column,
|
||||||
|
e.Type.String(),
|
||||||
|
e.Value,
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("failed to parse %s: %w", label, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitDiagnosticError(message string) (string, string) {
|
||||||
|
if idx := strings.IndexByte(message, '\n'); idx >= 0 {
|
||||||
|
return message[:idx], message[idx+1:]
|
||||||
|
}
|
||||||
|
return message, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripANSISequences(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(s))
|
||||||
|
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if s[i] != 0x1b {
|
||||||
|
b.WriteByte(s[i])
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i+1 >= len(s) || s[i+1] != '[' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
i += 2
|
||||||
|
for i < len(s) {
|
||||||
|
c := s[i]
|
||||||
|
if c >= '@' && c <= '~' {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func diagnosticPreviewForOffset(data []byte, offset int64) string {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
start, end := lineBoundsForOffset(data, offset)
|
||||||
|
if start >= end {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
lineNumber, column := lineAndColumnForOffset(data, offset)
|
||||||
|
line := strings.TrimRight(string(data[start:end]), "\r\n")
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
trimmedLine, trimOffset := trimDiagnosticLine(line, column)
|
||||||
|
if trimmedLine == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := fmt.Sprintf("%4d | ", lineNumber)
|
||||||
|
caretColumn := column - trimOffset
|
||||||
|
if caretColumn < 1 {
|
||||||
|
caretColumn = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if diagnosticsUseColor() {
|
||||||
|
linePrefix := "\x1b[2m" + prefix + "\x1b[0m"
|
||||||
|
caretPrefix := "\x1b[2m" + strings.Repeat(" ", len(fmt.Sprintf("%4d", lineNumber))) + " | " + "\x1b[0m"
|
||||||
|
highlighted := highlightDiagnosticColumn(trimmedLine, caretColumn)
|
||||||
|
caretPad := strings.Repeat(" ", maxRuneCount(trimmedLine, caretColumn-1))
|
||||||
|
return fmt.Sprintf(
|
||||||
|
" %s%s\n %s%s\x1b[1;31m^\x1b[0m",
|
||||||
|
linePrefix,
|
||||||
|
highlighted,
|
||||||
|
caretPrefix,
|
||||||
|
caretPad,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
caretPrefix := strings.Repeat(" ", len(prefix))
|
||||||
|
caretPad := strings.Repeat(" ", maxRuneCount(trimmedLine, caretColumn-1))
|
||||||
|
return fmt.Sprintf(
|
||||||
|
" %s%s\n %s%s^",
|
||||||
|
prefix,
|
||||||
|
trimmedLine,
|
||||||
|
caretPrefix,
|
||||||
|
caretPad,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func lineAndColumnForOffset(data []byte, offset int64) (int, int) {
|
||||||
|
if offset <= 0 {
|
||||||
|
return 1, 1
|
||||||
|
}
|
||||||
|
if offset > int64(len(data)) {
|
||||||
|
offset = int64(len(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
line := 1
|
||||||
|
column := 1
|
||||||
|
for i := int64(0); i < offset-1; i++ {
|
||||||
|
if data[i] == '\n' {
|
||||||
|
line++
|
||||||
|
column = 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
column++
|
||||||
|
}
|
||||||
|
return line, column
|
||||||
|
}
|
||||||
|
|
||||||
|
func lineBoundsForOffset(data []byte, offset int64) (int, int) {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if offset <= 0 {
|
||||||
|
offset = 1
|
||||||
|
}
|
||||||
|
if offset > int64(len(data)) {
|
||||||
|
offset = int64(len(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
index := int(offset - 1)
|
||||||
|
if index < 0 {
|
||||||
|
index = 0
|
||||||
|
}
|
||||||
|
if index >= len(data) {
|
||||||
|
index = len(data) - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
start := index
|
||||||
|
for start > 0 && data[start-1] != '\n' {
|
||||||
|
start--
|
||||||
|
}
|
||||||
|
|
||||||
|
end := index
|
||||||
|
for end < len(data) && data[end] != '\n' {
|
||||||
|
end++
|
||||||
|
}
|
||||||
|
|
||||||
|
return start, end
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimDiagnosticLine(line string, column int) (string, int) {
|
||||||
|
runes := []rune(line)
|
||||||
|
if len(runes) == 0 {
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(runes) <= 160 {
|
||||||
|
return line, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const contextBefore = 60
|
||||||
|
const maxWidth = 160
|
||||||
|
|
||||||
|
start := column - 1 - contextBefore
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
if start > len(runes)-maxWidth {
|
||||||
|
start = len(runes) - maxWidth
|
||||||
|
}
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
end := start + maxWidth
|
||||||
|
if end > len(runes) {
|
||||||
|
end = len(runes)
|
||||||
|
}
|
||||||
|
|
||||||
|
trimmed := string(runes[start:end])
|
||||||
|
trimOffset := start
|
||||||
|
|
||||||
|
if start > 0 {
|
||||||
|
trimmed = "..." + trimmed
|
||||||
|
trimOffset -= 3
|
||||||
|
}
|
||||||
|
if end < len(runes) {
|
||||||
|
trimmed += "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed, trimOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
func diagnosticsUseColor() bool {
|
||||||
|
return term.IsTerminal(int(os.Stdout.Fd()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func highlightDiagnosticColumn(line string, column int) string {
|
||||||
|
runes := []rune(line)
|
||||||
|
if column < 1 || column > len(runes) {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
index := column - 1
|
||||||
|
return string(runes[:index]) + "\x1b[31m" + string(runes[index]) + "\x1b[0m" + string(runes[index+1:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func maxRuneCount(s string, count int) int {
|
||||||
|
if count <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
runes := []rune(s)
|
||||||
|
if count > len(runes) {
|
||||||
|
count = len(runes)
|
||||||
|
}
|
||||||
|
return utf8.RuneCountInString(string(runes[:count]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectUnknownJSONFields(raw any, targetType reflect.Type, path string) []string {
|
||||||
|
targetType = derefType(targetType)
|
||||||
|
if targetType == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch targetType.Kind() {
|
||||||
|
case reflect.Struct:
|
||||||
|
obj, ok := raw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fieldMap := jsonFieldTypeMap(targetType)
|
||||||
|
var issues []string
|
||||||
|
for key, value := range obj {
|
||||||
|
fieldType, exists := fieldMap[key]
|
||||||
|
fieldPath := appendJSONPath(path, key)
|
||||||
|
if !exists {
|
||||||
|
issues = append(issues, fieldPath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
issues = append(issues, collectUnknownJSONFields(value, fieldType, fieldPath)...)
|
||||||
|
}
|
||||||
|
return issues
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
items, ok := raw.([]any)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var issues []string
|
||||||
|
elemType := targetType.Elem()
|
||||||
|
for i, item := range items {
|
||||||
|
itemPath := fmt.Sprintf("%s[%d]", path, i)
|
||||||
|
issues = append(issues, collectUnknownJSONFields(item, elemType, itemPath)...)
|
||||||
|
}
|
||||||
|
return issues
|
||||||
|
case reflect.Map:
|
||||||
|
obj, ok := raw.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var issues []string
|
||||||
|
elemType := targetType.Elem()
|
||||||
|
for key, value := range obj {
|
||||||
|
fieldPath := appendJSONPath(path, key)
|
||||||
|
issues = append(issues, collectUnknownJSONFields(value, elemType, fieldPath)...)
|
||||||
|
}
|
||||||
|
return issues
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonFieldTypeMap(t reflect.Type) map[string]reflect.Type {
|
||||||
|
result := make(map[string]reflect.Type)
|
||||||
|
populateJSONFieldTypeMap(result, derefType(t))
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func populateJSONFieldTypeMap(result map[string]reflect.Type, t reflect.Type) {
|
||||||
|
if t == nil || t.Kind() != reflect.Struct {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < t.NumField(); i++ {
|
||||||
|
field := t.Field(i)
|
||||||
|
if !field.IsExported() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
tag := field.Tag.Get("json")
|
||||||
|
name := strings.Split(tag, ",")[0]
|
||||||
|
if name == "-" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if field.Anonymous && name == "" {
|
||||||
|
populateJSONFieldTypeMap(result, derefType(field.Type))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if name == "" {
|
||||||
|
name = field.Name
|
||||||
|
}
|
||||||
|
result[name] = field.Type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func derefType(t reflect.Type) reflect.Type {
|
||||||
|
for t != nil && t.Kind() == reflect.Pointer {
|
||||||
|
t = t.Elem()
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendJSONPath(path, segment string) string {
|
||||||
|
if path == "" {
|
||||||
|
return segment
|
||||||
|
}
|
||||||
|
return path + "." + segment
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,59 @@ func buildModelWithProtocol(protocol, model string) string {
|
||||||
return protocol + "/" + model
|
return protocol + "/" + model
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type legacyDiagnosticConfig struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
Isolation IsolationConfig `json:"isolation,omitempty"`
|
||||||
|
Agents legacyDiagnosticAgents `json:"agents,omitempty"`
|
||||||
|
Session SessionConfig `json:"session,omitempty"`
|
||||||
|
Channels map[string]any `json:"channels,omitempty"`
|
||||||
|
ChannelList ChannelsConfig `json:"channel_list,omitempty"`
|
||||||
|
ModelList []map[string]any `json:"model_list,omitempty"`
|
||||||
|
Gateway GatewayConfig `json:"gateway,omitempty"`
|
||||||
|
Hooks HooksConfig `json:"hooks,omitempty"`
|
||||||
|
Tools ToolsConfig `json:"tools,omitempty"`
|
||||||
|
Heartbeat HeartbeatConfig `json:"heartbeat,omitempty"`
|
||||||
|
Devices DevicesConfig `json:"devices,omitempty"`
|
||||||
|
Voice VoiceConfig `json:"voice,omitempty"`
|
||||||
|
Bindings json.RawMessage `json:"bindings,omitempty"`
|
||||||
|
Providers json.RawMessage `json:"providers,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type legacyDiagnosticAgents struct {
|
||||||
|
Defaults legacyDiagnosticAgentDefaults `json:"defaults,omitempty"`
|
||||||
|
List []AgentConfig `json:"list,omitempty"`
|
||||||
|
Dispatch *DispatchConfig `json:"dispatch,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type legacyDiagnosticAgentDefaults struct {
|
||||||
|
AgentDefaults
|
||||||
|
LegacyModel string `json:"model,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateLegacyConfigDiagnostics(data []byte) error {
|
||||||
|
var cfg legacyDiagnosticConfig
|
||||||
|
return decodeJSONWithDiagnostics(data, &cfg, "config.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateLegacyAgentDefaultsModel(m map[string]any) {
|
||||||
|
agents, ok := m["agents"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defaults, ok := agents["defaults"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
model, hasModel := defaults["model"]
|
||||||
|
if !hasModel {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, hasModelName := defaults["model_name"]; !hasModelName {
|
||||||
|
defaults["model_name"] = model
|
||||||
|
}
|
||||||
|
delete(defaults, "model")
|
||||||
|
}
|
||||||
|
|
||||||
// loadConfigV1 loads a version 1 config (current schema)
|
// loadConfigV1 loads a version 1 config (current schema)
|
||||||
func loadConfig(data []byte) (*Config, error) {
|
func loadConfig(data []byte) (*Config, error) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
|
|
@ -38,14 +91,14 @@ func loadConfig(data []byte) (*Config, error) {
|
||||||
// index position. We only reset cfg.ModelList when the user actually provides
|
// index position. We only reset cfg.ModelList when the user actually provides
|
||||||
// entries; when count is 0 we keep DefaultConfig's built-in list as fallback.
|
// entries; when count is 0 we keep DefaultConfig's built-in list as fallback.
|
||||||
var tmp Config
|
var tmp Config
|
||||||
if err := json.Unmarshal(data, &tmp); err != nil {
|
if err := decodeJSONWithDiagnostics(data, &tmp, "config.json"); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(tmp.ModelList) > 0 {
|
if len(tmp.ModelList) > 0 {
|
||||||
cfg.ModelList = nil
|
cfg.ModelList = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(data, cfg); err != nil {
|
if err := decodeJSONWithDiagnostics(data, cfg, "config.json"); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
|
|
@ -96,17 +149,7 @@ func migrateV0ToV1(m map[string]any) error {
|
||||||
return fmt.Errorf("migrateV0ToV1: expected version 0, got %v", m["version"])
|
return fmt.Errorf("migrateV0ToV1: expected version 0, got %v", m["version"])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migrate agents.defaults.model → agents.defaults.model_name
|
migrateLegacyAgentDefaultsModel(m)
|
||||||
if agents, ok := m["agents"].(map[string]any); ok {
|
|
||||||
if defaults, ok := agents["defaults"].(map[string]any); ok {
|
|
||||||
if model, hasModel := defaults["model"]; hasModel {
|
|
||||||
if _, hasModelName := defaults["model_name"]; !hasModelName {
|
|
||||||
defaults["model_name"] = model
|
|
||||||
}
|
|
||||||
delete(defaults, "model")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Migrate legacy providers to model_list if no model_list exists
|
// Migrate legacy providers to model_list if no model_list exists
|
||||||
if _, hasModelList := m["model_list"]; !hasModelList {
|
if _, hasModelList := m["model_list"]; !hasModelList {
|
||||||
|
|
@ -275,6 +318,9 @@ func migrateV2ToV3(m map[string]any) error {
|
||||||
return fmt.Errorf("migrateV2ToV3: expected version 2, got %v", m["version"])
|
return fmt.Errorf("migrateV2ToV3: expected version 2, got %v", m["version"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
migrateLegacyAgentDefaultsModel(m)
|
||||||
|
delete(m, "bindings")
|
||||||
|
|
||||||
// Rename channels → channel_list
|
// Rename channels → channel_list
|
||||||
if channels, ok := m["channels"]; ok {
|
if channels, ok := m["channels"]; ok {
|
||||||
delete(m, "channels")
|
delete(m, "channels")
|
||||||
|
|
@ -334,7 +380,7 @@ func loadConfigMap(path string) (map[string]any, error) {
|
||||||
return nil, fmt.Errorf("failed to read config: %w", err)
|
return nil, fmt.Errorf("failed to read config: %w", err)
|
||||||
}
|
}
|
||||||
if err = json.Unmarshal(data, &m1); err != nil {
|
if err = json.Unmarshal(data, &m1); err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse config: %w", err)
|
return nil, wrapJSONError(data, err, "config.json")
|
||||||
}
|
}
|
||||||
secPath := securityPath(path)
|
secPath := securityPath(path)
|
||||||
data, err = os.ReadFile(secPath)
|
data, err = os.ReadFile(secPath)
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ func loadSecurityConfig(cfg *Config, securityPath string) error {
|
||||||
// Unmarshal non-channel fields from security.yml
|
// Unmarshal non-channel fields from security.yml
|
||||||
// This will resolve encrypted values for model_list, tools, etc.
|
// This will resolve encrypted values for model_list, tools, etc.
|
||||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||||
return fmt.Errorf("failed to parse security config: %w", err)
|
return fmt.Errorf("failed to parse security config %s: %w", securityPath, err)
|
||||||
}
|
}
|
||||||
if err := applyLegacySkillsSecurityConfig(cfg, data); err != nil {
|
if err := applyLegacySkillsSecurityConfig(cfg, data); err != nil {
|
||||||
return fmt.Errorf("failed to parse legacy skills security config: %w", err)
|
return fmt.Errorf("failed to parse legacy skills security config: %w", err)
|
||||||
|
|
|
||||||
|
|
@ -43,11 +43,10 @@ func TestSecurityConfigIntegration(t *testing.T) {
|
||||||
t.Run("Full workflow with security references", func(t *testing.T) {
|
t.Run("Full workflow with security references", func(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
// Create config.json with direct security values (not ref: references)
|
// Create config.json with direct security values using the current schema.
|
||||||
// These values should take precedence over .security.yml
|
|
||||||
configPath := filepath.Join(tmpDir, "config.json")
|
configPath := filepath.Join(tmpDir, "config.json")
|
||||||
configContent := `{
|
configContent := `{
|
||||||
"version": 1,
|
"version": 2,
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "test-model",
|
"model_name": "test-model",
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
@ -10,7 +11,9 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"reflect"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
@ -431,6 +434,10 @@ func computeConfigSignature(cfg *config.Config) string {
|
||||||
}
|
}
|
||||||
if cfg.Tools.Web.Enabled {
|
if cfg.Tools.Web.Enabled {
|
||||||
toolSignatures = append(toolSignatures, "web")
|
toolSignatures = append(toolSignatures, "web")
|
||||||
|
webConfig, err := json.Marshal(canonicalizeSignatureValue(reflect.ValueOf(cfg.Tools.Web)))
|
||||||
|
if err == nil {
|
||||||
|
parts = append(parts, "webcfg:"+string(webConfig))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if cfg.Tools.WebFetch.Enabled {
|
if cfg.Tools.WebFetch.Enabled {
|
||||||
toolSignatures = append(toolSignatures, "web_fetch")
|
toolSignatures = append(toolSignatures, "web_fetch")
|
||||||
|
|
@ -474,9 +481,175 @@ func computeConfigSignature(cfg *config.Config) string {
|
||||||
if len(toolSignatures) > 0 {
|
if len(toolSignatures) > 0 {
|
||||||
parts = append(parts, "tools:"+strings.Join(toolSignatures, ","))
|
parts = append(parts, "tools:"+strings.Join(toolSignatures, ","))
|
||||||
}
|
}
|
||||||
|
channelSignatures := computeChannelSignatures(cfg.Channels)
|
||||||
|
if len(channelSignatures) > 0 {
|
||||||
|
parts = append(parts, "channels:"+strings.Join(channelSignatures, ","))
|
||||||
|
}
|
||||||
return strings.Join(parts, ";")
|
return strings.Join(parts, ";")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func computeChannelSignatures(channels config.ChannelsConfig) []string {
|
||||||
|
if len(channels) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := make([]string, 0, len(channels))
|
||||||
|
for name := range channels {
|
||||||
|
keys = append(keys, name)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
signatures := make([]string, 0, len(keys))
|
||||||
|
for _, name := range keys {
|
||||||
|
channel := channels[name]
|
||||||
|
if channel == nil {
|
||||||
|
signatures = append(signatures, name+":<nil>")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
AllowFrom config.FlexibleStringSlice `json:"allow_from,omitempty"`
|
||||||
|
ReasoningChannelID string `json:"reasoning_channel_id,omitempty"`
|
||||||
|
GroupTrigger config.GroupTriggerConfig `json:"group_trigger,omitempty"`
|
||||||
|
Typing config.TypingConfig `json:"typing,omitempty"`
|
||||||
|
Placeholder config.PlaceholderConfig `json:"placeholder,omitempty"`
|
||||||
|
Settings json.RawMessage `json:"settings,omitempty"`
|
||||||
|
}{
|
||||||
|
Enabled: channel.Enabled,
|
||||||
|
Type: channel.Type,
|
||||||
|
AllowFrom: channel.AllowFrom,
|
||||||
|
ReasoningChannelID: channel.ReasoningChannelID,
|
||||||
|
GroupTrigger: channel.GroupTrigger,
|
||||||
|
Typing: channel.Typing,
|
||||||
|
Placeholder: channel.Placeholder,
|
||||||
|
Settings: normalizeChannelSettings(channel),
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
signatures = append(signatures, name+":<invalid>")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
signatures = append(signatures, name+":"+string(encoded))
|
||||||
|
}
|
||||||
|
|
||||||
|
return signatures
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeChannelSettings(channel *config.Channel) json.RawMessage {
|
||||||
|
if channel == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded, err := channel.GetDecoded()
|
||||||
|
if err == nil && decoded != nil {
|
||||||
|
normalized, err := json.Marshal(canonicalizeSignatureValue(reflect.ValueOf(decoded)))
|
||||||
|
if err == nil {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeRawJSON(channel.Settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRawJSON(raw config.RawNode) json.RawMessage {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var value any
|
||||||
|
if err := json.Unmarshal(raw, &value); err != nil {
|
||||||
|
return bytes.TrimSpace(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return bytes.TrimSpace(raw)
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalizeSignatureValue(value reflect.Value) any {
|
||||||
|
if !value.IsValid() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if value.CanInterface() {
|
||||||
|
switch typed := value.Interface().(type) {
|
||||||
|
case config.SecureString:
|
||||||
|
return typed.String()
|
||||||
|
case *config.SecureString:
|
||||||
|
if typed == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return typed.String()
|
||||||
|
case config.SecureStrings:
|
||||||
|
return typed.Values()
|
||||||
|
case *config.SecureStrings:
|
||||||
|
if typed == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return typed.Values()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch value.Kind() {
|
||||||
|
case reflect.Interface, reflect.Pointer:
|
||||||
|
if value.IsNil() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return canonicalizeSignatureValue(value.Elem())
|
||||||
|
case reflect.Struct:
|
||||||
|
result := make(map[string]any)
|
||||||
|
valueType := value.Type()
|
||||||
|
for i := 0; i < value.NumField(); i++ {
|
||||||
|
field := valueType.Field(i)
|
||||||
|
if field.PkgPath != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tag := field.Tag.Get("json")
|
||||||
|
name := field.Name
|
||||||
|
if tag != "" {
|
||||||
|
if comma := strings.Index(tag, ","); comma >= 0 {
|
||||||
|
tag = tag[:comma]
|
||||||
|
}
|
||||||
|
if tag == "-" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if tag != "" {
|
||||||
|
name = tag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result[name] = canonicalizeSignatureValue(value.Field(i))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
case reflect.Slice, reflect.Array:
|
||||||
|
length := value.Len()
|
||||||
|
result := make([]any, 0, length)
|
||||||
|
for i := 0; i < length; i++ {
|
||||||
|
result = append(result, canonicalizeSignatureValue(value.Index(i)))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
case reflect.Map:
|
||||||
|
if value.Type().Key().Kind() != reflect.String {
|
||||||
|
return value.Interface()
|
||||||
|
}
|
||||||
|
result := make(map[string]any, value.Len())
|
||||||
|
iter := value.MapRange()
|
||||||
|
for iter.Next() {
|
||||||
|
result[iter.Key().String()] = canonicalizeSignatureValue(iter.Value())
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
default:
|
||||||
|
if value.CanInterface() {
|
||||||
|
return value.Interface()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func gatewayRestartRequiredBySignature(bootSignature, currentSignature, gatewayStatus string) bool {
|
func gatewayRestartRequiredBySignature(bootSignature, currentSignature, gatewayStatus string) bool {
|
||||||
if gatewayStatus != "running" {
|
if gatewayStatus != "running" {
|
||||||
return false
|
return false
|
||||||
|
|
@ -742,6 +915,11 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
|
||||||
// Already holding gateway.mu from caller.
|
// Already holding gateway.mu from caller.
|
||||||
if changed {
|
if changed {
|
||||||
refreshPicoTokensLocked(h.configPath)
|
refreshPicoTokensLocked(h.configPath)
|
||||||
|
cfg, err = config.LoadConfig(h.configPath)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to reload config after ensuring pico channel: %w", err)
|
||||||
|
}
|
||||||
|
defaultModelName = strings.TrimSpace(cfg.Agents.Defaults.GetModelName())
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -286,6 +286,61 @@ func TestStartGatewayLocked_ForwardsWildcardHostForPublicLauncher(t *testing.T)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStartGatewayLocked_UsesReloadedConfigForBootSignature(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("sleep command differs on Windows")
|
||||||
|
}
|
||||||
|
|
||||||
|
resetGatewayTestState(t)
|
||||||
|
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
delete(cfg.Channels, "pico")
|
||||||
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
h.SetServerOptions(18800, false, false, nil)
|
||||||
|
gatewayExecCommand = func(_ string, _ ...string) *exec.Cmd {
|
||||||
|
return exec.Command("sleep", "30")
|
||||||
|
}
|
||||||
|
|
||||||
|
originalSignature := computeConfigSignature(cfg)
|
||||||
|
pid, err := h.startGatewayLocked("starting", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("startGatewayLocked() error = %v", err)
|
||||||
|
}
|
||||||
|
if pid <= 0 {
|
||||||
|
t.Fatalf("startGatewayLocked() pid = %d, want > 0", pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
gateway.mu.Lock()
|
||||||
|
cmd := gateway.cmd
|
||||||
|
bootSignature := gateway.bootConfigSignature
|
||||||
|
gateway.mu.Unlock()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if cmd != nil && cmd.Process != nil {
|
||||||
|
_ = cmd.Process.Kill()
|
||||||
|
}
|
||||||
|
if cmd != nil {
|
||||||
|
_ = cmd.Wait()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
updatedCfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
expectedSignature := computeConfigSignature(updatedCfg)
|
||||||
|
if expectedSignature == originalSignature {
|
||||||
|
t.Fatal("expected EnsurePicoChannel() to change the config signature during gateway start")
|
||||||
|
}
|
||||||
|
if bootSignature != expectedSignature {
|
||||||
|
t.Fatalf("bootConfigSignature = %q, want %q", bootSignature, expectedSignature)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGatewayStartReady_NoDefaultModel(t *testing.T) {
|
func TestGatewayStartReady_NoDefaultModel(t *testing.T) {
|
||||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
h := NewHandler(configPath)
|
h := NewHandler(configPath)
|
||||||
|
|
@ -1108,6 +1163,136 @@ func TestGatewayStatusRequiresRestartAfterToolChange(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGatewayStatusRequiresRestartAfterChannelChange(t *testing.T) {
|
||||||
|
resetGatewayTestState(t)
|
||||||
|
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
||||||
|
cfg.ModelList[0].SetAPIKey("test-key")
|
||||||
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
process, err := os.FindProcess(os.Getpid())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindProcess() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bootSignature := computeConfigSignature(cfg)
|
||||||
|
gateway.mu.Lock()
|
||||||
|
gateway.cmd = &exec.Cmd{Process: process}
|
||||||
|
gateway.bootDefaultModel = cfg.ModelList[0].ModelName
|
||||||
|
gateway.bootConfigSignature = bootSignature
|
||||||
|
setGatewayRuntimeStatusLocked("running")
|
||||||
|
gateway.mu.Unlock()
|
||||||
|
|
||||||
|
updatedCfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
telegram := updatedCfg.Channels.Get("telegram")
|
||||||
|
if telegram == nil {
|
||||||
|
t.Fatalf("expected default telegram channel config")
|
||||||
|
}
|
||||||
|
telegram.Enabled = !telegram.Enabled
|
||||||
|
if err := config.SaveConfig(configPath, updatedCfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
|
||||||
|
return mockGatewayHealthResponse(http.StatusOK, os.Getpid()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := body["gateway_status"]; got != "running" {
|
||||||
|
t.Fatalf("gateway_status = %#v, want %q", got, "running")
|
||||||
|
}
|
||||||
|
if got := body["gateway_restart_required"]; got != true {
|
||||||
|
t.Fatalf("gateway_restart_required = %#v, want true", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGatewayStatusRequiresRestartAfterWebSearchConfigChange(t *testing.T) {
|
||||||
|
resetGatewayTestState(t)
|
||||||
|
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
cfg := config.DefaultConfig()
|
||||||
|
cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName
|
||||||
|
cfg.ModelList[0].SetAPIKey("test-key")
|
||||||
|
cfg.Tools.Web.Enabled = true
|
||||||
|
cfg.Tools.Web.Provider = "sogou"
|
||||||
|
if err := config.SaveConfig(configPath, cfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := NewHandler(configPath)
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
process, err := os.FindProcess(os.Getpid())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindProcess() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bootSignature := computeConfigSignature(cfg)
|
||||||
|
gateway.mu.Lock()
|
||||||
|
gateway.cmd = &exec.Cmd{Process: process}
|
||||||
|
gateway.bootDefaultModel = cfg.ModelList[0].ModelName
|
||||||
|
gateway.bootConfigSignature = bootSignature
|
||||||
|
setGatewayRuntimeStatusLocked("running")
|
||||||
|
gateway.mu.Unlock()
|
||||||
|
|
||||||
|
updatedCfg, err := config.LoadConfig(configPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
updatedCfg.Tools.Web.Provider = "duckduckgo"
|
||||||
|
if err := config.SaveConfig(configPath, updatedCfg); err != nil {
|
||||||
|
t.Fatalf("SaveConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gatewayHealthGet = func(string, time.Duration) (*http.Response, error) {
|
||||||
|
return mockGatewayHealthResponse(http.StatusOK, os.Getpid()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil)
|
||||||
|
mux.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatalf("unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := body["gateway_status"]; got != "running" {
|
||||||
|
t.Fatalf("gateway_status = %#v, want %q", got, "running")
|
||||||
|
}
|
||||||
|
if got := body["gateway_restart_required"]; got != true {
|
||||||
|
t.Fatalf("gateway_restart_required = %#v, want true", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGatewayStatusNoRestartRequiredForNonSensitiveChanges(t *testing.T) {
|
func TestGatewayStatusNoRestartRequiredForNonSensitiveChanges(t *testing.T) {
|
||||||
resetGatewayTestState(t)
|
resetGatewayTestState(t)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useLayoutEffect, useRef } from "react"
|
import { useLayoutEffect, useRef } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
|
|
||||||
import { ToolLibraryTab } from "./tool-library-tab"
|
import { ToolLibraryTab } from "./tool-library-tab"
|
||||||
|
|
@ -26,6 +27,7 @@ export function ToolsPage() {
|
||||||
isToolsLoading,
|
isToolsLoading,
|
||||||
isWebSearchLoading,
|
isWebSearchLoading,
|
||||||
isWebSearchSaving,
|
isWebSearchSaving,
|
||||||
|
isWebSearchDirty,
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
setStatusFilter,
|
setStatusFilter,
|
||||||
|
|
@ -72,6 +74,7 @@ export function ToolsPage() {
|
||||||
isLoading={isWebSearchLoading}
|
isLoading={isWebSearchLoading}
|
||||||
hasError={hasWebSearchError}
|
hasError={hasWebSearchError}
|
||||||
isSaving={isWebSearchSaving}
|
isSaving={isWebSearchSaving}
|
||||||
|
isDirty={isWebSearchDirty}
|
||||||
onSave={saveWebSearchConfig}
|
onSave={saveWebSearchConfig}
|
||||||
onToggleProviderExpand={toggleExpandedProvider}
|
onToggleProviderExpand={toggleExpandedProvider}
|
||||||
onUpdateDraft={updateWebSearchDraft}
|
onUpdateDraft={updateWebSearchDraft}
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,13 @@ import { useTranslation } from "react-i18next"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
type WebSearchConfigResponse,
|
||||||
getTools,
|
getTools,
|
||||||
getWebSearchConfig,
|
getWebSearchConfig,
|
||||||
setToolEnabled,
|
setToolEnabled,
|
||||||
updateWebSearchConfig,
|
updateWebSearchConfig,
|
||||||
type WebSearchConfigResponse,
|
|
||||||
} from "@/api/tools"
|
} from "@/api/tools"
|
||||||
|
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
|
||||||
import { refreshGatewayState } from "@/store/gateway"
|
import { refreshGatewayState } from "@/store/gateway"
|
||||||
|
|
||||||
import type { GroupedTools, ToolStatusFilter, ToolsPageTab } from "./types"
|
import type { GroupedTools, ToolStatusFilter, ToolsPageTab } from "./types"
|
||||||
|
|
@ -35,24 +36,38 @@ export function useToolsPage() {
|
||||||
queryFn: getWebSearchConfig,
|
queryFn: getWebSearchConfig,
|
||||||
})
|
})
|
||||||
|
|
||||||
const tools = useMemo(() => toolsQuery.data?.tools ?? [], [toolsQuery.data?.tools])
|
const tools = useMemo(
|
||||||
|
() => toolsQuery.data?.tools ?? [],
|
||||||
|
[toolsQuery.data?.tools],
|
||||||
|
)
|
||||||
const normalizedSearchQuery = deferredSearchQuery.trim().toLowerCase()
|
const normalizedSearchQuery = deferredSearchQuery.trim().toLowerCase()
|
||||||
const webSearchDraft = webSearchDraftOverride ?? webSearchQuery.data ?? null
|
const webSearchDraft = webSearchDraftOverride ?? webSearchQuery.data ?? null
|
||||||
|
const isWebSearchDirty = useMemo(() => {
|
||||||
|
if (!webSearchDraft || !webSearchQuery.data) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
JSON.stringify(webSearchDraft) !== JSON.stringify(webSearchQuery.data)
|
||||||
|
)
|
||||||
|
}, [webSearchDraft, webSearchQuery.data])
|
||||||
|
|
||||||
const toggleToolMutation = useMutation({
|
const toggleToolMutation = useMutation({
|
||||||
mutationFn: async ({ name, enabled }: { name: string; enabled: boolean }) =>
|
mutationFn: async ({ name, enabled }: { name: string; enabled: boolean }) =>
|
||||||
setToolEnabled(name, enabled),
|
setToolEnabled(name, enabled),
|
||||||
onSuccess: (_, variables) => {
|
onSuccess: async (_, variables) => {
|
||||||
toast.success(
|
const gateway = await refreshGatewayState({ force: true })
|
||||||
|
showSaveSuccessOrRestartToast(
|
||||||
|
t,
|
||||||
variables.enabled
|
variables.enabled
|
||||||
? t("pages.agent.tools.enable_success", "Tool enabled successfully")
|
? t("pages.agent.tools.enable_success", "Tool enabled successfully")
|
||||||
: t(
|
: t(
|
||||||
"pages.agent.tools.disable_success",
|
"pages.agent.tools.disable_success",
|
||||||
"Tool disabled successfully",
|
"Tool disabled successfully",
|
||||||
),
|
),
|
||||||
|
t("navigation.tools", "Tools"),
|
||||||
|
gateway?.restartRequired === true,
|
||||||
)
|
)
|
||||||
void queryClient.invalidateQueries({ queryKey: ["tools"] })
|
void queryClient.invalidateQueries({ queryKey: ["tools"] })
|
||||||
void refreshGatewayState({ force: true })
|
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(
|
toast.error(
|
||||||
|
|
@ -65,20 +80,23 @@ export function useToolsPage() {
|
||||||
|
|
||||||
const saveWebSearchMutation = useMutation({
|
const saveWebSearchMutation = useMutation({
|
||||||
mutationFn: updateWebSearchConfig,
|
mutationFn: updateWebSearchConfig,
|
||||||
onSuccess: (updatedConfig) => {
|
onSuccess: async (updatedConfig) => {
|
||||||
queryClient.setQueryData(["tools", "web-search-config"], updatedConfig)
|
queryClient.setQueryData(["tools", "web-search-config"], updatedConfig)
|
||||||
setWebSearchDraftOverride(null)
|
setWebSearchDraftOverride(null)
|
||||||
toast.success(
|
const gateway = await refreshGatewayState({ force: true })
|
||||||
|
showSaveSuccessOrRestartToast(
|
||||||
|
t,
|
||||||
t(
|
t(
|
||||||
"pages.agent.tools.web_search.save_success",
|
"pages.agent.tools.web_search.save_success",
|
||||||
"Settings saved successfully",
|
"Settings saved successfully",
|
||||||
),
|
),
|
||||||
|
t("pages.agent.tools.web_search.title", "Web Search Configuration"),
|
||||||
|
gateway?.restartRequired === true,
|
||||||
)
|
)
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: ["tools", "web-search-config"],
|
queryKey: ["tools", "web-search-config"],
|
||||||
})
|
})
|
||||||
void queryClient.invalidateQueries({ queryKey: ["tools"] })
|
void queryClient.invalidateQueries({ queryKey: ["tools"] })
|
||||||
void refreshGatewayState({ force: true })
|
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error(
|
toast.error(
|
||||||
|
|
@ -105,7 +123,9 @@ export function useToolsPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedSearchQuery) {
|
if (normalizedSearchQuery) {
|
||||||
const matchesName = tool.name.toLowerCase().includes(normalizedSearchQuery)
|
const matchesName = tool.name
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(normalizedSearchQuery)
|
||||||
const matchesDescription = (tool.description || "")
|
const matchesDescription = (tool.description || "")
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(normalizedSearchQuery)
|
.includes(normalizedSearchQuery)
|
||||||
|
|
@ -177,6 +197,7 @@ export function useToolsPage() {
|
||||||
isToolsLoading: toolsQuery.isLoading,
|
isToolsLoading: toolsQuery.isLoading,
|
||||||
isWebSearchLoading: webSearchQuery.isLoading,
|
isWebSearchLoading: webSearchQuery.isLoading,
|
||||||
isWebSearchSaving: saveWebSearchMutation.isPending,
|
isWebSearchSaving: saveWebSearchMutation.isPending,
|
||||||
|
isWebSearchDirty,
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
setStatusFilter,
|
setStatusFilter,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import type { WebSearchConfigResponse } from "@/api/tools"
|
import type { WebSearchConfigResponse } from "@/api/tools"
|
||||||
|
import { ConfigChangeNotice } from "@/components/config-change-notice"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
|
||||||
|
|
@ -15,6 +16,7 @@ interface WebSearchTabProps {
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
hasError: boolean
|
hasError: boolean
|
||||||
isSaving: boolean
|
isSaving: boolean
|
||||||
|
isDirty: boolean
|
||||||
onSave: () => void
|
onSave: () => void
|
||||||
onToggleProviderExpand: (providerId: string) => void
|
onToggleProviderExpand: (providerId: string) => void
|
||||||
onUpdateDraft: WebSearchDraftUpdater
|
onUpdateDraft: WebSearchDraftUpdater
|
||||||
|
|
@ -27,6 +29,7 @@ export function WebSearchTab({
|
||||||
isLoading,
|
isLoading,
|
||||||
hasError,
|
hasError,
|
||||||
isSaving,
|
isSaving,
|
||||||
|
isDirty,
|
||||||
onSave,
|
onSave,
|
||||||
onToggleProviderExpand,
|
onToggleProviderExpand,
|
||||||
onUpdateDraft,
|
onUpdateDraft,
|
||||||
|
|
@ -66,13 +69,21 @@ export function WebSearchTab({
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={onSave}
|
onClick={onSave}
|
||||||
disabled={isSaving}
|
disabled={!isDirty || isSaving}
|
||||||
className="h-10 shrink-0 rounded-xl px-6 shadow-sm transition-all active:scale-95"
|
className="h-10 shrink-0 rounded-xl px-6 shadow-sm transition-all active:scale-95"
|
||||||
>
|
>
|
||||||
{t("pages.agent.tools.web_search.save", "Save Changes")}
|
{t("pages.agent.tools.web_search.save", "Save Changes")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isDirty && (
|
||||||
|
<ConfigChangeNotice
|
||||||
|
kind="save"
|
||||||
|
title={t("common.saveChangesTitle")}
|
||||||
|
description={t("pages.agent.tools.web_search.unsaved_prompt")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
<WebSearchGeneralSettings
|
<WebSearchGeneralSettings
|
||||||
draft={draft}
|
draft={draft}
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,12 @@ import { SlackForm } from "@/components/channels/channel-forms/slack-form"
|
||||||
import { TelegramForm } from "@/components/channels/channel-forms/telegram-form"
|
import { TelegramForm } from "@/components/channels/channel-forms/telegram-form"
|
||||||
import { WecomForm } from "@/components/channels/channel-forms/wecom-form"
|
import { WecomForm } from "@/components/channels/channel-forms/wecom-form"
|
||||||
import { WeixinForm } from "@/components/channels/channel-forms/weixin-form"
|
import { WeixinForm } from "@/components/channels/channel-forms/weixin-form"
|
||||||
|
import { ConfigChangeNotice } from "@/components/config-change-notice"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { useGateway } from "@/hooks/use-gateway"
|
import { useGateway } from "@/hooks/use-gateway"
|
||||||
|
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
|
||||||
import { refreshGatewayState } from "@/store/gateway"
|
import { refreshGatewayState } from "@/store/gateway"
|
||||||
|
|
||||||
interface ChannelConfigPageProps {
|
interface ChannelConfigPageProps {
|
||||||
|
|
@ -296,21 +298,34 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
const [enabled, setEnabled] = useState(false)
|
const [enabled, setEnabled] = useState(false)
|
||||||
const [arrayFieldResetVersion, setArrayFieldResetVersion] = useState(0)
|
const [arrayFieldResetVersion, setArrayFieldResetVersion] = useState(0)
|
||||||
const arrayFieldFlushersRef = useRef(new Map<string, ArrayFieldFlusher>())
|
const arrayFieldFlushersRef = useRef(new Map<string, ArrayFieldFlusher>())
|
||||||
|
const loadRequestIdRef = useRef(0)
|
||||||
|
|
||||||
|
const resetPageState = useCallback(() => {
|
||||||
|
arrayFieldFlushersRef.current.clear()
|
||||||
|
setChannel(null)
|
||||||
|
setBaseConfig({})
|
||||||
|
setEditConfig({})
|
||||||
|
setConfiguredSecrets([])
|
||||||
|
setEnabled(false)
|
||||||
|
setFetchError("")
|
||||||
|
setServerError("")
|
||||||
|
setFieldErrors({})
|
||||||
|
setArrayFieldResetVersion((version) => version + 1)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const loadData = useCallback(
|
const loadData = useCallback(
|
||||||
async (silent = false) => {
|
async (silent = false) => {
|
||||||
|
const requestId = loadRequestIdRef.current + 1
|
||||||
|
loadRequestIdRef.current = requestId
|
||||||
if (!silent) setLoading(true)
|
if (!silent) setLoading(true)
|
||||||
try {
|
try {
|
||||||
const catalog = await getChannelsCatalog()
|
const catalog = await getChannelsCatalog()
|
||||||
|
if (loadRequestIdRef.current !== requestId) return
|
||||||
const matched =
|
const matched =
|
||||||
catalog.channels.find((item) => item.name === channelName) ?? null
|
catalog.channels.find((item) => item.name === channelName) ?? null
|
||||||
|
|
||||||
if (!matched) {
|
if (!matched) {
|
||||||
setChannel(null)
|
resetPageState()
|
||||||
setBaseConfig({})
|
|
||||||
setEditConfig({})
|
|
||||||
setConfiguredSecrets([])
|
|
||||||
setEnabled(false)
|
|
||||||
setFetchError(
|
setFetchError(
|
||||||
t("channels.page.notFound", {
|
t("channels.page.notFound", {
|
||||||
name: channelName,
|
name: channelName,
|
||||||
|
|
@ -320,6 +335,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const channelConfig = await getChannelConfig(channelName)
|
const channelConfig = await getChannelConfig(channelName)
|
||||||
|
if (loadRequestIdRef.current !== requestId) return
|
||||||
const raw = asRecord(channelConfig.config)
|
const raw = asRecord(channelConfig.config)
|
||||||
const normalized = normalizeConfig(matched, raw)
|
const normalized = normalizeConfig(matched, raw)
|
||||||
|
|
||||||
|
|
@ -332,18 +348,23 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
setServerError("")
|
setServerError("")
|
||||||
setFieldErrors({})
|
setFieldErrors({})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (loadRequestIdRef.current !== requestId) return
|
||||||
setConfiguredSecrets([])
|
setConfiguredSecrets([])
|
||||||
setFetchError(e instanceof Error ? e.message : t("channels.loadError"))
|
setFetchError(e instanceof Error ? e.message : t("channels.loadError"))
|
||||||
} finally {
|
} finally {
|
||||||
if (!silent) setLoading(false)
|
if (!silent && loadRequestIdRef.current === requestId) {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[channelName, t],
|
[channelName, resetPageState, t],
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
resetPageState()
|
||||||
|
setLoading(true)
|
||||||
loadData()
|
loadData()
|
||||||
}, [loadData])
|
}, [loadData, resetPageState])
|
||||||
|
|
||||||
const previousGatewayStatusRef = useRef(gatewayState)
|
const previousGatewayStatusRef = useRef(gatewayState)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -359,6 +380,17 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
return isConfigured(channel, editConfig, configuredSecrets)
|
return isConfigured(channel, editConfig, configuredSecrets)
|
||||||
}, [channel, configuredSecrets, editConfig])
|
}, [channel, configuredSecrets, editConfig])
|
||||||
|
|
||||||
|
const isDirty = useMemo(() => {
|
||||||
|
if (loading || !channel || channel.name !== channelName) return false
|
||||||
|
const basePayload = buildSavePayload(
|
||||||
|
channel,
|
||||||
|
buildEditConfig(channel.name, baseConfig),
|
||||||
|
asBool(baseConfig.enabled),
|
||||||
|
)
|
||||||
|
const currentPayload = buildSavePayload(channel, editConfig, enabled)
|
||||||
|
return JSON.stringify(basePayload) !== JSON.stringify(currentPayload)
|
||||||
|
}, [baseConfig, channel, channelName, editConfig, enabled, loading])
|
||||||
|
|
||||||
const docsUrl = useMemo(() => {
|
const docsUrl = useMemo(() => {
|
||||||
if (!channel) return ""
|
if (!channel) return ""
|
||||||
if (CHANNELS_WITHOUT_DOCS.has(channel.name)) return ""
|
if (CHANNELS_WITHOUT_DOCS.has(channel.name)) return ""
|
||||||
|
|
@ -479,6 +511,13 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await loadData()
|
await loadData()
|
||||||
|
const gateway = await refreshGatewayState({ force: true })
|
||||||
|
showSaveSuccessOrRestartToast(
|
||||||
|
t,
|
||||||
|
t("channels.page.saveSuccess"),
|
||||||
|
channelDisplayName,
|
||||||
|
gateway?.restartRequired === true,
|
||||||
|
)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message =
|
const message =
|
||||||
e instanceof Error ? e.message : t("channels.page.saveError")
|
e instanceof Error ? e.message : t("channels.page.saveError")
|
||||||
|
|
@ -674,11 +713,23 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
|
||||||
<p className="text-destructive text-sm">{serverError}</p>
|
<p className="text-destructive text-sm">{serverError}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isDirty && (
|
||||||
|
<ConfigChangeNotice
|
||||||
|
kind="save"
|
||||||
|
title={t("common.saveChangesTitle")}
|
||||||
|
description={t("channels.page.savePrompt")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="border-border/60 flex justify-end gap-2 border-t py-4">
|
<div className="border-border/60 flex justify-end gap-2 border-t py-4">
|
||||||
<Button variant="outline" onClick={handleReset} disabled={saving}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={!isDirty || saving}
|
||||||
|
>
|
||||||
{t("common.reset")}
|
{t("common.reset")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave} disabled={saving}>
|
<Button onClick={handleSave} disabled={!isDirty || saving}>
|
||||||
{saving ? t("common.saving") : t("common.save")}
|
{saving ? t("common.saving") : t("common.save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
48
web/frontend/src/components/config-change-notice.tsx
Normal file
48
web/frontend/src/components/config-change-notice.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import {
|
||||||
|
IconAlertCircle,
|
||||||
|
IconDeviceFloppy,
|
||||||
|
IconRefresh,
|
||||||
|
} from "@tabler/icons-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
interface ConfigChangeNoticeProps {
|
||||||
|
kind: "save" | "restart"
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfigChangeNotice({
|
||||||
|
kind,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
className,
|
||||||
|
}: ConfigChangeNoticeProps) {
|
||||||
|
const Icon =
|
||||||
|
kind === "restart"
|
||||||
|
? IconRefresh
|
||||||
|
: kind === "save"
|
||||||
|
? IconDeviceFloppy
|
||||||
|
: IconAlertCircle
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-start gap-3 rounded-lg border px-3 py-2 text-sm",
|
||||||
|
kind === "restart"
|
||||||
|
? "border-amber-200 bg-amber-50 text-amber-900"
|
||||||
|
: "border-yellow-200 bg-yellow-50 text-yellow-900",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="mt-0.5 size-4 shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-medium">{title}</p>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-0.5 text-xs/5 opacity-85">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
setAutoStartEnabled as updateAutoStartEnabled,
|
setAutoStartEnabled as updateAutoStartEnabled,
|
||||||
setLauncherConfig as updateLauncherConfig,
|
setLauncherConfig as updateLauncherConfig,
|
||||||
} from "@/api/system"
|
} from "@/api/system"
|
||||||
|
import { ConfigChangeNotice } from "@/components/config-change-notice"
|
||||||
import {
|
import {
|
||||||
AgentDefaultsSection,
|
AgentDefaultsSection,
|
||||||
CronSection,
|
CronSection,
|
||||||
|
|
@ -36,6 +37,7 @@ import {
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
|
||||||
import { refreshGatewayState } from "@/store/gateway"
|
import { refreshGatewayState } from "@/store/gateway"
|
||||||
|
|
||||||
export function ConfigPage() {
|
export function ConfigPage() {
|
||||||
|
|
@ -334,8 +336,13 @@ export function ConfigPage() {
|
||||||
queryClient.setQueryData(["system", "autostart"], status)
|
queryClient.setQueryData(["system", "autostart"], status)
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success(t("pages.config.save_success"))
|
const gateway = await refreshGatewayState({ force: true })
|
||||||
void refreshGatewayState({ force: true })
|
showSaveSuccessOrRestartToast(
|
||||||
|
t,
|
||||||
|
t("pages.config.save_success"),
|
||||||
|
t("navigation.config"),
|
||||||
|
gateway?.restartRequired === true,
|
||||||
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(
|
toast.error(
|
||||||
err instanceof Error ? err.message : t("pages.config.save_error"),
|
err instanceof Error ? err.message : t("pages.config.save_error"),
|
||||||
|
|
@ -433,8 +440,12 @@ export function ConfigPage() {
|
||||||
{isDirty && (
|
{isDirty && (
|
||||||
<div className="border-border/70 bg-background/95 supports-backdrop-filter:bg-background/80 shrink-0 border-t px-3 py-3 shadow-[0_-12px_30px_rgba(15,23,42,0.10)] backdrop-blur lg:px-6">
|
<div className="border-border/70 bg-background/95 supports-backdrop-filter:bg-background/80 shrink-0 border-t px-3 py-3 shadow-[0_-12px_30px_rgba(15,23,42,0.10)] backdrop-blur lg:px-6">
|
||||||
<div className="mx-auto flex w-full max-w-[1000px] flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="mx-auto flex w-full max-w-[1000px] flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="text-muted-foreground/70 text-xs">
|
<div className="flex-1">
|
||||||
{t("pages.config.unsaved_changes")}
|
<ConfigChangeNotice
|
||||||
|
kind="save"
|
||||||
|
title={t("common.saveChangesTitle")}
|
||||||
|
description={t("pages.config.unsaved_changes")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{actionButtons}
|
{actionButtons}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { launcherFetch } from "@/api/http"
|
import { launcherFetch } from "@/api/http"
|
||||||
|
import { ConfigChangeNotice } from "@/components/config-change-notice"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
|
|
@ -20,6 +21,7 @@ import {
|
||||||
} from "@/components/ui/alert-dialog"
|
} from "@/components/ui/alert-dialog"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
|
||||||
import { refreshGatewayState } from "@/store/gateway"
|
import { refreshGatewayState } from "@/store/gateway"
|
||||||
|
|
||||||
export function RawConfigPage() {
|
export function RawConfigPage() {
|
||||||
|
|
@ -49,7 +51,6 @@ export function RawConfigPage() {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess: (_, submittedConfig) => {
|
onSuccess: (_, submittedConfig) => {
|
||||||
toast.success(t("pages.config.save_success"))
|
|
||||||
try {
|
try {
|
||||||
const savedConfig = JSON.parse(submittedConfig)
|
const savedConfig = JSON.parse(submittedConfig)
|
||||||
setLastSavedConfig(savedConfig)
|
setLastSavedConfig(savedConfig)
|
||||||
|
|
@ -58,7 +59,14 @@ export function RawConfigPage() {
|
||||||
} catch {
|
} catch {
|
||||||
queryClient.invalidateQueries({ queryKey: ["config"] })
|
queryClient.invalidateQueries({ queryKey: ["config"] })
|
||||||
}
|
}
|
||||||
void refreshGatewayState({ force: true })
|
void refreshGatewayState({ force: true }).then((gateway) => {
|
||||||
|
showSaveSuccessOrRestartToast(
|
||||||
|
t,
|
||||||
|
t("pages.config.save_success"),
|
||||||
|
t("navigation.config"),
|
||||||
|
gateway?.restartRequired === true,
|
||||||
|
)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast.error(t("pages.config.save_error"))
|
toast.error(t("pages.config.save_error"))
|
||||||
|
|
@ -141,9 +149,12 @@ export function RawConfigPage() {
|
||||||
) : (
|
) : (
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||||
{isDirty && (
|
{isDirty && (
|
||||||
<div className="shrink-0 rounded-lg border border-yellow-200 bg-yellow-50 p-2 text-sm text-yellow-700">
|
<ConfigChangeNotice
|
||||||
{t("pages.config.unsaved_changes")}
|
kind="save"
|
||||||
</div>
|
title={t("common.saveChangesTitle")}
|
||||||
|
description={t("pages.config.unsaved_changes")}
|
||||||
|
className="shrink-0"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="relative min-h-0 flex-1 overflow-hidden rounded-lg border shadow-sm">
|
<div className="relative min-h-0 flex-1 overflow-hidden rounded-lg border shadow-sm">
|
||||||
<Textarea
|
<Textarea
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useEffect, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import { addModel, setDefaultModel } from "@/api/models"
|
import { addModel, setDefaultModel } from "@/api/models"
|
||||||
|
import { ConfigChangeNotice } from "@/components/config-change-notice"
|
||||||
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
||||||
import {
|
import {
|
||||||
AdvancedSection,
|
AdvancedSection,
|
||||||
|
|
@ -21,6 +22,8 @@ import {
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from "@/components/ui/sheet"
|
} from "@/components/ui/sheet"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
|
||||||
|
import { refreshGatewayState } from "@/store/gateway"
|
||||||
|
|
||||||
interface AddForm {
|
interface AddForm {
|
||||||
modelName: string
|
modelName: string
|
||||||
|
|
@ -85,6 +88,8 @@ export function AddModelSheet({
|
||||||
form.apiKey,
|
form.apiKey,
|
||||||
t("models.field.apiKeyPlaceholder"),
|
t("models.field.apiKeyPlaceholder"),
|
||||||
)
|
)
|
||||||
|
const isDirty =
|
||||||
|
JSON.stringify(form) !== JSON.stringify(EMPTY_ADD_FORM) || setAsDefault
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
|
|
@ -152,6 +157,13 @@ export function AddModelSheet({
|
||||||
if (setAsDefault) {
|
if (setAsDefault) {
|
||||||
await setDefaultModel(modelName)
|
await setDefaultModel(modelName)
|
||||||
}
|
}
|
||||||
|
const gateway = await refreshGatewayState({ force: true })
|
||||||
|
showSaveSuccessOrRestartToast(
|
||||||
|
t,
|
||||||
|
t("models.add.saveSuccess"),
|
||||||
|
modelName,
|
||||||
|
gateway?.restartRequired === true,
|
||||||
|
)
|
||||||
onSaved()
|
onSaved()
|
||||||
onClose()
|
onClose()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -381,10 +393,17 @@ export function AddModelSheet({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SheetFooter className="border-t-muted border-t px-6 py-4">
|
<SheetFooter className="border-t-muted border-t px-6 py-4">
|
||||||
|
{isDirty && (
|
||||||
|
<ConfigChangeNotice
|
||||||
|
kind="save"
|
||||||
|
title={t("common.saveChangesTitle")}
|
||||||
|
description={t("models.unsavedPrompt")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Button variant="ghost" onClick={onClose} disabled={saving}>
|
<Button variant="ghost" onClick={onClose} disabled={saving}>
|
||||||
{t("common.cancel")}
|
{t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave} disabled={saving}>
|
<Button onClick={handleSave} disabled={!isDirty || saving}>
|
||||||
{saving && <IconLoader2 className="size-4 animate-spin" />}
|
{saving && <IconLoader2 className="size-4 animate-spin" />}
|
||||||
{t("models.add.confirm")}
|
{t("models.add.confirm")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useEffect, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
import { type ModelInfo, setDefaultModel, updateModel } from "@/api/models"
|
import { type ModelInfo, setDefaultModel, updateModel } from "@/api/models"
|
||||||
|
import { ConfigChangeNotice } from "@/components/config-change-notice"
|
||||||
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
|
||||||
import {
|
import {
|
||||||
AdvancedSection,
|
AdvancedSection,
|
||||||
|
|
@ -21,6 +22,8 @@ import {
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from "@/components/ui/sheet"
|
} from "@/components/ui/sheet"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
|
||||||
|
import { refreshGatewayState } from "@/store/gateway"
|
||||||
|
|
||||||
interface EditForm {
|
interface EditForm {
|
||||||
provider: string
|
provider: string
|
||||||
|
|
@ -47,6 +50,30 @@ interface EditModelSheetProps {
|
||||||
onSaved: () => void
|
onSaved: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildInitialEditForm(model: ModelInfo): EditForm {
|
||||||
|
return {
|
||||||
|
provider: model.provider ?? "",
|
||||||
|
modelId: model.model,
|
||||||
|
apiKey: "",
|
||||||
|
apiBase: model.api_base ?? "",
|
||||||
|
proxy: model.proxy ?? "",
|
||||||
|
authMethod: model.auth_method ?? "",
|
||||||
|
connectMode: model.connect_mode ?? "",
|
||||||
|
workspace: model.workspace ?? "",
|
||||||
|
rpm: model.rpm ? String(model.rpm) : "",
|
||||||
|
maxTokensField: model.max_tokens_field ?? "",
|
||||||
|
requestTimeout: model.request_timeout ? String(model.request_timeout) : "",
|
||||||
|
thinkingLevel: model.thinking_level ?? "",
|
||||||
|
toolSchemaTransform: model.tool_schema_transform ?? "", // <-- AGGIUNGI QUESTA RIGA
|
||||||
|
extraBody: model.extra_body
|
||||||
|
? JSON.stringify(model.extra_body, null, 2)
|
||||||
|
: "",
|
||||||
|
customHeaders: model.custom_headers
|
||||||
|
? JSON.stringify(model.custom_headers, null, 2)
|
||||||
|
: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function EditModelSheet({
|
export function EditModelSheet({
|
||||||
model,
|
model,
|
||||||
open,
|
open,
|
||||||
|
|
@ -74,36 +101,19 @@ export function EditModelSheet({
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [setAsDefault, setSetAsDefault] = useState(false)
|
const [setAsDefault, setSetAsDefault] = useState(false)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
|
const initialForm = model ? buildInitialEditForm(model) : null
|
||||||
|
const isDirty =
|
||||||
|
model != null &&
|
||||||
|
(JSON.stringify(form) !== JSON.stringify(initialForm) ||
|
||||||
|
setAsDefault !== model.is_default)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (model) {
|
if (model) {
|
||||||
setForm({
|
setForm(buildInitialEditForm(model))
|
||||||
provider: model.provider ?? "",
|
setSetAsDefault(model.is_default)
|
||||||
modelId: model.model,
|
setError("")
|
||||||
apiKey: "",
|
}
|
||||||
apiBase: model.api_base ?? "",
|
}, [model])
|
||||||
proxy: model.proxy ?? "",
|
|
||||||
authMethod: model.auth_method ?? "",
|
|
||||||
connectMode: model.connect_mode ?? "",
|
|
||||||
workspace: model.workspace ?? "",
|
|
||||||
rpm: model.rpm ? String(model.rpm) : "",
|
|
||||||
maxTokensField: model.max_tokens_field ?? "",
|
|
||||||
requestTimeout: model.request_timeout
|
|
||||||
? String(model.request_timeout)
|
|
||||||
: "",
|
|
||||||
thinkingLevel: model.thinking_level ?? "",
|
|
||||||
toolSchemaTransform: model.tool_schema_transform ?? "",
|
|
||||||
extraBody: model.extra_body
|
|
||||||
? JSON.stringify(model.extra_body, null, 2)
|
|
||||||
: "",
|
|
||||||
customHeaders: model.custom_headers
|
|
||||||
? JSON.stringify(model.custom_headers, null, 2)
|
|
||||||
: "",
|
|
||||||
})
|
|
||||||
setSetAsDefault(model.is_default)
|
|
||||||
setError("")
|
|
||||||
}
|
|
||||||
}, [model])
|
|
||||||
|
|
||||||
const setField =
|
const setField =
|
||||||
(key: keyof EditForm) =>
|
(key: keyof EditForm) =>
|
||||||
|
|
@ -146,6 +156,13 @@ export function EditModelSheet({
|
||||||
if (setAsDefault && !model.is_default) {
|
if (setAsDefault && !model.is_default) {
|
||||||
await setDefaultModel(model.model_name)
|
await setDefaultModel(model.model_name)
|
||||||
}
|
}
|
||||||
|
const gateway = await refreshGatewayState({ force: true })
|
||||||
|
showSaveSuccessOrRestartToast(
|
||||||
|
t,
|
||||||
|
t("models.edit.saveSuccess"),
|
||||||
|
model.model_name,
|
||||||
|
gateway?.restartRequired === true,
|
||||||
|
)
|
||||||
onSaved()
|
onSaved()
|
||||||
onClose()
|
onClose()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -374,10 +391,17 @@ export function EditModelSheet({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SheetFooter className="border-t-muted border-t px-6 py-4">
|
<SheetFooter className="border-t-muted border-t px-6 py-4">
|
||||||
|
{isDirty && (
|
||||||
|
<ConfigChangeNotice
|
||||||
|
kind="save"
|
||||||
|
title={t("common.saveChangesTitle")}
|
||||||
|
description={t("models.unsavedPrompt")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Button variant="ghost" onClick={onClose} disabled={saving}>
|
<Button variant="ghost" onClick={onClose} disabled={saving}>
|
||||||
{t("common.cancel")}
|
{t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave} disabled={saving}>
|
<Button onClick={handleSave} disabled={!isDirty || saving}>
|
||||||
{saving && <IconLoader2 className="size-4 animate-spin" />}
|
{saving && <IconLoader2 className="size-4 animate-spin" />}
|
||||||
{t("common.save")}
|
{t("common.save")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import { IconLoader2, IconPlus, IconStar } from "@tabler/icons-react"
|
import { IconLoader2, IconPlus, IconStar } from "@tabler/icons-react"
|
||||||
import { useCallback, useEffect, useState } from "react"
|
import { useCallback, useEffect, useState } from "react"
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { type ModelInfo, getModels, setDefaultModel } from "@/api/models"
|
import { type ModelInfo, getModels, setDefaultModel } from "@/api/models"
|
||||||
import { PageHeader } from "@/components/page-header"
|
import { PageHeader } from "@/components/page-header"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
|
||||||
|
import { refreshGatewayState } from "@/store/gateway"
|
||||||
|
|
||||||
import { AddModelSheet } from "./add-model-sheet"
|
import { AddModelSheet } from "./add-model-sheet"
|
||||||
import { DeleteModelDialog } from "./delete-model-dialog"
|
import { DeleteModelDialog } from "./delete-model-dialog"
|
||||||
|
|
@ -95,8 +98,15 @@ export function ModelsPage() {
|
||||||
try {
|
try {
|
||||||
await setDefaultModel(model.model_name)
|
await setDefaultModel(model.model_name)
|
||||||
await fetchModels()
|
await fetchModels()
|
||||||
} catch {
|
const gateway = await refreshGatewayState({ force: true })
|
||||||
// ignore
|
showSaveSuccessOrRestartToast(
|
||||||
|
t,
|
||||||
|
t("models.defaultChangeSuccess"),
|
||||||
|
model.model_name,
|
||||||
|
gateway?.restartRequired === true,
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : t("models.loadError"))
|
||||||
} finally {
|
} finally {
|
||||||
setSettingDefaultIndex(null)
|
setSettingDefaultIndex(null)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { type ModelInfo, getModels, setDefaultModel } from "@/api/models"
|
import { type ModelInfo, getModels, setDefaultModel } from "@/api/models"
|
||||||
|
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
|
||||||
|
import { refreshGatewayState } from "@/store/gateway"
|
||||||
|
|
||||||
interface UseChatModelsOptions {
|
interface UseChatModelsOptions {
|
||||||
isConnected: boolean
|
isConnected: boolean
|
||||||
|
|
@ -18,6 +22,7 @@ function isLocalModel(model: ModelInfo): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useChatModels({ isConnected }: UseChatModelsOptions) {
|
export function useChatModels({ isConnected }: UseChatModelsOptions) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [modelList, setModelList] = useState<ModelInfo[]>([])
|
const [modelList, setModelList] = useState<ModelInfo[]>([])
|
||||||
const [defaultModelName, setDefaultModelName] = useState("")
|
const [defaultModelName, setDefaultModelName] = useState("")
|
||||||
const setDefaultRequestIdRef = useRef(0)
|
const setDefaultRequestIdRef = useRef(0)
|
||||||
|
|
@ -58,11 +63,19 @@ export function useChatModels({ isConnected }: UseChatModelsOptions) {
|
||||||
if (data.models.some((m) => m.model_name === data.default_model)) {
|
if (data.models.some((m) => m.model_name === data.default_model)) {
|
||||||
setDefaultModelName(data.default_model)
|
setDefaultModelName(data.default_model)
|
||||||
}
|
}
|
||||||
|
const gateway = await refreshGatewayState({ force: true })
|
||||||
|
showSaveSuccessOrRestartToast(
|
||||||
|
t,
|
||||||
|
t("models.defaultChangeSuccess"),
|
||||||
|
modelName,
|
||||||
|
gateway?.restartRequired === true,
|
||||||
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to set default model:", err)
|
console.error("Failed to set default model:", err)
|
||||||
|
toast.error(err instanceof Error ? err.message : t("models.loadError"))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[defaultModelName],
|
[defaultModelName, t],
|
||||||
)
|
)
|
||||||
|
|
||||||
const hasAvailableModels = useMemo(
|
const hasAvailableModels = useMemo(
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,10 @@
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"saving": "Saving...",
|
"saving": "Saving...",
|
||||||
"reset": "Reset",
|
"reset": "Reset",
|
||||||
"confirm": "Confirm"
|
"confirm": "Confirm",
|
||||||
|
"saveChangesTitle": "You have unsaved configuration changes",
|
||||||
|
"restartRequiredTitle": "Gateway restart required",
|
||||||
|
"restartRequiredDesc": "The latest {{name}} configuration has been saved. Restart the gateway for it to take effect."
|
||||||
},
|
},
|
||||||
"labels": {
|
"labels": {
|
||||||
"loading": "Loading..."
|
"loading": "Loading..."
|
||||||
|
|
@ -210,6 +213,9 @@
|
||||||
},
|
},
|
||||||
"models": {
|
"models": {
|
||||||
"description": "Configure API keys for AI providers. Only configured models are available for chat.",
|
"description": "Configure API keys for AI providers. Only configured models are available for chat.",
|
||||||
|
"defaultChangeSuccess": "Default model updated.",
|
||||||
|
"unsavedPrompt": "This change has not been saved yet. Save to write it into the model configuration.",
|
||||||
|
"restartHint": "Model configuration changes take effect after the gateway restarts.",
|
||||||
"loadError": "Failed to load models",
|
"loadError": "Failed to load models",
|
||||||
"noDefaultHintPrefix": "No default model set yet. Click",
|
"noDefaultHintPrefix": "No default model set yet. Click",
|
||||||
"noDefaultHintSuffix": "to set one.",
|
"noDefaultHintSuffix": "to set one.",
|
||||||
|
|
@ -253,6 +259,7 @@
|
||||||
"errorRequired": "This field is required.",
|
"errorRequired": "This field is required.",
|
||||||
"errorDuplicateModelName": "Model alias already exists. Please use a different name.",
|
"errorDuplicateModelName": "Model alias already exists. Please use a different name.",
|
||||||
"saveError": "Failed to add model",
|
"saveError": "Failed to add model",
|
||||||
|
"saveSuccess": "Model added.",
|
||||||
"confirm": "Add Model"
|
"confirm": "Add Model"
|
||||||
},
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
|
|
@ -298,7 +305,8 @@
|
||||||
"title": "Configure {{name}}",
|
"title": "Configure {{name}}",
|
||||||
"apiKeyHint": "A key is already set. Leave blank to keep it unchanged.",
|
"apiKeyHint": "A key is already set. Leave blank to keep it unchanged.",
|
||||||
"oauthNote": "This provider uses OAuth — no API key required.",
|
"oauthNote": "This provider uses OAuth — no API key required.",
|
||||||
"saveError": "Failed to save"
|
"saveError": "Failed to save",
|
||||||
|
"saveSuccess": "Model configuration saved."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
|
|
@ -384,6 +392,7 @@
|
||||||
"notFound": "Channel \"{{name}}\" is not supported.",
|
"notFound": "Channel \"{{name}}\" is not supported.",
|
||||||
"saveSuccess": "Channel configuration saved.",
|
"saveSuccess": "Channel configuration saved.",
|
||||||
"saveError": "Failed to save channel configuration",
|
"saveError": "Failed to save channel configuration",
|
||||||
|
"savePrompt": "This change has not been saved yet. Save to write it into the channel configuration.",
|
||||||
"docLink": "Documentation",
|
"docLink": "Documentation",
|
||||||
"enableLabel": "Enable channel",
|
"enableLabel": "Enable channel",
|
||||||
"restartRequiredTitle": "Gateway restart required",
|
"restartRequiredTitle": "Gateway restart required",
|
||||||
|
|
@ -557,6 +566,7 @@
|
||||||
"web_search": {
|
"web_search": {
|
||||||
"title": "Web Search",
|
"title": "Web Search",
|
||||||
"description": "Provide web search capability for agents to find the latest real-world info. Automatically routes to the optimal active provider.",
|
"description": "Provide web search capability for agents to find the latest real-world info. Automatically routes to the optimal active provider.",
|
||||||
|
"unsaved_prompt": "This change has not been saved yet. Save to write it into the Web Search configuration.",
|
||||||
"global_settings": "General",
|
"global_settings": "General",
|
||||||
"providers_config": "Integrations",
|
"providers_config": "Integrations",
|
||||||
"load_error": "Failed to load web search configuration.",
|
"load_error": "Failed to load web search configuration.",
|
||||||
|
|
@ -675,6 +685,7 @@
|
||||||
"server_port": "Service Port",
|
"server_port": "Service Port",
|
||||||
"server_port_hint": "HTTP port used by PicoClaw Web.",
|
"server_port_hint": "HTTP port used by PicoClaw Web.",
|
||||||
"launcher_section_hint": "Changes in this section take effect after the launcher restarts.",
|
"launcher_section_hint": "Changes in this section take effect after the launcher restarts.",
|
||||||
|
"gateway_restart_hint": "Changes in this section take effect after the gateway restarts.",
|
||||||
"dashboard_password": "Login Password",
|
"dashboard_password": "Login Password",
|
||||||
"dashboard_password_hint": "Set a new login password.",
|
"dashboard_password_hint": "Set a new login password.",
|
||||||
"dashboard_password_placeholder": "At least 8 characters",
|
"dashboard_password_placeholder": "At least 8 characters",
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,10 @@
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
"saving": "保存中...",
|
"saving": "保存中...",
|
||||||
"reset": "重置",
|
"reset": "重置",
|
||||||
"confirm": "确认"
|
"confirm": "确认",
|
||||||
|
"saveChangesTitle": "有未保存的配置更改",
|
||||||
|
"restartRequiredTitle": "需要重启服务",
|
||||||
|
"restartRequiredDesc": "{{name}} 的最新配置已保存。重启服务后才能正式生效。"
|
||||||
},
|
},
|
||||||
"labels": {
|
"labels": {
|
||||||
"loading": "加载中..."
|
"loading": "加载中..."
|
||||||
|
|
@ -210,6 +213,9 @@
|
||||||
},
|
},
|
||||||
"models": {
|
"models": {
|
||||||
"description": "为 AI 服务商配置 API Key。只有已配置的模型可用于对话。",
|
"description": "为 AI 服务商配置 API Key。只有已配置的模型可用于对话。",
|
||||||
|
"defaultChangeSuccess": "默认模型已更新。",
|
||||||
|
"unsavedPrompt": "当前修改尚未保存,保存后才会写入模型配置。",
|
||||||
|
"restartHint": "模型配置保存后需要重启服务才能生效。",
|
||||||
"loadError": "加载模型列表失败",
|
"loadError": "加载模型列表失败",
|
||||||
"noDefaultHintPrefix": "尚未设置默认模型,点击",
|
"noDefaultHintPrefix": "尚未设置默认模型,点击",
|
||||||
"noDefaultHintSuffix": "设为默认。",
|
"noDefaultHintSuffix": "设为默认。",
|
||||||
|
|
@ -253,6 +259,7 @@
|
||||||
"errorRequired": "此字段为必填项。",
|
"errorRequired": "此字段为必填项。",
|
||||||
"errorDuplicateModelName": "模型别名已存在,请使用其他名称。",
|
"errorDuplicateModelName": "模型别名已存在,请使用其他名称。",
|
||||||
"saveError": "添加模型失败",
|
"saveError": "添加模型失败",
|
||||||
|
"saveSuccess": "模型已添加。",
|
||||||
"confirm": "添加模型"
|
"confirm": "添加模型"
|
||||||
},
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
|
|
@ -298,7 +305,8 @@
|
||||||
"title": "配置 {{name}}",
|
"title": "配置 {{name}}",
|
||||||
"apiKeyHint": "已设置 API Key,留空表示不修改。",
|
"apiKeyHint": "已设置 API Key,留空表示不修改。",
|
||||||
"oauthNote": "该服务商使用 OAuth 认证,无需 API Key。",
|
"oauthNote": "该服务商使用 OAuth 认证,无需 API Key。",
|
||||||
"saveError": "保存失败"
|
"saveError": "保存失败",
|
||||||
|
"saveSuccess": "模型配置已保存。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
|
|
@ -384,6 +392,7 @@
|
||||||
"notFound": "不支持频道“{{name}}”。",
|
"notFound": "不支持频道“{{name}}”。",
|
||||||
"saveSuccess": "频道配置已保存。",
|
"saveSuccess": "频道配置已保存。",
|
||||||
"saveError": "保存频道配置失败",
|
"saveError": "保存频道配置失败",
|
||||||
|
"savePrompt": "当前修改尚未保存,点击保存后才会写入频道配置。",
|
||||||
"docLink": "配置文档",
|
"docLink": "配置文档",
|
||||||
"enableLabel": "启用频道",
|
"enableLabel": "启用频道",
|
||||||
"restartRequiredTitle": "需要重启服务",
|
"restartRequiredTitle": "需要重启服务",
|
||||||
|
|
@ -557,6 +566,7 @@
|
||||||
"web_search": {
|
"web_search": {
|
||||||
"title": "网页搜索",
|
"title": "网页搜索",
|
||||||
"description": "为智能体提供网页搜索能力。自动路由到当前处于激活状态的最佳服务。",
|
"description": "为智能体提供网页搜索能力。自动路由到当前处于激活状态的最佳服务。",
|
||||||
|
"unsaved_prompt": "当前修改尚未保存,点击保存后才会写入 Web Search 配置。",
|
||||||
"global_settings": "常规",
|
"global_settings": "常规",
|
||||||
"providers_config": "集成",
|
"providers_config": "集成",
|
||||||
"load_error": "加载 Web Search 配置失败。",
|
"load_error": "加载 Web Search 配置失败。",
|
||||||
|
|
@ -675,6 +685,7 @@
|
||||||
"server_port": "服务端口",
|
"server_port": "服务端口",
|
||||||
"server_port_hint": "PicoClaw Web 的 HTTP 监听端口",
|
"server_port_hint": "PicoClaw Web 的 HTTP 监听端口",
|
||||||
"launcher_section_hint": "此分组中的改动需要在重启 launcher 后生效",
|
"launcher_section_hint": "此分组中的改动需要在重启 launcher 后生效",
|
||||||
|
"gateway_restart_hint": "这组配置保存后,需要重启服务才能正式生效。",
|
||||||
"dashboard_password": "登录密码",
|
"dashboard_password": "登录密码",
|
||||||
"dashboard_password_hint": "设置新的登录密码",
|
"dashboard_password_hint": "设置新的登录密码",
|
||||||
"dashboard_password_placeholder": "至少 8 个字符",
|
"dashboard_password_placeholder": "至少 8 个字符",
|
||||||
|
|
|
||||||
21
web/frontend/src/lib/restart-required.ts
Normal file
21
web/frontend/src/lib/restart-required.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import type { TFunction } from "i18next"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
export function showRestartRequiredToast(t: TFunction, name: string) {
|
||||||
|
toast.warning(t("common.restartRequiredTitle"), {
|
||||||
|
description: t("common.restartRequiredDesc", { name }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showSaveSuccessOrRestartToast(
|
||||||
|
t: TFunction,
|
||||||
|
savedMessage: string,
|
||||||
|
name: string,
|
||||||
|
restartRequired: boolean,
|
||||||
|
) {
|
||||||
|
if (restartRequired) {
|
||||||
|
showRestartRequiredToast(t, name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
toast.success(savedMessage)
|
||||||
|
}
|
||||||
|
|
@ -172,7 +172,7 @@ export async function refreshGatewayState(
|
||||||
if (options.force) {
|
if (options.force) {
|
||||||
return refreshGatewayState()
|
return refreshGatewayState()
|
||||||
}
|
}
|
||||||
return
|
return getDefaultStore().get(gatewayAtom)
|
||||||
}
|
}
|
||||||
|
|
||||||
gatewayPollingRequest = (async () => {
|
gatewayPollingRequest = (async () => {
|
||||||
|
|
@ -195,6 +195,8 @@ export async function refreshGatewayState(
|
||||||
gatewayPollingTimer = null
|
gatewayPollingTimer = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return getDefaultStore().get(gatewayAtom)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function subscribeGatewayPolling() {
|
export function subscribeGatewayPolling() {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue