From fc7d1839610187454d060fcbf3cf69fd158c684e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=83=8A=E3=83=A0?= Date: Wed, 8 Jul 2026 21:53:19 +0800 Subject: [PATCH] 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 --- cmd/gateway.go | 10 +- cmd/gateway_deps.go | 2 + cmd/gateway_http_handlers.go | 22 ++ docs/05-channels-messaging.md | 21 +- docs/07-bootstrap-skills-memory.md | 10 +- docs/18-http-api.md | 26 +- docs/project-changelog.md | 23 +- internal/channelmemory/config.go | 103 ++++++-- internal/channelmemory/config_test.go | 72 +++++ internal/channelmemory/extractor.go | 122 ++++++++- internal/channelmemory/extractor_test.go | 118 ++++++++- internal/channelmemory/review.go | 1 + internal/channelmemory/service.go | 155 ++++++++++- internal/channelmemory/service_test.go | 163 +++++++++++- internal/channels/discord/contact_refresh.go | 193 ++++++++++++++ .../channels/discord/contact_refresh_test.go | 125 +++++++++ internal/channels/discord/context_resolver.go | 77 ++++++ internal/channels/discord/discord.go | 26 +- internal/channels/discord/handler.go | 121 +++++++-- internal/channels/discord/handler_test.go | 64 +++++ internal/consolidation/episodic_worker.go | 1 + internal/consolidation/mock_test.go | 6 +- internal/consolidation/semantic_worker.go | 61 ++++- internal/consolidation/workers_test.go | 45 ++++ internal/eventbus/event_types.go | 9 +- internal/http/channel_instance_metadata.go | 51 ++++ .../http/channel_instance_metadata_test.go | 87 ++++++ internal/http/channel_instances.go | 25 ++ internal/http/channel_memory_extraction.go | 21 +- .../http/channel_memory_extraction_test.go | 140 ++++++++++ internal/store/contact_collector.go | 18 ++ internal/store/pg/pending_message_store.go | 89 ++++++- internal/store/sqlitestore/contacts.go | 5 +- .../store/sqlitestore/pending_messages.go | 89 ++++++- .../sqlitestore/pending_messages_test.go | 47 ++++ ui/web/src/i18n/locales/en/channels.json | 112 ++++++-- ui/web/src/i18n/locales/en/config.json | 28 +- ui/web/src/i18n/locales/ko/channels.json | 69 +++++ ui/web/src/i18n/locales/ko/config.json | 27 +- ui/web/src/i18n/locales/vi/channels.json | 247 ++++++++++++++---- ui/web/src/i18n/locales/vi/config.json | 28 +- ui/web/src/i18n/locales/zh/channels.json | 247 ++++++++++++++---- ui/web/src/i18n/locales/zh/config.json | 28 +- .../channel-advanced-dialog.tsx | 41 ++- .../channel-detail/channel-detail-dialogs.tsx | 3 + .../channel-detail/channel-detail-page.tsx | 2 + .../passive-memory-section-parts.test.tsx | 47 ++++ .../passive-memory-section-parts.tsx | 58 ++++ .../passive-memory-section.test.ts | 20 ++ .../channel-detail/passive-memory-section.tsx | 220 ++++++++++------ .../channels/hooks/use-channel-detail.ts | 17 ++ .../hooks/use-channel-memory-extraction.ts | 2 +- .../sections/behavior-passive-memory-card.tsx | 75 ++++++ .../config/sections/behavior-section.tsx | 2 + ui/web/src/types/channel.ts | 3 + 55 files changed, 2982 insertions(+), 442 deletions(-) create mode 100644 internal/channels/discord/contact_refresh.go create mode 100644 internal/channels/discord/contact_refresh_test.go create mode 100644 internal/channels/discord/context_resolver.go create mode 100644 internal/http/channel_instance_metadata.go create mode 100644 internal/http/channel_instance_metadata_test.go create mode 100644 internal/http/channel_memory_extraction_test.go create mode 100644 internal/store/sqlitestore/pending_messages_test.go create mode 100644 ui/web/src/pages/channels/channel-detail/passive-memory-section-parts.test.tsx create mode 100644 ui/web/src/pages/channels/channel-detail/passive-memory-section.test.ts create mode 100644 ui/web/src/pages/config/sections/behavior-passive-memory-card.tsx diff --git a/cmd/gateway.go b/cmd/gateway.go index e2a3b1cb..34faedc0 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -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 { diff --git a/cmd/gateway_deps.go b/cmd/gateway_deps.go index bc0e2f22..e15de5a2 100644 --- a/cmd/gateway_deps.go +++ b/cmd/gateway_deps.go @@ -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 diff --git a/cmd/gateway_http_handlers.go b/cmd/gateway_http_handlers.go index 231fe225..bf15c753 100644 --- a/cmd/gateway_http_handlers.go +++ b/cmd/gateway_http_handlers.go @@ -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) +} diff --git a/docs/05-channels-messaging.md b/docs/05-channels-messaging.md index 4dbd687b..83a896f8 100644 --- a/docs/05-channels-messaging.md +++ b/docs/05-channels-messaging.md @@ -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. --- diff --git a/docs/07-bootstrap-skills-memory.md b/docs/07-bootstrap-skills-memory.md index 242052ac..104006e3 100644 --- a/docs/07-bootstrap-skills-memory.md +++ b/docs/07-bootstrap-skills-memory.md @@ -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 diff --git a/docs/18-http-api.md b/docs/18-http-api.md index 4ec37822..15edd322 100644 --- a/docs/18-http-api.md +++ b/docs/18-http-api.md @@ -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 diff --git a/docs/project-changelog.md b/docs/project-changelog.md index ed188151..20593db1 100644 --- a/docs/project-changelog.md +++ b/docs/project-changelog.md @@ -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 diff --git a/internal/channelmemory/config.go b/internal/channelmemory/config.go index 7921b6fb..d392c24a 100644 --- a/internal/channelmemory/config.go +++ b/internal/channelmemory/config.go @@ -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 +} diff --git a/internal/channelmemory/config_test.go b/internal/channelmemory/config_test.go index 922e4d03..cfd2acd6 100644 --- a/internal/channelmemory/config_test.go +++ b/internal/channelmemory/config_test.go @@ -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 } diff --git a/internal/channelmemory/extractor.go b/internal/channelmemory/extractor.go index a5416220..7d8da242 100644 --- a/internal/channelmemory/extractor.go +++ b/internal/channelmemory/extractor.go @@ -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 diff --git a/internal/channelmemory/extractor_test.go b/internal/channelmemory/extractor_test.go index 22995762..68eb5955 100644 --- a/internal/channelmemory/extractor_test.go +++ b/internal/channelmemory/extractor_test.go @@ -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) diff --git a/internal/channelmemory/review.go b/internal/channelmemory/review.go index 722ec787..cf0e743c 100644 --- a/internal/channelmemory/review.go +++ b/internal/channelmemory/review.go @@ -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), }, }) diff --git a/internal/channelmemory/service.go b/internal/channelmemory/service.go index c68faf2f..2c18f4d3 100644 --- a/internal/channelmemory/service.go +++ b/internal/channelmemory/service.go @@ -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 +} diff --git a/internal/channelmemory/service_test.go b/internal/channelmemory/service_test.go index 86251199..6f811571 100644 --- a/internal/channelmemory/service_test.go +++ b/internal/channelmemory/service_test.go @@ -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() diff --git a/internal/channels/discord/contact_refresh.go b/internal/channels/discord/contact_refresh.go new file mode 100644 index 00000000..ff99edf5 --- /dev/null +++ b/internal/channels/discord/contact_refresh.go @@ -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 +} diff --git a/internal/channels/discord/contact_refresh_test.go b/internal/channels/discord/contact_refresh_test.go new file mode 100644 index 00000000..e1fa96b3 --- /dev/null +++ b/internal/channels/discord/contact_refresh_test.go @@ -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) + } + } +} diff --git a/internal/channels/discord/context_resolver.go b/internal/channels/discord/context_resolver.go new file mode 100644 index 00000000..3a99735f --- /dev/null +++ b/internal/channels/discord/context_resolver.go @@ -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 +} diff --git a/internal/channels/discord/discord.go b/internal/channels/discord/discord.go index 887b12ff..e90161f7 100644 --- a/internal/channels/discord/discord.go +++ b/internal/channels/discord/discord.go @@ -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) diff --git a/internal/channels/discord/handler.go b/internal/channels/discord/handler.go index 4f0d5ae5..eef5cad4 100644 --- a/internal/channels/discord/handler.go +++ b/internal/channels/discord/handler.go @@ -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 } diff --git a/internal/channels/discord/handler_test.go b/internal/channels/discord/handler_test.go index 208bcd9b..2e6fe63c 100644 --- a/internal/channels/discord/handler_test.go +++ b/internal/channels/discord/handler_test.go @@ -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") diff --git a/internal/consolidation/episodic_worker.go b/internal/consolidation/episodic_worker.go index 87cb2238..7fae25e7 100644 --- a/internal/consolidation/episodic_worker.go +++ b/internal/consolidation/episodic_worker.go @@ -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, }, }) diff --git a/internal/consolidation/mock_test.go b/internal/consolidation/mock_test.go index b7b739d0..2b992194 100644 --- a/internal/consolidation/mock_test.go +++ b/internal/consolidation/mock_test.go @@ -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" } diff --git a/internal/consolidation/semantic_worker.go b/internal/consolidation/semantic_worker.go index b8d073fd..d8f523ff 100644 --- a/internal/consolidation/semantic_worker.go +++ b/internal/consolidation/semantic_worker.go @@ -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 +} diff --git a/internal/consolidation/workers_test.go b/internal/consolidation/workers_test.go index ab6dda33..1394b6c8 100644 --- a/internal/consolidation/workers_test.go +++ b/internal/consolidation/workers_test.go @@ -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{ diff --git a/internal/eventbus/event_types.go b/internal/eventbus/event_types.go index a0133407..98ad4858 100644 --- a/internal/eventbus/event_types.go +++ b/internal/eventbus/event_types.go @@ -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" } diff --git a/internal/http/channel_instance_metadata.go b/internal/http/channel_instance_metadata.go new file mode 100644 index 00000000..f0af90fa --- /dev/null +++ b/internal/http/channel_instance_metadata.go @@ -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}) +} diff --git a/internal/http/channel_instance_metadata_test.go b/internal/http/channel_instance_metadata_test.go new file mode 100644 index 00000000..f6acdb44 --- /dev/null +++ b/internal/http/channel_instance_metadata_test.go @@ -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()) + } +} diff --git a/internal/http/channel_instances.go b/internal/http/channel_instances.go index b41001eb..bf31cb0f 100644 --- a/internal/http/channel_instances.go +++ b/internal/http/channel_instances.go @@ -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 { diff --git a/internal/http/channel_memory_extraction.go b/internal/http/channel_memory_extraction.go index 7f228d8b..f2f66e40 100644 --- a/internal/http/channel_memory_extraction.go +++ b/internal/http/channel_memory_extraction.go @@ -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}) } diff --git a/internal/http/channel_memory_extraction_test.go b/internal/http/channel_memory_extraction_test.go new file mode 100644 index 00000000..3de14c22 --- /dev/null +++ b/internal/http/channel_memory_extraction_test.go @@ -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) + } +} diff --git a/internal/store/contact_collector.go b/internal/store/contact_collector.go index b825df5d..c7400203 100644 --- a/internal/store/contact_collector.go +++ b/internal/store/contact_collector.go @@ -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) diff --git a/internal/store/pg/pending_message_store.go b/internal/store/pg/pending_message_store.go index bfa99b6d..e150a498 100644 --- a/internal/store/pg/pending_message_store.go +++ b/internal/store/pg/pending_message_store.go @@ -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 +} diff --git a/internal/store/sqlitestore/contacts.go b/internal/store/sqlitestore/contacts.go index f0db1857..cec6377f 100644 --- a/internal/store/sqlitestore/contacts.go +++ b/internal/store/sqlitestore/contacts.go @@ -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, diff --git a/internal/store/sqlitestore/pending_messages.go b/internal/store/sqlitestore/pending_messages.go index 75b05503..48f70b6a 100644 --- a/internal/store/sqlitestore/pending_messages.go +++ b/internal/store/sqlitestore/pending_messages.go @@ -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 +} diff --git a/internal/store/sqlitestore/pending_messages_test.go b/internal/store/sqlitestore/pending_messages_test.go new file mode 100644 index 00000000..1d4e82d2 --- /dev/null +++ b/internal/store/sqlitestore/pending_messages_test.go @@ -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"]) + } +} diff --git a/ui/web/src/i18n/locales/en/channels.json b/ui/web/src/i18n/locales/en/channels.json index 551af7e9..490119e1 100644 --- a/ui/web/src/i18n/locales/en/channels.json +++ b/ui/web/src/i18n/locales/en/channels.json @@ -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": { diff --git a/ui/web/src/i18n/locales/en/config.json b/ui/web/src/i18n/locales/en/config.json index 2688ad1a..82da55f7 100644 --- a/ui/web/src/i18n/locales/en/config.json +++ b/ui/web/src/i18n/locales/en/config.json @@ -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." } diff --git a/ui/web/src/i18n/locales/ko/channels.json b/ui/web/src/i18n/locales/ko/channels.json index 7d157afc..62ff692e 100644 --- a/ui/web/src/i18n/locales/ko/channels.json +++ b/ui/web/src/i18n/locales/ko/channels.json @@ -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": { diff --git a/ui/web/src/i18n/locales/ko/config.json b/ui/web/src/i18n/locales/ko/config.json index 4595973b..417dd03b 100644 --- a/ui/web/src/i18n/locales/ko/config.json +++ b/ui/web/src/i18n/locales/ko/config.json @@ -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": "예: 중복 사실을 피하고 반복 업데이트를 하나의 간결한 항목으로 병합합니다." } diff --git a/ui/web/src/i18n/locales/vi/channels.json b/ui/web/src/i18n/locales/vi/channels.json index ac852b08..926fb315 100644 --- a/ui/web/src/i18n/locales/vi/channels.json +++ b/ui/web/src/i18n/locales/vi/channels.json @@ -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", diff --git a/ui/web/src/i18n/locales/vi/config.json b/ui/web/src/i18n/locales/vi/config.json index b87fe6cf..17865cd7 100644 --- a/ui/web/src/i18n/locales/vi/config.json +++ b/ui/web/src/i18n/locales/vi/config.json @@ -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." } diff --git a/ui/web/src/i18n/locales/zh/channels.json b/ui/web/src/i18n/locales/zh/channels.json index 3e923b67..7341dff3 100644 --- a/ui/web/src/i18n/locales/zh/channels.json +++ b/ui/web/src/i18n/locales/zh/channels.json @@ -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": "群组策略", diff --git a/ui/web/src/i18n/locales/zh/config.json b/ui/web/src/i18n/locales/zh/config.json index e65aacb7..9a80bb66 100644 --- a/ui/web/src/i18n/locales/zh/config.json +++ b/ui/web/src/i18n/locales/zh/config.json @@ -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": "例如:避免重复事实,并将重复更新合并为一条简洁项目。" } diff --git a/ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.tsx b/ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.tsx index 81190d88..5492febc 100644 --- a/ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.tsx +++ b/ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.tsx @@ -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) => Promise; + onRefreshDiscordMetadata: () => Promise; } 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>(() => 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 ( @@ -96,6 +111,26 @@ export function ChannelAdvancedDialog({

{t("detail.config.noSchema")}

)} + {showDiscordMetadataRefresh && ( + <> + + + + )} + {groups.network.length > 0 && ( <> - - diff --git a/ui/web/src/pages/channels/channel-detail/channel-detail-dialogs.tsx b/ui/web/src/pages/channels/channel-detail/channel-detail-dialogs.tsx index f4d06719..36353963 100644 --- a/ui/web/src/pages/channels/channel-detail/channel-detail-dialogs.tsx +++ b/ui/web/src/pages/channels/channel-detail/channel-detail-dialogs.tsx @@ -17,6 +17,7 @@ interface ChannelDetailDialogsProps { supportsReauth: boolean; onDelete?: (instance: { id: string; name: string }) => void; onUpdate: (updates: Record) => Promise; + onRefreshDiscordMetadata: () => Promise; } 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 && ( diff --git a/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx b/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx index 5261654b..f4273120 100644 --- a/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx +++ b/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx @@ -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} /> ); diff --git a/ui/web/src/pages/channels/channel-detail/passive-memory-section-parts.test.tsx b/ui/web/src/pages/channels/channel-detail/passive-memory-section-parts.test.tsx new file mode 100644 index 00000000..9152e4df --- /dev/null +++ b/ui/web/src/pages/channels/channel-detail/passive-memory-section-parts.test.tsx @@ -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"); + }); +}); diff --git a/ui/web/src/pages/channels/channel-detail/passive-memory-section-parts.tsx b/ui/web/src/pages/channels/channel-detail/passive-memory-section-parts.tsx index 253508e7..060171cf 100644 --- a/ui/web/src/pages/channels/channel-detail/passive-memory-section-parts.tsx +++ b/ui/web/src/pages/channels/channel-detail/passive-memory-section-parts.tsx @@ -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): 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 (
@@ -120,6 +158,26 @@ export function MemoryItemRow({ {Math.round(item.confidence * 100)}%

{item.summary}

+ {debugBadges.length > 0 && ( +
+ + {debugBadges.map((badge) => ( + + + + + {badge.label} + + + + + {t(memoryItemDebugBadgeTooltipKey(badge.kind))} + + + ))} + +
+ )}
{item.status === "pending_review" && ( <> diff --git a/ui/web/src/pages/channels/channel-detail/passive-memory-section.test.ts b/ui/web/src/pages/channels/channel-detail/passive-memory-section.test.ts new file mode 100644 index 00000000..e034a934 --- /dev/null +++ b/ui/web/src/pages/channels/channel-detail/passive-memory-section.test.ts @@ -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"); + }); +}); diff --git a/ui/web/src/pages/channels/channel-detail/passive-memory-section.tsx b/ui/web/src/pages/channels/channel-detail/passive-memory-section.tsx index 3a1c28ee..52a3672b 100644 --- a/ui/web/src/pages/channels/channel-detail/passive-memory-section.tsx +++ b/ui/web/src/pages/channels/channel-detail/passive-memory-section.tsx @@ -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(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(); + 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
+ {supportsGroupExclude && (
-
- {t("detail.passiveMemory.excludeGroups")} -
- -
- setManualHistoryKey(event.target.value)} - placeholder={t("detail.passiveMemory.manualExcludePlaceholder")} - onKeyDown={(event) => { - if (event.key !== "Enter") return; - event.preventDefault(); - addExcludedHistoryKey(manualHistoryKey); - setManualHistoryKey(""); - }} - /> - - - ))} +
+
+ {t("detail.passiveMemory.excludeGroups")}
- ) : ( -
- {groupOptions.length === 0 - ? t("detail.passiveMemory.noExcludeGroups") - : t("detail.passiveMemory.noExcludedGroups")} -
- )} + + {excludedGroupOptions.length > 0 ? ( +
+ {excludedGroupOptions.map((group) => { + const label = formatPromptGroupLabel(group); + return ( + + {label} + + + ); + })} +
+ ) : ( +
+ {promptGroupOptions.length === 0 + ? t("detail.passiveMemory.noExcludeGroups") + : t("detail.passiveMemory.noExcludedGroups")} +
+ )} +
)}
@@ -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; diff --git a/ui/web/src/pages/channels/hooks/use-channel-detail.ts b/ui/web/src/pages/channels/hooks/use-channel-detail.ts index 6776f784..2f7c635d 100644 --- a/ui/web/src/pages/channels/hooks/use-channel-detail.ts +++ b/ui/web/src/pages/channels/hooks/use-channel-detail.ts @@ -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 => { @@ -216,6 +232,7 @@ export function useChannelDetail(instanceId: string | undefined) { setContextCredentials, deleteContextCredentials, listContacts, + refreshDiscordMetadata, refresh: invalidate, }; } diff --git a/ui/web/src/pages/channels/hooks/use-channel-memory-extraction.ts b/ui/web/src/pages/channels/hooks/use-channel-memory-extraction.ts index 78622cf7..0d74fd48 100644 --- a/ui/web/src/pages/channels/hooks/use-channel-memory-extraction.ts +++ b/ui/web/src/pages/channels/hooks/use-channel-memory-extraction.ts @@ -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, diff --git a/ui/web/src/pages/config/sections/behavior-passive-memory-card.tsx b/ui/web/src/pages/config/sections/behavior-passive-memory-card.tsx new file mode 100644 index 00000000..fdfdaa97 --- /dev/null +++ b/ui/web/src/pages/config/sections/behavior-passive-memory-card.tsx @@ -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>("/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 ( + + + + + {t("behavior.passiveMemoryTitle")} + + {t("behavior.passiveMemoryDescription")} + + +