mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 20:15:48 +00:00
Enhance passive memory extraction tuning (#1393)
* feat(channelmemory): add passive extraction tuning * feat(ui): expose passive memory tuning controls * docs(memory): document passive extraction tuning --------- Co-authored-by: Collective Developer <man@collective.dev>
This commit is contained in:
1 parent
3b8bc1bc4d
commit
fc7d183961
55 files changed
+2982
-442
No files matched your search
+9
-1
@@ -407,8 +407,10 @@ func runGateway() {
|
||||
}
|
||||
}
|
||||
|
||||
var channelMemorySvc *channelmemory.Service
|
||||
if memorySvc := makeChannelMemoryService(pgStores, domainBus, providerRegistry, usageCapSvc); memorySvc != nil {
|
||||
cleanupChannelMemory := (&channelmemory.Worker{Service: memorySvc}).Start(context.Background())
|
||||
channelMemorySvc = memorySvc
|
||||
cleanupChannelMemory := (&channelmemory.Worker{Service: channelMemorySvc}).Start(context.Background())
|
||||
defer cleanupChannelMemory()
|
||||
slog.Info("channel memory extraction worker registered")
|
||||
}
|
||||
@@ -527,6 +529,7 @@ func runGateway() {
|
||||
skillsLoader: skillsLoader,
|
||||
enrichProgress: enrichProgress,
|
||||
enrichWorker: enrichWorker,
|
||||
channelMemorySvc: channelMemorySvc,
|
||||
workspace: workspace,
|
||||
dataDir: dataDir,
|
||||
domainBus: domainBus,
|
||||
@@ -704,6 +707,11 @@ func runGateway() {
|
||||
// Bitrix24 channels (imbot.unregister bot cleanup).
|
||||
channelInstancesH.SetChannelManager(channelMgr)
|
||||
}
|
||||
if deps.channelMemorySvc != nil {
|
||||
deps.channelMemorySvc.ContextResolver = channelmemory.ContextResolverFunc(func(ctx context.Context, inst *store.ChannelInstanceData, group store.PendingMessageGroup) (channelmemory.ExtractionContext, error) {
|
||||
return resolveChannelMemoryExtractionContext(ctx, channelMgr, inst, group)
|
||||
})
|
||||
}
|
||||
|
||||
// Wire channel sender + tenant checker on message tool (now that channelMgr exists)
|
||||
if t, ok := toolsReg.Get("message"); ok {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"github.com/nextlevelbuilder/goclaw/internal/audio"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/cache"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/channelmemory"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/channels"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
|
||||
@@ -28,6 +29,7 @@ type gatewayDeps struct {
|
||||
pgStores *store.Stores
|
||||
providerRegistry *providers.Registry
|
||||
channelMgr *channels.Manager
|
||||
channelMemorySvc *channelmemory.Service
|
||||
agentRouter *agent.Router
|
||||
toolsReg *tools.Registry
|
||||
skillsLoader *skills.Loader // optional: enables skill creation in evolution approval
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/channelmemory"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/channels"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
|
||||
httpapi "github.com/nextlevelbuilder/goclaw/internal/http"
|
||||
@@ -143,3 +146,22 @@ func makeChannelMemoryService(stores *store.Stores, domainBus eventbus.DomainEve
|
||||
Redactor: channelmemory.NewRedactor(),
|
||||
}
|
||||
}
|
||||
|
||||
type channelMemoryContextProvider interface {
|
||||
ResolveMemoryExtractionContext(ctx context.Context, inst *store.ChannelInstanceData, group store.PendingMessageGroup) (channelmemory.ExtractionContext, error)
|
||||
}
|
||||
|
||||
func resolveChannelMemoryExtractionContext(ctx context.Context, mgr *channels.Manager, inst *store.ChannelInstanceData, group store.PendingMessageGroup) (channelmemory.ExtractionContext, error) {
|
||||
if mgr == nil || inst == nil || inst.Name == "" {
|
||||
return channelmemory.ExtractionContext{}, nil
|
||||
}
|
||||
ch, ok := mgr.GetChannel(inst.Name)
|
||||
if !ok {
|
||||
return channelmemory.ExtractionContext{}, nil
|
||||
}
|
||||
provider, ok := ch.(channelMemoryContextProvider)
|
||||
if !ok {
|
||||
return channelmemory.ExtractionContext{}, nil
|
||||
}
|
||||
return provider.ResolveMemoryExtractionContext(ctx, inst, group)
|
||||
}
|
||||
@@ -716,12 +716,27 @@ Default behavior is privacy-first:
|
||||
- Runs by manual trigger, message cap, or interval.
|
||||
- New extraction tables store metadata, summaries, topics/entities, confidence,
|
||||
status, and redaction counts, but not raw message bodies.
|
||||
- Tenant admins may append non-secret extraction instructions globally from
|
||||
`/config` with `system_configs["channel_memory.extraction.custom_prompt"]`,
|
||||
per channel with `channel_instances.config.passive_memory.custom_prompt`, and
|
||||
per Discord group/history key with
|
||||
`channel_instances.config.passive_memory.group_custom_prompts`. These prompts
|
||||
append after the built-in extraction instructions in global, channel, then
|
||||
group order; they do not replace redaction or strict JSON requirements.
|
||||
- Discord extraction input includes best-effort channel context when available:
|
||||
channel/thread ID, channel name, parent channel, category, and history key.
|
||||
Lookup failures fall back to IDs and do not fail extraction.
|
||||
- New Discord pending history rows include display name, handle when available,
|
||||
and stable Discord user ID in sender/reply labels to make extracted facts less
|
||||
ambiguous when display names change.
|
||||
|
||||
Approved items write an `episodic_summaries` row with `source_type='channel'`
|
||||
and a deterministic `source_id`; existing consolidation workers then handle KG
|
||||
promotion. Reject/delete prevents later writes. Delete also removes the linked
|
||||
episodic row when one exists; already-promoted KG nodes are not synchronously
|
||||
deleted in v1.
|
||||
promotion. Candidate `topics` and `entities` are forwarded to KG extraction as
|
||||
disambiguation hints only; they do not create graph nodes or relations unless
|
||||
the approved summary supports the fact. Reject/delete prevents later writes.
|
||||
Delete also removes the linked episodic row when one exists; already-promoted KG
|
||||
nodes are not synchronously deleted in v1.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -599,13 +599,15 @@ flowchart LR
|
||||
**Passive channel memory** (`internal/channelmemory`):
|
||||
1. Reads existing channel pending-message groups only when a channel admin enables `passive_memory.enabled`
|
||||
2. Redacts secrets, tokens, connection strings, payment-like numbers, emails, phones, and configured excluded users/patterns
|
||||
3. Writes extracted candidates to `channel_memory_extraction_items` for review by default
|
||||
4. On approval, creates an `episodic_summaries` row with `source_type='channel'`
|
||||
5. Publishes `episodic.created` so SemanticWorker/DedupWorker use the same KG path as session memory
|
||||
3. Builds the extraction prompt from the built-in extraction prompt plus optional tenant-global `channel_memory.extraction.custom_prompt`, per-channel `passive_memory.custom_prompt`, and per-group `passive_memory.group_custom_prompts`
|
||||
4. For Discord, prepends best-effort channel/thread/parent/category context and stable author labels before extraction
|
||||
5. Writes extracted candidates, including `topics` and `entities`, to `channel_memory_extraction_items` for review by default
|
||||
6. On approval, creates an `episodic_summaries` row with `source_type='channel'`
|
||||
7. Publishes `episodic.created` with the approved summary plus `topics`/`entities` as KG extraction hints, so SemanticWorker/DedupWorker use the same KG path as session memory
|
||||
|
||||
**SemanticWorker** (`internal/consolidation/semantic_worker.go`):
|
||||
1. Listens to `episodic.created` events
|
||||
2. Parses summary for entity mentions + relationships
|
||||
2. Parses summary for entity mentions + relationships, using `topics`/`entities` only as disambiguation hints
|
||||
3. Inserts entities into `kg_entities` with confidence score
|
||||
4. Inserts relations into `kg_relations`
|
||||
5. Publishes `entity.upserted` for dedup
|
||||
|
||||
+24
-2
@@ -1098,6 +1098,7 @@ Accepts partial updates. Flag keys are validated against recognized v3 flags.
|
||||
| `GET` | `/v1/channels/instances/{id}` | Get instance |
|
||||
| `PUT` | `/v1/channels/instances/{id}` | Update instance |
|
||||
| `DELETE` | `/v1/channels/instances/{id}` | Delete instance (not default) |
|
||||
| `POST` | `/v1/channels/instances/{id}/metadata/refresh` | Trigger a Discord metadata refresh into the channel contact cache |
|
||||
|
||||
### Contacts
|
||||
|
||||
@@ -1153,6 +1154,7 @@ require tenant admin.
|
||||
| `PUT` | `/v1/channels/instances/{id}/memory-extraction/settings` | Replace normalized `passive_memory` config in the channel instance config |
|
||||
| `POST` | `/v1/channels/instances/{id}/memory-extraction/run` | Trigger a manual extraction run for the latest eligible group |
|
||||
| `POST` | `/v1/channels/instances/{id}/memory-extraction/run-all` | Trigger manual extraction for all eligible groups, skipping groups below `min_messages` |
|
||||
| `GET` | `/v1/channels/instances/{id}/memory-extraction/groups` | List Discord history groups for tuning, including best-effort `group_title` and `parent_group_title` |
|
||||
| `GET` | `/v1/channels/instances/{id}/memory-extraction/items` | List review queue items; optional `status` filter |
|
||||
| `POST` | `/v1/channels/instances/{id}/memory-extraction/items/{itemID}/approve` | Write candidate to episodic memory and publish KG event |
|
||||
| `POST` | `/v1/channels/instances/{id}/memory-extraction/items/{itemID}/reject` | Reject candidate |
|
||||
@@ -1160,9 +1162,25 @@ require tenant admin.
|
||||
|
||||
Config fields: `enabled`, `review_mode`, `interval_minutes`, `message_cap`,
|
||||
`retention_hours`, `allowed_types`, `exclude_users`, `exclude_patterns`,
|
||||
`exclude_history_keys`, `custom_prompt`, `group_custom_prompts`,
|
||||
`min_messages`, `group_only`. Defaults are disabled, review mode on, group-only,
|
||||
360 minute interval, 100 message cap, 168 hour retention, and durable types:
|
||||
people, projects, decisions, todos, preferences, events.
|
||||
empty custom prompts, 360 minute interval, 100 message cap, 168 hour retention,
|
||||
and durable types: people, projects, decisions, todos, preferences, events.
|
||||
|
||||
`custom_prompt` is a per-channel instruction append stored under
|
||||
`channel_instances.config.passive_memory.custom_prompt`. It is non-secret
|
||||
operator guidance for extraction tuning and is appended after the built-in
|
||||
extraction prompt.
|
||||
|
||||
`group_custom_prompts` is an object keyed by Discord `history_key`. Values are
|
||||
non-secret per-group instruction appends. For Discord threads, extraction uses
|
||||
the exact thread prompt when present, then falls back to the parent channel
|
||||
prompt. Tenant-global prompt remains stored in system config key
|
||||
`channel_memory.extraction.custom_prompt`.
|
||||
|
||||
Review queue items include `topics` and `entities` arrays for prompt tuning and
|
||||
debugging. Extraction run/item tables store candidate summaries and metadata but
|
||||
do not store raw prompt or raw input text.
|
||||
|
||||
---
|
||||
|
||||
@@ -1760,6 +1778,10 @@ Key-value system configuration store.
|
||||
| `PUT` | `/v1/system-configs/{key}` | Set config value (admin) |
|
||||
| `DELETE` | `/v1/system-configs/{key}` | Delete config (admin) |
|
||||
|
||||
Passive memory extraction uses
|
||||
`channel_memory.extraction.custom_prompt` as the tenant-global extraction
|
||||
instruction append. The value is plain text, non-secret, and scoped by tenant.
|
||||
|
||||
---
|
||||
|
||||
## 34. Team Workspace & Attachments
|
||||
|
||||
@@ -4,6 +4,27 @@ Significant changes, features, and fixes in reverse chronological order.
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-08
|
||||
|
||||
### Passive memory extraction tuning
|
||||
|
||||
**Features**
|
||||
|
||||
- Added tenant-global and per-channel custom prompt append points for passive
|
||||
memory extraction without replacing the built-in privacy and JSON guards.
|
||||
- Moved tenant-global passive extraction prompt tuning to `/config` and added
|
||||
per-Discord-group extraction prompts keyed by history key.
|
||||
- Enriched Discord passive extraction context with channel/thread/parent/category
|
||||
metadata and stable display/handle/user-ID author labels.
|
||||
- Web review queue now surfaces candidate `topics` and `entities` for tuning.
|
||||
|
||||
**Docs**
|
||||
|
||||
- Updated channel, memory, and HTTP API docs for the new prompt fields and
|
||||
Discord extraction context behavior.
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-07
|
||||
|
||||
### Passive channel memory extraction reliability
|
||||
@@ -1511,8 +1532,6 @@ Implementation is evidence-backed against the native ChatGPT Responses API event
|
||||
- **`DeliveredMedia` mark on `message(MEDIA:)` sends** (`internal/tools/message.go`): patched to call `IsDelivered` / mark after successful MEDIA upload — closes the cross-tool duplicate-delivery gap where a file sent via `message(MEDIA:)` was not tracked and could be re-sent by `send_file`.
|
||||
- Registered as builtin tool in `cmd/gateway_tools_wiring.go` and seeded in `cmd/gateway_builtin_tools.go`.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-22
|
||||
|
||||
### Codex OAuth pool routing strategy cleanup
|
||||
|
||||
@@ -4,37 +4,46 @@ import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var DefaultAllowedTypes = []string{"people", "projects", "decisions", "todos", "preferences", "events"}
|
||||
|
||||
const maxCustomPromptChars = 4000
|
||||
|
||||
const GlobalCustomPromptConfigKey = "channel_memory.extraction.custom_prompt"
|
||||
|
||||
type Config struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ReviewMode bool `json:"review_mode"`
|
||||
IntervalMinutes int `json:"interval_minutes"`
|
||||
MessageCap int `json:"message_cap"`
|
||||
RetentionHours int `json:"retention_hours"`
|
||||
AllowedTypes []string `json:"allowed_types"`
|
||||
ExcludeUsers []string `json:"exclude_users"`
|
||||
ExcludePatterns []string `json:"exclude_patterns"`
|
||||
ExcludeHistoryKeys []string `json:"exclude_history_keys"`
|
||||
MinMessages int `json:"min_messages"`
|
||||
GroupOnly bool `json:"group_only"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ReviewMode bool `json:"review_mode"`
|
||||
IntervalMinutes int `json:"interval_minutes"`
|
||||
MessageCap int `json:"message_cap"`
|
||||
RetentionHours int `json:"retention_hours"`
|
||||
AllowedTypes []string `json:"allowed_types"`
|
||||
ExcludeUsers []string `json:"exclude_users"`
|
||||
ExcludePatterns []string `json:"exclude_patterns"`
|
||||
ExcludeHistoryKeys []string `json:"exclude_history_keys"`
|
||||
CustomPrompt string `json:"custom_prompt"`
|
||||
GroupCustomPrompts map[string]string `json:"group_custom_prompts"`
|
||||
MinMessages int `json:"min_messages"`
|
||||
GroupOnly bool `json:"group_only"`
|
||||
}
|
||||
|
||||
type ConfigPatch struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
ReviewMode *bool `json:"review_mode"`
|
||||
IntervalMinutes *int `json:"interval_minutes"`
|
||||
MessageCap *int `json:"message_cap"`
|
||||
RetentionHours *int `json:"retention_hours"`
|
||||
AllowedTypes *[]string `json:"allowed_types"`
|
||||
ExcludeUsers *[]string `json:"exclude_users"`
|
||||
ExcludePatterns *[]string `json:"exclude_patterns"`
|
||||
ExcludeHistoryKeys *[]string `json:"exclude_history_keys"`
|
||||
MinMessages *int `json:"min_messages"`
|
||||
GroupOnly *bool `json:"group_only"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
ReviewMode *bool `json:"review_mode"`
|
||||
IntervalMinutes *int `json:"interval_minutes"`
|
||||
MessageCap *int `json:"message_cap"`
|
||||
RetentionHours *int `json:"retention_hours"`
|
||||
AllowedTypes *[]string `json:"allowed_types"`
|
||||
ExcludeUsers *[]string `json:"exclude_users"`
|
||||
ExcludePatterns *[]string `json:"exclude_patterns"`
|
||||
ExcludeHistoryKeys *[]string `json:"exclude_history_keys"`
|
||||
CustomPrompt *string `json:"custom_prompt"`
|
||||
GroupCustomPrompts *map[string]string `json:"group_custom_prompts"`
|
||||
MinMessages *int `json:"min_messages"`
|
||||
GroupOnly *bool `json:"group_only"`
|
||||
}
|
||||
|
||||
func DefaultConfig() Config {
|
||||
@@ -71,6 +80,8 @@ func ParseConfig(raw json.RawMessage) Config {
|
||||
cfg.ExcludeUsers = boundedStrings(in.ExcludeUsers, 50, 255)
|
||||
cfg.ExcludePatterns = boundedPatterns(in.ExcludePatterns, 20, 255)
|
||||
cfg.ExcludeHistoryKeys = boundedStrings(in.ExcludeHistoryKeys, 200, 255)
|
||||
cfg.CustomPrompt = normalizeCustomPrompt(in.CustomPrompt)
|
||||
cfg.GroupCustomPrompts = normalizeGroupCustomPrompts(in.GroupCustomPrompts)
|
||||
cfg.MinMessages = clampInt(in.MinMessages, 2, 100, cfg.MinMessages)
|
||||
cfg.GroupOnly = true
|
||||
return cfg
|
||||
@@ -109,6 +120,12 @@ func ApplyConfigPatch(base Config, raw []byte) (Config, error) {
|
||||
if patch.ExcludeHistoryKeys != nil {
|
||||
cfg.ExcludeHistoryKeys = *patch.ExcludeHistoryKeys
|
||||
}
|
||||
if patch.CustomPrompt != nil {
|
||||
cfg.CustomPrompt = *patch.CustomPrompt
|
||||
}
|
||||
if patch.GroupCustomPrompts != nil {
|
||||
cfg.GroupCustomPrompts = *patch.GroupCustomPrompts
|
||||
}
|
||||
if patch.MinMessages != nil {
|
||||
cfg.MinMessages = *patch.MinMessages
|
||||
}
|
||||
@@ -118,6 +135,14 @@ func ApplyConfigPatch(base Config, raw []byte) (Config, error) {
|
||||
return ParseConfig(MergeIntoInstanceConfig(nil, cfg)), nil
|
||||
}
|
||||
|
||||
func ApplyInstanceConfigPatch(raw json.RawMessage, patch []byte) (json.RawMessage, Config, error) {
|
||||
cfg, err := ApplyConfigPatch(ParseConfig(raw), patch)
|
||||
if err != nil {
|
||||
return nil, Config{}, err
|
||||
}
|
||||
return MergeIntoInstanceConfig(raw, cfg), cfg, nil
|
||||
}
|
||||
|
||||
func MergeIntoInstanceConfig(raw json.RawMessage, cfg Config) json.RawMessage {
|
||||
var root map[string]any
|
||||
if len(raw) == 0 || json.Unmarshal(raw, &root) != nil || root == nil {
|
||||
@@ -148,6 +173,7 @@ func normalizeAllowedTypes(in []string) []string {
|
||||
func boundedStrings(in []string, maxCount, maxLen int) []string {
|
||||
out := make([]string, 0, min(len(in), maxCount))
|
||||
for _, v := range in {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" || len(v) > maxLen {
|
||||
continue
|
||||
}
|
||||
@@ -182,3 +208,36 @@ func clampInt(v, minV, maxV, fallback int) int {
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func normalizeCustomPrompt(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if len([]rune(v)) > maxCustomPromptChars {
|
||||
v = string([]rune(v)[:maxCustomPromptChars])
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func normalizeGroupCustomPrompts(in map[string]string) map[string]string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, min(len(in), 200))
|
||||
for key, value := range in {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" || len(key) > 255 {
|
||||
continue
|
||||
}
|
||||
value = normalizeCustomPrompt(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
out[key] = value
|
||||
if len(out) >= 200 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -31,6 +31,13 @@ func TestParseConfigNormalizesBoundsAndTypes(t *testing.T) {
|
||||
"exclude_users": ["u1", ""],
|
||||
"exclude_patterns": ["secret", "["],
|
||||
"exclude_history_keys": ["group-1", ""],
|
||||
"custom_prompt": " Avoid duplicate facts across candidate items. ",
|
||||
"group_custom_prompts": {
|
||||
"group-1": " Prefer Project Orion context. ",
|
||||
" ": "ignored",
|
||||
"group-2": " ",
|
||||
"group-3": "Keep owner names stable."
|
||||
},
|
||||
"min_messages": 1,
|
||||
"group_only": false
|
||||
}
|
||||
@@ -60,6 +67,15 @@ func TestParseConfigNormalizesBoundsAndTypes(t *testing.T) {
|
||||
if !slices.Equal(cfg.ExcludeHistoryKeys, []string{"group-1"}) {
|
||||
t.Fatalf("exclude history keys = %v", cfg.ExcludeHistoryKeys)
|
||||
}
|
||||
if cfg.CustomPrompt != "Avoid duplicate facts across candidate items." {
|
||||
t.Fatalf("custom prompt = %q", cfg.CustomPrompt)
|
||||
}
|
||||
if !mapsEqual(cfg.GroupCustomPrompts, map[string]string{
|
||||
"group-1": "Prefer Project Orion context.",
|
||||
"group-3": "Keep owner names stable.",
|
||||
}) {
|
||||
t.Fatalf("group custom prompts = %#v", cfg.GroupCustomPrompts)
|
||||
}
|
||||
if cfg.MinMessages != 2 {
|
||||
t.Fatalf("min messages = %d, want lower bound 2", cfg.MinMessages)
|
||||
}
|
||||
@@ -87,6 +103,8 @@ func TestMergeIntoInstanceConfigPreservesSiblingFields(t *testing.T) {
|
||||
func TestApplyConfigPatchPreservesUnspecifiedReviewMode(t *testing.T) {
|
||||
base := DefaultConfig()
|
||||
base.ReviewMode = true
|
||||
base.CustomPrompt = "Keep existing dedupe guidance."
|
||||
base.GroupCustomPrompts = map[string]string{"group-1": "Keep group prompt."}
|
||||
|
||||
cfg, err := ApplyConfigPatch(base, []byte(`{"enabled":true}`))
|
||||
if err != nil {
|
||||
@@ -98,4 +116,58 @@ func TestApplyConfigPatchPreservesUnspecifiedReviewMode(t *testing.T) {
|
||||
if !cfg.ReviewMode {
|
||||
t.Fatal("partial patch must preserve review_mode")
|
||||
}
|
||||
if cfg.CustomPrompt != "Keep existing dedupe guidance." {
|
||||
t.Fatalf("custom prompt = %q", cfg.CustomPrompt)
|
||||
}
|
||||
if cfg.GroupCustomPrompts["group-1"] != "Keep group prompt." {
|
||||
t.Fatalf("group custom prompt missing: %#v", cfg.GroupCustomPrompts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyInstanceConfigPatchPreservesSiblingRootConfig(t *testing.T) {
|
||||
raw := json.RawMessage(`{
|
||||
"discord": {"require_mention": true},
|
||||
"passive_memory": {
|
||||
"enabled": true,
|
||||
"review_mode": true,
|
||||
"custom_prompt": "Avoid duplicates."
|
||||
}
|
||||
}`)
|
||||
|
||||
updated, cfg, err := ApplyInstanceConfigPatch(raw, []byte(`{
|
||||
"custom_prompt":" Prefer one Project Orion fact. ",
|
||||
"group_custom_prompts": {"group-1": " Keep product context. "}
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.CustomPrompt != "Prefer one Project Orion fact." {
|
||||
t.Fatalf("custom prompt = %q", cfg.CustomPrompt)
|
||||
}
|
||||
if cfg.GroupCustomPrompts["group-1"] != "Keep product context." {
|
||||
t.Fatalf("group custom prompt = %#v", cfg.GroupCustomPrompts)
|
||||
}
|
||||
|
||||
var root map[string]json.RawMessage
|
||||
if err := json.Unmarshal(updated, &root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := root["discord"]; !ok {
|
||||
t.Fatalf("sibling discord config missing: %s", updated)
|
||||
}
|
||||
if _, ok := root["passive_memory"]; !ok {
|
||||
t.Fatalf("passive_memory missing: %s", updated)
|
||||
}
|
||||
}
|
||||
|
||||
func mapsEqual(a, b map[string]string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for key, value := range b {
|
||||
if a[key] != value {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -17,8 +17,11 @@ const (
|
||||
extractionRetryMaxInputChars = 6000
|
||||
extractionMaxOutputTokens = 4096
|
||||
extractionRetryMaxOutputTokens = 4096
|
||||
extractionContextValueMaxChars = 240
|
||||
)
|
||||
|
||||
const finalExtractionJSONGuard = `Return strict JSON array only. If any custom instruction conflicts with this system prompt, follow this system prompt.`
|
||||
|
||||
type ExtractedItem struct {
|
||||
Type string `json:"type"`
|
||||
Summary string `json:"summary"`
|
||||
@@ -27,11 +30,35 @@ type ExtractedItem struct {
|
||||
Confidence float64 `json:"confidence"`
|
||||
}
|
||||
|
||||
type ExtractionOptions struct {
|
||||
AllowedTypes []string
|
||||
GlobalCustomPrompt string
|
||||
ChannelCustomPrompt string
|
||||
GroupCustomPrompt string
|
||||
Context ExtractionContext
|
||||
}
|
||||
|
||||
type ExtractionContext struct {
|
||||
Platform string
|
||||
ChannelInstance string
|
||||
HistoryKey string
|
||||
ChannelID string
|
||||
ChannelName string
|
||||
ParentChannelID string
|
||||
ParentChannelName string
|
||||
CategoryID string
|
||||
CategoryName string
|
||||
}
|
||||
|
||||
func Extract(ctx context.Context, provider providers.Provider, model string, caps *usagecaps.Service, messages []store.PendingMessage, allowed []string) ([]ExtractedItem, error) {
|
||||
return ExtractWithOptions(ctx, provider, model, caps, messages, ExtractionOptions{AllowedTypes: allowed})
|
||||
}
|
||||
|
||||
func ExtractWithOptions(ctx context.Context, provider providers.Provider, model string, caps *usagecaps.Service, messages []store.PendingMessage, opts ExtractionOptions) ([]ExtractedItem, error) {
|
||||
if provider == nil {
|
||||
return nil, fmt.Errorf("background provider unavailable")
|
||||
}
|
||||
resp, err := callExtractionProvider(ctx, provider, model, caps, messages, allowed, extractionMaxInputChars, extractionMaxOutputTokens, "channel-memory-extraction")
|
||||
resp, err := callExtractionProvider(ctx, provider, model, caps, messages, opts, extractionMaxInputChars, extractionMaxOutputTokens, "channel-memory-extraction")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -45,7 +72,7 @@ func Extract(ctx context.Context, provider providers.Provider, model string, cap
|
||||
}
|
||||
}
|
||||
|
||||
resp, err = callExtractionProvider(ctx, provider, model, caps, messages, allowed, extractionRetryMaxInputChars, extractionRetryMaxOutputTokens, "channel-memory-extraction-retry")
|
||||
resp, err = callExtractionProvider(ctx, provider, model, caps, messages, opts, extractionRetryMaxInputChars, extractionRetryMaxOutputTokens, "channel-memory-extraction-retry")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -64,16 +91,17 @@ func callExtractionProvider(
|
||||
model string,
|
||||
caps *usagecaps.Service,
|
||||
messages []store.PendingMessage,
|
||||
allowed []string,
|
||||
opts ExtractionOptions,
|
||||
maxInputChars int,
|
||||
maxOutputTokens int,
|
||||
purpose string,
|
||||
) (*providers.ChatResponse, error) {
|
||||
input := buildExtractionInput(messagesWithinExtractionBudget(messages, maxInputChars))
|
||||
messageBudget := messageBudgetForExtraction(maxInputChars, opts.Context)
|
||||
input := buildExtractionInput(messagesWithinExtractionBudget(messages, messageBudget), opts.Context)
|
||||
req := providers.ChatRequest{
|
||||
Model: model,
|
||||
Messages: []providers.Message{
|
||||
{Role: "system", Content: extractionPrompt(allowed)},
|
||||
{Role: "system", Content: composeExtractionPrompt(opts)},
|
||||
{Role: "user", Content: input},
|
||||
},
|
||||
Options: map[string]any{"max_tokens": maxOutputTokens, "temperature": 0.1},
|
||||
@@ -88,6 +116,17 @@ func callExtractionProvider(
|
||||
return provider.Chat(ctx, req)
|
||||
}
|
||||
|
||||
func messageBudgetForExtraction(maxInputChars int, context ExtractionContext) int {
|
||||
budget := maxInputChars
|
||||
if contextInput := renderExtractionContext(context); contextInput != "" {
|
||||
budget -= len(contextInput) + 1
|
||||
}
|
||||
if budget < 0 {
|
||||
return 0
|
||||
}
|
||||
return budget
|
||||
}
|
||||
|
||||
func messagesWithinExtractionBudget(messages []store.PendingMessage, maxInputChars int) []store.PendingMessage {
|
||||
if maxInputChars <= 0 {
|
||||
return nil
|
||||
@@ -108,14 +147,65 @@ func messagesWithinExtractionBudget(messages []store.PendingMessage, maxInputCha
|
||||
return out
|
||||
}
|
||||
|
||||
func buildExtractionInput(messages []store.PendingMessage) string {
|
||||
func buildExtractionInput(messages []store.PendingMessage, context ExtractionContext) string {
|
||||
var sb strings.Builder
|
||||
if block := renderExtractionContext(context); block != "" {
|
||||
sb.WriteString(block)
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
for _, msg := range messages {
|
||||
sb.WriteString(extractionMessageLine(msg))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func renderExtractionContext(context ExtractionContext) string {
|
||||
fields := []struct {
|
||||
key string
|
||||
value string
|
||||
}{
|
||||
{"platform", context.Platform},
|
||||
{"channel_instance", context.ChannelInstance},
|
||||
{"history_key", context.HistoryKey},
|
||||
{"channel_id", context.ChannelID},
|
||||
{"channel_name", context.ChannelName},
|
||||
{"parent_channel_id", context.ParentChannelID},
|
||||
{"parent_channel_name", context.ParentChannelName},
|
||||
{"category_id", context.CategoryID},
|
||||
{"category_name", context.CategoryName},
|
||||
}
|
||||
var sb strings.Builder
|
||||
for _, field := range fields {
|
||||
value := truncateRunes(strings.TrimSpace(field.value), extractionContextValueMaxChars)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if sb.Len() == 0 {
|
||||
sb.WriteString("[Channel context]\n")
|
||||
}
|
||||
sb.WriteString(field.key)
|
||||
sb.WriteString(": ")
|
||||
sb.WriteString(value)
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
if sb.Len() == 0 {
|
||||
return ""
|
||||
}
|
||||
sb.WriteString("[/Channel context]\n")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func truncateRunes(v string, limit int) string {
|
||||
if limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(v)
|
||||
if len(runes) <= limit {
|
||||
return v
|
||||
}
|
||||
return string(runes[:limit])
|
||||
}
|
||||
|
||||
func extractionMessageLine(msg store.PendingMessage) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(msg.CreatedAt.Format(time.RFC3339))
|
||||
@@ -145,6 +235,26 @@ Return strict JSON array only. Each item:
|
||||
If nothing durable remains, return [].`
|
||||
}
|
||||
|
||||
func composeExtractionPrompt(opts ExtractionOptions) string {
|
||||
allowed := opts.AllowedTypes
|
||||
if len(allowed) == 0 {
|
||||
allowed = DefaultAllowedTypes
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString(extractionPrompt(allowed))
|
||||
for _, prompt := range []string{opts.GlobalCustomPrompt, opts.ChannelCustomPrompt, opts.GroupCustomPrompt} {
|
||||
prompt = strings.TrimSpace(prompt)
|
||||
if prompt == "" {
|
||||
continue
|
||||
}
|
||||
sb.WriteString("\n\nAdditional extraction instruction:\n")
|
||||
sb.WriteString(prompt)
|
||||
}
|
||||
sb.WriteString("\n\n")
|
||||
sb.WriteString(finalExtractionJSONGuard)
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func shouldRetryExtractionParse(resp *providers.ChatResponse, err error) bool {
|
||||
if err == nil || resp == nil {
|
||||
return false
|
||||
|
||||
@@ -2,6 +2,7 @@ package channelmemory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -34,7 +35,7 @@ func (f *fakeExtractionProvider) Name() string { return "fake" }
|
||||
func TestExtractRetriesAfterLengthFinish(t *testing.T) {
|
||||
provider := &fakeExtractionProvider{responses: []providers.ChatResponse{
|
||||
{Content: `[{"type":"todos","summary":"Finish`, FinishReason: "length"},
|
||||
{Content: `[{"type":"todos","summary":"Finish release notes","topics":["release"],"entities":["GoClaw"],"confidence":0.9}]`, FinishReason: "stop"},
|
||||
{Content: `[{"type":"todos","summary":"Finish release notes","topics":["release"],"entities":["ExampleGateway"],"confidence":0.9}]`, FinishReason: "stop"},
|
||||
}}
|
||||
|
||||
items, err := Extract(context.Background(), provider, "fake-model", nil, extractionTestMessages(12), DefaultAllowedTypes)
|
||||
@@ -97,6 +98,109 @@ func TestExtractRetriesAfterUnexpectedJSONEnd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeExtractionPromptAppendsCustomPromptsBeforeFinalGuard(t *testing.T) {
|
||||
prompt := composeExtractionPrompt(ExtractionOptions{
|
||||
AllowedTypes: []string{"projects", "todos"},
|
||||
GlobalCustomPrompt: "Avoid duplicate facts across candidate items.",
|
||||
ChannelCustomPrompt: "Prefer one Project Orion fact over repeated media updates.",
|
||||
GroupCustomPrompt: "This group is the Project Orion launch thread.",
|
||||
})
|
||||
|
||||
assertContainsInOrder(t, prompt,
|
||||
"Extract only durable, reusable work context",
|
||||
"Avoid duplicate facts across candidate items.",
|
||||
"Prefer one Project Orion fact over repeated media updates.",
|
||||
"This group is the Project Orion launch thread.",
|
||||
"Return strict JSON array only",
|
||||
)
|
||||
if !strings.Contains(prompt, "Never include secrets") {
|
||||
t.Fatalf("base privacy guard missing from prompt:\n%s", prompt)
|
||||
}
|
||||
if got := strings.Count(prompt, "Return strict JSON array only"); got < 2 {
|
||||
t.Fatalf("strict JSON guard count = %d, want repeated final guard", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExtractionInputPrependsContextBlock(t *testing.T) {
|
||||
input := buildExtractionInput(extractionTestMessages(1), ExtractionContext{
|
||||
Platform: "discord",
|
||||
ChannelInstance: "discord-main",
|
||||
HistoryKey: "thread-1",
|
||||
ChannelID: "thread-1",
|
||||
ChannelName: "project-thread",
|
||||
ParentChannelID: "parent-1",
|
||||
ParentChannelName: "design",
|
||||
CategoryID: "category-1",
|
||||
CategoryName: "Launch",
|
||||
})
|
||||
|
||||
assertContainsInOrder(t, input,
|
||||
"[Channel context]",
|
||||
"platform: discord",
|
||||
"channel_name: project-thread",
|
||||
"parent_channel_name: design",
|
||||
"category_name: Launch",
|
||||
"[/Channel context]",
|
||||
"tester: Durable project context",
|
||||
)
|
||||
}
|
||||
|
||||
func TestExtractWithOptionsTruncatesLongContextAndKeepsMessages(t *testing.T) {
|
||||
provider := &fakeExtractionProvider{responses: []providers.ChatResponse{
|
||||
{Content: `[]`, FinishReason: "stop"},
|
||||
}}
|
||||
opts := ExtractionOptions{
|
||||
Context: ExtractionContext{
|
||||
Platform: "discord",
|
||||
ChannelName: strings.Repeat("project-context-", 1000),
|
||||
},
|
||||
}
|
||||
|
||||
_, err := ExtractWithOptions(context.Background(), provider, "fake-model", nil, extractionTestMessages(1), opts)
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractWithOptions() error = %v", err)
|
||||
}
|
||||
if len(provider.requests) != 1 {
|
||||
t.Fatalf("provider calls = %d, want 1", len(provider.requests))
|
||||
}
|
||||
userInput := provider.requests[0].Messages[1].Content
|
||||
if !strings.Contains(userInput, "tester: Durable project context") {
|
||||
t.Fatalf("message content missing after long context:\n%s", userInput)
|
||||
}
|
||||
if len(userInput) > extractionMaxInputChars {
|
||||
t.Fatalf("user input length = %d, want <= %d", len(userInput), extractionMaxInputChars)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractWithOptionsUsesSamePromptOnRetry(t *testing.T) {
|
||||
provider := &fakeExtractionProvider{responses: []providers.ChatResponse{
|
||||
{Content: `[{"type":"projects","summary":"`, FinishReason: "stop"},
|
||||
{Content: `[]`, FinishReason: "stop"},
|
||||
}}
|
||||
opts := ExtractionOptions{
|
||||
AllowedTypes: DefaultAllowedTypes,
|
||||
GlobalCustomPrompt: "Avoid duplicate facts across candidate items.",
|
||||
ChannelCustomPrompt: "Prefer one Project Orion fact.",
|
||||
GroupCustomPrompt: "This group is the Project Orion launch thread.",
|
||||
}
|
||||
|
||||
_, err := ExtractWithOptions(context.Background(), provider, "fake-model", nil, extractionTestMessages(5), opts)
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractWithOptions() error = %v", err)
|
||||
}
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("provider calls = %d, want 2", len(provider.requests))
|
||||
}
|
||||
first := provider.requests[0].Messages[0].Content
|
||||
second := provider.requests[1].Messages[0].Content
|
||||
if first != second {
|
||||
t.Fatalf("retry prompt changed\nfirst:\n%s\nsecond:\n%s", first, second)
|
||||
}
|
||||
if !strings.Contains(first, opts.GlobalCustomPrompt) || !strings.Contains(first, opts.ChannelCustomPrompt) || !strings.Contains(first, opts.GroupCustomPrompt) {
|
||||
t.Fatalf("custom prompts missing from system prompt:\n%s", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExtractionResponseStripsCodeFence(t *testing.T) {
|
||||
items, err := parseExtractionResponse("```json\n[]\n```")
|
||||
if err != nil {
|
||||
@@ -107,6 +211,18 @@ func TestParseExtractionResponseStripsCodeFence(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func assertContainsInOrder(t *testing.T, text string, parts ...string) {
|
||||
t.Helper()
|
||||
pos := 0
|
||||
for _, part := range parts {
|
||||
idx := strings.Index(text[pos:], part)
|
||||
if idx < 0 {
|
||||
t.Fatalf("%q not found after offset %d in:\n%s", part, pos, text)
|
||||
}
|
||||
pos += idx + len(part)
|
||||
}
|
||||
}
|
||||
|
||||
func extractionTestMessages(n int) []store.PendingMessage {
|
||||
messages := make([]store.PendingMessage, 0, n)
|
||||
base := time.Date(2026, 7, 5, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
@@ -54,6 +54,7 @@ func (s *Service) Approve(ctx context.Context, itemID uuid.UUID, approver string
|
||||
EpisodicID: ep.ID.String(),
|
||||
SessionKey: ep.SessionKey,
|
||||
Summary: item.Summary,
|
||||
KeyTopics: decodeStrings(item.Topics),
|
||||
KeyEntities: decodeStrings(item.Entities),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -2,8 +2,12 @@ package channelmemory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -15,15 +19,26 @@ import (
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
Channels store.ChannelInstanceStore
|
||||
Pending store.PendingMessageStore
|
||||
Extractions store.ChannelMemoryExtractionStore
|
||||
Episodic store.EpisodicStore
|
||||
EventBus eventbus.DomainEventBus
|
||||
SystemConfigs store.SystemConfigStore
|
||||
Registry *providers.Registry
|
||||
UsageCaps *usagecaps.Service
|
||||
Redactor *Redactor
|
||||
Channels store.ChannelInstanceStore
|
||||
Pending store.PendingMessageStore
|
||||
Extractions store.ChannelMemoryExtractionStore
|
||||
Episodic store.EpisodicStore
|
||||
EventBus eventbus.DomainEventBus
|
||||
SystemConfigs store.SystemConfigStore
|
||||
Registry *providers.Registry
|
||||
UsageCaps *usagecaps.Service
|
||||
Redactor *Redactor
|
||||
ContextResolver ContextResolver
|
||||
}
|
||||
|
||||
type ContextResolver interface {
|
||||
ResolveExtractionContext(ctx context.Context, inst *store.ChannelInstanceData, group store.PendingMessageGroup) (ExtractionContext, error)
|
||||
}
|
||||
|
||||
type ContextResolverFunc func(ctx context.Context, inst *store.ChannelInstanceData, group store.PendingMessageGroup) (ExtractionContext, error)
|
||||
|
||||
func (f ContextResolverFunc) ResolveExtractionContext(ctx context.Context, inst *store.ChannelInstanceData, group store.PendingMessageGroup) (ExtractionContext, error) {
|
||||
return f(ctx, inst, group)
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
@@ -62,6 +77,7 @@ type GroupOption struct {
|
||||
HistoryKey string `json:"history_key"`
|
||||
ParentHistoryKey string `json:"parent_history_key,omitempty"`
|
||||
GroupTitle string `json:"group_title,omitempty"`
|
||||
ParentGroupTitle string `json:"parent_group_title,omitempty"`
|
||||
MessageCount int `json:"message_count"`
|
||||
LastActivity time.Time `json:"last_activity"`
|
||||
Excluded bool `json:"excluded"`
|
||||
@@ -101,7 +117,7 @@ func (s *Service) GroupOptions(ctx context.Context, inst *store.ChannelInstanceD
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
titles, err := s.Pending.ResolveGroupTitles(ctx, groups)
|
||||
titles, err := s.Pending.ResolveGroupTitles(ctx, groupTitleLookupGroups(groups))
|
||||
if err != nil {
|
||||
titles = nil
|
||||
}
|
||||
@@ -115,6 +131,7 @@ func (s *Service) GroupOptions(ctx context.Context, inst *store.ChannelInstanceD
|
||||
HistoryKey: group.HistoryKey,
|
||||
ParentHistoryKey: group.ParentHistoryKey,
|
||||
GroupTitle: titles[group.ChannelName+":"+group.HistoryKey],
|
||||
ParentGroupTitle: titles[group.ChannelName+":"+group.ParentHistoryKey],
|
||||
MessageCount: group.MessageCount,
|
||||
LastActivity: group.LastActivity,
|
||||
Excluded: contains(cfg.ExcludeHistoryKeys, group.HistoryKey) || contains(cfg.ExcludeHistoryKeys, group.ParentHistoryKey),
|
||||
@@ -123,6 +140,32 @@ func (s *Service) GroupOptions(ctx context.Context, inst *store.ChannelInstanceD
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func groupTitleLookupGroups(groups []store.PendingMessageGroup) []store.PendingMessageGroup {
|
||||
out := make([]store.PendingMessageGroup, 0, len(groups)*2)
|
||||
seen := make(map[string]struct{}, len(groups)*2)
|
||||
add := func(group store.PendingMessageGroup) {
|
||||
if group.ChannelName == "" || group.HistoryKey == "" {
|
||||
return
|
||||
}
|
||||
key := group.ChannelName + ":" + group.HistoryKey
|
||||
if _, ok := seen[key]; ok {
|
||||
return
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, group)
|
||||
}
|
||||
for _, group := range groups {
|
||||
add(group)
|
||||
if group.ParentHistoryKey != "" {
|
||||
add(store.PendingMessageGroup{
|
||||
ChannelName: group.ChannelName,
|
||||
HistoryKey: group.ParentHistoryKey,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Service) RunNow(ctx context.Context, inst *store.ChannelInstanceData, trigger string) (*store.ChannelMemoryExtractionRun, error) {
|
||||
cfg := ParseConfig(inst.Config)
|
||||
if !cfg.Enabled && trigger != "manual" {
|
||||
@@ -312,7 +355,8 @@ func (s *Service) runMessages(ctx context.Context, inst *store.ChannelInstanceDa
|
||||
if len(redacted.Messages) < cfg.MinMessages {
|
||||
return nil, fmt.Errorf("not enough redacted messages")
|
||||
}
|
||||
consumed := messagesWithinExtractionBudget(redacted.Messages, extractionRetryMaxInputChars)
|
||||
extractionCtx := s.extractionContext(ctx, inst, group)
|
||||
consumed := messagesWithinExtractionBudget(redacted.Messages, messageBudgetForExtraction(extractionRetryMaxInputChars, extractionCtx))
|
||||
if len(consumed) < cfg.MinMessages {
|
||||
return nil, fmt.Errorf("not enough extractable messages")
|
||||
}
|
||||
@@ -353,7 +397,13 @@ func (s *Service) runMessages(ctx context.Context, inst *store.ChannelInstanceDa
|
||||
run.ErrorMessage = err.Error()
|
||||
}()
|
||||
provider, model := providerresolve.ResolveBackgroundProvider(ctx, run.TenantID, s.Registry, s.SystemConfigs)
|
||||
items, err := Extract(ctx, provider, model, s.UsageCaps, consumed, cfg.AllowedTypes)
|
||||
items, err := ExtractWithOptions(ctx, provider, model, s.UsageCaps, consumed, ExtractionOptions{
|
||||
AllowedTypes: cfg.AllowedTypes,
|
||||
GlobalCustomPrompt: s.globalCustomPrompt(ctx),
|
||||
ChannelCustomPrompt: cfg.CustomPrompt,
|
||||
GroupCustomPrompt: groupCustomPrompt(cfg, group),
|
||||
Context: extractionCtx,
|
||||
})
|
||||
if err != nil {
|
||||
return run, err
|
||||
}
|
||||
@@ -380,3 +430,84 @@ func (s *Service) runMessages(ctx context.Context, inst *store.ChannelInstanceDa
|
||||
completed = true
|
||||
return run, nil
|
||||
}
|
||||
|
||||
func (s *Service) globalCustomPrompt(ctx context.Context) string {
|
||||
if s == nil || s.SystemConfigs == nil {
|
||||
return ""
|
||||
}
|
||||
value, err := s.SystemConfigs.Get(ctx, GlobalCustomPromptConfigKey)
|
||||
if err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) && !strings.Contains(err.Error(), "system config not found") {
|
||||
slog.Warn("channel_memory.extraction.custom_prompt unavailable", "error", err)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return normalizeCustomPrompt(value)
|
||||
}
|
||||
|
||||
func groupCustomPrompt(cfg Config, group store.PendingMessageGroup) string {
|
||||
if len(cfg.GroupCustomPrompts) == 0 {
|
||||
return ""
|
||||
}
|
||||
if prompt := cfg.GroupCustomPrompts[group.HistoryKey]; prompt != "" {
|
||||
return prompt
|
||||
}
|
||||
if group.ParentHistoryKey != "" {
|
||||
return cfg.GroupCustomPrompts[group.ParentHistoryKey]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Service) extractionContext(ctx context.Context, inst *store.ChannelInstanceData, group store.PendingMessageGroup) ExtractionContext {
|
||||
base := ExtractionContext{
|
||||
ChannelInstance: inst.Name,
|
||||
HistoryKey: group.HistoryKey,
|
||||
ChannelID: group.HistoryKey,
|
||||
ParentChannelID: group.ParentHistoryKey,
|
||||
}
|
||||
if inst.ChannelType != "" {
|
||||
base.Platform = inst.ChannelType
|
||||
} else {
|
||||
base.Platform = group.ChannelName
|
||||
}
|
||||
if s == nil || s.ContextResolver == nil {
|
||||
return base
|
||||
}
|
||||
resolved, err := s.ContextResolver.ResolveExtractionContext(ctx, inst, group)
|
||||
if err != nil {
|
||||
slog.Debug("channel_memory extraction context resolver failed", "channel", inst.Name, "history_key", group.HistoryKey, "error", err)
|
||||
return base
|
||||
}
|
||||
return mergeExtractionContext(base, resolved)
|
||||
}
|
||||
|
||||
func mergeExtractionContext(base, resolved ExtractionContext) ExtractionContext {
|
||||
if resolved.Platform != "" {
|
||||
base.Platform = resolved.Platform
|
||||
}
|
||||
if resolved.ChannelInstance != "" {
|
||||
base.ChannelInstance = resolved.ChannelInstance
|
||||
}
|
||||
if resolved.HistoryKey != "" {
|
||||
base.HistoryKey = resolved.HistoryKey
|
||||
}
|
||||
if resolved.ChannelID != "" {
|
||||
base.ChannelID = resolved.ChannelID
|
||||
}
|
||||
if resolved.ChannelName != "" {
|
||||
base.ChannelName = resolved.ChannelName
|
||||
}
|
||||
if resolved.ParentChannelID != "" {
|
||||
base.ParentChannelID = resolved.ParentChannelID
|
||||
}
|
||||
if resolved.ParentChannelName != "" {
|
||||
base.ParentChannelName = resolved.ParentChannelName
|
||||
}
|
||||
if resolved.CategoryID != "" {
|
||||
base.CategoryID = resolved.CategoryID
|
||||
}
|
||||
if resolved.CategoryName != "" {
|
||||
base.CategoryName = resolved.CategoryName
|
||||
}
|
||||
return base
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/eventbus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
@@ -116,6 +117,7 @@ func (f *fakeExtractionStore) UpdateItem(_ context.Context, id uuid.UUID, update
|
||||
type fakePendingStore struct {
|
||||
groups []store.PendingMessageGroup
|
||||
messages map[string][]store.PendingMessage
|
||||
titles map[string]string
|
||||
}
|
||||
|
||||
func (f *fakePendingStore) AppendBatch(context.Context, []store.PendingMessage) error {
|
||||
@@ -151,7 +153,7 @@ func (f *fakePendingStore) CountByKey(context.Context, string, string) (int, err
|
||||
}
|
||||
|
||||
func (f *fakePendingStore) ResolveGroupTitles(context.Context, []store.PendingMessageGroup) (map[string]string, error) {
|
||||
return nil, nil
|
||||
return f.titles, nil
|
||||
}
|
||||
|
||||
type fakeChannelStore struct {
|
||||
@@ -177,6 +179,22 @@ type fakeEpisodicStore struct {
|
||||
getByErr error
|
||||
}
|
||||
|
||||
type fakeDomainEventBus struct {
|
||||
published []eventbus.DomainEvent
|
||||
}
|
||||
|
||||
func (f *fakeDomainEventBus) Publish(event eventbus.DomainEvent) {
|
||||
f.published = append(f.published, event)
|
||||
}
|
||||
|
||||
func (f *fakeDomainEventBus) Subscribe(eventbus.EventType, eventbus.DomainEventHandler) func() {
|
||||
return func() {}
|
||||
}
|
||||
|
||||
func (f *fakeDomainEventBus) Start(context.Context) {}
|
||||
|
||||
func (f *fakeDomainEventBus) Drain(time.Duration) error { return nil }
|
||||
|
||||
func (f *fakeEpisodicStore) Create(_ context.Context, ep *store.EpisodicSummary) error {
|
||||
if f.createErr != nil {
|
||||
return f.createErr
|
||||
@@ -355,6 +373,39 @@ func TestRunAllSkipsThreadWhenParentHistoryKeyExcluded(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupOptionsIncludesParentTitle(t *testing.T) {
|
||||
inst := &store.ChannelInstanceData{
|
||||
BaseModel: store.BaseModel{ID: uuid.New()},
|
||||
Name: "discord",
|
||||
ChannelType: "discord",
|
||||
Config: MergeIntoInstanceConfig(nil, Config{Enabled: true}),
|
||||
}
|
||||
pending := &fakePendingStore{
|
||||
groups: []store.PendingMessageGroup{
|
||||
{ChannelName: "discord", HistoryKey: "thread-1", ParentHistoryKey: "parent-channel", MessageCount: 3},
|
||||
},
|
||||
titles: map[string]string{
|
||||
"discord:thread-1": "launch-thread",
|
||||
"discord:parent-channel": "product-planning",
|
||||
},
|
||||
}
|
||||
svc := &Service{Pending: pending, Extractions: &fakeExtractionStore{}}
|
||||
|
||||
options, err := svc.GroupOptions(context.Background(), inst)
|
||||
if err != nil {
|
||||
t.Fatalf("GroupOptions returned error: %v", err)
|
||||
}
|
||||
if len(options) != 1 {
|
||||
t.Fatalf("len(options) = %d, want 1", len(options))
|
||||
}
|
||||
if options[0].GroupTitle != "launch-thread" {
|
||||
t.Fatalf("group title = %q, want launch-thread", options[0].GroupTitle)
|
||||
}
|
||||
if options[0].ParentGroupTitle != "product-planning" {
|
||||
t.Fatalf("parent group title = %q, want product-planning", options[0].ParentGroupTitle)
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemHashIsStableAcrossRuns(t *testing.T) {
|
||||
runA := &store.ChannelMemoryExtractionRun{ID: uuid.New(), ChannelInstanceID: uuid.New(), HistoryKey: "group"}
|
||||
runB := *runA
|
||||
@@ -418,7 +469,13 @@ func TestRunMessagesCheckpointsOnlyExtractedBudget(t *testing.T) {
|
||||
CreatedAt: time.Date(2026, 7, 7, 10, i, 0, 0, time.UTC),
|
||||
})
|
||||
}
|
||||
consumed := messagesWithinExtractionBudget(messages, extractionRetryMaxInputChars)
|
||||
extractionCtx := ExtractionContext{
|
||||
Platform: "discord",
|
||||
ChannelName: strings.Repeat("operations-", 40),
|
||||
ParentChannelName: strings.Repeat("parent-", 40),
|
||||
CategoryName: strings.Repeat("planning-", 40),
|
||||
}
|
||||
consumed := messagesWithinExtractionBudget(messages, messageBudgetForExtraction(extractionRetryMaxInputChars, extractionCtx))
|
||||
if len(consumed) == 0 || len(consumed) == len(messages) {
|
||||
t.Fatalf("test fixture should be partially consumed, got %d of %d", len(consumed), len(messages))
|
||||
}
|
||||
@@ -426,7 +483,13 @@ func TestRunMessagesCheckpointsOnlyExtractedBudget(t *testing.T) {
|
||||
registry := providers.NewRegistry(store.TenantIDFromContext)
|
||||
registry.RegisterForTenant(tenantID, provider)
|
||||
extractions := &fakeExtractionStore{}
|
||||
svc := &Service{Extractions: extractions, Registry: registry}
|
||||
svc := &Service{
|
||||
Extractions: extractions,
|
||||
Registry: registry,
|
||||
ContextResolver: ContextResolverFunc(func(context.Context, *store.ChannelInstanceData, store.PendingMessageGroup) (ExtractionContext, error) {
|
||||
return extractionCtx, nil
|
||||
}),
|
||||
}
|
||||
|
||||
ctx := store.WithTenantID(context.Background(), tenantID)
|
||||
run, err := svc.runMessages(ctx, inst, cfg, store.PendingMessageGroup{ChannelName: "discord", HistoryKey: "group-a"}, messages, "manual")
|
||||
@@ -459,7 +522,7 @@ func TestRunMessagesMarksRunFailedWhenItemWriteFails(t *testing.T) {
|
||||
cfg.AllowedTypes = DefaultAllowedTypes
|
||||
cfg.ReviewMode = true
|
||||
provider := &fakeExtractionProvider{responses: []providers.ChatResponse{{
|
||||
Content: `[{"type":"todos","summary":"Follow up on launch checklist","topics":["launch"],"entities":["GoClaw"],"confidence":0.91}]`,
|
||||
Content: `[{"type":"todos","summary":"Follow up on launch checklist","topics":["launch"],"entities":["ExampleGateway"],"confidence":0.91}]`,
|
||||
FinishReason: "stop",
|
||||
}}}
|
||||
registry := providers.NewRegistry(store.TenantIDFromContext)
|
||||
@@ -484,6 +547,45 @@ func TestRunMessagesMarksRunFailedWhenItemWriteFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMessagesAppendsGroupCustomPrompt(t *testing.T) {
|
||||
tenantID := uuid.New()
|
||||
inst := &store.ChannelInstanceData{
|
||||
BaseModel: store.BaseModel{ID: uuid.New()},
|
||||
TenantID: tenantID,
|
||||
Name: "discord",
|
||||
AgentID: uuid.New(),
|
||||
CreatedBy: "user-1",
|
||||
}
|
||||
cfg := DefaultConfig()
|
||||
cfg.MinMessages = 2
|
||||
cfg.AllowedTypes = DefaultAllowedTypes
|
||||
cfg.ReviewMode = true
|
||||
cfg.GroupCustomPrompts = map[string]string{
|
||||
"parent-channel": "This parent channel is only for Project Orion launch planning.",
|
||||
}
|
||||
provider := &fakeExtractionProvider{responses: []providers.ChatResponse{{Content: `[]`, FinishReason: "stop"}}}
|
||||
registry := providers.NewRegistry(store.TenantIDFromContext)
|
||||
registry.RegisterForTenant(tenantID, provider)
|
||||
svc := &Service{Extractions: &fakeExtractionStore{}, Registry: registry}
|
||||
|
||||
ctx := store.WithTenantID(context.Background(), tenantID)
|
||||
_, err := svc.runMessages(ctx, inst, cfg, store.PendingMessageGroup{
|
||||
ChannelName: "discord",
|
||||
HistoryKey: "thread-1",
|
||||
ParentHistoryKey: "parent-channel",
|
||||
}, extractionTestMessages(3), "manual")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(provider.requests) != 1 {
|
||||
t.Fatalf("provider calls = %d, want 1", len(provider.requests))
|
||||
}
|
||||
systemPrompt := provider.requests[0].Messages[0].Content
|
||||
if !strings.Contains(systemPrompt, cfg.GroupCustomPrompts["parent-channel"]) {
|
||||
t.Fatalf("group custom prompt missing from system prompt:\n%s", systemPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveUsesConfiguredRetentionHours(t *testing.T) {
|
||||
instID := uuid.New()
|
||||
agentID := uuid.New()
|
||||
@@ -530,6 +632,59 @@ func TestApproveUsesConfiguredRetentionHours(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApprovePublishesTopicsAndEntitiesForSemanticHints(t *testing.T) {
|
||||
instID := uuid.New()
|
||||
itemID := uuid.New()
|
||||
eventBus := &fakeDomainEventBus{}
|
||||
extractions := &fakeExtractionStore{items: map[uuid.UUID]*store.ChannelMemoryExtractionItem{
|
||||
itemID: {
|
||||
ID: itemID,
|
||||
TenantID: uuid.New(),
|
||||
ChannelInstanceID: instID,
|
||||
AgentID: uuid.New(),
|
||||
UserID: "user-1",
|
||||
SourceID: "channel:item-1",
|
||||
Status: store.ChannelMemoryItemPendingReview,
|
||||
Summary: "Project Orion uses ExampleCo for collaboration.",
|
||||
Topics: []byte(`["collaboration","planning"]`),
|
||||
Entities: []byte(`["Project Orion","ExampleCo"]`),
|
||||
},
|
||||
}}
|
||||
episodic := &fakeEpisodicStore{}
|
||||
svc := &Service{
|
||||
Extractions: extractions,
|
||||
Episodic: episodic,
|
||||
EventBus: eventBus,
|
||||
Channels: &fakeChannelStore{inst: &store.ChannelInstanceData{
|
||||
BaseModel: store.BaseModel{ID: instID},
|
||||
Config: MergeIntoInstanceConfig(nil, DefaultConfig()),
|
||||
}},
|
||||
}
|
||||
|
||||
if _, err := svc.Approve(context.Background(), itemID, "admin"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(episodic.created) != 1 {
|
||||
t.Fatalf("created episodic count = %d, want 1", len(episodic.created))
|
||||
}
|
||||
if got := episodic.created[0].KeyTopics; strings.Join(got, ",") != "collaboration,planning" {
|
||||
t.Fatalf("episodic key_topics = %#v", got)
|
||||
}
|
||||
if len(eventBus.published) != 1 {
|
||||
t.Fatalf("published events = %d, want 1", len(eventBus.published))
|
||||
}
|
||||
payload, ok := eventBus.published[0].Payload.(*eventbus.EpisodicCreatedPayload)
|
||||
if !ok {
|
||||
t.Fatalf("published payload type = %T", eventBus.published[0].Payload)
|
||||
}
|
||||
if got := strings.Join(payload.KeyTopics, ","); got != "collaboration,planning" {
|
||||
t.Fatalf("payload KeyTopics = %q", got)
|
||||
}
|
||||
if got := strings.Join(payload.KeyEntities, ","); got != "Project Orion,ExampleCo" {
|
||||
t.Fatalf("payload KeyEntities = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveExistingSourceWritesExistingEpisodicID(t *testing.T) {
|
||||
itemID := uuid.New()
|
||||
existingID := uuid.New()
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package discord
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/channels"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
const contactRefreshInterval = time.Hour
|
||||
|
||||
// RefreshContactCache forces a Discord metadata refresh into channel_contacts.
|
||||
func (c *Channel) RefreshContactCache(ctx context.Context) error {
|
||||
if c == nil || c.session == nil {
|
||||
return fmt.Errorf("discord session unavailable")
|
||||
}
|
||||
if c.ContactCollector() == nil {
|
||||
return fmt.Errorf("contact collector unavailable")
|
||||
}
|
||||
c.refreshContactCache(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Channel) runContactRefreshLoop(ctx context.Context) {
|
||||
if c == nil || c.session == nil || c.ContactCollector() == nil {
|
||||
return
|
||||
}
|
||||
c.refreshContactCache(ctx)
|
||||
|
||||
ticker := time.NewTicker(contactRefreshInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
c.refreshContactCache(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Channel) refreshContactCache(ctx context.Context) {
|
||||
if c == nil || c.session == nil {
|
||||
return
|
||||
}
|
||||
cc := c.ContactCollector()
|
||||
if cc == nil {
|
||||
return
|
||||
}
|
||||
cacheCtx := ctx
|
||||
if tenantID := c.TenantID(); tenantID != uuid.Nil {
|
||||
cacheCtx = store.WithTenantID(ctx, tenantID)
|
||||
}
|
||||
|
||||
count := 0
|
||||
seen := make(map[string]struct{})
|
||||
userCount := 0
|
||||
seenUsers := make(map[string]struct{})
|
||||
upsert := func(ch *discordgo.Channel) {
|
||||
if ch == nil || ch.ID == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seen[ch.ID]; ok {
|
||||
return
|
||||
}
|
||||
title := channels.SanitizeDisplayName(ch.Name)
|
||||
if title == "" {
|
||||
return
|
||||
}
|
||||
seen[ch.ID] = struct{}{}
|
||||
cc.RefreshContact(cacheCtx, c.Type(), c.Name(), ch.ID, "", title, "", "group", "group", "", "")
|
||||
count++
|
||||
}
|
||||
upsertMember := func(member *discordgo.Member) {
|
||||
if member == nil || member.User == nil || member.User.ID == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := seenUsers[member.User.ID]; ok {
|
||||
return
|
||||
}
|
||||
displayName := discordDisplayName(member.User, member)
|
||||
handle := discordHandle(member.User)
|
||||
if displayName == "" && handle == "" {
|
||||
return
|
||||
}
|
||||
seenUsers[member.User.ID] = struct{}{}
|
||||
cc.RefreshContact(cacheCtx, c.Type(), c.Name(), member.User.ID, member.User.ID, displayName, handle, "group", "user", "", "")
|
||||
userCount++
|
||||
}
|
||||
|
||||
for _, ch := range c.stateChannels() {
|
||||
upsert(ch)
|
||||
}
|
||||
for _, member := range c.stateMembers() {
|
||||
upsertMember(member)
|
||||
}
|
||||
for _, guildID := range c.guildIDs() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
lookupCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
guildChannels, err := c.session.GuildChannels(guildID, discordgo.WithContext(lookupCtx))
|
||||
cancel()
|
||||
if err != nil {
|
||||
slog.Debug("discord contact cache channel sync failed", "channel", c.Name(), "guild_id", guildID, "error", err)
|
||||
continue
|
||||
}
|
||||
for _, ch := range guildChannels {
|
||||
upsert(ch)
|
||||
}
|
||||
|
||||
threadCtx, threadCancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
activeThreads, err := c.session.GuildThreadsActive(guildID, discordgo.WithContext(threadCtx))
|
||||
threadCancel()
|
||||
if err != nil {
|
||||
slog.Debug("discord contact cache thread sync failed", "channel", c.Name(), "guild_id", guildID, "error", err)
|
||||
continue
|
||||
}
|
||||
if activeThreads != nil {
|
||||
for _, ch := range activeThreads.Threads {
|
||||
upsert(ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
if count > 0 || userCount > 0 {
|
||||
slog.Debug("discord contact cache refreshed", "channel", c.Name(), "groups", count, "users", userCount)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Channel) guildIDs() []string {
|
||||
if c == nil || c.session == nil || c.session.State == nil {
|
||||
return nil
|
||||
}
|
||||
c.session.State.RLock()
|
||||
defer c.session.State.RUnlock()
|
||||
|
||||
ids := make([]string, 0, len(c.session.State.Guilds))
|
||||
seen := make(map[string]struct{}, len(c.session.State.Guilds))
|
||||
for _, guild := range c.session.State.Guilds {
|
||||
if guild == nil || guild.ID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[guild.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[guild.ID] = struct{}{}
|
||||
ids = append(ids, guild.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func (c *Channel) stateChannels() []*discordgo.Channel {
|
||||
if c == nil || c.session == nil || c.session.State == nil {
|
||||
return nil
|
||||
}
|
||||
c.session.State.RLock()
|
||||
defer c.session.State.RUnlock()
|
||||
|
||||
var out []*discordgo.Channel
|
||||
for _, guild := range c.session.State.Guilds {
|
||||
if guild == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, guild.Channels...)
|
||||
out = append(out, guild.Threads...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *Channel) stateMembers() []*discordgo.Member {
|
||||
if c == nil || c.session == nil || c.session.State == nil {
|
||||
return nil
|
||||
}
|
||||
c.session.State.RLock()
|
||||
defer c.session.State.RUnlock()
|
||||
|
||||
var out []*discordgo.Member
|
||||
for _, guild := range c.session.State.Guilds {
|
||||
if guild == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, guild.Members...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package discord
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/cache"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/channels"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
type contactRefreshStore struct {
|
||||
contacts map[string]string
|
||||
}
|
||||
|
||||
func (s *contactRefreshStore) UpsertContact(_ context.Context, _ string, channelInstance string, senderID string, _ string, displayName string, _ string, _ string, contactType string, _ string, _ string) error {
|
||||
if s.contacts == nil {
|
||||
s.contacts = make(map[string]string)
|
||||
}
|
||||
s.contacts[channelInstance+":"+contactType+":"+senderID] = displayName
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *contactRefreshStore) ListContacts(context.Context, store.ContactListOpts) ([]store.ChannelContact, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *contactRefreshStore) CountContacts(context.Context, store.ContactListOpts) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (s *contactRefreshStore) GetContactsBySenderIDs(context.Context, []string) (map[string]store.ChannelContact, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *contactRefreshStore) GetContactByID(context.Context, uuid.UUID) (*store.ChannelContact, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *contactRefreshStore) GetSenderIDsByContactIDs(context.Context, []uuid.UUID) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *contactRefreshStore) MergeContacts(context.Context, []uuid.UUID, uuid.UUID) error {
|
||||
return nil
|
||||
}
|
||||
func (s *contactRefreshStore) UnmergeContacts(context.Context, []uuid.UUID) error {
|
||||
return nil
|
||||
}
|
||||
func (s *contactRefreshStore) GetContactsByMergedID(context.Context, uuid.UUID) ([]store.ChannelContact, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *contactRefreshStore) ResolveTenantUserID(context.Context, string, string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func TestRefreshContactCacheStoresDiscordChannelTitles(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/guilds/guild-1/channels":
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"id":"parent-1","name":"product-planning","type":0},
|
||||
{"id":"category-1","name":"operations","type":4}
|
||||
]`))
|
||||
case "/guilds/guild-1/threads/active":
|
||||
_, _ = w.Write([]byte(`{"threads":[{"id":"thread-1","name":"launch-thread","type":11}],"members":[],"has_more":false}`))
|
||||
default:
|
||||
t.Fatalf("unexpected Discord API path: %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
prevGuildChannels := discordgo.EndpointGuildChannels
|
||||
prevGuildActiveThreads := discordgo.EndpointGuildActiveThreads
|
||||
discordgo.EndpointGuildChannels = func(gID string) string { return server.URL + "/guilds/" + gID + "/channels" }
|
||||
discordgo.EndpointGuildActiveThreads = func(gID string) string { return server.URL + "/guilds/" + gID + "/threads/active" }
|
||||
t.Cleanup(func() {
|
||||
discordgo.EndpointGuildChannels = prevGuildChannels
|
||||
discordgo.EndpointGuildActiveThreads = prevGuildActiveThreads
|
||||
})
|
||||
|
||||
session, err := discordgo.New("Bot test-token")
|
||||
if err != nil {
|
||||
t.Fatalf("discordgo.New: %v", err)
|
||||
}
|
||||
session.Client = server.Client()
|
||||
session.State = discordgo.NewState()
|
||||
if err := session.State.GuildAdd(&discordgo.Guild{
|
||||
ID: "guild-1",
|
||||
Channels: []*discordgo.Channel{
|
||||
{ID: "state-channel", Name: "support", Type: discordgo.ChannelTypeGuildText},
|
||||
},
|
||||
Members: []*discordgo.Member{
|
||||
{Nick: "Casey", User: &discordgo.User{ID: "user-1", Username: "casey.dev", GlobalName: "Casey Dev"}},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("GuildAdd: %v", err)
|
||||
}
|
||||
|
||||
contactStore := &contactRefreshStore{}
|
||||
ch := &Channel{
|
||||
BaseChannel: channels.NewBaseChannel(channels.TypeDiscord, bus.New(), nil),
|
||||
session: session,
|
||||
}
|
||||
ch.SetName("discord-main")
|
||||
ch.SetType(channels.TypeDiscord)
|
||||
ch.SetContactCollector(store.NewContactCollector(contactStore, cache.NewInMemoryCache[bool]()))
|
||||
|
||||
ch.refreshContactCache(context.Background())
|
||||
|
||||
want := map[string]string{
|
||||
"discord-main:group:state-channel": "support",
|
||||
"discord-main:group:parent-1": "product-planning",
|
||||
"discord-main:group:category-1": "operations",
|
||||
"discord-main:group:thread-1": "launch-thread",
|
||||
"discord-main:user:user-1": "Casey",
|
||||
}
|
||||
for key, title := range want {
|
||||
if contactStore.contacts[key] != title {
|
||||
t.Fatalf("contact %s = %q, want %q; all=%#v", key, contactStore.contacts[key], title, contactStore.contacts)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package discord
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/channelmemory"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/channels"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
func (c *Channel) ResolveMemoryExtractionContext(ctx context.Context, inst *store.ChannelInstanceData, group store.PendingMessageGroup) (channelmemory.ExtractionContext, error) {
|
||||
out := channelmemory.ExtractionContext{
|
||||
Platform: channels.TypeDiscord,
|
||||
ChannelInstance: group.ChannelName,
|
||||
HistoryKey: group.HistoryKey,
|
||||
ChannelID: group.HistoryKey,
|
||||
ParentChannelID: group.ParentHistoryKey,
|
||||
}
|
||||
if inst != nil && inst.Name != "" {
|
||||
out.ChannelInstance = inst.Name
|
||||
}
|
||||
ch := c.resolveDiscordChannel(ctx, group.HistoryKey)
|
||||
if ch == nil {
|
||||
return out, nil
|
||||
}
|
||||
out.ChannelID = ch.ID
|
||||
out.ChannelName = channels.SanitizeDisplayName(ch.Name)
|
||||
|
||||
parentID := group.ParentHistoryKey
|
||||
if parentID == "" && ch.IsThread() {
|
||||
parentID = ch.ParentID
|
||||
}
|
||||
if parentID != "" {
|
||||
out.ParentChannelID = parentID
|
||||
parent := c.resolveDiscordChannel(ctx, parentID)
|
||||
if parent != nil {
|
||||
out.ParentChannelName = channels.SanitizeDisplayName(parent.Name)
|
||||
if parent.ParentID != "" {
|
||||
out.CategoryID = parent.ParentID
|
||||
if category := c.resolveDiscordChannel(ctx, parent.ParentID); category != nil {
|
||||
out.CategoryName = channels.SanitizeDisplayName(category.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if out.CategoryID == "" && ch.ParentID != "" && !ch.IsThread() {
|
||||
category := c.resolveDiscordChannel(ctx, ch.ParentID)
|
||||
if category != nil && category.Type == discordgo.ChannelTypeGuildCategory {
|
||||
out.CategoryID = category.ID
|
||||
out.CategoryName = channels.SanitizeDisplayName(category.Name)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Channel) resolveDiscordChannel(ctx context.Context, channelID string) *discordgo.Channel {
|
||||
if c == nil || c.session == nil || channelID == "" {
|
||||
return nil
|
||||
}
|
||||
if c.session.State != nil {
|
||||
if ch, err := c.session.State.Channel(channelID); err == nil && ch != nil {
|
||||
return ch
|
||||
}
|
||||
}
|
||||
lookupCtx, cancel := context.WithTimeout(ctx, 750*time.Millisecond)
|
||||
defer cancel()
|
||||
ch, err := c.session.Channel(channelID, discordgo.WithContext(lookupCtx))
|
||||
if err != nil {
|
||||
slog.Debug("discord: extraction context channel lookup failed", "channel_id", channelID, "error", err)
|
||||
return nil
|
||||
}
|
||||
return ch
|
||||
}
|
||||
@@ -24,14 +24,15 @@ const pairingDebounceTime = 60 * time.Second
|
||||
// Channel connects to Discord via the Bot API using gateway events.
|
||||
type Channel struct {
|
||||
*channels.BaseChannel
|
||||
session *discordgo.Session
|
||||
config config.DiscordConfig
|
||||
botUserID string // populated on start
|
||||
placeholders sync.Map // placeholderKey string → messageID string
|
||||
typingCtrls sync.Map // channelID string → *typing.Controller
|
||||
agentStore store.AgentStore // for agent key lookup (nil = writer commands disabled)
|
||||
configPermStore store.ConfigPermissionStore // for group file writer management (nil = writer commands disabled)
|
||||
audioMgr *audio.Manager // unified STT via audio.Manager (nil = no STT)
|
||||
session *discordgo.Session
|
||||
config config.DiscordConfig
|
||||
botUserID string // populated on start
|
||||
placeholders sync.Map // placeholderKey string → messageID string
|
||||
typingCtrls sync.Map // channelID string → *typing.Controller
|
||||
agentStore store.AgentStore // for agent key lookup (nil = writer commands disabled)
|
||||
configPermStore store.ConfigPermissionStore // for group file writer management (nil = writer commands disabled)
|
||||
audioMgr *audio.Manager // unified STT via audio.Manager (nil = no STT)
|
||||
contactRefreshCancel context.CancelFunc
|
||||
// pairingService, pairingDebounce, approvedGroups, groupHistory, historyLimit, requireMention
|
||||
// are inherited from channels.BaseChannel.
|
||||
}
|
||||
@@ -81,7 +82,7 @@ func New(cfg config.DiscordConfig, msgBus *bus.MessageBus, pairingSvc store.Pair
|
||||
}
|
||||
|
||||
// Start opens the Discord gateway connection and begins receiving events.
|
||||
func (c *Channel) Start(_ context.Context) error {
|
||||
func (c *Channel) Start(ctx context.Context) error {
|
||||
c.GroupHistory().StartFlusher()
|
||||
slog.Info("starting discord bot")
|
||||
|
||||
@@ -101,6 +102,9 @@ func (c *Channel) Start(_ context.Context) error {
|
||||
|
||||
c.SetRunning(true)
|
||||
slog.Info("discord bot connected", "username", user.Username, "id", user.ID)
|
||||
refreshCtx, cancel := context.WithCancel(ctx)
|
||||
c.contactRefreshCancel = cancel
|
||||
go c.runContactRefreshLoop(refreshCtx)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -127,6 +131,10 @@ func (c *Channel) SetPendingHistoryTenantID(id uuid.UUID) {
|
||||
|
||||
// Stop closes the Discord gateway connection.
|
||||
func (c *Channel) Stop(_ context.Context) error {
|
||||
if c.contactRefreshCancel != nil {
|
||||
c.contactRefreshCancel()
|
||||
c.contactRefreshCancel = nil
|
||||
}
|
||||
c.GroupHistory().StopFlusher()
|
||||
slog.Info("stopping discord bot")
|
||||
c.SetRunning(false)
|
||||
|
||||
@@ -35,7 +35,8 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate
|
||||
}
|
||||
|
||||
senderID := m.Author.ID
|
||||
senderName := resolveDisplayName(m)
|
||||
displayName := resolveDisplayName(m)
|
||||
authorLabel := discordAuthorLabel(m.Author, m.Member)
|
||||
|
||||
channelID := m.ChannelID
|
||||
isDM := m.GuildID == ""
|
||||
@@ -71,7 +72,7 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate
|
||||
if !c.checkGroupPolicy(ctx, senderID, channelID, mentioned) {
|
||||
slog.Debug("discord group message rejected by policy",
|
||||
"user_id", senderID,
|
||||
"username", senderName,
|
||||
"username", displayName,
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -89,7 +90,7 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate
|
||||
if m.ReferencedMessage != nil {
|
||||
author := "unknown"
|
||||
if m.ReferencedMessage.Author != nil {
|
||||
author = m.ReferencedMessage.Author.Username
|
||||
author = discordAuthorLabel(m.ReferencedMessage.Author, m.ReferencedMessage.Member)
|
||||
}
|
||||
body := channels.Truncate(m.ReferencedMessage.Content, 500)
|
||||
replyCtx := fmt.Sprintf("[Replying to %s]\n%s\n[/Replying]", author, body)
|
||||
@@ -198,7 +199,7 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate
|
||||
}
|
||||
parentHistoryKey := c.parentHistoryKeyForChannel(ctx, channelID)
|
||||
c.GroupHistory().Record(channelID, channels.HistoryEntry{
|
||||
Sender: senderName,
|
||||
Sender: authorLabel,
|
||||
SenderID: senderID,
|
||||
Body: content,
|
||||
ParentHistoryKey: parentHistoryKey,
|
||||
@@ -209,14 +210,14 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate
|
||||
|
||||
// Collect contact even when bot is not mentioned (cache prevents DB spam).
|
||||
if cc := c.ContactCollector(); cc != nil {
|
||||
cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, senderName, m.Author.Username, "group", "user", "", "")
|
||||
cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, displayName, m.Author.Username, "group", "user", "", "")
|
||||
cc.EnsureContact(ctx, c.Type(), c.Name(), channelID, "", c.resolveCachedChannelTitle(channelID), "", "group", "group", "", "")
|
||||
}
|
||||
|
||||
slog.Debug("discord group message recorded (no mention)",
|
||||
"channel_id", channelID,
|
||||
"user_id", senderID,
|
||||
"username", senderName,
|
||||
"username", displayName,
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -270,7 +271,7 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate
|
||||
// Build final content with group context.
|
||||
finalContent := content
|
||||
if peerKind == "group" {
|
||||
annotated := fmt.Sprintf("[From: %s (<@%s>)]\n%s", senderName, senderID, content)
|
||||
annotated := fmt.Sprintf("[From: %s (<@%s>)]\n%s", displayName, senderID, content)
|
||||
if threadBackfill.Context != "" {
|
||||
annotated = threadBackfill.Context + "\n\n" + annotated
|
||||
}
|
||||
@@ -294,7 +295,7 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate
|
||||
"message_id": m.ID,
|
||||
"user_id": senderID,
|
||||
"username": m.Author.Username,
|
||||
"display_name": channels.SanitizeDisplayName(senderName),
|
||||
"display_name": channels.SanitizeDisplayName(displayName),
|
||||
"guild_id": m.GuildID,
|
||||
"channel_id": channelID,
|
||||
"is_dm": fmt.Sprintf("%t", isDM),
|
||||
@@ -310,7 +311,7 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate
|
||||
|
||||
// Collect contact for processed messages (DM + group-mentioned).
|
||||
if cc := c.ContactCollector(); cc != nil {
|
||||
cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, senderName, m.Author.Username, peerKind, "user", "", "")
|
||||
cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, displayName, m.Author.Username, peerKind, "user", "", "")
|
||||
if peerKind == "group" {
|
||||
cc.EnsureContact(ctx, c.Type(), c.Name(), channelID, "", c.resolveCachedChannelTitle(channelID), "", "group", "group", "", "")
|
||||
}
|
||||
@@ -421,13 +422,68 @@ func (c *Channel) sendPairingReply(ctx context.Context, senderID, channelID stri
|
||||
// resolveDisplayName returns the best available display name for a Discord message author.
|
||||
// Priority: server nickname > global display name > username.
|
||||
func resolveDisplayName(m *discordgo.MessageCreate) string {
|
||||
if m.Member != nil && m.Member.Nick != "" {
|
||||
return m.Member.Nick
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
if m.Author.GlobalName != "" {
|
||||
return m.Author.GlobalName
|
||||
return discordDisplayName(m.Author, m.Member)
|
||||
}
|
||||
|
||||
func discordAuthorLabel(user *discordgo.User, member *discordgo.Member) string {
|
||||
display := discordDisplayName(user, member)
|
||||
if display == "" {
|
||||
display = "unknown"
|
||||
}
|
||||
return m.Author.Username
|
||||
handle := discordHandle(user)
|
||||
if handle == "" && user != nil {
|
||||
handle = user.Username
|
||||
}
|
||||
if user == nil || user.ID == "" {
|
||||
if handle == "" {
|
||||
return display
|
||||
}
|
||||
return fmt.Sprintf("%s (@%s)", display, handle)
|
||||
}
|
||||
if handle == "" {
|
||||
return fmt.Sprintf("%s (id: %s)", display, user.ID)
|
||||
}
|
||||
return fmt.Sprintf("%s (@%s, id: %s)", display, handle, user.ID)
|
||||
}
|
||||
|
||||
func discordDisplayName(user *discordgo.User, member *discordgo.Member) string {
|
||||
if member != nil {
|
||||
if member.Nick != "" {
|
||||
return member.Nick
|
||||
}
|
||||
if member.User != nil {
|
||||
if member.User.GlobalName != "" {
|
||||
return member.User.GlobalName
|
||||
}
|
||||
if member.User.Username != "" {
|
||||
return member.User.Username
|
||||
}
|
||||
}
|
||||
}
|
||||
if user == nil {
|
||||
return ""
|
||||
}
|
||||
if user.GlobalName != "" {
|
||||
return user.GlobalName
|
||||
}
|
||||
return user.Username
|
||||
}
|
||||
|
||||
func discordHandle(user *discordgo.User) string {
|
||||
if user == nil {
|
||||
return ""
|
||||
}
|
||||
handle := strings.TrimSpace(user.Username)
|
||||
if handle == "" {
|
||||
return ""
|
||||
}
|
||||
if user.Discriminator != "" && user.Discriminator != "0" {
|
||||
return handle + "#" + user.Discriminator
|
||||
}
|
||||
return handle
|
||||
}
|
||||
|
||||
func (c *Channel) resolveCachedChannelTitle(channelID string) string {
|
||||
@@ -464,18 +520,21 @@ func (c *Channel) ResolveGroupTitles(ctx context.Context, channelIDs []string) (
|
||||
}
|
||||
remaining[id] = struct{}{}
|
||||
}
|
||||
if len(remaining) == 0 || c.session.State == nil {
|
||||
if len(remaining) == 0 {
|
||||
return titles, nil
|
||||
}
|
||||
|
||||
c.session.State.RLock()
|
||||
guildIDs := make([]string, 0, len(c.session.State.Guilds))
|
||||
for _, guild := range c.session.State.Guilds {
|
||||
if guild != nil && guild.ID != "" {
|
||||
guildIDs = append(guildIDs, guild.ID)
|
||||
var guildIDs []string
|
||||
if c.session.State != nil {
|
||||
c.session.State.RLock()
|
||||
guildIDs = make([]string, 0, len(c.session.State.Guilds))
|
||||
for _, guild := range c.session.State.Guilds {
|
||||
if guild != nil && guild.ID != "" {
|
||||
guildIDs = append(guildIDs, guild.ID)
|
||||
}
|
||||
}
|
||||
c.session.State.RUnlock()
|
||||
}
|
||||
c.session.State.RUnlock()
|
||||
|
||||
for _, guildID := range guildIDs {
|
||||
select {
|
||||
@@ -506,5 +565,25 @@ func (c *Channel) ResolveGroupTitles(ctx context.Context, channelIDs []string) (
|
||||
break
|
||||
}
|
||||
}
|
||||
for id := range remaining {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return titles, nil
|
||||
default:
|
||||
}
|
||||
lookupCtx, cancel := context.WithTimeout(ctx, 750*time.Millisecond)
|
||||
ch, err := c.session.Channel(id, discordgo.WithContext(lookupCtx))
|
||||
cancel()
|
||||
if err != nil {
|
||||
slog.Debug("discord channel title direct lookup failed", "channel_id", id, "error", err)
|
||||
continue
|
||||
}
|
||||
if ch == nil {
|
||||
continue
|
||||
}
|
||||
if title := channels.SanitizeDisplayName(ch.Name); title != "" {
|
||||
titles[id] = title
|
||||
}
|
||||
}
|
||||
return titles, nil
|
||||
}
|
||||
@@ -10,9 +10,11 @@ import (
|
||||
"github.com/bwmarrin/discordgo"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/channelmemory"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/channels"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/channels/media"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// --- resolveDisplayName ---
|
||||
@@ -51,6 +53,22 @@ func TestResolveDisplayName(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscordAuthorLabelIncludesHandleAndID(t *testing.T) {
|
||||
m := &discordgo.MessageCreate{Message: &discordgo.Message{
|
||||
Author: &discordgo.User{
|
||||
ID: "u123",
|
||||
Username: "alexproducer",
|
||||
GlobalName: "Alex Producer",
|
||||
},
|
||||
}}
|
||||
|
||||
got := discordAuthorLabel(m.Author, m.Member)
|
||||
want := "Alex Producer (@alexproducer, id: u123)"
|
||||
if got != want {
|
||||
t.Fatalf("discordAuthorLabel() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCachedChannelTitle(t *testing.T) {
|
||||
session, err := discordgo.New("Bot test-token")
|
||||
if err != nil {
|
||||
@@ -73,6 +91,52 @@ func TestResolveCachedChannelTitle(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMemoryExtractionContextIncludesThreadParentAndCategory(t *testing.T) {
|
||||
session, err := discordgo.New("Bot test-token")
|
||||
if err != nil {
|
||||
t.Fatalf("discordgo.New() error = %v", err)
|
||||
}
|
||||
session.State = discordgo.NewState()
|
||||
if err := session.State.GuildAdd(&discordgo.Guild{ID: "guild-1"}); err != nil {
|
||||
t.Fatalf("GuildAdd() error = %v", err)
|
||||
}
|
||||
for _, ch := range []*discordgo.Channel{
|
||||
{ID: "cat-1", GuildID: "guild-1", Name: "Launch", Type: discordgo.ChannelTypeGuildCategory},
|
||||
{ID: "parent-1", GuildID: "guild-1", Name: "design", ParentID: "cat-1", Type: discordgo.ChannelTypeGuildText},
|
||||
{ID: "thread-1", GuildID: "guild-1", Name: "project-thread", ParentID: "parent-1", Type: discordgo.ChannelTypeGuildPublicThread},
|
||||
} {
|
||||
if err := session.State.ChannelAdd(ch); err != nil {
|
||||
t.Fatalf("ChannelAdd(%s) error = %v", ch.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
ch := &Channel{BaseChannel: channels.NewBaseChannel(channels.TypeDiscord, nil, nil), session: session}
|
||||
ch.SetName("discord-main")
|
||||
|
||||
ctx, err := ch.ResolveMemoryExtractionContext(context.Background(), &store.ChannelInstanceData{Name: "discord-main"}, store.PendingMessageGroup{
|
||||
ChannelName: "discord-main",
|
||||
HistoryKey: "thread-1",
|
||||
ParentHistoryKey: "parent-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMemoryExtractionContext() error = %v", err)
|
||||
}
|
||||
want := channelmemory.ExtractionContext{
|
||||
Platform: channels.TypeDiscord,
|
||||
ChannelInstance: "discord-main",
|
||||
HistoryKey: "thread-1",
|
||||
ChannelID: "thread-1",
|
||||
ChannelName: "project-thread",
|
||||
ParentChannelID: "parent-1",
|
||||
ParentChannelName: "design",
|
||||
CategoryID: "cat-1",
|
||||
CategoryName: "Launch",
|
||||
}
|
||||
if ctx != want {
|
||||
t.Fatalf("context = %+v, want %+v", ctx, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetAgentIDUsesConfiguredChannelAgent(t *testing.T) {
|
||||
ch := &Channel{BaseChannel: channels.NewBaseChannel(channels.TypeDiscord, nil, nil)}
|
||||
ch.SetAgentID("co-assistant")
|
||||
|
||||
@@ -122,6 +122,7 @@ func (w *episodicWorker) Handle(ctx context.Context, event eventbus.DomainEvent)
|
||||
EpisodicID: ep.ID.String(),
|
||||
SessionKey: payload.SessionKey,
|
||||
Summary: summary,
|
||||
KeyTopics: entities,
|
||||
KeyEntities: entities,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -22,9 +22,11 @@ func testRegistry(p providers.Provider) *providers.Registry {
|
||||
type mockExtractor struct {
|
||||
result *knowledgegraph.ExtractionResult
|
||||
err error
|
||||
inputs []string
|
||||
}
|
||||
|
||||
func (m *mockExtractor) Extract(_ context.Context, _ string) (*knowledgegraph.ExtractionResult, error) {
|
||||
func (m *mockExtractor) Extract(_ context.Context, text string) (*knowledgegraph.ExtractionResult, error) {
|
||||
m.inputs = append(m.inputs, text)
|
||||
return m.result, m.err
|
||||
}
|
||||
|
||||
@@ -43,4 +45,4 @@ func (m *mockProvider) ChatStream(_ context.Context, _ providers.ChatRequest, _
|
||||
}
|
||||
|
||||
func (m *mockProvider) Name() string { return "mock" }
|
||||
func (m *mockProvider) DefaultModel() string { return "mock-model" }
|
||||
func (m *mockProvider) DefaultModel() string { return "mock-model" }
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -34,12 +35,13 @@ func (w *semanticWorker) Handle(ctx context.Context, event eventbus.DomainEvent)
|
||||
}
|
||||
}
|
||||
|
||||
if w.extractor == nil || payload.Summary == "" {
|
||||
extractionInput := formatSemanticExtractionInput(payload)
|
||||
if w.extractor == nil || extractionInput == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract entities/relations from summary (much cheaper than full session)
|
||||
result, err := w.extractor.Extract(ctx, payload.Summary)
|
||||
result, err := w.extractor.Extract(ctx, extractionInput)
|
||||
if err != nil {
|
||||
bgalert.ReportProviderError(ctx, w.alertDeps, "kg_extraction", err)
|
||||
slog.Warn("semantic: extraction failed", "episodic_id", payload.EpisodicID, "err", err)
|
||||
@@ -86,3 +88,58 @@ func (w *semanticWorker) Handle(ctx context.Context, event eventbus.DomainEvent)
|
||||
"relations", len(result.Relations), "episodic_id", payload.EpisodicID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatSemanticExtractionInput(payload *eventbus.EpisodicCreatedPayload) string {
|
||||
if payload == nil {
|
||||
return ""
|
||||
}
|
||||
summary := strings.TrimSpace(payload.Summary)
|
||||
if summary == "" {
|
||||
return ""
|
||||
}
|
||||
topics := compactSemanticHints(payload.KeyTopics)
|
||||
entities := compactSemanticHints(payload.KeyEntities)
|
||||
if len(topics) == 0 && len(entities) == 0 {
|
||||
return summary
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("Fact summary:\n")
|
||||
b.WriteString(summary)
|
||||
b.WriteString("\n\nContext hints for disambiguation only. Do not create entities or relations solely because a value appears below; only extract facts supported by the fact summary.")
|
||||
if len(entities) > 0 {
|
||||
b.WriteString("\n\nCandidate entities:\n")
|
||||
for _, entity := range entities {
|
||||
b.WriteString("- ")
|
||||
b.WriteString(entity)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
if len(topics) > 0 {
|
||||
b.WriteString("\nTopics / context tags:\n")
|
||||
for _, topic := range topics {
|
||||
b.WriteString("- ")
|
||||
b.WriteString(topic)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
func compactSemanticHints(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(value)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -593,6 +593,51 @@ func TestSemanticWorkerHandle_WithValidExtraction(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemanticWorkerHandle_PassesTopicsAndEntitiesAsContextHints(t *testing.T) {
|
||||
mockExtractor := &mockExtractor{result: &knowledgegraph.ExtractionResult{}}
|
||||
worker := &semanticWorker{
|
||||
kgStore: &mockKGStore{},
|
||||
extractor: mockExtractor,
|
||||
eventBus: newMockDomainEventBus(),
|
||||
}
|
||||
|
||||
err := worker.Handle(context.Background(), eventbus.DomainEvent{
|
||||
Type: eventbus.EventEpisodicCreated,
|
||||
TenantID: uuid.New().String(),
|
||||
AgentID: uuid.New().String(),
|
||||
UserID: "test-user",
|
||||
Payload: &eventbus.EpisodicCreatedPayload{
|
||||
EpisodicID: "ep-123",
|
||||
Summary: "Project Orion uses ExampleCo for collaboration.",
|
||||
KeyEntities: []string{"Project Orion", "ExampleCo"},
|
||||
KeyTopics: []string{"collaboration", "planning"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Handle failed: %v", err)
|
||||
}
|
||||
if len(mockExtractor.inputs) != 1 {
|
||||
t.Fatalf("extractor calls = %d, want 1", len(mockExtractor.inputs))
|
||||
}
|
||||
input := mockExtractor.inputs[0]
|
||||
for _, want := range []string{
|
||||
"Fact summary:",
|
||||
"Project Orion uses ExampleCo for collaboration.",
|
||||
"Context hints for disambiguation only",
|
||||
"Do not create entities or relations solely because a value appears below",
|
||||
"Candidate entities:",
|
||||
"- Project Orion",
|
||||
"- ExampleCo",
|
||||
"Topics / context tags:",
|
||||
"- collaboration",
|
||||
"- planning",
|
||||
} {
|
||||
if !strings.Contains(input, want) {
|
||||
t.Fatalf("semantic extraction input missing %q:\n%s", want, input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemanticWorkerHandle_SuccessClearsBackgroundProviderAlert(t *testing.T) {
|
||||
mockKG := &mockKGStore{}
|
||||
mockExtractor := &mockExtractor{
|
||||
|
||||
@@ -12,8 +12,8 @@ type EventType string
|
||||
const (
|
||||
EventSessionCompleted EventType = "session.completed"
|
||||
EventEpisodicCreated EventType = "episodic.created"
|
||||
EventEntityUpserted EventType = "entity.upserted"
|
||||
EventRunCompleted EventType = "run.completed"
|
||||
EventEntityUpserted EventType = "entity.upserted"
|
||||
EventRunCompleted EventType = "run.completed"
|
||||
EventToolExecuted EventType = "tool.executed"
|
||||
|
||||
// Context pruning observability (Phase 05)
|
||||
@@ -69,6 +69,7 @@ type EpisodicCreatedPayload struct {
|
||||
EpisodicID string
|
||||
SessionKey string
|
||||
Summary string
|
||||
KeyTopics []string
|
||||
KeyEntities []string
|
||||
}
|
||||
|
||||
@@ -127,8 +128,8 @@ type ContextPrunedPayload struct {
|
||||
TokensBefore int
|
||||
TokensAfter int
|
||||
Budget int
|
||||
ResultsTrimmed int // soft-trimmed count
|
||||
ResultsCleared int // hard-cleared count
|
||||
ResultsTrimmed int // soft-trimmed count
|
||||
ResultsCleared int // hard-cleared count
|
||||
Compacted bool
|
||||
Trigger string // "soft" | "hard" | "compact"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/channels"
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
)
|
||||
|
||||
type channelMetadataRefresher interface {
|
||||
RefreshContactCache(ctx context.Context) error
|
||||
}
|
||||
|
||||
func (h *ChannelInstancesHandler) handleRefreshChannelMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireTenantAdmin(w, r, h.tenantStore) {
|
||||
return
|
||||
}
|
||||
inst, ok := h.resolveInstance(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if inst.ChannelType != channels.TypeDiscord {
|
||||
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, "metadata refresh is only supported for Discord channels")
|
||||
return
|
||||
}
|
||||
if h.channelMgr == nil {
|
||||
writeError(w, http.StatusConflict, protocol.ErrInvalidRequest, "channel manager is not available")
|
||||
return
|
||||
}
|
||||
ch, ok := h.channelMgr.GetChannel(inst.Name)
|
||||
if !ok {
|
||||
writeError(w, http.StatusConflict, protocol.ErrInvalidRequest, "Discord channel is not running")
|
||||
return
|
||||
}
|
||||
if !ch.IsRunning() {
|
||||
writeError(w, http.StatusConflict, protocol.ErrInvalidRequest, "Discord channel is not running")
|
||||
return
|
||||
}
|
||||
refresher, ok := ch.(channelMetadataRefresher)
|
||||
if !ok {
|
||||
writeError(w, http.StatusConflict, protocol.ErrInvalidRequest, "Discord channel metadata refresh is not available")
|
||||
return
|
||||
}
|
||||
if err := refresher.RefreshContactCache(r.Context()); err != nil {
|
||||
writeError(w, http.StatusConflict, protocol.ErrInvalidRequest, err.Error())
|
||||
return
|
||||
}
|
||||
emitAudit(h.msgBus, r, "channel_metadata.refresh_triggered", "channel_instance", inst.ID.String())
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/channels"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
type metadataRefreshChannel struct {
|
||||
*channels.BaseChannel
|
||||
called bool
|
||||
}
|
||||
|
||||
func (c *metadataRefreshChannel) Start(context.Context) error { return nil }
|
||||
func (c *metadataRefreshChannel) Stop(context.Context) error { return nil }
|
||||
func (c *metadataRefreshChannel) Send(context.Context, bus.OutboundMessage) error {
|
||||
return nil
|
||||
}
|
||||
func (c *metadataRefreshChannel) IsRunning() bool { return true }
|
||||
func (c *metadataRefreshChannel) IsAllowed(string) bool {
|
||||
return true
|
||||
}
|
||||
func (c *metadataRefreshChannel) RefreshContactCache(context.Context) error {
|
||||
c.called = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRefreshChannelMetadataCallsDiscordRefresher(t *testing.T) {
|
||||
instID := uuid.New()
|
||||
channelName := "discord-main"
|
||||
handler := NewChannelInstancesHandler(
|
||||
&stubChannelInstanceStore{inst: &store.ChannelInstanceData{
|
||||
BaseModel: store.BaseModel{ID: instID},
|
||||
Name: channelName,
|
||||
ChannelType: channels.TypeDiscord,
|
||||
}},
|
||||
nil, nil, nil, nil, nil,
|
||||
)
|
||||
manager := channels.NewManager(bus.New())
|
||||
ch := &metadataRefreshChannel{BaseChannel: channels.NewBaseChannel(channels.TypeDiscord, nil, nil)}
|
||||
ch.SetName(channelName)
|
||||
ch.SetType(channels.TypeDiscord)
|
||||
manager.RegisterChannel(channelName, ch)
|
||||
handler.SetChannelManager(manager)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/channels/instances/"+instID.String()+"/metadata/refresh", nil)
|
||||
req = req.WithContext(store.WithRole(req.Context(), store.RoleOwner))
|
||||
req.SetPathValue("id", instID.String())
|
||||
rec := httptest.NewRecorder()
|
||||
handler.handleRefreshChannelMetadata(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !ch.called {
|
||||
t.Fatal("RefreshContactCache was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshChannelMetadataRejectsNonDiscord(t *testing.T) {
|
||||
instID := uuid.New()
|
||||
handler := NewChannelInstancesHandler(
|
||||
&stubChannelInstanceStore{inst: &store.ChannelInstanceData{
|
||||
BaseModel: store.BaseModel{ID: instID},
|
||||
Name: "telegram-main",
|
||||
ChannelType: channels.TypeTelegram,
|
||||
}},
|
||||
nil, nil, nil, nil, nil,
|
||||
)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/channels/instances/"+instID.String()+"/metadata/refresh", nil)
|
||||
req = req.WithContext(store.WithRole(req.Context(), store.RoleOwner))
|
||||
req.SetPathValue("id", instID.String())
|
||||
rec := httptest.NewRecorder()
|
||||
handler.handleRefreshChannelMetadata(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,30 @@ func (h *ChannelInstancesHandler) SetMemberResolver(r channels.MemberResolver) {
|
||||
// in cmd/gateway.go's startup ordering.
|
||||
func (h *ChannelInstancesHandler) SetChannelManager(mgr *channels.Manager) {
|
||||
h.channelMgr = mgr
|
||||
if h.memoryService != nil {
|
||||
h.memoryService.ContextResolver = channelmemory.ContextResolverFunc(func(ctx context.Context, inst *store.ChannelInstanceData, group store.PendingMessageGroup) (channelmemory.ExtractionContext, error) {
|
||||
return resolveMemoryExtractionContextFromManager(ctx, mgr, inst, group)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type memoryExtractionContextProvider interface {
|
||||
ResolveMemoryExtractionContext(ctx context.Context, inst *store.ChannelInstanceData, group store.PendingMessageGroup) (channelmemory.ExtractionContext, error)
|
||||
}
|
||||
|
||||
func resolveMemoryExtractionContextFromManager(ctx context.Context, mgr *channels.Manager, inst *store.ChannelInstanceData, group store.PendingMessageGroup) (channelmemory.ExtractionContext, error) {
|
||||
if mgr == nil || inst == nil || inst.Name == "" {
|
||||
return channelmemory.ExtractionContext{}, nil
|
||||
}
|
||||
ch, ok := mgr.GetChannel(inst.Name)
|
||||
if !ok {
|
||||
return channelmemory.ExtractionContext{}, nil
|
||||
}
|
||||
provider, ok := ch.(memoryExtractionContextProvider)
|
||||
if !ok {
|
||||
return channelmemory.ExtractionContext{}, nil
|
||||
}
|
||||
return provider.ResolveMemoryExtractionContext(ctx, inst, group)
|
||||
}
|
||||
|
||||
// SetCapabilityStores wires MCP and Secure CLI stores for channel-context
|
||||
@@ -103,6 +127,7 @@ func (h *ChannelInstancesHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /v1/channels/instances/{id}", h.auth(h.handleGet))
|
||||
mux.HandleFunc("PUT /v1/channels/instances/{id}", h.adminAuth(h.handleUpdate))
|
||||
mux.HandleFunc("DELETE /v1/channels/instances/{id}", h.adminAuth(h.handleDelete))
|
||||
mux.HandleFunc("POST /v1/channels/instances/{id}/metadata/refresh", h.adminAuth(h.handleRefreshChannelMetadata))
|
||||
|
||||
// Channel contacts (global, not per-agent)
|
||||
if h.contactStore != nil {
|
||||
|
||||
@@ -73,13 +73,12 @@ func (h *ChannelInstancesHandler) handleMemoryExtractionSettings(w http.Response
|
||||
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON))
|
||||
return
|
||||
}
|
||||
normalized, err := channelmemory.ApplyConfigPatch(channelmemory.ParseConfig(inst.Config), body)
|
||||
configJSON, normalized, err := channelmemory.ApplyInstanceConfigPatch(inst.Config, body)
|
||||
if err != nil {
|
||||
locale := store.LocaleFromContext(r.Context())
|
||||
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON))
|
||||
return
|
||||
}
|
||||
configJSON := channelmemory.MergeIntoInstanceConfig(inst.Config, normalized)
|
||||
if err := h.store.Update(r.Context(), inst.ID, map[string]any{"config": configJSON}); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, "failed to update memory extraction settings")
|
||||
return
|
||||
@@ -103,24 +102,6 @@ func (h *ChannelInstancesHandler) handleMemoryExtractionGroups(w http.ResponseWr
|
||||
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, "failed to list memory extraction groups")
|
||||
return
|
||||
}
|
||||
if h.channelMgr != nil {
|
||||
historyKeys := make([]string, 0, len(groups))
|
||||
for i := range groups {
|
||||
if groups[i].HistoryKey != "" {
|
||||
historyKeys = append(historyKeys, groups[i].HistoryKey)
|
||||
}
|
||||
}
|
||||
titles, err := h.channelMgr.ResolveGroupTitles(r.Context(), inst.Name, historyKeys)
|
||||
if err == nil {
|
||||
for i := range groups {
|
||||
title := titles[groups[i].HistoryKey]
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
groups[i].GroupTitle = title
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"groups": groups})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/channelmemory"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/channels"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
type memoryExtractionPendingStore struct {
|
||||
groups []store.PendingMessageGroup
|
||||
titles map[string]string
|
||||
}
|
||||
|
||||
func (s *memoryExtractionPendingStore) AppendBatch(context.Context, []store.PendingMessage) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *memoryExtractionPendingStore) ListByKey(context.Context, string, string) ([]store.PendingMessage, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *memoryExtractionPendingStore) DeleteByKey(context.Context, string, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *memoryExtractionPendingStore) Compact(context.Context, []uuid.UUID, *store.PendingMessage) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *memoryExtractionPendingStore) DeleteStale(context.Context, time.Duration) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s *memoryExtractionPendingStore) ListGroups(context.Context) ([]store.PendingMessageGroup, error) {
|
||||
return s.groups, nil
|
||||
}
|
||||
|
||||
func (s *memoryExtractionPendingStore) CountAll(context.Context) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s *memoryExtractionPendingStore) CountByKey(context.Context, string, string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s *memoryExtractionPendingStore) ResolveGroupTitles(context.Context, []store.PendingMessageGroup) (map[string]string, error) {
|
||||
return s.titles, nil
|
||||
}
|
||||
|
||||
type liveTitleTrapChannel struct {
|
||||
*channels.BaseChannel
|
||||
called bool
|
||||
}
|
||||
|
||||
func (c *liveTitleTrapChannel) Start(context.Context) error { return nil }
|
||||
func (c *liveTitleTrapChannel) Stop(context.Context) error { return nil }
|
||||
func (c *liveTitleTrapChannel) Send(context.Context, bus.OutboundMessage) error {
|
||||
return nil
|
||||
}
|
||||
func (c *liveTitleTrapChannel) IsRunning() bool { return true }
|
||||
func (c *liveTitleTrapChannel) IsAllowed(string) bool { return true }
|
||||
func (c *liveTitleTrapChannel) ResolveGroupTitle(context.Context, string) (string, error) {
|
||||
c.called = true
|
||||
return "", nil
|
||||
}
|
||||
func (c *liveTitleTrapChannel) ResolveGroupTitles(context.Context, []string) (map[string]string, error) {
|
||||
c.called = true
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestMemoryExtractionGroupsUsesDBTitlesOnly(t *testing.T) {
|
||||
instID := uuid.New()
|
||||
channelName := "discord-main"
|
||||
handler := NewChannelInstancesHandler(
|
||||
&stubChannelInstanceStore{inst: &store.ChannelInstanceData{
|
||||
BaseModel: store.BaseModel{ID: instID},
|
||||
Name: channelName,
|
||||
ChannelType: channels.TypeDiscord,
|
||||
}},
|
||||
nil, nil, nil, nil, nil,
|
||||
)
|
||||
handler.memoryService = &channelmemory.Service{Pending: &memoryExtractionPendingStore{
|
||||
groups: []store.PendingMessageGroup{
|
||||
{
|
||||
ChannelName: channelName,
|
||||
HistoryKey: "thread-1",
|
||||
ParentHistoryKey: "parent-1",
|
||||
MessageCount: 4,
|
||||
LastActivity: time.Date(2026, 7, 8, 8, 0, 0, 0, time.UTC),
|
||||
},
|
||||
},
|
||||
titles: map[string]string{
|
||||
channelName + ":thread-1": "launch-thread",
|
||||
channelName + ":parent-1": "product-planning",
|
||||
},
|
||||
}}
|
||||
manager := channels.NewManager(bus.New())
|
||||
trap := &liveTitleTrapChannel{BaseChannel: channels.NewBaseChannel(channels.TypeDiscord, nil, nil)}
|
||||
trap.SetName(channelName)
|
||||
trap.SetType(channels.TypeDiscord)
|
||||
manager.RegisterChannel(channelName, trap)
|
||||
handler.SetChannelManager(manager)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/channels/instances/"+instID.String()+"/memory-extraction/groups", nil)
|
||||
req.SetPathValue("id", instID.String())
|
||||
rec := httptest.NewRecorder()
|
||||
handler.handleMemoryExtractionGroups(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if trap.called {
|
||||
t.Fatal("memory extraction groups called live channel title resolver")
|
||||
}
|
||||
var body struct {
|
||||
Groups []channelmemory.GroupOption `json:"groups"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(body.Groups) != 1 {
|
||||
t.Fatalf("group count = %d, want 1", len(body.Groups))
|
||||
}
|
||||
if body.Groups[0].GroupTitle != "launch-thread" {
|
||||
t.Fatalf("group title = %q, want launch-thread", body.Groups[0].GroupTitle)
|
||||
}
|
||||
if body.Groups[0].ParentGroupTitle != "product-planning" {
|
||||
t.Fatalf("parent group title = %q, want product-planning", body.Groups[0].ParentGroupTitle)
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,24 @@ func (c *ContactCollector) EnsureContact(ctx context.Context, channelType, chann
|
||||
c.seen.Set(ctx, key, true, contactSeenTTL)
|
||||
}
|
||||
|
||||
// RefreshContact upserts a contact without the short-lived hot-path dedup.
|
||||
// Background metadata syncs use this so fresher platform names are persisted
|
||||
// even when the message ingestion path recently saw the same ID.
|
||||
func (c *ContactCollector) RefreshContact(ctx context.Context, channelType, channelInstance, senderID, userID, displayName, username, peerKind, contactType, threadID, threadType string) {
|
||||
if contactType == "" {
|
||||
contactType = "user"
|
||||
}
|
||||
if err := c.store.UpsertContact(ctx, channelType, channelInstance, senderID, userID, displayName, username, peerKind, contactType, threadID, threadType); err != nil {
|
||||
slog.Warn("contact_collector.refresh_failed",
|
||||
"error", err,
|
||||
"tenant_id", TenantIDFromContext(ctx),
|
||||
"channel", channelType,
|
||||
"instance", channelInstance,
|
||||
"sender", senderID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveTenantUserID delegates to the underlying ContactStore.
|
||||
func (c *ContactCollector) ResolveTenantUserID(ctx context.Context, channelType, senderID string) (string, error) {
|
||||
return c.store.ResolveTenantUserID(ctx, channelType, senderID)
|
||||
|
||||
@@ -214,10 +214,15 @@ func (s *PGPendingMessageStore) ResolveGroupTitles(ctx context.Context, groups [
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
result, missing := s.resolveGroupTitlesFromContacts(ctx, groups)
|
||||
if len(missing) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Build OR conditions: session_key LIKE '%:{channel}:group:{key}%'
|
||||
conditions := make([]string, 0, len(groups))
|
||||
args := make([]any, 0, len(groups)*2)
|
||||
for i, g := range groups {
|
||||
conditions := make([]string, 0, len(missing))
|
||||
args := make([]any, 0, len(missing)*2)
|
||||
for i, g := range missing {
|
||||
conditions = append(conditions, fmt.Sprintf(
|
||||
"(session_key LIKE '%%:' || $%d || ':group:' || $%d || '%%')",
|
||||
i*2+1, i*2+2,
|
||||
@@ -248,14 +253,13 @@ func (s *PGPendingMessageStore) ResolveGroupTitles(ctx context.Context, groups [
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string]string)
|
||||
for rows.Next() {
|
||||
var sessionKey, title string
|
||||
if err := rows.Scan(&sessionKey, &title); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Match session_key back to channel:key pair
|
||||
for _, g := range groups {
|
||||
for _, g := range missing {
|
||||
pattern := ":" + g.ChannelName + ":group:" + g.HistoryKey
|
||||
if strings.Contains(sessionKey, pattern) {
|
||||
mapKey := g.ChannelName + ":" + g.HistoryKey
|
||||
@@ -268,3 +272,78 @@ func (s *PGPendingMessageStore) ResolveGroupTitles(ctx context.Context, groups [
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PGPendingMessageStore) resolveGroupTitlesFromContacts(ctx context.Context, groups []store.PendingMessageGroup) (map[string]string, []store.PendingMessageGroup) {
|
||||
result := make(map[string]string, len(groups))
|
||||
unique := uniquePendingTitleGroups(groups)
|
||||
if len(unique) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
conditions := make([]string, 0, len(unique))
|
||||
args := make([]any, 0, len(unique)*2+1)
|
||||
for i, g := range unique {
|
||||
conditions = append(conditions, fmt.Sprintf("(channel_instance = $%d AND sender_id = $%d)", i*2+1, i*2+2))
|
||||
args = append(args, g.ChannelName, g.HistoryKey)
|
||||
}
|
||||
|
||||
tenantFilter := ""
|
||||
if !store.IsCrossTenant(ctx) {
|
||||
tid := store.TenantIDFromContext(ctx)
|
||||
if tid == uuid.Nil {
|
||||
tid = store.MasterTenantID
|
||||
}
|
||||
tenantFilter = fmt.Sprintf(" AND tenant_id = $%d", len(args)+1)
|
||||
args = append(args, tid)
|
||||
}
|
||||
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT channel_instance, sender_id, display_name
|
||||
FROM channel_contacts
|
||||
WHERE display_name IS NOT NULL
|
||||
AND display_name <> ''
|
||||
AND contact_type IN ('group', 'topic')
|
||||
AND (`+strings.Join(conditions, " OR ")+`)`+tenantFilter,
|
||||
args...,
|
||||
)
|
||||
if err != nil {
|
||||
return result, unique
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var channelName, senderID, title string
|
||||
if err := rows.Scan(&channelName, &senderID, &title); err != nil {
|
||||
return result, unique
|
||||
}
|
||||
result[channelName+":"+senderID] = title
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return result, unique
|
||||
}
|
||||
|
||||
missing := make([]store.PendingMessageGroup, 0, len(unique)-len(result))
|
||||
for _, g := range unique {
|
||||
if result[g.ChannelName+":"+g.HistoryKey] == "" {
|
||||
missing = append(missing, g)
|
||||
}
|
||||
}
|
||||
return result, missing
|
||||
}
|
||||
|
||||
func uniquePendingTitleGroups(groups []store.PendingMessageGroup) []store.PendingMessageGroup {
|
||||
out := make([]store.PendingMessageGroup, 0, len(groups))
|
||||
seen := make(map[string]struct{}, len(groups))
|
||||
for _, g := range groups {
|
||||
if g.ChannelName == "" || g.HistoryKey == "" {
|
||||
continue
|
||||
}
|
||||
key := g.ChannelName + ":" + g.HistoryKey
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, g)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -29,8 +29,8 @@ func (s *SQLiteContactStore) UpsertContact(ctx context.Context, channelType, cha
|
||||
tenantID = store.MasterTenantID
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO channel_contacts (channel_type, channel_instance, sender_id, user_id, display_name, username, peer_kind, contact_type, thread_id, thread_type, tenant_id)
|
||||
VALUES (?, NULLIF(?,?), ?, NULLIF(?,?), NULLIF(?,?), NULLIF(?,?), NULLIF(?,?), ?, NULLIF(?,?), NULLIF(?,?), ?)
|
||||
INSERT INTO channel_contacts (id, channel_type, channel_instance, sender_id, user_id, display_name, username, peer_kind, contact_type, thread_id, thread_type, tenant_id)
|
||||
VALUES (?, ?, NULLIF(?,?), ?, NULLIF(?,?), NULLIF(?,?), NULLIF(?,?), NULLIF(?,?), ?, NULLIF(?,?), NULLIF(?,?), ?)
|
||||
ON CONFLICT (tenant_id, channel_type, sender_id, COALESCE(thread_id, '')) DO UPDATE SET
|
||||
display_name = COALESCE(NULLIF(excluded.display_name,''), channel_contacts.display_name),
|
||||
username = COALESCE(NULLIF(excluded.username,''), channel_contacts.username),
|
||||
@@ -40,6 +40,7 @@ func (s *SQLiteContactStore) UpsertContact(ctx context.Context, channelType, cha
|
||||
contact_type = excluded.contact_type,
|
||||
thread_type = COALESCE(NULLIF(excluded.thread_type,''), channel_contacts.thread_type),
|
||||
last_seen_at = CURRENT_TIMESTAMP`,
|
||||
store.GenNewID().String(),
|
||||
channelType,
|
||||
channelInstance, "",
|
||||
senderID,
|
||||
|
||||
@@ -232,10 +232,15 @@ func (s *SQLitePendingMessageStore) ResolveGroupTitles(ctx context.Context, grou
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
result, missing := s.resolveGroupTitlesFromContacts(ctx, groups)
|
||||
if len(missing) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Build OR conditions using LIKE with ? placeholders
|
||||
conditions := make([]string, 0, len(groups))
|
||||
args := make([]any, 0, len(groups)*2)
|
||||
for _, g := range groups {
|
||||
conditions := make([]string, 0, len(missing))
|
||||
args := make([]any, 0, len(missing)*2)
|
||||
for _, g := range missing {
|
||||
conditions = append(conditions, "(session_key LIKE '%:' || ? || ':group:' || ? || '%')")
|
||||
args = append(args, g.ChannelName, g.HistoryKey)
|
||||
}
|
||||
@@ -265,13 +270,12 @@ func (s *SQLitePendingMessageStore) ResolveGroupTitles(ctx context.Context, grou
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string]string)
|
||||
for rows.Next() {
|
||||
var sessionKey, title string
|
||||
if err := rows.Scan(&sessionKey, &title); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, g := range groups {
|
||||
for _, g := range missing {
|
||||
pattern := ":" + g.ChannelName + ":group:" + g.HistoryKey
|
||||
if strings.Contains(sessionKey, pattern) {
|
||||
mapKey := g.ChannelName + ":" + g.HistoryKey
|
||||
@@ -284,3 +288,78 @@ func (s *SQLitePendingMessageStore) ResolveGroupTitles(ctx context.Context, grou
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLitePendingMessageStore) resolveGroupTitlesFromContacts(ctx context.Context, groups []store.PendingMessageGroup) (map[string]string, []store.PendingMessageGroup) {
|
||||
result := make(map[string]string, len(groups))
|
||||
unique := uniquePendingTitleGroups(groups)
|
||||
if len(unique) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
conditions := make([]string, 0, len(unique))
|
||||
args := make([]any, 0, len(unique)*2+1)
|
||||
for _, g := range unique {
|
||||
conditions = append(conditions, "(channel_instance = ? AND sender_id = ?)")
|
||||
args = append(args, g.ChannelName, g.HistoryKey)
|
||||
}
|
||||
|
||||
tenantFilter := ""
|
||||
if !store.IsCrossTenant(ctx) {
|
||||
tid := store.TenantIDFromContext(ctx)
|
||||
if tid == uuid.Nil {
|
||||
tid = store.MasterTenantID
|
||||
}
|
||||
tenantFilter = " AND tenant_id = ?"
|
||||
args = append(args, tid.String())
|
||||
}
|
||||
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT channel_instance, sender_id, display_name
|
||||
FROM channel_contacts
|
||||
WHERE display_name IS NOT NULL
|
||||
AND display_name <> ''
|
||||
AND contact_type IN ('group', 'topic')
|
||||
AND (`+strings.Join(conditions, " OR ")+`)`+tenantFilter,
|
||||
args...,
|
||||
)
|
||||
if err != nil {
|
||||
return result, unique
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var channelName, senderID, title string
|
||||
if err := rows.Scan(&channelName, &senderID, &title); err != nil {
|
||||
return result, unique
|
||||
}
|
||||
result[channelName+":"+senderID] = title
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return result, unique
|
||||
}
|
||||
|
||||
missing := make([]store.PendingMessageGroup, 0, len(unique)-len(result))
|
||||
for _, g := range unique {
|
||||
if result[g.ChannelName+":"+g.HistoryKey] == "" {
|
||||
missing = append(missing, g)
|
||||
}
|
||||
}
|
||||
return result, missing
|
||||
}
|
||||
|
||||
func uniquePendingTitleGroups(groups []store.PendingMessageGroup) []store.PendingMessageGroup {
|
||||
out := make([]store.PendingMessageGroup, 0, len(groups))
|
||||
seen := make(map[string]struct{}, len(groups))
|
||||
for _, g := range groups {
|
||||
if g.ChannelName == "" || g.HistoryKey == "" {
|
||||
continue
|
||||
}
|
||||
key := g.ChannelName + ":" + g.HistoryKey
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, g)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//go:build sqlite || sqliteonly
|
||||
|
||||
package sqlitestore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
func TestResolveGroupTitlesUsesChannelContacts(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
if err := EnsureSchema(db); err != nil {
|
||||
t.Fatalf("EnsureSchema: %v", err)
|
||||
}
|
||||
tenantID := uuid.New()
|
||||
if _, err := db.Exec(`INSERT INTO tenants (id, name, slug, status) VALUES (?, 'T', 't-pending-title', 'active')`, tenantID.String()); err != nil {
|
||||
t.Fatalf("insert tenant: %v", err)
|
||||
}
|
||||
|
||||
ctx := store.WithTenantID(context.Background(), tenantID)
|
||||
contacts := NewSQLiteContactStore(db)
|
||||
if err := contacts.UpsertContact(ctx, "discord", "discord-main", "thread-1", "", "launch-thread", "", "group", "group", "", ""); err != nil {
|
||||
t.Fatalf("upsert thread contact: %v", err)
|
||||
}
|
||||
if err := contacts.UpsertContact(ctx, "discord", "discord-main", "parent-1", "", "product-planning", "", "group", "group", "", ""); err != nil {
|
||||
t.Fatalf("upsert parent contact: %v", err)
|
||||
}
|
||||
|
||||
pending := NewSQLitePendingMessageStore(db)
|
||||
titles, err := pending.ResolveGroupTitles(ctx, []store.PendingMessageGroup{
|
||||
{ChannelName: "discord-main", HistoryKey: "thread-1"},
|
||||
{ChannelName: "discord-main", HistoryKey: "parent-1"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveGroupTitles: %v", err)
|
||||
}
|
||||
if titles["discord-main:thread-1"] != "launch-thread" {
|
||||
t.Fatalf("thread title = %q, want launch-thread", titles["discord-main:thread-1"])
|
||||
}
|
||||
if titles["discord-main:parent-1"] != "product-planning" {
|
||||
t.Fatalf("parent title = %q, want product-planning", titles["discord-main:parent-1"])
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,12 @@
|
||||
"accessControlDesc": "User access restrictions",
|
||||
"telegramManagement": "Telegram Management",
|
||||
"telegramManagementDesc": "Enable the hidden tool and choose Telegram actions for agents using this channel",
|
||||
"discordMetadata": "Discord Metadata",
|
||||
"discordMetadataDesc": "Refresh cached Discord channel, category, thread, and known user names from the running bot.",
|
||||
"refreshDiscordMetadata": "Refresh Discord Metadata",
|
||||
"refreshingDiscordMetadata": "Refreshing...",
|
||||
"discordMetadataRefreshed": "Discord metadata refreshed",
|
||||
"discordMetadataRefreshFailed": "Failed to refresh Discord metadata",
|
||||
"reviewDiagnostics": "Review the latest diagnosis in this channel before changing settings.",
|
||||
"whatHappened": "What happened",
|
||||
"recommendedAction": "Recommended action",
|
||||
@@ -214,6 +220,7 @@
|
||||
"types": "Memory types",
|
||||
"excludeUsers": "Excluded users",
|
||||
"excludePatterns": "Excluded patterns",
|
||||
"channelCustomPrompt": "Channel extraction instructions",
|
||||
"excludeGroups": "Excluded Discord channels",
|
||||
"excludeGroupsPlaceholder": "Select a channel to exclude",
|
||||
"manualExcludePlaceholder": "Paste Discord channel ID",
|
||||
@@ -246,6 +253,10 @@
|
||||
"approve": "Approve",
|
||||
"reject": "Reject",
|
||||
"delete": "Delete",
|
||||
"debugBadge": {
|
||||
"topic": "Topic",
|
||||
"entity": "Entity"
|
||||
},
|
||||
"type": {
|
||||
"people": "People",
|
||||
"projects": "Projects",
|
||||
@@ -253,7 +264,10 @@
|
||||
"todos": "Todos",
|
||||
"preferences": "Preferences",
|
||||
"events": "Events"
|
||||
}
|
||||
},
|
||||
"groupCustomPrompt": "Group extraction instructions",
|
||||
"groupCustomPromptPlaceholder": "Select a group to tune",
|
||||
"noPromptGroups": "No Discord channel history found yet."
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Timeline",
|
||||
@@ -303,8 +317,13 @@
|
||||
}
|
||||
},
|
||||
"fieldConfig": {
|
||||
"token": { "label": "Bot Token", "help": "From @BotFather" },
|
||||
"bot_token": { "label": "Bot Token" },
|
||||
"token": {
|
||||
"label": "Bot Token",
|
||||
"help": "From @BotFather"
|
||||
},
|
||||
"bot_token": {
|
||||
"label": "Bot Token"
|
||||
},
|
||||
"app_token": {
|
||||
"label": "App-Level Token",
|
||||
"help": "App-Level Token with connections:write scope (required for Socket Mode)"
|
||||
@@ -313,14 +332,23 @@
|
||||
"label": "User Token (Optional)",
|
||||
"help": "Optional: User OAuth Token for custom bot identity. Leave empty to use default bot identity."
|
||||
},
|
||||
"app_id": { "label": "App ID" },
|
||||
"app_secret": { "label": "App Secret" },
|
||||
"encrypt_key": { "label": "Encrypt Key", "help": "For webhook mode" },
|
||||
"app_id": {
|
||||
"label": "App ID"
|
||||
},
|
||||
"app_secret": {
|
||||
"label": "App Secret"
|
||||
},
|
||||
"encrypt_key": {
|
||||
"label": "Encrypt Key",
|
||||
"help": "For webhook mode"
|
||||
},
|
||||
"verification_token": {
|
||||
"label": "Verification Token",
|
||||
"help": "For webhook mode"
|
||||
},
|
||||
"webhook_secret": { "label": "Webhook Secret" },
|
||||
"webhook_secret": {
|
||||
"label": "Webhook Secret"
|
||||
},
|
||||
"api_server": {
|
||||
"label": "API Server URL",
|
||||
"help": "Custom Telegram Bot API server for large file uploads (up to 2GB). Leave empty for default."
|
||||
@@ -329,8 +357,12 @@
|
||||
"label": "HTTP Proxy",
|
||||
"help": "Route bot traffic through an HTTP proxy"
|
||||
},
|
||||
"dm_policy": { "label": "DM Policy" },
|
||||
"group_policy": { "label": "Group Policy" },
|
||||
"dm_policy": {
|
||||
"label": "DM Policy"
|
||||
},
|
||||
"group_policy": {
|
||||
"label": "Group Policy"
|
||||
},
|
||||
"require_mention": {
|
||||
"label": "Require @mention in groups",
|
||||
"disabledHint": "Multi-bot mode always listens to all messages"
|
||||
@@ -359,10 +391,18 @@
|
||||
"label": "Show Reasoning",
|
||||
"help": "Choose how model reasoning is shown in channel messages."
|
||||
},
|
||||
"reaction_level": { "label": "Reaction Level" },
|
||||
"media_max_mb": { "label": "Max Media Size (MB)" },
|
||||
"link_preview": { "label": "Link Preview" },
|
||||
"allow_from": { "label": "Allowed Users" },
|
||||
"reaction_level": {
|
||||
"label": "Reaction Level"
|
||||
},
|
||||
"media_max_mb": {
|
||||
"label": "Max Media Size (MB)"
|
||||
},
|
||||
"link_preview": {
|
||||
"label": "Link Preview"
|
||||
},
|
||||
"allow_from": {
|
||||
"label": "Allowed Users"
|
||||
},
|
||||
"telegram_manager": {
|
||||
"enabled": {
|
||||
"label": "Enable Telegram management tool",
|
||||
@@ -373,7 +413,9 @@
|
||||
"help": "Choose which Telegram actions the agent may use. If no permission is selected, the tool is not granted to the agent."
|
||||
}
|
||||
},
|
||||
"domain": { "label": "Domain" },
|
||||
"domain": {
|
||||
"label": "Domain"
|
||||
},
|
||||
"connection_mode": {
|
||||
"label": "Connection Mode",
|
||||
"help": "WebSocket needs no public IP — outbound connection only"
|
||||
@@ -386,12 +428,16 @@
|
||||
"label": "Webhook Path",
|
||||
"help": "Path on main server for Lark events"
|
||||
},
|
||||
"webhook_url": { "label": "Webhook URL" },
|
||||
"webhook_url": {
|
||||
"label": "Webhook URL"
|
||||
},
|
||||
"topic_session_mode": {
|
||||
"label": "Topic Session Mode",
|
||||
"help": "Use thread root_id for session isolation"
|
||||
},
|
||||
"render_mode": { "label": "Render Mode" },
|
||||
"render_mode": {
|
||||
"label": "Render Mode"
|
||||
},
|
||||
"text_chunk_limit": {
|
||||
"label": "Text Chunk Limit",
|
||||
"help": "Max characters per message"
|
||||
@@ -420,7 +466,9 @@
|
||||
"label": "Tool Allowlist",
|
||||
"help": "Restrict which tools the agent can use in this group"
|
||||
},
|
||||
"system_prompt": { "label": "System Prompt" },
|
||||
"system_prompt": {
|
||||
"label": "System Prompt"
|
||||
},
|
||||
"chat_behavior": {
|
||||
"enabled": {
|
||||
"label": "Human-like Delivery",
|
||||
@@ -493,8 +541,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"platform": { "label": "Platform", "help": "Select the platform this Pancake page serves." },
|
||||
"tiktok_type": { "label": "TikTok Type", "help": "Select the TikTok account type for this page" },
|
||||
"platform": {
|
||||
"label": "Platform",
|
||||
"help": "Select the platform this Pancake page serves."
|
||||
},
|
||||
"tiktok_type": {
|
||||
"label": "TikTok Type",
|
||||
"help": "Select the TikTok account type for this page"
|
||||
},
|
||||
"features.private_reply": {
|
||||
"label": "Private Reply (Comment → DM)",
|
||||
"help": "Send a one-time DM to commenters after the public reply. Facebook/Instagram only. Meta allows DM within 7 days of the comment."
|
||||
@@ -599,17 +653,17 @@
|
||||
"card": "Card"
|
||||
},
|
||||
"platform": {
|
||||
"facebook": "Facebook",
|
||||
"instagram": "Instagram",
|
||||
"threads": "Threads (Beta)",
|
||||
"tiktok": "TikTok",
|
||||
"youtube": "YouTube (Beta)",
|
||||
"shopee": "Shopee",
|
||||
"line": "Line",
|
||||
"google": "Google",
|
||||
"facebook": "Facebook",
|
||||
"instagram": "Instagram",
|
||||
"threads": "Threads (Beta)",
|
||||
"tiktok": "TikTok",
|
||||
"youtube": "YouTube (Beta)",
|
||||
"shopee": "Shopee",
|
||||
"line": "Line",
|
||||
"google": "Google",
|
||||
"chat_plugin": "Chat Plugin",
|
||||
"lazada": "Lazada",
|
||||
"tokopedia": "Tokopedia"
|
||||
"lazada": "Lazada",
|
||||
"tokopedia": "Tokopedia"
|
||||
}
|
||||
},
|
||||
"groupOverrides": {
|
||||
|
||||
@@ -11,14 +11,12 @@
|
||||
"failedToSave": "Failed to save configuration",
|
||||
"saveConfig": "Save Config",
|
||||
"usingGlobalDefaults": "Using global defaults",
|
||||
|
||||
"tabs.server": "Server",
|
||||
"tabs.behavior": "Behavior",
|
||||
"tabs.aiDefaults": "AI Defaults",
|
||||
"tabs.quota": "Quota",
|
||||
"tabs.tools": "Tools",
|
||||
"tabs.integrations": "Integrations",
|
||||
|
||||
"server.title": "Gateway Server",
|
||||
"server.description": "Network binding, authentication token, and access control",
|
||||
"branding.title": "Appearance Settings",
|
||||
@@ -53,7 +51,6 @@
|
||||
"branding.uploadFailed": "Branding asset upload failed",
|
||||
"branding.uploadErrors.too_large": "File must be 2 MB or smaller.",
|
||||
"branding.uploadErrors.unsupported_type": "Use SVG, PNG, JPG, WEBP, or ICO files.",
|
||||
|
||||
"gateway.host": "Host",
|
||||
"gateway.hostTip": "The network interface the gateway listens on. Use 0.0.0.0 to listen on all interfaces.",
|
||||
"gateway.port": "Port",
|
||||
@@ -74,7 +71,6 @@
|
||||
"gateway.inboundDebounceMs": "Inbound Debounce (ms)",
|
||||
"gateway.inboundDebounceMsTip": "Delay in milliseconds before processing rapid channel or Web Chat follow-ups. Set 0 for no debounce; positive values merge follow-ups after that wait.",
|
||||
"gateway.injectionAction": "Injection Detection Action",
|
||||
|
||||
"agents.title": "Agent Defaults",
|
||||
"agents.description": "Default LLM provider, model, and runtime settings applied to all agents unless overridden",
|
||||
"agents.providerTip": "Default LLM provider used when an agent does not specify one.",
|
||||
@@ -93,7 +89,6 @@
|
||||
"agents.restrictToWorkspaceTip": "Prevent agents from reading or writing files outside the workspace path.",
|
||||
"agents.intentClassify": "Busy Agent Message Classification",
|
||||
"agents.saveError": "Failed to save agent defaults",
|
||||
|
||||
"agents.subagents.title": "Subagents",
|
||||
"agents.subagents.desc": "Sub-agent spawning limits",
|
||||
"agents.subagents.maxConcurrent": "Max Concurrent",
|
||||
@@ -107,7 +102,6 @@
|
||||
"agents.subagents.modelOverride": "Model Override",
|
||||
"agents.subagents.modelOverrideTip": "Force all sub-agents to use a specific model. Leave empty to inherit from the parent agent.",
|
||||
"agents.subagents.modelOverridePlaceholder": "(inherit from agent)",
|
||||
|
||||
"agents.memory.title": "Memory",
|
||||
"agents.memory.desc": "Semantic memory search and embeddings",
|
||||
"agents.memory.enabled": "Enabled",
|
||||
@@ -123,7 +117,6 @@
|
||||
"agents.memory.chunkOverlapTip": "Overlap characters between chunks for context continuity.",
|
||||
"agents.memory.minScore": "Min Score",
|
||||
"agents.memory.minScoreTip": "Minimum similarity score (0-1) for a memory entry to be included in results.",
|
||||
|
||||
"agents.compaction.title": "Compaction",
|
||||
"agents.compaction.desc": "Context window compaction settings",
|
||||
"agents.compaction.reserveTokensFloor": "Reserve Tokens Floor",
|
||||
@@ -132,13 +125,11 @@
|
||||
"agents.compaction.maxHistoryShareTip": "Fraction of the context window that conversation history can occupy.",
|
||||
"agents.compaction.timeoutSeconds": "Timeout Seconds",
|
||||
"agents.compaction.timeoutSecondsTip": "Maximum time to wait for compaction before keeping the original history.",
|
||||
|
||||
"agents.pruning.title": "Context Pruning",
|
||||
"agents.pruning.desc": "Trim old tool results to free context space",
|
||||
"agents.pruning.mode": "Mode",
|
||||
"agents.pruning.keepLastAssistants": "Keep Last Assistants",
|
||||
"agents.pruning.keepLastAssistantsTip": "Number of recent assistant turns to always preserve during pruning.",
|
||||
|
||||
"agents.sandbox.title": "Sandbox",
|
||||
"agents.sandbox.desc": "Docker sandbox for code execution",
|
||||
"agents.sandbox.mode": "Mode",
|
||||
@@ -151,7 +142,6 @@
|
||||
"agents.sandbox.timeoutSec": "Timeout (sec)",
|
||||
"agents.sandbox.timeoutSecTip": "Maximum execution time in seconds before the sandbox is killed.",
|
||||
"agents.sandbox.networkEnabled": "Network Enabled",
|
||||
|
||||
"behavior.uxTitle": "UX Behavior",
|
||||
"behavior.uxDescription": "High-impact toggles that change how the gateway interacts with users",
|
||||
"behavior.intentClassifyHint": "When the agent is already processing a previous turn, classify new messages as status questions, cancel requests, steering instructions, or new requests.",
|
||||
@@ -225,12 +215,10 @@
|
||||
"behavior.scrubCredentialsHint": "Automatically redact API keys, tokens, and passwords found in tool output before returning to the agent.",
|
||||
"behavior.scrubCredentialsInfo": "Credential patterns in tool responses are masked before the agent sees them.",
|
||||
"behavior.scrubCredentialsOffInfo": "Credential scrubbing is disabled. Secrets in tool output may be exposed to the agent.",
|
||||
|
||||
"sessions.scope": "Session Scope",
|
||||
"sessions.scopeTip": "Determines how sessions are partitioned. 'Per Sender' gives each user a separate session; 'Global' shares one session across all users.",
|
||||
"sessions.dmScope": "DM Scope",
|
||||
"sessions.dmScopeTip": "How direct-message sessions are keyed. 'Per Channel Peer' is the recommended default.",
|
||||
|
||||
"quota.title": "Usage Quota",
|
||||
"quota.description": "Per-user message quota limits with optional per-provider, channel, and group overrides",
|
||||
"quota.enabled": "Enable Quotas",
|
||||
@@ -255,7 +243,6 @@
|
||||
"quota.selectProvider": "Select provider",
|
||||
"quota.selectChannel": "Select channel",
|
||||
"quota.selectGroup": "Select group",
|
||||
|
||||
"tools.title": "Tool Profile",
|
||||
"tools.description": "Default tool availability and per-hour call rate limit for all agents",
|
||||
"tools.profile": "Profile",
|
||||
@@ -283,7 +270,6 @@
|
||||
"tools.commandKeywordAllowlistJsonError": "Invalid JSON.",
|
||||
"tools.commandKeywordAllowlistArrayError": "Use a JSON array of rules.",
|
||||
"tools.scrubCredentials": "Scrub Credentials",
|
||||
|
||||
"browser.title": "Browser Automation",
|
||||
"browser.description": "Runtime settings for the server-side browser tool",
|
||||
"browser.enabled": "Enabled",
|
||||
@@ -300,14 +286,12 @@
|
||||
"browser.idleTimeoutTip": "Close idle pages after this duration. Set 0 to disable.",
|
||||
"browser.cookieSync": "Selected Cookie Sync",
|
||||
"browser.cookieSyncHint": "Apply explicitly synced cookies to matching user and agent browser sessions.",
|
||||
|
||||
"tts.title": "Text-to-Speech",
|
||||
"tts.configured": "Configured",
|
||||
"tts.disabled": "Disabled",
|
||||
"tts.providerInfo": "Provider: {{provider}} — Auto: {{auto}}",
|
||||
"tts.noProvider": "No TTS provider configured.",
|
||||
"tts.manageLink": "Manage TTS settings",
|
||||
|
||||
"cron.title": "Cron Scheduler",
|
||||
"cron.description": "Retry behaviour for failed scheduled tasks",
|
||||
"cron.maxRetries": "Max Retries",
|
||||
@@ -319,7 +303,6 @@
|
||||
"cron.defaultTimezone": "Default Timezone",
|
||||
"cron.defaultTimezoneTip": "IANA timezone for cron expressions when not set per-job (e.g. Asia/Ho_Chi_Minh). Leave empty to use server system timezone.",
|
||||
"cron.defaultTimezonePlaceholder": "System timezone",
|
||||
|
||||
"telemetry.title": "Telemetry (OpenTelemetry)",
|
||||
"telemetry.description": "Export traces and metrics to an OpenTelemetry-compatible backend",
|
||||
"telemetry.enabled": "Enabled",
|
||||
@@ -337,7 +320,6 @@
|
||||
"telemetry.headerKeyPlaceholder": "Header name",
|
||||
"telemetry.headerValuePlaceholder": "Header value",
|
||||
"telemetry.addHeader": "Add Header",
|
||||
|
||||
"bindings.title": "Agent Bindings",
|
||||
"bindings.description": "Route specific channels, peers, or accounts to a designated agent",
|
||||
"bindings.add": "Add Binding",
|
||||
@@ -346,7 +328,6 @@
|
||||
"bindings.channel": "Channel",
|
||||
"bindings.peerKind": "Peer Kind",
|
||||
"bindings.peerId": "Peer ID",
|
||||
|
||||
"providers.title": "Provider API Keys",
|
||||
"providers.description": "Override API keys and base URLs for configured LLM providers",
|
||||
"providers.noProviders": "No providers found in the configuration file.",
|
||||
@@ -354,7 +335,6 @@
|
||||
"providers.apiBaseUrl": "Base URL",
|
||||
"providers.apiBaseUrlPlaceholder": "https://api.example.com/v1",
|
||||
"providers.managedVia": "Managed via {{envKey}} environment variable.",
|
||||
|
||||
"channels.title": "Channel Settings",
|
||||
"channels.description": "Configure built-in channel integrations (tokens, policies, streaming)",
|
||||
"channels.noChannels": "No channels found in the configuration file.",
|
||||
@@ -384,9 +364,7 @@
|
||||
"channels.reactionFull": "Full",
|
||||
"channels.connectionWebsocket": "WebSocket",
|
||||
"channels.connectionWebhook": "Webhook",
|
||||
|
||||
"bindings.any": "Any",
|
||||
|
||||
"tabs.systemMessages": "System Messages",
|
||||
"systemMessages.title": "Custom System Messages",
|
||||
"systemMessages.description": "Override built-in gateway messages sent directly by channels",
|
||||
@@ -405,9 +383,11 @@
|
||||
"systemMessages.defaultTemplate": "Default Template",
|
||||
"systemMessages.defaultTemplateTip": "Built-in fallback template used when the override is empty.",
|
||||
"systemMessages.useDefault": "Use Default",
|
||||
|
||||
"toast": {
|
||||
"saved": "Config saved",
|
||||
"saveFailed": "Failed to save config"
|
||||
}
|
||||
},
|
||||
"behavior.passiveMemoryTitle": "Passive memory extraction",
|
||||
"behavior.passiveMemoryDescription": "Tenant-wide extraction instructions for passive channel memory.",
|
||||
"behavior.passiveMemoryPromptPlaceholder": "Example: Avoid duplicate facts and merge repeated updates into one concise item."
|
||||
}
|
||||
@@ -110,6 +110,12 @@
|
||||
"behaviorDesc": "응답 동작 설정",
|
||||
"accessControl": "접근 제어",
|
||||
"accessControlDesc": "사용자 접근 제한",
|
||||
"discordMetadata": "Discord 메타데이터",
|
||||
"discordMetadataDesc": "실행 중인 봇에서 캐시된 Discord 채널, 카테고리, 스레드 및 알려진 사용자 이름을 새로 고칩니다.",
|
||||
"refreshDiscordMetadata": "Discord 메타데이터 새로 고침",
|
||||
"refreshingDiscordMetadata": "새로 고치는 중...",
|
||||
"discordMetadataRefreshed": "Discord 메타데이터가 새로 고쳐졌습니다",
|
||||
"discordMetadataRefreshFailed": "Discord 메타데이터 새로 고침 실패",
|
||||
"reviewDiagnostics": "설정을 변경하기 전에 이 채널의 최신 진단을 검토하세요.",
|
||||
"whatHappened": "무슨 일이 있었는지",
|
||||
"recommendedAction": "권장 조치",
|
||||
@@ -202,6 +208,69 @@
|
||||
"failedAdd": "관리자 추가 실패"
|
||||
}
|
||||
}
|
||||
},
|
||||
"passiveMemory": {
|
||||
"title": "수동 메모리 추출",
|
||||
"description": "그룹 메시지에서 오래 유지할 사실을 추출해 검토 가능한 메모리로 만듭니다.",
|
||||
"pendingCount": "{{count}}개 검토 대기",
|
||||
"unprocessedCount": "{{count}}개 미처리 메시지",
|
||||
"enable": "수동 추출 활성화",
|
||||
"reviewMode": "저장 전 검토 필요",
|
||||
"interval": "간격 (분)",
|
||||
"messageCap": "메시지 한도",
|
||||
"retention": "보관 (시간)",
|
||||
"minMessages": "최소 메시지",
|
||||
"types": "메모리 유형",
|
||||
"excludeUsers": "제외 사용자",
|
||||
"excludePatterns": "제외 패턴",
|
||||
"channelCustomPrompt": "채널 추출 지침",
|
||||
"excludeGroups": "제외된 Discord 채널",
|
||||
"excludeGroupsPlaceholder": "제외할 채널 선택",
|
||||
"manualExcludePlaceholder": "Discord 채널 ID 붙여넣기",
|
||||
"addExcludedGroup": "추가",
|
||||
"noExcludeGroups": "아직 Discord 채널 기록이 없습니다.",
|
||||
"noExcludedGroups": "제외된 채널이 없습니다.",
|
||||
"removeExcludedGroup": "제외 채널에서 {{group}} 제거",
|
||||
"runNow": "최신 그룹 실행",
|
||||
"processAll": "모든 그룹 실행",
|
||||
"save": "저장",
|
||||
"saving": "저장 중...",
|
||||
"saved": "메모리 설정이 저장되었습니다",
|
||||
"saveFailed": "메모리 설정 저장 실패",
|
||||
"runQueued": "메모리 추출이 시작되었습니다",
|
||||
"runAllDone": "{{runs}}개 그룹에서 {{messages}}개 메시지를 처리했습니다",
|
||||
"runFailed": "메모리 추출 실패",
|
||||
"runAllProgress": "모든 그룹 실행 진행률",
|
||||
"runAllProgressStats": "{{runs}} 완료, {{skipped}} 건너뜀, {{errors}} 실패, {{messages}} 메시지",
|
||||
"groupCompleted": "{{historyKey}} 그룹 완료",
|
||||
"groupSkipped": "최소 메시지 수 미달로 {{historyKey}} 그룹 건너뜀 ({{messageCount}}개 메시지)",
|
||||
"groupFailed": "{{historyKey}} 그룹 실패: {{error}}",
|
||||
"itemUpdated": "메모리 항목이 업데이트되었습니다",
|
||||
"itemFailed": "메모리 항목 작업 실패",
|
||||
"reviewQueue": "검토 대기열",
|
||||
"loading": "메모리 상태 불러오는 중...",
|
||||
"noRuns": "아직 추출 실행 기록이 없습니다.",
|
||||
"noItems": "아직 메모리 후보가 없습니다.",
|
||||
"lastRun": "마지막 실행",
|
||||
"runStats": "{{messages}}개 메시지, {{items}}개 항목, {{redactions}}개 삭제",
|
||||
"approve": "승인",
|
||||
"reject": "거부",
|
||||
"delete": "삭제",
|
||||
"debugBadge": {
|
||||
"topic": "주제",
|
||||
"entity": "엔티티"
|
||||
},
|
||||
"type": {
|
||||
"people": "사람",
|
||||
"projects": "프로젝트",
|
||||
"decisions": "결정",
|
||||
"todos": "할 일",
|
||||
"preferences": "선호",
|
||||
"events": "이벤트"
|
||||
},
|
||||
"groupCustomPrompt": "그룹 추출 지침",
|
||||
"groupCustomPromptPlaceholder": "조정할 그룹 선택",
|
||||
"noPromptGroups": "아직 Discord 채널 기록이 없습니다."
|
||||
}
|
||||
},
|
||||
"fieldConfig": {
|
||||
|
||||
@@ -11,14 +11,12 @@
|
||||
"failedToSave": "설정 저장 실패",
|
||||
"saveConfig": "설정 저장",
|
||||
"usingGlobalDefaults": "전역 기본값 사용",
|
||||
|
||||
"tabs.server": "서버",
|
||||
"tabs.behavior": "동작",
|
||||
"tabs.aiDefaults": "AI 기본값",
|
||||
"tabs.quota": "할당량",
|
||||
"tabs.tools": "도구",
|
||||
"tabs.integrations": "통합",
|
||||
|
||||
"server.title": "게이트웨이 서버",
|
||||
"server.description": "네트워크 바인딩, 인증 토큰 및 접근 제어",
|
||||
"branding.title": "화면 설정",
|
||||
@@ -53,7 +51,6 @@
|
||||
"branding.uploadFailed": "브랜딩 자산 업로드 실패",
|
||||
"branding.uploadErrors.too_large": "파일은 2 MB 이하여야 합니다.",
|
||||
"branding.uploadErrors.unsupported_type": "SVG, PNG, JPG, WEBP 또는 ICO 파일을 사용하세요.",
|
||||
|
||||
"gateway.host": "호스트",
|
||||
"gateway.hostTip": "게이트웨이가 수신하는 네트워크 인터페이스입니다. 모든 인터페이스에서 수신하려면 0.0.0.0을 사용하세요.",
|
||||
"gateway.port": "포트",
|
||||
@@ -76,7 +73,6 @@
|
||||
"gateway.inboundDebounceMs": "인바운드 디바운스 (ms)",
|
||||
"gateway.inboundDebounceMsTip": "새 메시지를 처리하기 전 대기할 밀리초입니다. 빠른 후속 메시지를 통합하기 위해 사용합니다. 비활성화하려면 -1로 설정하세요.",
|
||||
"gateway.injectionAction": "주입 감지 조치",
|
||||
|
||||
"agents.title": "에이전트 기본값",
|
||||
"agents.description": "재정의하지 않는 한 모든 에이전트에 적용되는 기본 LLM 공급자, 모델 및 런타임 설정",
|
||||
"agents.providerTip": "에이전트가 지정하지 않을 때 사용되는 기본 LLM 공급자입니다.",
|
||||
@@ -95,7 +91,6 @@
|
||||
"agents.restrictToWorkspaceTip": "에이전트가 워크스페이스 경로 외부의 파일을 읽거나 쓸 수 없도록 합니다.",
|
||||
"agents.intentClassify": "에이전트 작업 중 메시지 분류",
|
||||
"agents.saveError": "에이전트 기본값 저장 실패",
|
||||
|
||||
"agents.subagents.title": "서브에이전트",
|
||||
"agents.subagents.desc": "서브에이전트 생성 제한",
|
||||
"agents.subagents.maxConcurrent": "최대 동시",
|
||||
@@ -109,7 +104,6 @@
|
||||
"agents.subagents.modelOverride": "모델 재정의",
|
||||
"agents.subagents.modelOverrideTip": "모든 서브에이전트가 특정 모델을 사용하도록 강제합니다. 부모 에이전트에서 상속하려면 비워두세요.",
|
||||
"agents.subagents.modelOverridePlaceholder": "(에이전트에서 상속)",
|
||||
|
||||
"agents.memory.title": "메모리",
|
||||
"agents.memory.desc": "의미 메모리 검색 및 임베딩",
|
||||
"agents.memory.enabled": "활성화됨",
|
||||
@@ -125,20 +119,17 @@
|
||||
"agents.memory.chunkOverlapTip": "컨텍스트 연속성을 위한 청크 간 겹침 문자 수입니다.",
|
||||
"agents.memory.minScore": "최소 점수",
|
||||
"agents.memory.minScoreTip": "결과에 포함될 메모리 항목의 최소 유사도 점수 (0-1)입니다.",
|
||||
|
||||
"agents.compaction.title": "압축",
|
||||
"agents.compaction.desc": "컨텍스트 창 압축 설정",
|
||||
"agents.compaction.reserveTokensFloor": "예약 토큰 하한",
|
||||
"agents.compaction.reserveTokensFloorTip": "압축 전 새 응답을 위해 예약할 최소 토큰 버퍼입니다.",
|
||||
"agents.compaction.maxHistoryShare": "최대 기록 비율 (0-1)",
|
||||
"agents.compaction.maxHistoryShareTip": "대화 기록이 차지할 수 있는 컨텍스트 창의 비율입니다.",
|
||||
|
||||
"agents.pruning.title": "컨텍스트 정리",
|
||||
"agents.pruning.desc": "컨텍스트 공간 확보를 위해 오래된 도구 결과 제거",
|
||||
"agents.pruning.mode": "모드",
|
||||
"agents.pruning.keepLastAssistants": "마지막 어시스턴트 유지",
|
||||
"agents.pruning.keepLastAssistantsTip": "정리 중 항상 보존할 최근 어시스턴트 턴 수입니다.",
|
||||
|
||||
"agents.sandbox.title": "샌드박스",
|
||||
"agents.sandbox.desc": "코드 실행을 위한 Docker 샌드박스",
|
||||
"agents.sandbox.mode": "모드",
|
||||
@@ -151,7 +142,6 @@
|
||||
"agents.sandbox.timeoutSec": "타임아웃 (초)",
|
||||
"agents.sandbox.timeoutSecTip": "샌드박스가 종료되기 전 최대 실행 시간(초)입니다.",
|
||||
"agents.sandbox.networkEnabled": "네트워크 활성화됨",
|
||||
|
||||
"behavior.uxTitle": "UX 동작",
|
||||
"behavior.uxDescription": "게이트웨이가 사용자와 상호작용하는 방식을 변경하는 주요 토글",
|
||||
"behavior.toolStatusHint": "에이전트 실행 중 사용자에게 실시간 도구 실행 상태 메시지를 표시합니다.",
|
||||
@@ -191,12 +181,10 @@
|
||||
"behavior.scrubCredentialsHint": "에이전트에 반환하기 전에 도구 출력에서 발견된 API 키, 토큰 및 비밀번호를 자동으로 수정합니다.",
|
||||
"behavior.scrubCredentialsInfo": "도구 응답의 자격증명 패턴은 에이전트가 보기 전에 마스킹됩니다.",
|
||||
"behavior.scrubCredentialsOffInfo": "자격증명 스크러빙이 비활성화되어 있습니다. 도구 출력의 시크릿이 에이전트에 노출될 수 있습니다.",
|
||||
|
||||
"sessions.scope": "세션 범위",
|
||||
"sessions.scopeTip": "세션 분할 방법을 결정합니다. '발신자별'은 각 사용자에게 별도 세션을 제공하고, '전역'은 모든 사용자가 하나의 세션을 공유합니다.",
|
||||
"sessions.dmScope": "DM 범위",
|
||||
"sessions.dmScopeTip": "DM 세션이 키로 구분되는 방법입니다. '채널 피어별'이 권장 기본값입니다.",
|
||||
|
||||
"quota.title": "사용 할당량",
|
||||
"quota.description": "선택적 공급자, 채널 및 그룹별 재정의가 있는 사용자별 메시지 할당량 제한",
|
||||
"quota.enabled": "할당량 활성화",
|
||||
@@ -221,7 +209,6 @@
|
||||
"quota.selectProvider": "공급자 선택",
|
||||
"quota.selectChannel": "채널 선택",
|
||||
"quota.selectGroup": "그룹 선택",
|
||||
|
||||
"tools.title": "도구 프로파일",
|
||||
"tools.description": "모든 에이전트에 대한 기본 도구 가용성 및 시간당 호출 속도 제한",
|
||||
"tools.profile": "프로파일",
|
||||
@@ -260,14 +247,12 @@
|
||||
"tools.browserEnabled": "활성화됨",
|
||||
"tools.browserHeadless": "헤드리스",
|
||||
"tools.scrubCredentials": "자격증명 스크러빙",
|
||||
|
||||
"tts.title": "텍스트 음성 변환",
|
||||
"tts.configured": "설정됨",
|
||||
"tts.disabled": "비활성화됨",
|
||||
"tts.providerInfo": "공급자: {{provider}} — 자동: {{auto}}",
|
||||
"tts.noProvider": "TTS 공급자가 설정되지 않았습니다.",
|
||||
"tts.manageLink": "TTS 설정 관리",
|
||||
|
||||
"cron.title": "Cron 스케줄러",
|
||||
"cron.description": "실패한 예약 작업의 재시도 동작",
|
||||
"cron.maxRetries": "최대 재시도",
|
||||
@@ -279,7 +264,6 @@
|
||||
"cron.defaultTimezone": "기본 시간대",
|
||||
"cron.defaultTimezoneTip": "작업별로 설정되지 않은 경우 cron 표현식의 IANA 시간대입니다 (예: Asia/Seoul). 서버 시스템 시간대를 사용하려면 비워두세요.",
|
||||
"cron.defaultTimezonePlaceholder": "시스템 시간대",
|
||||
|
||||
"telemetry.title": "텔레메트리 (OpenTelemetry)",
|
||||
"telemetry.description": "OpenTelemetry 호환 백엔드로 추적 및 메트릭 내보내기",
|
||||
"telemetry.enabled": "활성화됨",
|
||||
@@ -297,7 +281,6 @@
|
||||
"telemetry.headerKeyPlaceholder": "헤더 이름",
|
||||
"telemetry.headerValuePlaceholder": "헤더 값",
|
||||
"telemetry.addHeader": "헤더 추가",
|
||||
|
||||
"bindings.title": "에이전트 바인딩",
|
||||
"bindings.description": "특정 채널, 피어 또는 계정을 지정된 에이전트로 라우팅합니다",
|
||||
"bindings.add": "바인딩 추가",
|
||||
@@ -306,7 +289,6 @@
|
||||
"bindings.channel": "채널",
|
||||
"bindings.peerKind": "피어 종류",
|
||||
"bindings.peerId": "피어 ID",
|
||||
|
||||
"providers.title": "공급자 API 키",
|
||||
"providers.description": "설정된 LLM 공급자의 API 키와 기본 URL을 재정의합니다",
|
||||
"providers.noProviders": "설정 파일에서 공급자를 찾을 수 없습니다.",
|
||||
@@ -314,7 +296,6 @@
|
||||
"providers.apiBaseUrl": "기본 URL",
|
||||
"providers.apiBaseUrlPlaceholder": "https://api.example.com/v1",
|
||||
"providers.managedVia": "{{envKey}} 환경 변수를 통해 관리됩니다.",
|
||||
|
||||
"channels.title": "채널 설정",
|
||||
"channels.description": "내장 채널 통합 설정 (토큰, 정책, 스트리밍)",
|
||||
"channels.noChannels": "설정 파일에서 채널을 찾을 수 없습니다.",
|
||||
@@ -341,9 +322,7 @@
|
||||
"channels.reactionFull": "전체",
|
||||
"channels.connectionWebsocket": "WebSocket",
|
||||
"channels.connectionWebhook": "웹훅",
|
||||
|
||||
"bindings.any": "모두",
|
||||
|
||||
"tabs.systemMessages": "시스템 메시지",
|
||||
"systemMessages.title": "사용자 지정 시스템 메시지",
|
||||
"systemMessages.description": "채널에서 직접 보내는 기본 게이트웨이 메시지를 재정의합니다",
|
||||
@@ -362,9 +341,11 @@
|
||||
"systemMessages.defaultTemplate": "기본 템플릿",
|
||||
"systemMessages.defaultTemplateTip": "재정의가 비어 있을 때 사용하는 기본 템플릿입니다.",
|
||||
"systemMessages.useDefault": "기본값 사용",
|
||||
|
||||
"toast": {
|
||||
"saved": "설정 저장됨",
|
||||
"saveFailed": "설정 저장 실패"
|
||||
}
|
||||
},
|
||||
"behavior.passiveMemoryTitle": "수동 메모리 추출",
|
||||
"behavior.passiveMemoryDescription": "채널 수동 메모리에 적용되는 테넌트 전체 추출 지침입니다.",
|
||||
"behavior.passiveMemoryPromptPlaceholder": "예: 중복 사실을 피하고 반복 업데이트를 하나의 간결한 항목으로 병합합니다."
|
||||
}
|
||||
@@ -111,6 +111,12 @@
|
||||
"accessControlDesc": "Giới hạn truy cập người dùng",
|
||||
"telegramManagement": "Quản trị Telegram",
|
||||
"telegramManagementDesc": "Bật tool ẩn và chọn quyền thao tác Telegram cho agent chạy qua channel này",
|
||||
"discordMetadata": "Metadata Discord",
|
||||
"discordMetadataDesc": "Làm mới cache tên channel, category, thread và user Discord đã biết từ bot đang chạy.",
|
||||
"refreshDiscordMetadata": "Làm mới metadata Discord",
|
||||
"refreshingDiscordMetadata": "Đang làm mới...",
|
||||
"discordMetadataRefreshed": "Đã làm mới metadata Discord",
|
||||
"discordMetadataRefreshFailed": "Làm mới metadata Discord thất bại",
|
||||
"reviewDiagnostics": "Xem chẩn đoán mới nhất của channel này trước khi thay đổi cài đặt.",
|
||||
"whatHappened": "Điều gì đã xảy ra",
|
||||
"recommendedAction": "Hành động được khuyến nghị",
|
||||
@@ -213,6 +219,7 @@
|
||||
"types": "Loại bộ nhớ",
|
||||
"excludeUsers": "User loại trừ",
|
||||
"excludePatterns": "Pattern loại trừ",
|
||||
"channelCustomPrompt": "Prompt trích xuất cấp channel",
|
||||
"excludeGroups": "Channel Discord loại trừ",
|
||||
"excludeGroupsPlaceholder": "Chọn channel cần loại trừ",
|
||||
"manualExcludePlaceholder": "Dán Discord channel ID",
|
||||
@@ -245,6 +252,10 @@
|
||||
"approve": "Duyệt",
|
||||
"reject": "Từ chối",
|
||||
"delete": "Xóa",
|
||||
"debugBadge": {
|
||||
"topic": "Chủ đề",
|
||||
"entity": "Thực thể"
|
||||
},
|
||||
"type": {
|
||||
"people": "Con người",
|
||||
"projects": "Dự án",
|
||||
@@ -252,7 +263,10 @@
|
||||
"todos": "Việc cần làm",
|
||||
"preferences": "Sở thích",
|
||||
"events": "Sự kiện"
|
||||
}
|
||||
},
|
||||
"groupCustomPrompt": "Prompt trích xuất cấp group",
|
||||
"groupCustomPromptPlaceholder": "Chọn group cần tinh chỉnh",
|
||||
"noPromptGroups": "Chưa có lịch sử channel Discord."
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Dòng thời gian",
|
||||
@@ -302,30 +316,92 @@
|
||||
}
|
||||
},
|
||||
"fieldConfig": {
|
||||
"token": { "label": "Bot Token", "help": "Từ @BotFather" },
|
||||
"bot_token": { "label": "Bot Token" },
|
||||
"app_token": { "label": "Token cấp ứng dụng", "help": "Token cấp ứng dụng với scope connections:write (bắt buộc cho Socket Mode)" },
|
||||
"user_token": { "label": "Token người dùng (Tùy chọn)", "help": "Tùy chọn: Token OAuth người dùng cho danh tính bot tùy chỉnh. Để trống để dùng danh tính bot mặc định." },
|
||||
"app_id": { "label": "App ID" },
|
||||
"app_secret": { "label": "App Secret" },
|
||||
"encrypt_key": { "label": "Khóa mã hóa", "help": "Cho chế độ webhook" },
|
||||
"verification_token": { "label": "Token xác minh", "help": "Cho chế độ webhook" },
|
||||
"webhook_secret": { "label": "Webhook Secret" },
|
||||
"api_server": { "label": "URL máy chủ API", "help": "Máy chủ API Bot Telegram tùy chỉnh cho tải file lớn (lên đến 2GB). Để trống cho mặc định." },
|
||||
"proxy": { "label": "Proxy HTTP", "help": "Định tuyến lưu lượng bot qua proxy HTTP" },
|
||||
"dm_policy": { "label": "Chính sách DM" },
|
||||
"group_policy": { "label": "Chính sách nhóm" },
|
||||
"require_mention": { "label": "Yêu cầu @đề cập trong nhóm", "disabledHint": "Chế độ đa bot luôn lắng nghe tất cả tin nhắn" },
|
||||
"mention_mode": { "label": "Hành vi phản hồi nhóm", "help": "Cách bot quyết định khi nào phản hồi trong nhóm có nhiều bot." },
|
||||
"history_limit": { "label": "Giới hạn lịch sử nhóm", "help": "Số tin nhắn nhóm chờ tối đa cho ngữ cảnh (0 = tắt)" },
|
||||
"dm_stream": { "label": "Stream DM", "help": "Truyền phản hồi liên tục trong DM" },
|
||||
"group_stream": { "label": "Stream nhóm", "help": "Truyền phản hồi liên tục trong nhóm" },
|
||||
"draft_transport": { "label": "Xem trước nháp", "help": "Dùng bản nháp ẩn cho stream trả lời trong DM — không thông báo mỗi lần chỉnh sửa (cần bật Stream DM)" },
|
||||
"reasoning_delivery": { "label": "Hiện suy luận", "help": "Chọn cách hiển thị suy luận của model trong tin nhắn channel." },
|
||||
"reaction_level": { "label": "Mức độ reaction" },
|
||||
"media_max_mb": { "label": "Kích thước media tối đa (MB)" },
|
||||
"link_preview": { "label": "Xem trước liên kết" },
|
||||
"allow_from": { "label": "Người dùng được phép" },
|
||||
"token": {
|
||||
"label": "Bot Token",
|
||||
"help": "Từ @BotFather"
|
||||
},
|
||||
"bot_token": {
|
||||
"label": "Bot Token"
|
||||
},
|
||||
"app_token": {
|
||||
"label": "Token cấp ứng dụng",
|
||||
"help": "Token cấp ứng dụng với scope connections:write (bắt buộc cho Socket Mode)"
|
||||
},
|
||||
"user_token": {
|
||||
"label": "Token người dùng (Tùy chọn)",
|
||||
"help": "Tùy chọn: Token OAuth người dùng cho danh tính bot tùy chỉnh. Để trống để dùng danh tính bot mặc định."
|
||||
},
|
||||
"app_id": {
|
||||
"label": "App ID"
|
||||
},
|
||||
"app_secret": {
|
||||
"label": "App Secret"
|
||||
},
|
||||
"encrypt_key": {
|
||||
"label": "Khóa mã hóa",
|
||||
"help": "Cho chế độ webhook"
|
||||
},
|
||||
"verification_token": {
|
||||
"label": "Token xác minh",
|
||||
"help": "Cho chế độ webhook"
|
||||
},
|
||||
"webhook_secret": {
|
||||
"label": "Webhook Secret"
|
||||
},
|
||||
"api_server": {
|
||||
"label": "URL máy chủ API",
|
||||
"help": "Máy chủ API Bot Telegram tùy chỉnh cho tải file lớn (lên đến 2GB). Để trống cho mặc định."
|
||||
},
|
||||
"proxy": {
|
||||
"label": "Proxy HTTP",
|
||||
"help": "Định tuyến lưu lượng bot qua proxy HTTP"
|
||||
},
|
||||
"dm_policy": {
|
||||
"label": "Chính sách DM"
|
||||
},
|
||||
"group_policy": {
|
||||
"label": "Chính sách nhóm"
|
||||
},
|
||||
"require_mention": {
|
||||
"label": "Yêu cầu @đề cập trong nhóm",
|
||||
"disabledHint": "Chế độ đa bot luôn lắng nghe tất cả tin nhắn"
|
||||
},
|
||||
"mention_mode": {
|
||||
"label": "Hành vi phản hồi nhóm",
|
||||
"help": "Cách bot quyết định khi nào phản hồi trong nhóm có nhiều bot."
|
||||
},
|
||||
"history_limit": {
|
||||
"label": "Giới hạn lịch sử nhóm",
|
||||
"help": "Số tin nhắn nhóm chờ tối đa cho ngữ cảnh (0 = tắt)"
|
||||
},
|
||||
"dm_stream": {
|
||||
"label": "Stream DM",
|
||||
"help": "Truyền phản hồi liên tục trong DM"
|
||||
},
|
||||
"group_stream": {
|
||||
"label": "Stream nhóm",
|
||||
"help": "Truyền phản hồi liên tục trong nhóm"
|
||||
},
|
||||
"draft_transport": {
|
||||
"label": "Xem trước nháp",
|
||||
"help": "Dùng bản nháp ẩn cho stream trả lời trong DM — không thông báo mỗi lần chỉnh sửa (cần bật Stream DM)"
|
||||
},
|
||||
"reasoning_delivery": {
|
||||
"label": "Hiện suy luận",
|
||||
"help": "Chọn cách hiển thị suy luận của model trong tin nhắn channel."
|
||||
},
|
||||
"reaction_level": {
|
||||
"label": "Mức độ reaction"
|
||||
},
|
||||
"media_max_mb": {
|
||||
"label": "Kích thước media tối đa (MB)"
|
||||
},
|
||||
"link_preview": {
|
||||
"label": "Xem trước liên kết"
|
||||
},
|
||||
"allow_from": {
|
||||
"label": "Người dùng được phép"
|
||||
},
|
||||
"telegram_manager": {
|
||||
"enabled": {
|
||||
"label": "Bật công cụ quản trị Telegram",
|
||||
@@ -336,21 +412,62 @@
|
||||
"help": "Chọn các thao tác Telegram mà agent được phép dùng. Nếu chưa chọn quyền nào thì tool không được cấp cho agent."
|
||||
}
|
||||
},
|
||||
"domain": { "label": "Tên miền" },
|
||||
"connection_mode": { "label": "Chế độ kết nối", "help": "WebSocket không cần IP công khai — chỉ kết nối ra ngoài" },
|
||||
"webhook_port": { "label": "Cổng webhook", "help": "0 = chia sẻ cổng gateway chính (khuyến nghị)" },
|
||||
"webhook_path": { "label": "Đường dẫn webhook", "help": "Đường dẫn trên máy chủ chính cho sự kiện Lark" },
|
||||
"webhook_url": { "label": "URL Webhook" },
|
||||
"topic_session_mode": { "label": "Chế độ phiên chủ đề", "help": "Dùng root_id của luồng để cách ly phiên" },
|
||||
"render_mode": { "label": "Chế độ hiển thị" },
|
||||
"text_chunk_limit": { "label": "Giới hạn ký tự", "help": "Số ký tự tối đa mỗi tin nhắn" },
|
||||
"group_allow_from": { "label": "Người dùng nhóm được phép", "help": "Danh sách cho phép riêng cho người gửi nhóm" },
|
||||
"native_stream": { "label": "Stream gốc (Agents & AI Apps)", "help": "Dùng API ChatStreamer của Slack cho stream gốc. Quay về chỉnh sửa tại chỗ nếu không khả dụng." },
|
||||
"debounce_delay": { "label": "Độ trễ debounce (ms)", "help": "Mili giây chờ trước khi gửi tin nhắn nhanh. Đặt 0 để tắt." },
|
||||
"thread_ttl": { "label": "TTL tham gia luồng (giờ)", "help": "Số giờ trước khi bot ngừng tự động trả lời trong các luồng đã tham gia. 0 = luôn yêu cầu @đề cập." },
|
||||
"skills": { "label": "Bộ lọc skill", "help": "Giới hạn skill khả dụng cho nhóm này" },
|
||||
"tools": { "label": "Danh sách công cụ được phép", "help": "Giới hạn công cụ agent có thể dùng trong nhóm này" },
|
||||
"system_prompt": { "label": "Prompt hệ thống" },
|
||||
"domain": {
|
||||
"label": "Tên miền"
|
||||
},
|
||||
"connection_mode": {
|
||||
"label": "Chế độ kết nối",
|
||||
"help": "WebSocket không cần IP công khai — chỉ kết nối ra ngoài"
|
||||
},
|
||||
"webhook_port": {
|
||||
"label": "Cổng webhook",
|
||||
"help": "0 = chia sẻ cổng gateway chính (khuyến nghị)"
|
||||
},
|
||||
"webhook_path": {
|
||||
"label": "Đường dẫn webhook",
|
||||
"help": "Đường dẫn trên máy chủ chính cho sự kiện Lark"
|
||||
},
|
||||
"webhook_url": {
|
||||
"label": "URL Webhook"
|
||||
},
|
||||
"topic_session_mode": {
|
||||
"label": "Chế độ phiên chủ đề",
|
||||
"help": "Dùng root_id của luồng để cách ly phiên"
|
||||
},
|
||||
"render_mode": {
|
||||
"label": "Chế độ hiển thị"
|
||||
},
|
||||
"text_chunk_limit": {
|
||||
"label": "Giới hạn ký tự",
|
||||
"help": "Số ký tự tối đa mỗi tin nhắn"
|
||||
},
|
||||
"group_allow_from": {
|
||||
"label": "Người dùng nhóm được phép",
|
||||
"help": "Danh sách cho phép riêng cho người gửi nhóm"
|
||||
},
|
||||
"native_stream": {
|
||||
"label": "Stream gốc (Agents & AI Apps)",
|
||||
"help": "Dùng API ChatStreamer của Slack cho stream gốc. Quay về chỉnh sửa tại chỗ nếu không khả dụng."
|
||||
},
|
||||
"debounce_delay": {
|
||||
"label": "Độ trễ debounce (ms)",
|
||||
"help": "Mili giây chờ trước khi gửi tin nhắn nhanh. Đặt 0 để tắt."
|
||||
},
|
||||
"thread_ttl": {
|
||||
"label": "TTL tham gia luồng (giờ)",
|
||||
"help": "Số giờ trước khi bot ngừng tự động trả lời trong các luồng đã tham gia. 0 = luôn yêu cầu @đề cập."
|
||||
},
|
||||
"skills": {
|
||||
"label": "Bộ lọc skill",
|
||||
"help": "Giới hạn skill khả dụng cho nhóm này"
|
||||
},
|
||||
"tools": {
|
||||
"label": "Danh sách công cụ được phép",
|
||||
"help": "Giới hạn công cụ agent có thể dùng trong nhóm này"
|
||||
},
|
||||
"system_prompt": {
|
||||
"label": "Prompt hệ thống"
|
||||
},
|
||||
"chat_behavior": {
|
||||
"enabled": {
|
||||
"label": "Hành vi gửi tin tự nhiên",
|
||||
@@ -423,8 +540,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"platform": { "label": "Nền tảng", "help": "Chọn nền tảng mà trang Pancake này phục vụ." },
|
||||
"tiktok_type": { "label": "Loại tài khoản TikTok", "help": "Chọn loại tài khoản TikTok cho trang này" },
|
||||
"platform": {
|
||||
"label": "Nền tảng",
|
||||
"help": "Chọn nền tảng mà trang Pancake này phục vụ."
|
||||
},
|
||||
"tiktok_type": {
|
||||
"label": "Loại tài khoản TikTok",
|
||||
"help": "Chọn loại tài khoản TikTok cho trang này"
|
||||
},
|
||||
"features.private_reply": {
|
||||
"label": "Private Reply (Comment → DM)",
|
||||
"help": "Gửi tin nhắn riêng cho người bình luận sau khi đã trả lời công khai. Chỉ Facebook/Instagram. Meta cho phép DM trong vòng 7 ngày kể từ khi có bình luận."
|
||||
@@ -529,17 +652,17 @@
|
||||
"card": "Thẻ"
|
||||
},
|
||||
"platform": {
|
||||
"facebook": "Facebook",
|
||||
"instagram": "Instagram",
|
||||
"threads": "Threads (Beta)",
|
||||
"tiktok": "TikTok",
|
||||
"youtube": "YouTube (Beta)",
|
||||
"shopee": "Shopee",
|
||||
"line": "Line",
|
||||
"google": "Google",
|
||||
"facebook": "Facebook",
|
||||
"instagram": "Instagram",
|
||||
"threads": "Threads (Beta)",
|
||||
"tiktok": "TikTok",
|
||||
"youtube": "YouTube (Beta)",
|
||||
"shopee": "Shopee",
|
||||
"line": "Line",
|
||||
"google": "Google",
|
||||
"chat_plugin": "Chat Plugin (Website)",
|
||||
"lazada": "Lazada",
|
||||
"tokopedia": "Tokopedia"
|
||||
"lazada": "Lazada",
|
||||
"tokopedia": "Tokopedia"
|
||||
}
|
||||
},
|
||||
"groupOverrides": {
|
||||
@@ -555,10 +678,22 @@
|
||||
"addTopicPlaceholder": "ID luồng chủ đề",
|
||||
"addTopic": "Thêm chủ đề",
|
||||
"fieldConfig": {
|
||||
"dm_stream": { "label": "Stream DM", "help": "Truyền phản hồi liên tục trong DM" },
|
||||
"group_stream": { "label": "Stream Nhóm", "help": "Truyền phản hồi liên tục trong nhóm" },
|
||||
"draft_transport": { "label": "Xem trước nháp", "help": "Dùng bản nháp ẩn cho stream trả lời trong DM — không thông báo mỗi lần chỉnh sửa (cần bật Stream DM)" },
|
||||
"reasoning_delivery": { "label": "Hiện suy luận", "help": "Chọn cách hiển thị suy luận của model trong tin nhắn channel." }
|
||||
"dm_stream": {
|
||||
"label": "Stream DM",
|
||||
"help": "Truyền phản hồi liên tục trong DM"
|
||||
},
|
||||
"group_stream": {
|
||||
"label": "Stream Nhóm",
|
||||
"help": "Truyền phản hồi liên tục trong nhóm"
|
||||
},
|
||||
"draft_transport": {
|
||||
"label": "Xem trước nháp",
|
||||
"help": "Dùng bản nháp ẩn cho stream trả lời trong DM — không thông báo mỗi lần chỉnh sửa (cần bật Stream DM)"
|
||||
},
|
||||
"reasoning_delivery": {
|
||||
"label": "Hiện suy luận",
|
||||
"help": "Chọn cách hiển thị suy luận của model trong tin nhắn channel."
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"groupPolicy": "Chính sách nhóm",
|
||||
|
||||
@@ -11,14 +11,12 @@
|
||||
"failedToSave": "Lưu cấu hình thất bại",
|
||||
"saveConfig": "Lưu cấu hình",
|
||||
"usingGlobalDefaults": "Đang dùng cài đặt mặc định",
|
||||
|
||||
"tabs.server": "Máy chủ",
|
||||
"tabs.behavior": "Hành vi",
|
||||
"tabs.aiDefaults": "Mặc định AI",
|
||||
"tabs.quota": "Hạn mức",
|
||||
"tabs.tools": "Công cụ",
|
||||
"tabs.integrations": "Tích hợp",
|
||||
|
||||
"server.title": "Máy chủ Gateway",
|
||||
"server.description": "Cài đặt mạng, token xác thực và kiểm soát truy cập",
|
||||
"branding.title": "Cài đặt giao diện",
|
||||
@@ -53,7 +51,6 @@
|
||||
"branding.uploadFailed": "Tải asset thương hiệu thất bại",
|
||||
"branding.uploadErrors.too_large": "Tệp phải nhỏ hơn hoặc bằng 2 MB.",
|
||||
"branding.uploadErrors.unsupported_type": "Dùng tệp SVG, PNG, JPG, WEBP hoặc ICO.",
|
||||
|
||||
"gateway.host": "Host",
|
||||
"gateway.hostTip": "Giao diện mạng mà gateway lắng nghe. Dùng 0.0.0.0 để lắng nghe trên tất cả giao diện.",
|
||||
"gateway.port": "Cổng",
|
||||
@@ -74,7 +71,6 @@
|
||||
"gateway.inboundDebounceMs": "Debounce đầu vào (ms)",
|
||||
"gateway.inboundDebounceMsTip": "Độ trễ tính bằng mili giây trước khi xử lý các tin nhắn nhanh liên tiếp từ channel hoặc Web Chat. Đặt 0 để không debounce; giá trị dương sẽ gộp tin nhắn sau thời gian chờ đó.",
|
||||
"gateway.injectionAction": "Hành động phát hiện injection",
|
||||
|
||||
"agents.title": "Mặc định agent",
|
||||
"agents.description": "Provider LLM, model và cài đặt runtime mặc định áp dụng cho tất cả agent trừ khi được ghi đè",
|
||||
"agents.providerTip": "Provider LLM mặc định dùng khi agent không chỉ định provider.",
|
||||
@@ -93,7 +89,6 @@
|
||||
"agents.restrictToWorkspaceTip": "Ngăn agent đọc hoặc ghi tệp ngoài đường dẫn workspace.",
|
||||
"agents.intentClassify": "Phân loại tin nhắn khi agent đang bận",
|
||||
"agents.saveError": "Lưu mặc định agent thất bại",
|
||||
|
||||
"agents.subagents.title": "Subagent",
|
||||
"agents.subagents.desc": "Giới hạn tạo subagent",
|
||||
"agents.subagents.maxConcurrent": "Tối đa đồng thời",
|
||||
@@ -107,7 +102,6 @@
|
||||
"agents.subagents.modelOverride": "Ghi đè model",
|
||||
"agents.subagents.modelOverrideTip": "Buộc tất cả subagent dùng model cụ thể. Để trống để kế thừa từ agent cha.",
|
||||
"agents.subagents.modelOverridePlaceholder": "(kế thừa từ agent)",
|
||||
|
||||
"agents.memory.title": "Bộ nhớ",
|
||||
"agents.memory.desc": "Tìm kiếm bộ nhớ ngữ nghĩa và nhúng",
|
||||
"agents.memory.enabled": "Đã bật",
|
||||
@@ -123,7 +117,6 @@
|
||||
"agents.memory.chunkOverlapTip": "Số ký tự chồng lấp giữa các chunk để giữ ngữ cảnh.",
|
||||
"agents.memory.minScore": "Điểm tối thiểu",
|
||||
"agents.memory.minScoreTip": "Điểm tương đồng tối thiểu (0-1) để đưa mục bộ nhớ vào kết quả.",
|
||||
|
||||
"agents.compaction.title": "Nén ngữ cảnh",
|
||||
"agents.compaction.desc": "Cài đặt nén cửa sổ ngữ cảnh",
|
||||
"agents.compaction.reserveTokensFloor": "Sàn token dự trữ",
|
||||
@@ -132,13 +125,11 @@
|
||||
"agents.compaction.maxHistoryShareTip": "Tỷ lệ cửa sổ ngữ cảnh mà lịch sử cuộc trò chuyện có thể chiếm.",
|
||||
"agents.compaction.timeoutSeconds": "Thời gian chờ (giây)",
|
||||
"agents.compaction.timeoutSecondsTip": "Thời gian tối đa chờ nén ngữ cảnh trước khi giữ nguyên lịch sử cũ.",
|
||||
|
||||
"agents.pruning.title": "Cắt bớt ngữ cảnh",
|
||||
"agents.pruning.desc": "Cắt bỏ kết quả công cụ cũ để giải phóng không gian ngữ cảnh",
|
||||
"agents.pruning.mode": "Chế độ",
|
||||
"agents.pruning.keepLastAssistants": "Giữ trợ lý cuối",
|
||||
"agents.pruning.keepLastAssistantsTip": "Số lượt trợ lý gần nhất luôn được giữ lại trong quá trình cắt bớt.",
|
||||
|
||||
"agents.sandbox.title": "Hộp cát",
|
||||
"agents.sandbox.desc": "Hộp cát Docker để thực thi code",
|
||||
"agents.sandbox.mode": "Chế độ",
|
||||
@@ -151,7 +142,6 @@
|
||||
"agents.sandbox.timeoutSec": "Hết giờ (giây)",
|
||||
"agents.sandbox.timeoutSecTip": "Thời gian thực thi tối đa tính bằng giây trước khi hộp cát bị dừng.",
|
||||
"agents.sandbox.networkEnabled": "Bật mạng",
|
||||
|
||||
"behavior.uxTitle": "Hành vi giao diện",
|
||||
"behavior.uxDescription": "Các công tắc có tác động lớn thay đổi cách gateway tương tác với người dùng",
|
||||
"behavior.intentClassifyHint": "Khi agent đang xử lý một lượt trước đó, phân loại tin nhắn mới thành hỏi trạng thái, huỷ, bổ sung hướng dẫn hoặc yêu cầu mới.",
|
||||
@@ -224,12 +214,10 @@
|
||||
"behavior.scrubCredentialsHint": "Tự động ẩn khóa API, token và mật khẩu tìm thấy trong đầu ra công cụ trước khi trả về agent.",
|
||||
"behavior.scrubCredentialsInfo": "Các mẫu thông tin xác thực trong phản hồi công cụ được che đi trước khi agent nhìn thấy.",
|
||||
"behavior.scrubCredentialsOffInfo": "Tính năng ẩn thông tin xác thực bị tắt. Bí mật trong đầu ra công cụ có thể bị lộ với agent.",
|
||||
|
||||
"sessions.scope": "Phạm vi phiên",
|
||||
"sessions.scopeTip": "Xác định cách phân vùng phiên. 'Theo người gửi' cho mỗi người dùng một phiên riêng; 'Toàn cục' chia sẻ một phiên cho tất cả người dùng.",
|
||||
"sessions.dmScope": "Phạm vi tin nhắn riêng",
|
||||
"sessions.dmScopeTip": "Cách đặt khóa phiên tin nhắn riêng. 'Theo kênh và đối tác' là mặc định được khuyến nghị.",
|
||||
|
||||
"quota.title": "Hạn mức sử dụng",
|
||||
"quota.description": "Giới hạn hạn mức tin nhắn mỗi người dùng với tùy chọn ghi đè theo provider, kênh và nhóm",
|
||||
"quota.enabled": "Bật hạn mức",
|
||||
@@ -254,7 +242,6 @@
|
||||
"quota.selectProvider": "Chọn provider",
|
||||
"quota.selectChannel": "Chọn kênh",
|
||||
"quota.selectGroup": "Chọn nhóm",
|
||||
|
||||
"tools.title": "Hồ sơ công cụ",
|
||||
"tools.description": "Tính khả dụng công cụ mặc định và giới hạn tốc độ gọi mỗi giờ cho tất cả agent",
|
||||
"tools.profile": "Hồ sơ",
|
||||
@@ -282,7 +269,6 @@
|
||||
"tools.commandKeywordAllowlistJsonError": "JSON không hợp lệ.",
|
||||
"tools.commandKeywordAllowlistArrayError": "Dùng mảng JSON các rule.",
|
||||
"tools.scrubCredentials": "Ẩn thông tin xác thực",
|
||||
|
||||
"browser.title": "Tự động hóa trình duyệt",
|
||||
"browser.description": "Cài đặt runtime cho công cụ trình duyệt phía máy chủ",
|
||||
"browser.enabled": "Đã bật",
|
||||
@@ -299,14 +285,12 @@
|
||||
"browser.idleTimeoutTip": "Đóng trang không hoạt động sau thời lượng này. Đặt 0 để tắt.",
|
||||
"browser.cookieSync": "Đồng bộ cookie đã chọn",
|
||||
"browser.cookieSyncHint": "Áp dụng cookie được đồng bộ rõ ràng vào phiên trình duyệt đúng người dùng và agent.",
|
||||
|
||||
"tts.title": "Chuyển văn bản thành giọng nói",
|
||||
"tts.configured": "Đã cấu hình",
|
||||
"tts.disabled": "Đã tắt",
|
||||
"tts.providerInfo": "Provider: {{provider}} — Tự động: {{auto}}",
|
||||
"tts.noProvider": "Chưa cấu hình provider TTS.",
|
||||
"tts.manageLink": "Quản lý cài đặt TTS",
|
||||
|
||||
"cron.title": "Trình lập lịch Cron",
|
||||
"cron.description": "Hành vi thử lại cho các tác vụ định kỳ thất bại",
|
||||
"cron.maxRetries": "Số lần thử lại tối đa",
|
||||
@@ -318,7 +302,6 @@
|
||||
"cron.defaultTimezone": "Múi giờ mặc định",
|
||||
"cron.defaultTimezoneTip": "Múi giờ IANA cho biểu thức cron khi không được đặt cho từng job (vd: Asia/Ho_Chi_Minh). Để trống sẽ dùng múi giờ hệ thống.",
|
||||
"cron.defaultTimezonePlaceholder": "Múi giờ hệ thống",
|
||||
|
||||
"telemetry.title": "Telemetry (OpenTelemetry)",
|
||||
"telemetry.description": "Xuất trace và metric sang backend tương thích OpenTelemetry",
|
||||
"telemetry.enabled": "Đã bật",
|
||||
@@ -336,7 +319,6 @@
|
||||
"telemetry.headerKeyPlaceholder": "Tên header",
|
||||
"telemetry.headerValuePlaceholder": "Giá trị header",
|
||||
"telemetry.addHeader": "Thêm header",
|
||||
|
||||
"bindings.title": "Ràng buộc agent",
|
||||
"bindings.description": "Định tuyến kênh, đối tác hoặc tài khoản cụ thể đến agent được chỉ định",
|
||||
"bindings.add": "Thêm ràng buộc",
|
||||
@@ -345,7 +327,6 @@
|
||||
"bindings.channel": "Kênh",
|
||||
"bindings.peerKind": "Loại đối tác",
|
||||
"bindings.peerId": "ID đối tác",
|
||||
|
||||
"providers.title": "Khóa API provider",
|
||||
"providers.description": "Ghi đè khóa API và URL cơ sở cho các provider LLM đã cấu hình",
|
||||
"providers.noProviders": "Không tìm thấy provider nào trong tệp cấu hình.",
|
||||
@@ -353,7 +334,6 @@
|
||||
"providers.apiBaseUrl": "URL cơ sở",
|
||||
"providers.apiBaseUrlPlaceholder": "https://api.example.com/v1",
|
||||
"providers.managedVia": "Được quản lý qua biến môi trường {{envKey}}.",
|
||||
|
||||
"channels.title": "Cài đặt kênh",
|
||||
"channels.description": "Cấu hình tích hợp kênh tích hợp sẵn (token, chính sách, streaming)",
|
||||
"channels.noChannels": "Không tìm thấy kênh nào trong tệp cấu hình.",
|
||||
@@ -383,9 +363,7 @@
|
||||
"channels.reactionFull": "Đầy đủ",
|
||||
"channels.connectionWebsocket": "WebSocket",
|
||||
"channels.connectionWebhook": "Webhook",
|
||||
|
||||
"bindings.any": "Bất kỳ",
|
||||
|
||||
"tabs.systemMessages": "Thông báo hệ thống",
|
||||
"systemMessages.title": "Tùy chỉnh thông báo hệ thống",
|
||||
"systemMessages.description": "Ghi đè các thông báo mà gateway gửi trực tiếp qua kênh",
|
||||
@@ -404,9 +382,11 @@
|
||||
"systemMessages.defaultTemplate": "Mẫu mặc định",
|
||||
"systemMessages.defaultTemplateTip": "Mẫu có sẵn được dùng khi phần ghi đè để trống.",
|
||||
"systemMessages.useDefault": "Dùng mặc định",
|
||||
|
||||
"toast": {
|
||||
"saved": "Đã lưu cấu hình",
|
||||
"saveFailed": "Không thể lưu cấu hình"
|
||||
}
|
||||
},
|
||||
"behavior.passiveMemoryTitle": "Trích xuất bộ nhớ thụ động",
|
||||
"behavior.passiveMemoryDescription": "Prompt trích xuất cấp tenant cho bộ nhớ thụ động từ channel.",
|
||||
"behavior.passiveMemoryPromptPlaceholder": "Ví dụ: Tránh fact trùng lặp và gộp các cập nhật lặp lại thành một mục ngắn gọn."
|
||||
}
|
||||
@@ -111,6 +111,12 @@
|
||||
"accessControlDesc": "用户访问限制",
|
||||
"telegramManagement": "Telegram 管理",
|
||||
"telegramManagementDesc": "为使用此频道的 Agent 启用隐藏工具并选择 Telegram 操作权限",
|
||||
"discordMetadata": "Discord 元数据",
|
||||
"discordMetadataDesc": "从正在运行的机器人刷新缓存的 Discord 频道、分类、线程和已知用户名称。",
|
||||
"refreshDiscordMetadata": "刷新 Discord 元数据",
|
||||
"refreshingDiscordMetadata": "刷新中...",
|
||||
"discordMetadataRefreshed": "Discord 元数据已刷新",
|
||||
"discordMetadataRefreshFailed": "刷新 Discord 元数据失败",
|
||||
"reviewDiagnostics": "在更改设置前,请先查看此 Channel 的最新诊断。",
|
||||
"whatHappened": "发生了什么",
|
||||
"recommendedAction": "建议操作",
|
||||
@@ -213,6 +219,7 @@
|
||||
"types": "记忆类型",
|
||||
"excludeUsers": "排除用户",
|
||||
"excludePatterns": "排除规则",
|
||||
"channelCustomPrompt": "频道提取指令",
|
||||
"excludeGroups": "排除的 Discord 频道",
|
||||
"excludeGroupsPlaceholder": "选择要排除的频道",
|
||||
"manualExcludePlaceholder": "粘贴 Discord 频道 ID",
|
||||
@@ -245,6 +252,10 @@
|
||||
"approve": "批准",
|
||||
"reject": "拒绝",
|
||||
"delete": "删除",
|
||||
"debugBadge": {
|
||||
"topic": "主题",
|
||||
"entity": "实体"
|
||||
},
|
||||
"type": {
|
||||
"people": "人物",
|
||||
"projects": "项目",
|
||||
@@ -252,7 +263,10 @@
|
||||
"todos": "待办",
|
||||
"preferences": "偏好",
|
||||
"events": "事件"
|
||||
}
|
||||
},
|
||||
"groupCustomPrompt": "群组提取指令",
|
||||
"groupCustomPromptPlaceholder": "选择要调优的群组",
|
||||
"noPromptGroups": "尚未发现 Discord 频道历史。"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "时间线",
|
||||
@@ -302,30 +316,92 @@
|
||||
}
|
||||
},
|
||||
"fieldConfig": {
|
||||
"token": { "label": "Bot Token", "help": "来自 @BotFather" },
|
||||
"bot_token": { "label": "Bot Token" },
|
||||
"app_token": { "label": "应用级Token", "help": "具有 connections:write 权限的应用级Token(Socket Mode 必需)" },
|
||||
"user_token": { "label": "用户Token(可选)", "help": "可选:用于自定义Bot身份的用户OAuth Token。留空使用默认Bot身份。" },
|
||||
"app_id": { "label": "App ID" },
|
||||
"app_secret": { "label": "App Secret" },
|
||||
"encrypt_key": { "label": "加密密钥", "help": "用于Webhook模式" },
|
||||
"verification_token": { "label": "验证Token", "help": "用于Webhook模式" },
|
||||
"webhook_secret": { "label": "Webhook Secret" },
|
||||
"api_server": { "label": "API 服务器 URL", "help": "自定义 Telegram Bot API 服务器用于大文件上传(最大 2GB)。留空使用默认服务器。" },
|
||||
"proxy": { "label": "HTTP 代理", "help": "通过 HTTP 代理路由Bot流量" },
|
||||
"dm_policy": { "label": "私聊策略" },
|
||||
"group_policy": { "label": "群组策略" },
|
||||
"require_mention": { "label": "群组中需要 @提及", "disabledHint": "多机器人模式始终监听所有消息" },
|
||||
"mention_mode": { "label": "群组响应行为", "help": "机器人在多机器人群组中如何决定何时响应。" },
|
||||
"history_limit": { "label": "群组历史限制", "help": "上下文中最大待处理群组消息数(0 = 禁用)" },
|
||||
"dm_stream": { "label": "私聊流式传输", "help": "在私聊中逐步传输回复" },
|
||||
"group_stream": { "label": "群聊流式传输", "help": "在群聊中逐步传输回复" },
|
||||
"draft_transport": { "label": "草稿预览", "help": "在私聊中使用隐形草稿预览回答流 — 每次编辑无通知(需启用私聊流式传输)" },
|
||||
"reasoning_delivery": { "label": "显示推理", "help": "选择如何在频道消息中显示模型推理。" },
|
||||
"reaction_level": { "label": "表情回应级别" },
|
||||
"media_max_mb": { "label": "最大媒体大小 (MB)" },
|
||||
"link_preview": { "label": "链接预览" },
|
||||
"allow_from": { "label": "允许的用户" },
|
||||
"token": {
|
||||
"label": "Bot Token",
|
||||
"help": "来自 @BotFather"
|
||||
},
|
||||
"bot_token": {
|
||||
"label": "Bot Token"
|
||||
},
|
||||
"app_token": {
|
||||
"label": "应用级Token",
|
||||
"help": "具有 connections:write 权限的应用级Token(Socket Mode 必需)"
|
||||
},
|
||||
"user_token": {
|
||||
"label": "用户Token(可选)",
|
||||
"help": "可选:用于自定义Bot身份的用户OAuth Token。留空使用默认Bot身份。"
|
||||
},
|
||||
"app_id": {
|
||||
"label": "App ID"
|
||||
},
|
||||
"app_secret": {
|
||||
"label": "App Secret"
|
||||
},
|
||||
"encrypt_key": {
|
||||
"label": "加密密钥",
|
||||
"help": "用于Webhook模式"
|
||||
},
|
||||
"verification_token": {
|
||||
"label": "验证Token",
|
||||
"help": "用于Webhook模式"
|
||||
},
|
||||
"webhook_secret": {
|
||||
"label": "Webhook Secret"
|
||||
},
|
||||
"api_server": {
|
||||
"label": "API 服务器 URL",
|
||||
"help": "自定义 Telegram Bot API 服务器用于大文件上传(最大 2GB)。留空使用默认服务器。"
|
||||
},
|
||||
"proxy": {
|
||||
"label": "HTTP 代理",
|
||||
"help": "通过 HTTP 代理路由Bot流量"
|
||||
},
|
||||
"dm_policy": {
|
||||
"label": "私聊策略"
|
||||
},
|
||||
"group_policy": {
|
||||
"label": "群组策略"
|
||||
},
|
||||
"require_mention": {
|
||||
"label": "群组中需要 @提及",
|
||||
"disabledHint": "多机器人模式始终监听所有消息"
|
||||
},
|
||||
"mention_mode": {
|
||||
"label": "群组响应行为",
|
||||
"help": "机器人在多机器人群组中如何决定何时响应。"
|
||||
},
|
||||
"history_limit": {
|
||||
"label": "群组历史限制",
|
||||
"help": "上下文中最大待处理群组消息数(0 = 禁用)"
|
||||
},
|
||||
"dm_stream": {
|
||||
"label": "私聊流式传输",
|
||||
"help": "在私聊中逐步传输回复"
|
||||
},
|
||||
"group_stream": {
|
||||
"label": "群聊流式传输",
|
||||
"help": "在群聊中逐步传输回复"
|
||||
},
|
||||
"draft_transport": {
|
||||
"label": "草稿预览",
|
||||
"help": "在私聊中使用隐形草稿预览回答流 — 每次编辑无通知(需启用私聊流式传输)"
|
||||
},
|
||||
"reasoning_delivery": {
|
||||
"label": "显示推理",
|
||||
"help": "选择如何在频道消息中显示模型推理。"
|
||||
},
|
||||
"reaction_level": {
|
||||
"label": "表情回应级别"
|
||||
},
|
||||
"media_max_mb": {
|
||||
"label": "最大媒体大小 (MB)"
|
||||
},
|
||||
"link_preview": {
|
||||
"label": "链接预览"
|
||||
},
|
||||
"allow_from": {
|
||||
"label": "允许的用户"
|
||||
},
|
||||
"telegram_manager": {
|
||||
"enabled": {
|
||||
"label": "启用 Telegram 管理工具",
|
||||
@@ -336,21 +412,62 @@
|
||||
"help": "选择 Agent 可执行的 Telegram 操作。未选择任何权限时,不会向 Agent 授予此工具。"
|
||||
}
|
||||
},
|
||||
"domain": { "label": "域名" },
|
||||
"connection_mode": { "label": "连接模式", "help": "WebSocket 无需公网 IP — 仅需出站连接" },
|
||||
"webhook_port": { "label": "Webhook 端口", "help": "0 = 共享主网关端口(推荐)" },
|
||||
"webhook_path": { "label": "Webhook 路径", "help": "主服务器上 Lark 事件的路径" },
|
||||
"webhook_url": { "label": "Webhook URL" },
|
||||
"topic_session_mode": { "label": "话题会话模式", "help": "使用线程 root_id 进行会话隔离" },
|
||||
"render_mode": { "label": "渲染模式" },
|
||||
"text_chunk_limit": { "label": "文本分段限制", "help": "每条消息最大字符数" },
|
||||
"group_allow_from": { "label": "群组允许的用户", "help": "群组发送者的单独白名单" },
|
||||
"native_stream": { "label": "原生流式传输 (Agents & AI Apps)", "help": "使用 Slack 的 ChatStreamer API 进行原生流式传输。不可用时回退到原地编辑。" },
|
||||
"debounce_delay": { "label": "防抖延迟 (ms)", "help": "发送快速消息前等待的毫秒数。设为 0 禁用。" },
|
||||
"thread_ttl": { "label": "线程参与 TTL(小时)", "help": "Bot 停止自动回复已参与线程的小时数。0 = 始终需要 @提及。" },
|
||||
"skills": { "label": "Skill过滤", "help": "限制此群组可用的Skill" },
|
||||
"tools": { "label": "工具白名单", "help": "限制Agent在此群组中可使用的工具" },
|
||||
"system_prompt": { "label": "系统提示词" },
|
||||
"domain": {
|
||||
"label": "域名"
|
||||
},
|
||||
"connection_mode": {
|
||||
"label": "连接模式",
|
||||
"help": "WebSocket 无需公网 IP — 仅需出站连接"
|
||||
},
|
||||
"webhook_port": {
|
||||
"label": "Webhook 端口",
|
||||
"help": "0 = 共享主网关端口(推荐)"
|
||||
},
|
||||
"webhook_path": {
|
||||
"label": "Webhook 路径",
|
||||
"help": "主服务器上 Lark 事件的路径"
|
||||
},
|
||||
"webhook_url": {
|
||||
"label": "Webhook URL"
|
||||
},
|
||||
"topic_session_mode": {
|
||||
"label": "话题会话模式",
|
||||
"help": "使用线程 root_id 进行会话隔离"
|
||||
},
|
||||
"render_mode": {
|
||||
"label": "渲染模式"
|
||||
},
|
||||
"text_chunk_limit": {
|
||||
"label": "文本分段限制",
|
||||
"help": "每条消息最大字符数"
|
||||
},
|
||||
"group_allow_from": {
|
||||
"label": "群组允许的用户",
|
||||
"help": "群组发送者的单独白名单"
|
||||
},
|
||||
"native_stream": {
|
||||
"label": "原生流式传输 (Agents & AI Apps)",
|
||||
"help": "使用 Slack 的 ChatStreamer API 进行原生流式传输。不可用时回退到原地编辑。"
|
||||
},
|
||||
"debounce_delay": {
|
||||
"label": "防抖延迟 (ms)",
|
||||
"help": "发送快速消息前等待的毫秒数。设为 0 禁用。"
|
||||
},
|
||||
"thread_ttl": {
|
||||
"label": "线程参与 TTL(小时)",
|
||||
"help": "Bot 停止自动回复已参与线程的小时数。0 = 始终需要 @提及。"
|
||||
},
|
||||
"skills": {
|
||||
"label": "Skill过滤",
|
||||
"help": "限制此群组可用的Skill"
|
||||
},
|
||||
"tools": {
|
||||
"label": "工具白名单",
|
||||
"help": "限制Agent在此群组中可使用的工具"
|
||||
},
|
||||
"system_prompt": {
|
||||
"label": "系统提示词"
|
||||
},
|
||||
"chat_behavior": {
|
||||
"enabled": {
|
||||
"label": "自然投递行为",
|
||||
@@ -423,8 +540,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"platform": { "label": "平台", "help": "选择此 Pancake 页面所服务的平台。" },
|
||||
"tiktok_type": { "label": "TikTok 类型", "help": "选择此页面的 TikTok 账户类型" },
|
||||
"platform": {
|
||||
"label": "平台",
|
||||
"help": "选择此 Pancake 页面所服务的平台。"
|
||||
},
|
||||
"tiktok_type": {
|
||||
"label": "TikTok 类型",
|
||||
"help": "选择此页面的 TikTok 账户类型"
|
||||
},
|
||||
"features.private_reply": {
|
||||
"label": "私信回复(评论 → 私信)",
|
||||
"help": "在公开回复后向评论者发送一次性私信。仅支持 Facebook/Instagram,Meta 允许在评论后 7 天内发送。"
|
||||
@@ -529,17 +652,17 @@
|
||||
"card": "卡片"
|
||||
},
|
||||
"platform": {
|
||||
"facebook": "Facebook",
|
||||
"instagram": "Instagram",
|
||||
"threads": "Threads (测试版)",
|
||||
"tiktok": "TikTok",
|
||||
"youtube": "YouTube (测试版)",
|
||||
"shopee": "Shopee",
|
||||
"line": "Line",
|
||||
"google": "Google",
|
||||
"facebook": "Facebook",
|
||||
"instagram": "Instagram",
|
||||
"threads": "Threads (测试版)",
|
||||
"tiktok": "TikTok",
|
||||
"youtube": "YouTube (测试版)",
|
||||
"shopee": "Shopee",
|
||||
"line": "Line",
|
||||
"google": "Google",
|
||||
"chat_plugin": "Chat Plugin",
|
||||
"lazada": "Lazada",
|
||||
"tokopedia": "Tokopedia"
|
||||
"lazada": "Lazada",
|
||||
"tokopedia": "Tokopedia"
|
||||
}
|
||||
},
|
||||
"groupOverrides": {
|
||||
@@ -555,10 +678,22 @@
|
||||
"addTopicPlaceholder": "话题线程 ID",
|
||||
"addTopic": "添加话题",
|
||||
"fieldConfig": {
|
||||
"dm_stream": { "label": "私聊流式传输", "help": "在私聊中逐步传输回复" },
|
||||
"group_stream": { "label": "群聊流式传输", "help": "在群聊中逐步传输回复" },
|
||||
"draft_transport": { "label": "草稿预览", "help": "在私聊中使用隐形草稿预览回答流 — 每次编辑无通知(需启用私聊流式传输)" },
|
||||
"reasoning_delivery": { "label": "显示推理", "help": "选择如何在频道消息中显示模型推理。" }
|
||||
"dm_stream": {
|
||||
"label": "私聊流式传输",
|
||||
"help": "在私聊中逐步传输回复"
|
||||
},
|
||||
"group_stream": {
|
||||
"label": "群聊流式传输",
|
||||
"help": "在群聊中逐步传输回复"
|
||||
},
|
||||
"draft_transport": {
|
||||
"label": "草稿预览",
|
||||
"help": "在私聊中使用隐形草稿预览回答流 — 每次编辑无通知(需启用私聊流式传输)"
|
||||
},
|
||||
"reasoning_delivery": {
|
||||
"label": "显示推理",
|
||||
"help": "选择如何在频道消息中显示模型推理。"
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"groupPolicy": "群组策略",
|
||||
|
||||
@@ -11,14 +11,12 @@
|
||||
"failedToSave": "保存配置失败",
|
||||
"saveConfig": "保存配置",
|
||||
"usingGlobalDefaults": "使用全局默认值",
|
||||
|
||||
"tabs.server": "服务器",
|
||||
"tabs.behavior": "行为",
|
||||
"tabs.aiDefaults": "AI 默认值",
|
||||
"tabs.quota": "配额",
|
||||
"tabs.tools": "工具",
|
||||
"tabs.integrations": "集成",
|
||||
|
||||
"server.title": "网关服务器",
|
||||
"server.description": "网络绑定、认证令牌和访问控制",
|
||||
"branding.title": "界面设置",
|
||||
@@ -53,7 +51,6 @@
|
||||
"branding.uploadFailed": "品牌资源上传失败",
|
||||
"branding.uploadErrors.too_large": "文件必须不超过 2 MB。",
|
||||
"branding.uploadErrors.unsupported_type": "请使用 SVG、PNG、JPG、WEBP 或 ICO 文件。",
|
||||
|
||||
"gateway.host": "主机",
|
||||
"gateway.hostTip": "网关监听的网络接口。使用 0.0.0.0 监听所有接口。",
|
||||
"gateway.port": "端口",
|
||||
@@ -74,7 +71,6 @@
|
||||
"gateway.inboundDebounceMs": "入站防抖(毫秒)",
|
||||
"gateway.inboundDebounceMsTip": "处理来自频道或 Web Chat 的快速连续消息前的延迟毫秒数。设为 0 表示不防抖;正数会在该等待时间后合并连续消息。",
|
||||
"gateway.injectionAction": "注入检测动作",
|
||||
|
||||
"agents.title": "Agent 默认值",
|
||||
"agents.description": "默认 LLM 提供商、模型和运行时设置,适用于所有 Agent(除非被覆盖)",
|
||||
"agents.providerTip": "当 Agent 未指定提供商时使用的默认 LLM 提供商。",
|
||||
@@ -93,7 +89,6 @@
|
||||
"agents.restrictToWorkspaceTip": "阻止 Agent 读写工作区路径以外的文件。",
|
||||
"agents.intentClassify": "Agent 忙碌时的消息分类",
|
||||
"agents.saveError": "保存 Agent 默认值失败",
|
||||
|
||||
"agents.subagents.title": "子 Agent",
|
||||
"agents.subagents.desc": "子 Agent 生成限制",
|
||||
"agents.subagents.maxConcurrent": "最大并发数",
|
||||
@@ -107,7 +102,6 @@
|
||||
"agents.subagents.modelOverride": "模型覆盖",
|
||||
"agents.subagents.modelOverrideTip": "强制所有子 Agent 使用特定模型。留空则从父 Agent 继承。",
|
||||
"agents.subagents.modelOverridePlaceholder": "(从 Agent 继承)",
|
||||
|
||||
"agents.memory.title": "记忆",
|
||||
"agents.memory.desc": "语义记忆搜索和嵌入",
|
||||
"agents.memory.enabled": "已启用",
|
||||
@@ -123,7 +117,6 @@
|
||||
"agents.memory.chunkOverlapTip": "连续块之间的重叠字符数,用于保持上下文连续性。",
|
||||
"agents.memory.minScore": "最低分数",
|
||||
"agents.memory.minScoreTip": "记忆条目被纳入结果的最低相似度分数(0-1)。",
|
||||
|
||||
"agents.compaction.title": "上下文压缩",
|
||||
"agents.compaction.desc": "上下文窗口压缩设置",
|
||||
"agents.compaction.reserveTokensFloor": "保留 Token 下限",
|
||||
@@ -132,13 +125,11 @@
|
||||
"agents.compaction.maxHistoryShareTip": "对话历史可占用的上下文窗口比例。",
|
||||
"agents.compaction.timeoutSeconds": "超时秒数",
|
||||
"agents.compaction.timeoutSecondsTip": "等待上下文压缩的最长时间,超时后保留原始历史。",
|
||||
|
||||
"agents.pruning.title": "上下文裁剪",
|
||||
"agents.pruning.desc": "裁剪旧工具结果以释放上下文空间",
|
||||
"agents.pruning.mode": "模式",
|
||||
"agents.pruning.keepLastAssistants": "保留最后的助手轮次",
|
||||
"agents.pruning.keepLastAssistantsTip": "裁剪期间始终保留的最近助手轮次数量。",
|
||||
|
||||
"agents.sandbox.title": "沙箱",
|
||||
"agents.sandbox.desc": "用于代码执行的 Docker 沙箱",
|
||||
"agents.sandbox.mode": "模式",
|
||||
@@ -151,7 +142,6 @@
|
||||
"agents.sandbox.timeoutSec": "超时(秒)",
|
||||
"agents.sandbox.timeoutSecTip": "沙箱被终止前的最大执行时间(秒)。",
|
||||
"agents.sandbox.networkEnabled": "启用网络",
|
||||
|
||||
"behavior.uxTitle": "界面行为",
|
||||
"behavior.uxDescription": "影响网关与用户交互方式的高影响开关",
|
||||
"behavior.intentClassifyHint": "当 Agent 正在处理上一轮请求时,将新消息分类为状态询问、取消、补充指令或新的请求。",
|
||||
@@ -224,12 +214,10 @@
|
||||
"behavior.scrubCredentialsHint": "在返回给 Agent 之前,自动从工具输出中删除 API 密钥、令牌和密码。",
|
||||
"behavior.scrubCredentialsInfo": "工具响应中的凭证模式在 Agent 看到之前被屏蔽。",
|
||||
"behavior.scrubCredentialsOffInfo": "凭证擦除已禁用,工具输出中的密钥可能暴露给 Agent。",
|
||||
|
||||
"sessions.scope": "会话范围",
|
||||
"sessions.scopeTip": "确定会话分区方式。「按发送者」为每个用户提供单独的会话;「全局」在所有用户之间共享一个会话。",
|
||||
"sessions.dmScope": "私信范围",
|
||||
"sessions.dmScopeTip": "私信会话的键控方式。推荐默认使用「按频道对等方」。",
|
||||
|
||||
"quota.title": "使用配额",
|
||||
"quota.description": "每用户消息配额限制,可选按提供商、频道和群组覆盖",
|
||||
"quota.enabled": "启用配额",
|
||||
@@ -254,7 +242,6 @@
|
||||
"quota.selectProvider": "选择提供商",
|
||||
"quota.selectChannel": "选择频道",
|
||||
"quota.selectGroup": "选择群组",
|
||||
|
||||
"tools.title": "工具配置文件",
|
||||
"tools.description": "所有 Agent 的默认工具可用性和每小时调用速率限制",
|
||||
"tools.profile": "配置文件",
|
||||
@@ -282,7 +269,6 @@
|
||||
"tools.commandKeywordAllowlistJsonError": "JSON 无效。",
|
||||
"tools.commandKeywordAllowlistArrayError": "请使用规则 JSON 数组。",
|
||||
"tools.scrubCredentials": "擦除凭证",
|
||||
|
||||
"browser.title": "浏览器自动化",
|
||||
"browser.description": "服务器端浏览器工具的运行时设置",
|
||||
"browser.enabled": "已启用",
|
||||
@@ -299,14 +285,12 @@
|
||||
"browser.idleTimeoutTip": "页面空闲超过此时间后关闭。设为 0 禁用。",
|
||||
"browser.cookieSync": "选定 Cookie 同步",
|
||||
"browser.cookieSyncHint": "将明确同步的 Cookie 应用于匹配的用户和 Agent 浏览器会话。",
|
||||
|
||||
"tts.title": "文字转语音",
|
||||
"tts.configured": "已配置",
|
||||
"tts.disabled": "已禁用",
|
||||
"tts.providerInfo": "提供商:{{provider}} — 自动:{{auto}}",
|
||||
"tts.noProvider": "未配置 TTS 提供商。",
|
||||
"tts.manageLink": "管理 TTS 设置",
|
||||
|
||||
"cron.title": "定时任务调度器",
|
||||
"cron.description": "失败定时任务的重试行为",
|
||||
"cron.maxRetries": "最大重试次数",
|
||||
@@ -318,7 +302,6 @@
|
||||
"cron.defaultTimezone": "默认时区",
|
||||
"cron.defaultTimezoneTip": "未为单个任务设置时区时,cron 表达式使用的 IANA 时区(例如 Asia/Shanghai)。留空则使用服务器系统时区。",
|
||||
"cron.defaultTimezonePlaceholder": "系统时区",
|
||||
|
||||
"telemetry.title": "遥测(OpenTelemetry)",
|
||||
"telemetry.description": "将追踪和指标导出到兼容 OpenTelemetry 的后端",
|
||||
"telemetry.enabled": "已启用",
|
||||
@@ -336,7 +319,6 @@
|
||||
"telemetry.headerKeyPlaceholder": "请求头名称",
|
||||
"telemetry.headerValuePlaceholder": "请求头值",
|
||||
"telemetry.addHeader": "添加请求头",
|
||||
|
||||
"bindings.title": "Agent 绑定",
|
||||
"bindings.description": "将特定频道、对等方或账户路由到指定的 Agent",
|
||||
"bindings.add": "添加绑定",
|
||||
@@ -345,7 +327,6 @@
|
||||
"bindings.channel": "频道",
|
||||
"bindings.peerKind": "对等方类型",
|
||||
"bindings.peerId": "对等方 ID",
|
||||
|
||||
"providers.title": "提供商 API 密钥",
|
||||
"providers.description": "覆盖已配置 LLM 提供商的 API 密钥和基础 URL",
|
||||
"providers.noProviders": "配置文件中未找到任何提供商。",
|
||||
@@ -353,7 +334,6 @@
|
||||
"providers.apiBaseUrl": "基础 URL",
|
||||
"providers.apiBaseUrlPlaceholder": "https://api.example.com/v1",
|
||||
"providers.managedVia": "通过 {{envKey}} 环境变量管理。",
|
||||
|
||||
"channels.title": "频道设置",
|
||||
"channels.description": "配置内置频道集成(令牌、策略、流式传输)",
|
||||
"channels.noChannels": "配置文件中未找到任何频道。",
|
||||
@@ -383,9 +363,7 @@
|
||||
"channels.reactionFull": "完整",
|
||||
"channels.connectionWebsocket": "WebSocket",
|
||||
"channels.connectionWebhook": "Webhook",
|
||||
|
||||
"bindings.any": "任意",
|
||||
|
||||
"tabs.systemMessages": "系统消息",
|
||||
"systemMessages.title": "自定义系统消息",
|
||||
"systemMessages.description": "覆盖频道直接发送的内置网关消息",
|
||||
@@ -404,9 +382,11 @@
|
||||
"systemMessages.defaultTemplate": "默认模板",
|
||||
"systemMessages.defaultTemplateTip": "覆盖为空时使用的内置回退模板。",
|
||||
"systemMessages.useDefault": "使用默认值",
|
||||
|
||||
"toast": {
|
||||
"saved": "配置已保存",
|
||||
"saveFailed": "保存配置失败"
|
||||
}
|
||||
},
|
||||
"behavior.passiveMemoryTitle": "被动记忆提取",
|
||||
"behavior.passiveMemoryDescription": "面向租户的频道被动记忆提取指令。",
|
||||
"behavior.passiveMemoryPromptPlaceholder": "例如:避免重复事实,并将重复更新合并为一条简洁项目。"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Save, Settings, Loader2 } from "lucide-react";
|
||||
import { RefreshCw, Save, Settings, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -23,6 +23,7 @@ interface ChannelAdvancedDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
instance: ChannelInstanceData;
|
||||
onUpdate: (updates: Record<string, unknown>) => Promise<void>;
|
||||
onRefreshDiscordMetadata: () => Promise<void>;
|
||||
}
|
||||
|
||||
const NETWORK_KEYS = new Set(["api_server", "proxy", "domain", "connection_mode", "webhook_port", "webhook_path", "webhook_url"]);
|
||||
@@ -50,12 +51,14 @@ export function ChannelAdvancedDialog({
|
||||
onOpenChange,
|
||||
instance,
|
||||
onUpdate,
|
||||
onRefreshDiscordMetadata,
|
||||
}: ChannelAdvancedDialogProps) {
|
||||
const { t } = useTranslation("channels");
|
||||
const groups = getAdvancedFields(instance.channel_type);
|
||||
|
||||
const [values, setValues] = useState<Record<string, unknown>>(() => deriveAdvancedInitialValues(instance.config));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [refreshingMetadata, setRefreshingMetadata] = useState(false);
|
||||
|
||||
// Re-sync local state when dialog opens
|
||||
useEffect(() => {
|
||||
@@ -78,7 +81,19 @@ export function ChannelAdvancedDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefreshMetadata = async () => {
|
||||
setRefreshingMetadata(true);
|
||||
try {
|
||||
await onRefreshDiscordMetadata();
|
||||
} catch {
|
||||
// toast shown by hook
|
||||
} finally {
|
||||
setRefreshingMetadata(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasAnyGroup = Object.values(groups).some((g) => g.length > 0);
|
||||
const showDiscordMetadataRefresh = instance.channel_type === "discord";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -96,6 +111,26 @@ export function ChannelAdvancedDialog({
|
||||
<p className="text-sm text-muted-foreground">{t("detail.config.noSchema")}</p>
|
||||
)}
|
||||
|
||||
{showDiscordMetadataRefresh && (
|
||||
<>
|
||||
<ConfigGroupHeader
|
||||
title={t("detail.discordMetadata")}
|
||||
description={t("detail.discordMetadataDesc")}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleRefreshMetadata}
|
||||
disabled={refreshingMetadata}
|
||||
>
|
||||
{refreshingMetadata ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
{refreshingMetadata
|
||||
? t("detail.refreshingDiscordMetadata")
|
||||
: t("detail.refreshDiscordMetadata")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{groups.network.length > 0 && (
|
||||
<>
|
||||
<ConfigGroupHeader
|
||||
@@ -190,10 +225,10 @@ export function ChannelAdvancedDialog({
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 pt-4 border-t shrink-0">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving || refreshingMetadata}>
|
||||
{t("form.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
<Button onClick={handleSave} disabled={saving || refreshingMetadata}>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
{saving ? t("form.saving") : t("detail.config.saveConfig")}
|
||||
</Button>
|
||||
|
||||
@@ -17,6 +17,7 @@ interface ChannelDetailDialogsProps {
|
||||
supportsReauth: boolean;
|
||||
onDelete?: (instance: { id: string; name: string }) => void;
|
||||
onUpdate: (updates: Record<string, unknown>) => Promise<void>;
|
||||
onRefreshDiscordMetadata: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function ChannelDetailDialogs({
|
||||
@@ -30,6 +31,7 @@ export function ChannelDetailDialogs({
|
||||
supportsReauth,
|
||||
onDelete,
|
||||
onUpdate,
|
||||
onRefreshDiscordMetadata,
|
||||
}: ChannelDetailDialogsProps) {
|
||||
const { t } = useTranslation("channels");
|
||||
|
||||
@@ -44,6 +46,7 @@ export function ChannelDetailDialogs({
|
||||
onOpenChange={setAdvancedOpen}
|
||||
instance={instance}
|
||||
onUpdate={onUpdate}
|
||||
onRefreshDiscordMetadata={onRefreshDiscordMetadata}
|
||||
/>
|
||||
|
||||
{ReauthDialog && (
|
||||
|
||||
@@ -70,6 +70,7 @@ export function ChannelDetailPage({
|
||||
deleteContextGrant,
|
||||
setContextCredentials,
|
||||
deleteContextCredentials,
|
||||
refreshDiscordMetadata,
|
||||
} = useChannelDetail(instanceId);
|
||||
const { agents } = useAgents();
|
||||
const { channels } = useChannels();
|
||||
@@ -289,6 +290,7 @@ export function ChannelDetailPage({
|
||||
supportsReauth={supportsReauth}
|
||||
onDelete={onDelete}
|
||||
onUpdate={updateInstance}
|
||||
onRefreshDiscordMetadata={refreshDiscordMetadata}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
memoryItemDebugBadgeClass,
|
||||
memoryItemDebugBadges,
|
||||
memoryItemDebugBadgeTooltipKey,
|
||||
} from "./passive-memory-section-parts";
|
||||
import type { ChannelMemoryExtractionItem } from "@/types/channel";
|
||||
|
||||
describe("memoryItemDebugBadges", () => {
|
||||
const baseItem: ChannelMemoryExtractionItem = {
|
||||
id: "item-1",
|
||||
run_id: "run-1",
|
||||
item_type: "projects",
|
||||
summary: "Project Orion uses the ExampleCo collaboration platform",
|
||||
confidence: 0.7,
|
||||
status: "pending_review",
|
||||
created_at: "2026-07-07T05:34:11Z",
|
||||
};
|
||||
|
||||
it("returns topics and entities as compact debug badges", () => {
|
||||
const badges = memoryItemDebugBadges({
|
||||
...baseItem,
|
||||
topics: ["planning", "design"],
|
||||
entities: ["Alex Example", "Project Orion"],
|
||||
});
|
||||
|
||||
expect(badges).toEqual([
|
||||
{ kind: "topic", label: "planning" },
|
||||
{ kind: "topic", label: "design" },
|
||||
{ kind: "entity", label: "Alex Example" },
|
||||
{ kind: "entity", label: "Project Orion" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not emit empty badge groups", () => {
|
||||
expect(memoryItemDebugBadges({ ...baseItem, topics: [], entities: [] })).toEqual([]);
|
||||
expect(memoryItemDebugBadges(baseItem)).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses distinct styling and tooltip keys for topics and entities", () => {
|
||||
expect(memoryItemDebugBadgeClass("topic")).toContain("sky");
|
||||
expect(memoryItemDebugBadgeClass("entity")).toContain("violet");
|
||||
expect(memoryItemDebugBadgeClass("topic")).not.toEqual(memoryItemDebugBadgeClass("entity"));
|
||||
expect(memoryItemDebugBadgeTooltipKey("topic")).toBe("detail.passiveMemory.debugBadge.topic");
|
||||
expect(memoryItemDebugBadgeTooltipKey("entity")).toBe("detail.passiveMemory.debugBadge.entity");
|
||||
});
|
||||
});
|
||||
@@ -5,9 +5,46 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import type { ChannelMemoryExtractionItem } from "@/types/channel";
|
||||
|
||||
export type MemoryItemDebugBadge = {
|
||||
kind: "topic" | "entity";
|
||||
label: string;
|
||||
};
|
||||
|
||||
export function memoryItemDebugBadges(item: Pick<ChannelMemoryExtractionItem, "topics" | "entities">): MemoryItemDebugBadge[] {
|
||||
const badges: MemoryItemDebugBadge[] = [];
|
||||
for (const topic of item.topics ?? []) {
|
||||
const label = topic.trim();
|
||||
if (label) badges.push({ kind: "topic", label });
|
||||
}
|
||||
for (const entity of item.entities ?? []) {
|
||||
const label = entity.trim();
|
||||
if (label) badges.push({ kind: "entity", label });
|
||||
}
|
||||
return badges;
|
||||
}
|
||||
|
||||
export function memoryItemDebugBadgeClass(kind: MemoryItemDebugBadge["kind"]): string {
|
||||
if (kind === "topic") {
|
||||
return "cursor-help border-sky-500/30 bg-sky-500/10 text-sky-700 dark:border-sky-500/25 dark:bg-sky-500/10 dark:text-sky-300";
|
||||
}
|
||||
return "cursor-help border-violet-500/30 bg-violet-500/10 text-violet-700 dark:border-violet-500/25 dark:bg-violet-500/10 dark:text-violet-300";
|
||||
}
|
||||
|
||||
export function memoryItemDebugBadgeTooltipKey(kind: MemoryItemDebugBadge["kind"]): string {
|
||||
return kind === "topic"
|
||||
? "detail.passiveMemory.debugBadge.topic"
|
||||
: "detail.passiveMemory.debugBadge.entity";
|
||||
}
|
||||
|
||||
export function ToggleRow({
|
||||
label,
|
||||
checked,
|
||||
@@ -110,6 +147,7 @@ export function MemoryItemRow({
|
||||
onAction: (action: "approve" | "reject" | "delete") => void;
|
||||
}) {
|
||||
const { t } = useTranslation("channels");
|
||||
const debugBadges = memoryItemDebugBadges(item);
|
||||
return (
|
||||
<div className="rounded-lg border p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -120,6 +158,26 @@ export function MemoryItemRow({
|
||||
<span className="text-xs text-muted-foreground">{Math.round(item.confidence * 100)}%</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm">{item.summary}</p>
|
||||
{debugBadges.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
<TooltipProvider>
|
||||
{debugBadges.map((badge) => (
|
||||
<Tooltip key={`${badge.kind}:${badge.label}`}>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">
|
||||
<Badge variant="outline" className={memoryItemDebugBadgeClass(badge.kind)}>
|
||||
{badge.label}
|
||||
</Badge>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{t(memoryItemDebugBadgeTooltipKey(badge.kind))}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{item.status === "pending_review" && (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatPromptGroupLabel } from "./passive-memory-section";
|
||||
|
||||
describe("formatPromptGroupLabel", () => {
|
||||
it("renders child channel with parent title when available", () => {
|
||||
expect(formatPromptGroupLabel({
|
||||
history_key: "thread-1",
|
||||
group_title: "launch-thread",
|
||||
parent_history_key: "parent-1",
|
||||
parent_group_title: "product-planning",
|
||||
})).toBe("launch-thread / product-planning");
|
||||
});
|
||||
|
||||
it("falls back to ids when cached titles are missing", () => {
|
||||
expect(formatPromptGroupLabel({
|
||||
history_key: "thread-1",
|
||||
parent_history_key: "parent-1",
|
||||
})).toBe("thread-1 / parent-1");
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import { Brain, ChevronsRight, Play, RefreshCw, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Pagination } from "@/components/shared/pagination";
|
||||
import {
|
||||
Select,
|
||||
@@ -34,6 +33,8 @@ const fallbackConfig: ChannelMemoryConfig = {
|
||||
exclude_users: [],
|
||||
exclude_patterns: [],
|
||||
exclude_history_keys: [],
|
||||
custom_prompt: "",
|
||||
group_custom_prompts: {},
|
||||
min_messages: 5,
|
||||
group_only: true,
|
||||
};
|
||||
@@ -43,6 +44,13 @@ interface PassiveMemorySectionProps {
|
||||
channelType: string;
|
||||
}
|
||||
|
||||
interface PromptGroupOption {
|
||||
history_key: string;
|
||||
group_title?: string;
|
||||
parent_history_key?: string;
|
||||
parent_group_title?: string;
|
||||
}
|
||||
|
||||
export function PassiveMemorySection({ instanceId, channelType }: PassiveMemorySectionProps) {
|
||||
const { t } = useTranslation("channels");
|
||||
const supportsGroupExclude = channelType === "discord";
|
||||
@@ -61,17 +69,46 @@ export function PassiveMemorySection({ instanceId, channelType }: PassiveMemoryS
|
||||
itemAction,
|
||||
} = useChannelMemoryExtraction(instanceId, { page: reviewPage, pageSize: reviewPageSize, loadGroupOptions: supportsGroupExclude });
|
||||
const [config, setConfig] = useState<ChannelMemoryConfig>(fallbackConfig);
|
||||
const [channelCustomPrompt, setChannelCustomPrompt] = useState("");
|
||||
const [excludeUsers, setExcludeUsers] = useState("");
|
||||
const [excludePatterns, setExcludePatterns] = useState("");
|
||||
const [manualHistoryKey, setManualHistoryKey] = useState("");
|
||||
const [selectedPromptHistoryKey, setSelectedPromptHistoryKey] = useState("");
|
||||
|
||||
const promptGroupOptions = useMemo(() => {
|
||||
const options: PromptGroupOption[] = [];
|
||||
const seen = new Set<string>();
|
||||
groupOptions.forEach((group) => {
|
||||
if (!group.history_key || seen.has(group.history_key)) return;
|
||||
seen.add(group.history_key);
|
||||
options.push({
|
||||
history_key: group.history_key,
|
||||
group_title: group.group_title,
|
||||
parent_history_key: group.parent_history_key,
|
||||
parent_group_title: group.parent_group_title,
|
||||
});
|
||||
});
|
||||
return options;
|
||||
}, [groupOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!status?.config) return;
|
||||
setConfig(status.config);
|
||||
setChannelCustomPrompt(status.config.custom_prompt ?? "");
|
||||
setExcludeUsers((status.config.exclude_users ?? []).join("\n"));
|
||||
setExcludePatterns((status.config.exclude_patterns ?? []).join("\n"));
|
||||
}, [status?.config]);
|
||||
|
||||
useEffect(() => {
|
||||
if (promptGroupOptions.length === 0) {
|
||||
setSelectedPromptHistoryKey("");
|
||||
return;
|
||||
}
|
||||
if (selectedPromptHistoryKey && promptGroupOptions.some((group) => group.history_key === selectedPromptHistoryKey)) {
|
||||
return;
|
||||
}
|
||||
setSelectedPromptHistoryKey(promptGroupOptions[0]?.history_key ?? "");
|
||||
}, [promptGroupOptions, selectedPromptHistoryKey]);
|
||||
|
||||
const pendingItems = useMemo(() => {
|
||||
return items.filter((item) => item.status === "pending_review");
|
||||
}, [items]);
|
||||
@@ -81,16 +118,14 @@ export function PassiveMemorySection({ instanceId, channelType }: PassiveMemoryS
|
||||
const latestRunAllEvent = runAllEvents[runAllEvents.length - 1] ?? null;
|
||||
const visibleRunAllEvents = runAllEvents.filter((event) => event.type !== "final").slice(-5).reverse();
|
||||
const excludedHistoryKeys = config.exclude_history_keys ?? [];
|
||||
const groupCustomPrompts = config.group_custom_prompts ?? {};
|
||||
const selectedPromptGroup = promptGroupOptions.find((group) => group.history_key === selectedPromptHistoryKey);
|
||||
const excludedGroupOptions = excludedHistoryKeys.map((historyKey) => (
|
||||
groupOptions.find((group) => group.history_key === historyKey) ?? {
|
||||
channel_name: "",
|
||||
promptGroupOptions.find((group) => group.history_key === historyKey) ?? {
|
||||
history_key: historyKey,
|
||||
message_count: 0,
|
||||
last_activity: "",
|
||||
excluded: true,
|
||||
}
|
||||
));
|
||||
const availableGroupOptions = groupOptions.filter((group) => {
|
||||
const availableExcludeGroupOptions = promptGroupOptions.filter((group) => {
|
||||
return !excludedHistoryKeys.includes(group.history_key) &&
|
||||
!excludedHistoryKeys.includes(group.parent_history_key ?? "");
|
||||
});
|
||||
@@ -105,9 +140,22 @@ export function PassiveMemorySection({ instanceId, channelType }: PassiveMemoryS
|
||||
updateConfig({ exclude_history_keys: [...excludedHistoryKeys, trimmed] });
|
||||
};
|
||||
|
||||
const updateGroupPrompt = (historyKey: string, value: string) => {
|
||||
if (!historyKey) return;
|
||||
const next = { ...groupCustomPrompts };
|
||||
if (value.trim() === "") {
|
||||
delete next[historyKey];
|
||||
} else {
|
||||
next[historyKey] = value;
|
||||
}
|
||||
updateConfig({ group_custom_prompts: next });
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
saveSettings.mutate({
|
||||
...config,
|
||||
custom_prompt: channelCustomPrompt,
|
||||
group_custom_prompts: supportsGroupExclude ? groupCustomPrompts : {},
|
||||
exclude_users: splitLines(excludeUsers),
|
||||
exclude_patterns: splitLines(excludePatterns),
|
||||
exclude_history_keys: supportsGroupExclude ? excludedHistoryKeys : (config.exclude_history_keys ?? []),
|
||||
@@ -243,78 +291,88 @@ export function PassiveMemorySection({ instanceId, channelType }: PassiveMemoryS
|
||||
</div>
|
||||
<TextareaBlock label={t("detail.passiveMemory.excludeUsers")} value={excludeUsers} onChange={setExcludeUsers} />
|
||||
<TextareaBlock label={t("detail.passiveMemory.excludePatterns")} value={excludePatterns} onChange={setExcludePatterns} />
|
||||
<TextareaBlock label={t("detail.passiveMemory.channelCustomPrompt")} value={channelCustomPrompt} onChange={setChannelCustomPrompt} />
|
||||
{supportsGroupExclude && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
{t("detail.passiveMemory.excludeGroups")}
|
||||
</div>
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={(historyKey) => {
|
||||
addExcludedHistoryKey(historyKey);
|
||||
}}
|
||||
disabled={availableGroupOptions.length === 0}
|
||||
>
|
||||
<SelectTrigger size="sm">
|
||||
<SelectValue placeholder={t("detail.passiveMemory.excludeGroupsPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableGroupOptions.map((group) => (
|
||||
<SelectItem key={group.history_key} value={group.history_key}>
|
||||
{formatGroupLabel(group)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
className="h-8"
|
||||
value={manualHistoryKey}
|
||||
onChange={(event) => setManualHistoryKey(event.target.value)}
|
||||
placeholder={t("detail.passiveMemory.manualExcludePlaceholder")}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter") return;
|
||||
event.preventDefault();
|
||||
addExcludedHistoryKey(manualHistoryKey);
|
||||
setManualHistoryKey("");
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
addExcludedHistoryKey(manualHistoryKey);
|
||||
setManualHistoryKey("");
|
||||
}}
|
||||
disabled={manualHistoryKey.trim() === ""}
|
||||
<div className="space-y-2 rounded-md border p-3">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
{t("detail.passiveMemory.groupCustomPrompt")}
|
||||
</div>
|
||||
<Select
|
||||
value={selectedPromptHistoryKey}
|
||||
onValueChange={setSelectedPromptHistoryKey}
|
||||
disabled={promptGroupOptions.length === 0}
|
||||
>
|
||||
{t("detail.passiveMemory.addExcludedGroup")}
|
||||
</Button>
|
||||
<SelectTrigger size="sm">
|
||||
<SelectValue placeholder={t("detail.passiveMemory.groupCustomPromptPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{promptGroupOptions.map((group) => (
|
||||
<SelectItem key={group.history_key} value={group.history_key}>
|
||||
{formatPromptGroupLabel(group)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedPromptHistoryKey ? (
|
||||
<TextareaBlock
|
||||
label={selectedPromptGroup ? formatPromptGroupLabel(selectedPromptGroup) : selectedPromptHistoryKey}
|
||||
value={groupCustomPrompts[selectedPromptHistoryKey] ?? ""}
|
||||
onChange={(value) => updateGroupPrompt(selectedPromptHistoryKey, value)}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed px-3 py-2 text-xs text-muted-foreground">
|
||||
{t("detail.passiveMemory.noPromptGroups")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{excludedGroupOptions.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{excludedGroupOptions.map((group) => (
|
||||
<Badge key={group.history_key} variant="secondary" className="gap-1 pr-1">
|
||||
<span className="max-w-[220px] truncate">{formatGroupLabel(group)}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-0.5 hover:bg-background/80"
|
||||
onClick={() => updateConfig({ exclude_history_keys: excludedHistoryKeys.filter((key) => key !== group.history_key) })}
|
||||
aria-label={t("detail.passiveMemory.removeExcludedGroup", { group: formatGroupLabel(group) })}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
<div className="space-y-2 rounded-md border p-3">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
{t("detail.passiveMemory.excludeGroups")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed px-3 py-2 text-xs text-muted-foreground">
|
||||
{groupOptions.length === 0
|
||||
? t("detail.passiveMemory.noExcludeGroups")
|
||||
: t("detail.passiveMemory.noExcludedGroups")}
|
||||
</div>
|
||||
)}
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={addExcludedHistoryKey}
|
||||
disabled={availableExcludeGroupOptions.length === 0}
|
||||
>
|
||||
<SelectTrigger size="sm">
|
||||
<SelectValue placeholder={t("detail.passiveMemory.excludeGroupsPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableExcludeGroupOptions.map((group) => (
|
||||
<SelectItem key={group.history_key} value={group.history_key}>
|
||||
{formatPromptGroupLabel(group)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{excludedGroupOptions.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{excludedGroupOptions.map((group) => {
|
||||
const label = formatPromptGroupLabel(group);
|
||||
return (
|
||||
<Badge key={group.history_key} variant="secondary" className="gap-1 pr-1">
|
||||
<span className="max-w-[220px] truncate">{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full p-0.5 hover:bg-background/80"
|
||||
onClick={() => updateConfig({ exclude_history_keys: excludedHistoryKeys.filter((key) => key !== group.history_key) })}
|
||||
aria-label={t("detail.passiveMemory.removeExcludedGroup", { group: label })}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed px-3 py-2 text-xs text-muted-foreground">
|
||||
{promptGroupOptions.length === 0
|
||||
? t("detail.passiveMemory.noExcludeGroups")
|
||||
: t("detail.passiveMemory.noExcludedGroups")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -366,6 +424,18 @@ function formatGroupLabel(group: { history_key: string; group_title?: string })
|
||||
return group.group_title || group.history_key;
|
||||
}
|
||||
|
||||
export function formatPromptGroupLabel(group: PromptGroupOption) {
|
||||
const label = formatGroupLabel(group);
|
||||
if (!group.parent_history_key) {
|
||||
return label;
|
||||
}
|
||||
const parentLabel = group.parent_group_title || group.parent_history_key;
|
||||
if (!parentLabel || parentLabel === label) {
|
||||
return label;
|
||||
}
|
||||
return `${label} / ${parentLabel}`;
|
||||
}
|
||||
|
||||
function formatRunAllEvent(event: {
|
||||
type: string;
|
||||
history_key?: string;
|
||||
|
||||
@@ -71,6 +71,22 @@ export function useChannelDetail(instanceId: string | undefined) {
|
||||
[instanceId, http, invalidate],
|
||||
);
|
||||
|
||||
const refreshDiscordMetadata = useCallback(
|
||||
async () => {
|
||||
if (!instanceId) return;
|
||||
try {
|
||||
await http.post(`/v1/channels/instances/${instanceId}/metadata/refresh`);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.channels.memoryExtractionGroups(instanceId), exact: true });
|
||||
await invalidate();
|
||||
toast.success(i18next.t("channels:detail.discordMetadataRefreshed"));
|
||||
} catch (err) {
|
||||
toast.error(i18next.t("channels:detail.discordMetadataRefreshFailed"), userFriendlyError(err));
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[http, instanceId, invalidate, queryClient],
|
||||
);
|
||||
|
||||
// Managers API (backend routes still use /writers paths)
|
||||
const listManagerGroups = useCallback(
|
||||
async (): Promise<GroupManagerGroupInfo[]> => {
|
||||
@@ -216,6 +232,7 @@ export function useChannelDetail(instanceId: string | undefined) {
|
||||
setContextCredentials,
|
||||
deleteContextCredentials,
|
||||
listContacts,
|
||||
refreshDiscordMetadata,
|
||||
refresh: invalidate,
|
||||
};
|
||||
}
|
||||
@@ -152,7 +152,7 @@ export function useChannelMemoryExtraction(instanceId: string | undefined, param
|
||||
items: itemsQuery.data?.items ?? statusQuery.data?.recent_items ?? [],
|
||||
itemsTotal: itemsQuery.data?.total ?? statusQuery.data?.recent_items?.length ?? 0,
|
||||
groupOptions: groupsQuery.data ?? [],
|
||||
loading: statusQuery.isLoading || itemsQuery.isLoading || (params.loadGroupOptions === true && groupsQuery.isLoading),
|
||||
loading: statusQuery.isLoading || itemsQuery.isLoading,
|
||||
saveSettings,
|
||||
runNow,
|
||||
runAll,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Brain, Save } from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
import { userFriendlyError } from "@/lib/error-utils";
|
||||
import { toast } from "@/stores/use-toast-store";
|
||||
|
||||
const globalCustomPromptKey = "channel_memory.extraction.custom_prompt";
|
||||
const queryKey = ["system-configs", globalCustomPromptKey] as const;
|
||||
|
||||
export function BehaviorPassiveMemoryCard() {
|
||||
const { t } = useTranslation("config");
|
||||
const http = useHttp();
|
||||
const queryClient = useQueryClient();
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
const promptQuery = useQuery({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
const configs = await http.get<Record<string, string>>("/v1/system-configs");
|
||||
return configs[globalCustomPromptKey] ?? "";
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (promptQuery.data === undefined) return;
|
||||
setValue(promptQuery.data);
|
||||
}, [promptQuery.data]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: () => http.put(`/v1/system-configs/${globalCustomPromptKey}`, { value }),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey, exact: true });
|
||||
toast.success(t("toast.saved"));
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(t("toast.saveFailed"), userFriendlyError(err));
|
||||
},
|
||||
});
|
||||
|
||||
const dirty = value !== (promptQuery.data ?? "");
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Brain className="h-4 w-4" />
|
||||
{t("behavior.passiveMemoryTitle")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("behavior.passiveMemoryDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
className="min-h-[120px] text-base md:text-sm"
|
||||
placeholder={t("behavior.passiveMemoryPromptPlaceholder")}
|
||||
disabled={promptQuery.isLoading || save.isPending}
|
||||
/>
|
||||
{dirty && (
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={() => save.mutate()} disabled={save.isPending} className="gap-1.5">
|
||||
<Save className="h-3.5 w-3.5" /> {save.isPending ? t("saving") : t("save")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { BehaviorSessionsCard } from "./behavior-sessions-card";
|
||||
import { BehaviorSecurityCard } from "./behavior-security-card";
|
||||
import { BehaviorPendingCompactionCard, type PendingCompactionValues } from "./behavior-pending-compaction-card";
|
||||
import { BehaviorChatCard, type ChatBehaviorValues } from "./behavior-chat-card";
|
||||
import { BehaviorPassiveMemoryCard } from "./behavior-passive-memory-card";
|
||||
|
||||
|
||||
|
||||
@@ -110,6 +111,7 @@ export function BehaviorSection({ config, onPatch, saving }: Props) {
|
||||
<BehaviorSessionsCard value={sessions} onChange={markDirty(setSessions)} />
|
||||
<BehaviorSecurityCard value={security} onChange={markDirty(setSecurity)} />
|
||||
<BehaviorPendingCompactionCard value={pendingCompaction} onChange={markDirty(setPendingCompaction)} />
|
||||
<BehaviorPassiveMemoryCard />
|
||||
|
||||
{dirty && (
|
||||
<div className="flex justify-end pt-2">
|
||||
|
||||
@@ -61,6 +61,8 @@ export interface ChannelMemoryConfig {
|
||||
exclude_users?: string[];
|
||||
exclude_patterns?: string[];
|
||||
exclude_history_keys?: string[];
|
||||
custom_prompt?: string;
|
||||
group_custom_prompts?: Record<string, string>;
|
||||
min_messages: number;
|
||||
group_only: boolean;
|
||||
}
|
||||
@@ -116,6 +118,7 @@ export interface ChannelMemoryGroupOption {
|
||||
history_key: string;
|
||||
parent_history_key?: string;
|
||||
group_title?: string;
|
||||
parent_group_title?: string;
|
||||
message_count: number;
|
||||
last_activity: string;
|
||||
excluded: boolean;
|
||||
|
||||
Reference in new issue
Block a user