From 5c63f1dca0cd5dcf96a448c17c9323c0161fba0e Mon Sep 17 00:00:00 2001 From: Matrix-X Date: Tue, 6 Jan 2026 01:23:12 +0800 Subject: [PATCH 01/44] =?UTF-8?q?fix(agent):=20=E4=BF=9D=E5=AD=98=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/database/seed/seed_agent.go | 3 + backend/config/defaults.go | 2 +- backend/config/validator.go | 130 +++--- backend/internal/http/middleware.go | 73 +++ .../server/agent/drivers/eino/llm/factory.go | 2 +- .../server/agent/drivers/eino/llm/openai.go | 142 +++++- .../agent/persistence/model/agent_gorm.go | 11 +- .../persistence/repository/agent_repo.go | 13 + .../internal/service/agent/agent_service.go | 12 + .../http/admin/agent/agent_handler.go | 125 +++--- .../http/admin/agent/agent_session_handler.go | 55 ++- .../transport/http/admin/agent/api.go | 20 +- .../http/admin/agent/chat_handler.go | 38 +- .../internal/transport/http/admin/menu/api.go | 3 +- .../http/admin/menu/merge_handler.go | 4 +- .../http/admin/menu/system_menus_handler.go | 17 +- backend/pkg/plugin_mgr/types.go | 1 + docs/plan/content/media.md | 207 +++++++++ make_files/database.mk | 2 + .../contracts/http-admin.yaml | 0 .../contracts/http-openapi.yaml | 0 .../contracts/tests/grpc_media_asset_test.md | 0 .../contracts/tests/http_media_asset_test.md | 0 .../contracts/tests/http_openapi_test.md | 2 +- .../data-model.md | 0 .../plan.md | 36 +- .../quickstart.md | 22 + .../research.md | 0 .../spec.md | 15 +- .../tasks.md | 25 +- web-admin/.gitignore | 1 + .../agent/AgentPluginRenderView.vue | 6 +- .../app/components/agent/AgentSelector.vue | 132 +++--- .../app/components/agent/AgentSidebar.vue | 116 +++-- .../app/components/agent/ConfigPanel.vue | 226 +++++++--- .../content/media/MediaAssetDetailPanel.vue | 170 +++++++ .../content/media/MediaFilterBar.vue | 91 ++++ .../components/content/media/MediaGrid.vue | 74 ++++ .../components/content/media/MediaPreview.vue | 75 ++++ .../components/content/media/MediaTable.vue | 127 ++++++ .../content/media/MediaUploadDrawer.vue | 344 +++++++++++++++ .../app/composables/agent/useAgentManager.ts | 38 +- .../app/composables/agent/useChatSessions.ts | 121 +++-- .../agent/useDualChannelConnection.ts | 74 +++- web-admin/app/composables/api/index.ts | 9 + .../__tests__/mediaAssetService.test.ts | 90 ++++ .../composables/api/services/agentService.ts | 8 +- .../api/services/mediaAssetService.ts | 182 ++++++++ web-admin/app/pages/agent/index.vue | 216 ++++++--- web-admin/app/pages/content/media/[uuid].vue | 415 ++++++++++++++++++ web-admin/app/pages/content/media/index.vue | 226 ++++++++++ web-admin/app/plugins/api.ts | 3 +- web-admin/app/stores/agentSession.ts | 45 +- web-admin/app/types/agent.ts | 9 +- web-admin/i18n/locales/en.json | 4 +- web-admin/i18n/locales/ja.json | 19 +- web-admin/i18n/locales/ko.json | 19 +- web-admin/i18n/locales/zh.json | 2 + web-admin/nuxt.config.ts | 43 +- 59 files changed, 3378 insertions(+), 467 deletions(-) create mode 100644 docs/plan/content/media.md rename specs/{001-docs-media-storage => 001-media-storage}/contracts/http-admin.yaml (100%) rename specs/{001-docs-media-storage => 001-media-storage}/contracts/http-openapi.yaml (100%) rename specs/{001-docs-media-storage => 001-media-storage}/contracts/tests/grpc_media_asset_test.md (100%) rename specs/{001-docs-media-storage => 001-media-storage}/contracts/tests/http_media_asset_test.md (100%) rename specs/{001-docs-media-storage => 001-media-storage}/contracts/tests/http_openapi_test.md (81%) rename specs/{001-docs-media-storage => 001-media-storage}/data-model.md (100%) rename specs/{001-docs-media-storage => 001-media-storage}/plan.md (85%) rename specs/{001-docs-media-storage => 001-media-storage}/quickstart.md (75%) rename specs/{001-docs-media-storage => 001-media-storage}/research.md (100%) rename specs/{001-docs-media-storage => 001-media-storage}/spec.md (79%) rename specs/{001-docs-media-storage => 001-media-storage}/tasks.md (73%) create mode 100644 web-admin/app/components/content/media/MediaAssetDetailPanel.vue create mode 100644 web-admin/app/components/content/media/MediaFilterBar.vue create mode 100644 web-admin/app/components/content/media/MediaGrid.vue create mode 100644 web-admin/app/components/content/media/MediaPreview.vue create mode 100644 web-admin/app/components/content/media/MediaTable.vue create mode 100644 web-admin/app/components/content/media/MediaUploadDrawer.vue create mode 100644 web-admin/app/composables/api/services/__tests__/mediaAssetService.test.ts create mode 100644 web-admin/app/composables/api/services/mediaAssetService.ts create mode 100644 web-admin/app/pages/content/media/[uuid].vue create mode 100644 web-admin/app/pages/content/media/index.vue diff --git a/backend/cmd/database/seed/seed_agent.go b/backend/cmd/database/seed/seed_agent.go index c9f80cf6..cc8565c7 100644 --- a/backend/cmd/database/seed/seed_agent.go +++ b/backend/cmd/database/seed/seed_agent.go @@ -8,6 +8,7 @@ import ( agentr "github.com/ArtisanCloud/PowerX/internal/server/agent/persistence/repository" tenantmodel "github.com/ArtisanCloud/PowerX/pkg/corex/db/persistence/model/tenant" tenantrepo "github.com/ArtisanCloud/PowerX/pkg/corex/db/persistence/repository/tenant" + "github.com/google/uuid" "gorm.io/datatypes" "gorm.io/gorm" ) @@ -30,6 +31,7 @@ func SeedSystemDefaultAgent(db *gorm.DB) error { const ( agentKey = "core.system.default" agentName = "System Default Agent" + agentUUID = "6b31adf3-4f7d-4c77-9d1d-c58fb3a7cf2a" ) // 已存在直接返回(幂等) @@ -66,6 +68,7 @@ func SeedSystemDefaultAgent(db *gorm.DB) error { "tags": []string{"system", "default"}, }, } + a.UUID = uuid.MustParse(agentUUID) // 用仓库 Upsert(租户级唯一:env + tenant_id + key) if err := agentRepo.UpsertByScopeKey(ctx, env, &tenantUUID, a); err != nil { diff --git a/backend/config/defaults.go b/backend/config/defaults.go index cdc2cf92..d508ca89 100644 --- a/backend/config/defaults.go +++ b/backend/config/defaults.go @@ -280,7 +280,7 @@ func GetDefaults() *Config { }, FeatureGate: FeatureGateConfig{ LicenseKey: "demo-license-xyz", - EnableEventFabric: true, + EnableEventFabric: false, EnableWorkflow: true, EnableKnowledgeSpace: true, EnableMediaPlatform: true, diff --git a/backend/config/validator.go b/backend/config/validator.go index 1e7fa3d0..ab040edb 100644 --- a/backend/config/validator.go +++ b/backend/config/validator.go @@ -52,73 +52,75 @@ func (c *Config) Validate() error { } // --- Event Fabric --- - if c.Event.Fabric.AckTimeoutSeconds <= 0 { - errors = append(errors, "event_fabric.ack_timeout_seconds 必须大于0") - } - if c.Event.Fabric.DefaultMaxRetry <= 0 { - errors = append(errors, "event_fabric.default_max_retry 必须大于0") - } - if strings.TrimSpace(c.Event.Fabric.RetryKeyPrefix) == "" { - errors = append(errors, "event_fabric.retry_key_prefix 不能为空") - } - if strings.TrimSpace(c.Event.Fabric.ReplayKeyPrefix) == "" { - errors = append(errors, "event_fabric.replay_key_prefix 不能为空") - } - if c.Event.Fabric.SchedulerInterval <= 0 { - errors = append(errors, "event_fabric.scheduler_interval 必须大于0") - } - if strings.TrimSpace(c.Event.Fabric.RedisAddr) == "" { - errors = append(errors, "event_fabric.redis_addr 不能为空") - } - if strings.TrimSpace(c.Event.Fabric.Security.SignatureSecret) != "" { - if strings.TrimSpace(c.Event.Fabric.Security.SignatureHeader) == "" { - errors = append(errors, "event_fabric.security.signature_header 不能为空") + if c.FeatureGate.EnableEventFabric { + if c.Event.Fabric.AckTimeoutSeconds <= 0 { + errors = append(errors, "event_fabric.ack_timeout_seconds 必须大于0") } - if strings.TrimSpace(c.Event.Fabric.Security.TimestampHeader) == "" { - errors = append(errors, "event_fabric.security.timestamp_header 不能为空") + if c.Event.Fabric.DefaultMaxRetry <= 0 { + errors = append(errors, "event_fabric.default_max_retry 必须大于0") } - if c.Event.Fabric.Security.AllowedClockSkewSeconds <= 0 { - errors = append(errors, "event_fabric.security.allowed_clock_skew_seconds 必须大于0") + if strings.TrimSpace(c.Event.Fabric.RetryKeyPrefix) == "" { + errors = append(errors, "event_fabric.retry_key_prefix 不能为空") + } + if strings.TrimSpace(c.Event.Fabric.ReplayKeyPrefix) == "" { + errors = append(errors, "event_fabric.replay_key_prefix 不能为空") + } + if c.Event.Fabric.SchedulerInterval <= 0 { + errors = append(errors, "event_fabric.scheduler_interval 必须大于0") + } + if strings.TrimSpace(c.Event.Fabric.RedisAddr) == "" { + errors = append(errors, "event_fabric.redis_addr 不能为空") + } + if strings.TrimSpace(c.Event.Fabric.Security.SignatureSecret) != "" { + if strings.TrimSpace(c.Event.Fabric.Security.SignatureHeader) == "" { + errors = append(errors, "event_fabric.security.signature_header 不能为空") + } + if strings.TrimSpace(c.Event.Fabric.Security.TimestampHeader) == "" { + errors = append(errors, "event_fabric.security.timestamp_header 不能为空") + } + if c.Event.Fabric.Security.AllowedClockSkewSeconds <= 0 { + errors = append(errors, "event_fabric.security.allowed_clock_skew_seconds 必须大于0") + } + } + if c.Event.Fabric.Authorization.CacheTTLSeconds <= 0 { + errors = append(errors, "event_fabric.authorization.cache_ttl_seconds 必须大于0") + } + if c.Event.Fabric.Authorization.LocalCacheTTLSeconds <= 0 { + errors = append(errors, "event_fabric.authorization.local_cache_ttl_seconds 必须大于0") + } + if strings.TrimSpace(c.Event.Fabric.Authorization.RedisAddr) == "" { + errors = append(errors, "event_fabric.authorization.redis_addr 不能为空") + } + if strings.TrimSpace(c.Event.Fabric.Authorization.CacheInvalidateChannel) == "" { + errors = append(errors, "event_fabric.authorization.cache_invalidate_channel 不能为空") + } + if c.Event.Fabric.Authorization.ChallengeSLASeconds <= 0 { + errors = append(errors, "event_fabric.authorization.challenge_sla_seconds 必须大于0") + } + if strings.TrimSpace(c.Event.Fabric.Authorization.ChallengeTopic) == "" { + errors = append(errors, "event_fabric.authorization.challenge_topic 不能为空") + } + if strings.TrimSpace(c.Event.Fabric.Authorization.ChallengeConsumerGroup) == "" { + errors = append(errors, "event_fabric.authorization.challenge_consumer_group 不能为空") + } + if c.Event.Fabric.Authorization.TimeoutSweepIntervalSeconds <= 0 { + errors = append(errors, "event_fabric.authorization.timeout_sweep_interval_seconds 必须大于0") + } + if c.Event.Fabric.Authorization.AuditRetentionDays <= 0 { + errors = append(errors, "event_fabric.authorization.audit_retention_days 必须大于0") + } + if strings.TrimSpace(c.Event.Fabric.Authorization.AuditArchiveBucket) == "" { + errors = append(errors, "event_fabric.authorization.audit_archive_bucket 不能为空") + } + if strings.TrimSpace(c.Event.Fabric.Authorization.AuditArchivePrefix) == "" { + errors = append(errors, "event_fabric.authorization.audit_archive_prefix 不能为空") + } + if c.Event.Fabric.Authorization.Secrets.CacheTTLSeconds < 0 { + errors = append(errors, "event_fabric.authorization.secrets.cache_ttl_seconds 不能为负数") + } + if c.Event.Fabric.Authorization.Secrets.RotationIntervalSeconds < 0 { + errors = append(errors, "event_fabric.authorization.secrets.rotation_interval_seconds 不能为负数") } - } - if c.Event.Fabric.Authorization.CacheTTLSeconds <= 0 { - errors = append(errors, "event_fabric.authorization.cache_ttl_seconds 必须大于0") - } - if c.Event.Fabric.Authorization.LocalCacheTTLSeconds <= 0 { - errors = append(errors, "event_fabric.authorization.local_cache_ttl_seconds 必须大于0") - } - if strings.TrimSpace(c.Event.Fabric.Authorization.RedisAddr) == "" { - errors = append(errors, "event_fabric.authorization.redis_addr 不能为空") - } - if strings.TrimSpace(c.Event.Fabric.Authorization.CacheInvalidateChannel) == "" { - errors = append(errors, "event_fabric.authorization.cache_invalidate_channel 不能为空") - } - if c.Event.Fabric.Authorization.ChallengeSLASeconds <= 0 { - errors = append(errors, "event_fabric.authorization.challenge_sla_seconds 必须大于0") - } - if strings.TrimSpace(c.Event.Fabric.Authorization.ChallengeTopic) == "" { - errors = append(errors, "event_fabric.authorization.challenge_topic 不能为空") - } - if strings.TrimSpace(c.Event.Fabric.Authorization.ChallengeConsumerGroup) == "" { - errors = append(errors, "event_fabric.authorization.challenge_consumer_group 不能为空") - } - if c.Event.Fabric.Authorization.TimeoutSweepIntervalSeconds <= 0 { - errors = append(errors, "event_fabric.authorization.timeout_sweep_interval_seconds 必须大于0") - } - if c.Event.Fabric.Authorization.AuditRetentionDays <= 0 { - errors = append(errors, "event_fabric.authorization.audit_retention_days 必须大于0") - } - if strings.TrimSpace(c.Event.Fabric.Authorization.AuditArchiveBucket) == "" { - errors = append(errors, "event_fabric.authorization.audit_archive_bucket 不能为空") - } - if strings.TrimSpace(c.Event.Fabric.Authorization.AuditArchivePrefix) == "" { - errors = append(errors, "event_fabric.authorization.audit_archive_prefix 不能为空") - } - if c.Event.Fabric.Authorization.Secrets.CacheTTLSeconds < 0 { - errors = append(errors, "event_fabric.authorization.secrets.cache_ttl_seconds 不能为负数") - } - if c.Event.Fabric.Authorization.Secrets.RotationIntervalSeconds < 0 { - errors = append(errors, "event_fabric.authorization.secrets.rotation_interval_seconds 不能为负数") } // --- Capability Registry --- diff --git a/backend/internal/http/middleware.go b/backend/internal/http/middleware.go index 07d678ae..81c4192d 100644 --- a/backend/internal/http/middleware.go +++ b/backend/internal/http/middleware.go @@ -2,6 +2,9 @@ package http import ( "context" + "net/url" + "sort" + "strings" "time" "github.com/ArtisanCloud/PowerX/pkg/corex/audit" @@ -27,9 +30,11 @@ func RequestLoggingMiddleware() gin.HandlerFunc { status := c.Writer.Status() tenantUUID := reqctx.GetTenantUUID(c.Request.Context()) traceID := audit.GetTraceID(c.Request.Context()) + query := sanitizeQuery(c.Request.URL.Query()) logger.Info(c.Request.Context(), "http_request", zap.String("method", c.Request.Method), zap.String("path", c.FullPath()), + zap.String("query", query), zap.Int("status", status), zap.Int64("latency_ms", latency.Milliseconds()), zap.String("tenant_uuid", tenantUUID), @@ -38,6 +43,74 @@ func RequestLoggingMiddleware() gin.HandlerFunc { } } +func sanitizeQuery(v url.Values) string { + if len(v) == 0 { + return "" + } + // 避免把用户内容/敏感信息写入日志(SSE 的 q/message、token 等) + redactKeys := map[string]struct{}{ + "q": {}, + "message": {}, + "prompt": {}, + "system_prompt": {}, + "systemPrompt": {}, + "api_key": {}, + "apiKey": {}, + "authorization": {}, + "access_token": {}, + "refresh_token": {}, + "token": {}, + "bearer": {}, + "password": {}, + "secret": {}, + "client_secret": {}, + "private_key": {}, + "signature": {}, + "sig": {}, + "x-api-key": {}, + "x_api_key": {}, + "apikey": {}, + "openai_api_key": {}, + } + + // clone + redact + out := make(url.Values, len(v)) + keys := make([]string, 0, len(v)) + for k := range v { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + kl := strings.ToLower(strings.TrimSpace(k)) + if _, ok := redactKeys[kl]; ok { + out[k] = []string{""} + continue + } + vals := v[k] + clean := make([]string, 0, len(vals)) + for _, s := range vals { + s = strings.TrimSpace(s) + if s == "" { + continue + } + // 单个值也做截断,避免日志过长 + if len(s) > 200 { + s = s[:200] + "…" + } + clean = append(clean, s) + } + if len(clean) > 0 { + out[k] = clean + } + } + + encoded := out.Encode() + if len(encoded) > 800 { + return encoded[:800] + "…" + } + return encoded +} + // TraceInjectionMiddleware 确保每个请求都有 trace_id(从 header 继承或新建)并注入 context func TraceInjectionMiddleware() gin.HandlerFunc { return func(c *gin.Context) { diff --git a/backend/internal/server/agent/drivers/eino/llm/factory.go b/backend/internal/server/agent/drivers/eino/llm/factory.go index 40cd3bdd..d228b7fc 100644 --- a/backend/internal/server/agent/drivers/eino/llm/factory.go +++ b/backend/internal/server/agent/drivers/eino/llm/factory.go @@ -9,7 +9,7 @@ import ( func NewClient(provider string) (LLMClient, error) { switch normalize(provider) { case "openai": - return NewOpenAIClient(), nil + return NewOpenAIClient(provider), nil case "hunyuan": return NewHunyuanClient(), nil case "ollama": diff --git a/backend/internal/server/agent/drivers/eino/llm/openai.go b/backend/internal/server/agent/drivers/eino/llm/openai.go index d4011bb7..a517b0c0 100644 --- a/backend/internal/server/agent/drivers/eino/llm/openai.go +++ b/backend/internal/server/agent/drivers/eino/llm/openai.go @@ -8,16 +8,27 @@ import ( "errors" "fmt" "github.com/ArtisanCloud/PowerX/internal/server/agent/drivers/eino/config" + "github.com/ArtisanCloud/PowerX/pkg/corex/audit" + "github.com/ArtisanCloud/PowerX/pkg/corex/iam/reqctx" + "github.com/ArtisanCloud/PowerX/pkg/utils/logger" "io" "net/http" + "net/url" "strings" "time" + + "go.uber.org/zap" ) // 兼容现有接口 -type openaiClient struct{ NoopStream } +type openaiClient struct { + NoopStream + rawProvider string +} -func NewOpenAIClient() LLMClient { return &openaiClient{} } +func NewOpenAIClient(rawProvider string) LLMClient { + return &openaiClient{rawProvider: strings.TrimSpace(rawProvider)} +} const defaultOpenAIPath = "/v1/chat/completions" @@ -131,6 +142,43 @@ func (c *openaiClient) makeBody(mc *config.ModelConfig, userMessage string, stre return json.Marshal(m) } +func sanitizeLLMURL(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + u, err := url.Parse(raw) + if err != nil || u == nil { + if len(raw) > 300 { + return raw[:300] + "…" + } + return raw + } + u.Fragment = "" + s := u.String() + if len(s) > 300 { + return s[:300] + "…" + } + return s +} + +func (c *openaiClient) logRequest(ctx context.Context, reqURL string, mc *config.ModelConfig, streaming bool) { + provider := strings.TrimSpace(c.rawProvider) + if provider == "" { + provider = "openai" + } + logger.Info(ctx, "llm_request", + zap.String("trace_id", audit.GetTraceID(ctx)), + zap.String("tenant_uuid", reqctx.GetTenantUUID(ctx)), + zap.String("driver", "openai"), + zap.String("provider", provider), + zap.String("model", strings.TrimSpace(mc.Model)), + zap.Bool("stream", streaming), + zap.Bool("azure", strings.TrimSpace(mc.AzureDeployment) != ""), + zap.String("url", sanitizeLLMURL(reqURL)), + ) +} + func (c *openaiClient) httpClient(mc *config.ModelConfig) *http.Client { to := mc.Timeout if to <= 0 { @@ -144,10 +192,12 @@ func (c *openaiClient) httpClient(mc *config.ModelConfig) *http.Client { /* ------------ Invoke(非流) ------------ */ func (c *openaiClient) Invoke(ctx context.Context, mc *config.ModelConfig, userMessage string) (string, error) { + start := time.Now() url, headers, err := c.buildEndpointAndHeaders(mc, false) if err != nil { return "", err } + c.logRequest(ctx, url, mc, false) body, err := c.makeBody(mc, userMessage, false) if err != nil { return "", err @@ -158,20 +208,58 @@ func (c *openaiClient) Invoke(ctx context.Context, mc *config.ModelConfig, userM resp, err := c.httpClient(mc).Do(req) if err != nil { + logger.Info(ctx, "llm_response", + zap.String("trace_id", audit.GetTraceID(ctx)), + zap.String("driver", "openai"), + zap.String("provider", strings.TrimSpace(c.rawProvider)), + zap.String("model", strings.TrimSpace(mc.Model)), + zap.Bool("stream", false), + zap.Int64("latency_ms", time.Since(start).Milliseconds()), + zap.String("error", err.Error()), + ) return "", err } defer resp.Body.Close() bt, _ := io.ReadAll(resp.Body) if resp.StatusCode/100 != 2 { + logger.Info(ctx, "llm_response", + zap.String("trace_id", audit.GetTraceID(ctx)), + zap.String("driver", "openai"), + zap.String("provider", strings.TrimSpace(c.rawProvider)), + zap.String("model", strings.TrimSpace(mc.Model)), + zap.Bool("stream", false), + zap.Int("status", resp.StatusCode), + zap.Int64("latency_ms", time.Since(start).Milliseconds()), + ) return "", fmt.Errorf("openai invoke url=%s status=%d body=%s", url, resp.StatusCode, string(bt)) } var jr openAINonStreamResp if err := json.Unmarshal(bt, &jr); err != nil { + logger.Info(ctx, "llm_response", + zap.String("trace_id", audit.GetTraceID(ctx)), + zap.String("driver", "openai"), + zap.String("provider", strings.TrimSpace(c.rawProvider)), + zap.String("model", strings.TrimSpace(mc.Model)), + zap.Bool("stream", false), + zap.Int("status", resp.StatusCode), + zap.Int64("latency_ms", time.Since(start).Milliseconds()), + zap.String("error", err.Error()), + ) return "", fmt.Errorf("openai decode failed: %w (body=%s)", err, string(bt)) } if jr.Error != nil && strings.TrimSpace(jr.Error.Message) != "" { + logger.Info(ctx, "llm_response", + zap.String("trace_id", audit.GetTraceID(ctx)), + zap.String("driver", "openai"), + zap.String("provider", strings.TrimSpace(c.rawProvider)), + zap.String("model", strings.TrimSpace(mc.Model)), + zap.Bool("stream", false), + zap.Int("status", resp.StatusCode), + zap.Int64("latency_ms", time.Since(start).Milliseconds()), + zap.String("error", strings.TrimSpace(jr.Error.Message)), + ) return "", fmt.Errorf("openai error: %s", strings.TrimSpace(jr.Error.Message)) } if len(jr.Choices) == 0 { @@ -198,19 +286,40 @@ func (c *openaiClient) Invoke(ctx context.Context, mc *config.ModelConfig, userM if len(trim) > 2000 { trim = trim[:2000] + "…" } + logger.Info(ctx, "llm_response", + zap.String("trace_id", audit.GetTraceID(ctx)), + zap.String("driver", "openai"), + zap.String("provider", strings.TrimSpace(c.rawProvider)), + zap.String("model", strings.TrimSpace(mc.Model)), + zap.Bool("stream", false), + zap.Int("status", resp.StatusCode), + zap.Int64("latency_ms", time.Since(start).Milliseconds()), + zap.String("error", "empty choices"), + ) return "", fmt.Errorf("openai: empty choices (url=%s body=%s)", url, trim) } + logger.Info(ctx, "llm_response", + zap.String("trace_id", audit.GetTraceID(ctx)), + zap.String("driver", "openai"), + zap.String("provider", strings.TrimSpace(c.rawProvider)), + zap.String("model", strings.TrimSpace(mc.Model)), + zap.Bool("stream", false), + zap.Int("status", resp.StatusCode), + zap.Int64("latency_ms", time.Since(start).Milliseconds()), + ) return jr.Choices[0].Message.Content, nil } /* ------------ Stream(优先流;不支持时自动回退) ------------ */ func (c *openaiClient) Stream(ctx context.Context, mc *config.ModelConfig, prompt string, onDelta func(string)) (string, error) { + start := time.Now() // 若上层“配置禁用流”,你可以在外部判断;这里即便请求流,也能自动回退到 Invoke url, headers, err := c.buildEndpointAndHeaders(mc, true) if err != nil { return "", err } + c.logRequest(ctx, url, mc, true) body, err := c.makeBody(mc, prompt, true) if err != nil { return "", err @@ -221,6 +330,15 @@ func (c *openaiClient) Stream(ctx context.Context, mc *config.ModelConfig, promp resp, err := c.httpClient(mc).Do(req) if err != nil { + logger.Info(ctx, "llm_response", + zap.String("trace_id", audit.GetTraceID(ctx)), + zap.String("driver", "openai"), + zap.String("provider", strings.TrimSpace(c.rawProvider)), + zap.String("model", strings.TrimSpace(mc.Model)), + zap.Bool("stream", true), + zap.Int64("latency_ms", time.Since(start).Milliseconds()), + zap.String("error", err.Error()), + ) return "", err } defer resp.Body.Close() @@ -236,6 +354,17 @@ func (c *openaiClient) Stream(ctx context.Context, mc *config.ModelConfig, promp // 仍然尝试回退 _ = bt } + logger.Info(ctx, "llm_response", + zap.String("trace_id", audit.GetTraceID(ctx)), + zap.String("driver", "openai"), + zap.String("provider", strings.TrimSpace(c.rawProvider)), + zap.String("model", strings.TrimSpace(mc.Model)), + zap.Bool("stream", true), + zap.Int("status", resp.StatusCode), + zap.Int64("latency_ms", time.Since(start).Milliseconds()), + zap.Bool("fallback_invoke", true), + zap.String("content_type", resp.Header.Get("Content-Type")), + ) // 回退 return c.Invoke(ctx, mc, prompt) } @@ -284,6 +413,15 @@ func (c *openaiClient) Stream(ctx context.Context, mc *config.ModelConfig, promp final.WriteString(delta) } } + logger.Info(ctx, "llm_response", + zap.String("trace_id", audit.GetTraceID(ctx)), + zap.String("driver", "openai"), + zap.String("provider", strings.TrimSpace(c.rawProvider)), + zap.String("model", strings.TrimSpace(mc.Model)), + zap.Bool("stream", true), + zap.Int("status", resp.StatusCode), + zap.Int64("latency_ms", time.Since(start).Milliseconds()), + ) return final.String(), nil } diff --git a/backend/internal/server/agent/persistence/model/agent_gorm.go b/backend/internal/server/agent/persistence/model/agent_gorm.go index a2b43735..5bafa15b 100644 --- a/backend/internal/server/agent/persistence/model/agent_gorm.go +++ b/backend/internal/server/agent/persistence/model/agent_gorm.go @@ -3,7 +3,9 @@ package model import ( coremodel "github.com/ArtisanCloud/PowerX/pkg/corex/db/persistence/model" + "github.com/google/uuid" "gorm.io/datatypes" + "gorm.io/gorm" ) // ---------- 表名常量 ---------- @@ -44,7 +46,7 @@ const ( // ---------- 1) Agent 主表 ---------- // 说明:沿用 Env + TenantUUID 的作用域与索引策略;(Env, TenantUUID, Key) 在同一租户内唯一。 type Agent struct { - coremodel.PowerModel + coremodel.PowerUUIDModel // 作用域 Env string `gorm:"size:32;index:agent_key_uniq_global,unique,priority:1,where:tenant_uuid IS NULL;index:agent_key_uniq_tenant,unique,priority:1" json:"-"` @@ -77,6 +79,13 @@ type Agent struct { Meta datatypes.JSONMap `gorm:"type:jsonb;default:'{}'::jsonb" json:"meta"` } +func (mdl *Agent) BeforeCreate(tx *gorm.DB) error { + if mdl.UUID == uuid.Nil { + mdl.UUID = uuid.New() + } + return nil +} + // 表名(带 schema) func (mdl *Agent) TableName() string { return coremodel.PowerXSchema + "." + TableAgent diff --git a/backend/internal/server/agent/persistence/repository/agent_repo.go b/backend/internal/server/agent/persistence/repository/agent_repo.go index 9cf2b277..b2734241 100644 --- a/backend/internal/server/agent/persistence/repository/agent_repo.go +++ b/backend/internal/server/agent/persistence/repository/agent_repo.go @@ -8,6 +8,7 @@ import ( dbmodel "github.com/ArtisanCloud/PowerX/internal/server/agent/persistence/model" coreRepo "github.com/ArtisanCloud/PowerX/pkg/corex/db/persistence/repository" + "github.com/google/uuid" "gorm.io/gorm" ) @@ -79,6 +80,18 @@ func (r *AgentRepository) FindByScopeKey(ctx context.Context, env string, tenant return &out, nil } +func (r *AgentRepository) FindByScopeUUID(ctx context.Context, env string, tenantUUID *string, agentUUID uuid.UUID) (*dbmodel.Agent, error) { + var out dbmodel.Agent + err := r.db.WithContext(ctx). + Scopes(dbmodel.WithScope(env, tenantUUID)). + Where("uuid = ?", agentUUID). + First(&out).Error + if err != nil { + return nil, err + } + return &out, nil +} + func (r *AgentRepository) GetByID(ctx context.Context, id uint64) (*dbmodel.Agent, error) { var out dbmodel.Agent if err := r.db.WithContext(ctx).First(&out, id).Error; err != nil { diff --git a/backend/internal/service/agent/agent_service.go b/backend/internal/service/agent/agent_service.go index bd2d274c..b8c099d9 100644 --- a/backend/internal/service/agent/agent_service.go +++ b/backend/internal/service/agent/agent_service.go @@ -9,6 +9,7 @@ import ( dbmodel "github.com/ArtisanCloud/PowerX/internal/server/agent/persistence/model" repo "github.com/ArtisanCloud/PowerX/internal/server/agent/persistence/repository" + "github.com/google/uuid" "gorm.io/datatypes" "gorm.io/gorm" ) @@ -185,6 +186,17 @@ func (s *AgentService) Get(ctx context.Context, env string, tenantUUID *string, return out, nil } +func (s *AgentService) GetByUUID(ctx context.Context, env string, tenantUUID *string, agentUUID uuid.UUID) (*dbmodel.Agent, error) { + out, err := s.agRepo.FindByScopeUUID(ctx, env, tenantUUID, agentUUID) + if err != nil { + return nil, err + } + if !equalTenant(tenantUUID, out.TenantUUID) { + return nil, gorm.ErrRecordNotFound + } + return out, nil +} + //func (s *AgentService) List( // ctx context.Context, env string, tenantUUID *string, statuses ...string, //) ([]dbmodel.Agent, error) { diff --git a/backend/internal/transport/http/admin/agent/agent_handler.go b/backend/internal/transport/http/admin/agent/agent_handler.go index 2cc4f889..2c95746d 100644 --- a/backend/internal/transport/http/admin/agent/agent_handler.go +++ b/backend/internal/transport/http/admin/agent/agent_handler.go @@ -10,6 +10,7 @@ import ( "github.com/ArtisanCloud/PowerX/pkg/corex/iam/reqctx" dtoRequest "github.com/ArtisanCloud/PowerX/pkg/dto" "github.com/ArtisanCloud/PowerX/pkg/utils" + "github.com/google/uuid" "gorm.io/datatypes" "strings" "time" @@ -30,7 +31,9 @@ func NewAgentHandler(dep *shared.Deps) *AgentHandler { } type AgentStatusRequest struct { - AgentID string `form:"agent_id" json:"agent_id,omitempty"` // GET 用 form/query + // 兼容字段:历史实现要求 agent_id(运行期 manager key),但前端不会传。 + AgentID string `form:"agent_id" json:"agent_id,omitempty"` + AgentUUID string `form:"agent_uuid" json:"agent_uuid,omitempty"` } type AgentStatusResponse struct { @@ -55,35 +58,16 @@ func (h *AgentHandler) Status(c *gin.Context) { dtoRequest.ResponseValidationError(c, err) return } - if strings.TrimSpace(req.AgentID) == "" { - dtoRequest.ResponseError(c, 400, "agent_id 不能为空", nil) - return - } - - mgr := agent.GetAgentManager() - sysAg, _, rt, err := mgr.Get(req.AgentID) - if err != nil { - // Not found 更合适 - dtoRequest.ResponseError(c, 404, "未找到指定的 Agent", err) - return - } + // 该接口用于前端“启动阶段探活”,必须允许无 agent_id/agent_uuid 的调用。 + dtoRequest.ResponseSuccess(c, gin.H{ + "status": "ok", + "message": "success", + }) +} - resp := &AgentStatusResponse{ - AgentInfo: &agentschema.AgentInfo{ - AgentID: sysAg.AgentID, - Name: sysAg.Name, - Description: sysAg.Description, - Status: string(sysAg.Status), - Config: sysAg.Config, - CreatedAt: sysAg.CreatedAt, - UpdatedAt: sysAg.UpdatedAt, - LastBeatAt: sysAg.LastBeatAt, - Runtime: rt, - Extras: sysAg.Extras, - }, - } - dtoRequest.ResponseSuccess(c, resp) - return +func parseAgentUUIDParam(c *gin.Context) (uuid.UUID, error) { + raw := strings.TrimSpace(utils.FirstNonEmpty(c.Param("uuid"), c.Param("id"))) + return uuid.Parse(raw) } // /api/agents/intent 支持单意图(默认) 或 多任务(?multi=1) @@ -294,12 +278,12 @@ func (h *AgentHandler) GetAgent(c *gin.Context) { return } tenantRef := tenantCtx.UUIDPtr() - agentID, err := utils.ParseUintID(c.Param("id")) + agentUUID, err := parseAgentUUIDParam(c) if err != nil { - dtoRequest.ResponseError(c, 400, "id 非法", nil) + dtoRequest.ResponseError(c, 400, "uuid 非法", nil) return } - out, err := h.srv.Get(c.Request.Context(), env, tenantRef, agentID) + out, err := h.srv.GetByUUID(c.Request.Context(), env, tenantRef, agentUUID) if err != nil { dtoRequest.ResponseError(c, 404, "未找到", err) return @@ -320,9 +304,14 @@ func (h *AgentHandler) UpdateAgent(c *gin.Context) { return } tenantRef := tenantCtx.UUIDPtr() - agentID, err := utils.ParseUintID(c.Param("id")) + agentUUID, err := parseAgentUUIDParam(c) + if err != nil { + dtoRequest.ResponseError(c, 400, "uuid 非法", nil) + return + } + exist, err := h.srv.GetByUUID(c.Request.Context(), env, tenantRef, agentUUID) if err != nil { - dtoRequest.ResponseError(c, 400, "id 非法", nil) + dtoRequest.ResponseError(c, 404, "未找到", err) return } @@ -339,7 +328,7 @@ func (h *AgentHandler) UpdateAgent(c *gin.Context) { KBStrategy: req.KBStrategy, Meta: req.Meta, } - out, err := h.srv.Update(c.Request.Context(), env, tenantRef, agentID, patch) + out, err := h.srv.Update(c.Request.Context(), env, tenantRef, exist.ID, patch) if err != nil { dtoRequest.ResponseError(c, 400, err.Error(), nil) return @@ -357,12 +346,17 @@ func (h *AgentHandler) setAgentStatus(c *gin.Context, status string) { return } tenantRef := tenantCtx.UUIDPtr() - agentID, err := utils.ParseUintID(c.Param("id")) + agentUUID, err := parseAgentUUIDParam(c) if err != nil { - dtoRequest.ResponseError(c, 400, "id 非法", nil) + dtoRequest.ResponseError(c, 400, "uuid 非法", nil) + return + } + exist, err := h.srv.GetByUUID(c.Request.Context(), env, tenantRef, agentUUID) + if err != nil { + dtoRequest.ResponseError(c, 404, "未找到", err) return } - if err := h.srv.SetStatus(c.Request.Context(), env, tenantRef, agentID, status); err != nil { + if err := h.srv.SetStatus(c.Request.Context(), env, tenantRef, exist.ID, status); err != nil { dtoRequest.ResponseError(c, 400, err.Error(), nil) return } @@ -377,12 +371,17 @@ func (h *AgentHandler) DeleteAgent(c *gin.Context) { return } tenantRef := tenantCtx.UUIDPtr() - agentID, err := utils.ParseUintID(c.Param("id")) + agentUUID, err := parseAgentUUIDParam(c) if err != nil { - dtoRequest.ResponseError(c, 400, "id 非法", nil) + dtoRequest.ResponseError(c, 400, "uuid 非法", nil) return } - if err := h.srv.Delete(c.Request.Context(), env, tenantRef, agentID); err != nil { + exist, err := h.srv.GetByUUID(c.Request.Context(), env, tenantRef, agentUUID) + if err != nil { + dtoRequest.ResponseError(c, 404, "未找到", err) + return + } + if err := h.srv.Delete(c.Request.Context(), env, tenantRef, exist.ID); err != nil { dtoRequest.ResponseError(c, 400, err.Error(), nil) return } @@ -408,12 +407,17 @@ func (h *AgentHandler) GetAgentAISetting(c *gin.Context) { return } tenantRef := tenantCtx.UUIDPtr() - agentID, err := utils.ParseUintID(c.Param("id")) + agentUUID, err := parseAgentUUIDParam(c) + if err != nil { + dtoRequest.ResponseError(c, 400, "uuid 非法", nil) + return + } + exist, err := h.srv.GetByUUID(c.Request.Context(), env, tenantRef, agentUUID) if err != nil { - dtoRequest.ResponseError(c, 400, "id 非法", nil) + dtoRequest.ResponseError(c, 404, "未找到", err) return } - setting, err := h.srv.GetAgentAISetting(c.Request.Context(), env, tenantRef, agentID) + setting, err := h.srv.GetAgentAISetting(c.Request.Context(), env, tenantRef, exist.ID) if err != nil { dtoRequest.ResponseError(c, 404, "未找到", err) return @@ -433,15 +437,20 @@ func (h *AgentHandler) UpsertAgentAISetting(c *gin.Context) { return } tenantRef := tenantCtx.UUIDPtr() - agentID, err := utils.ParseUintID(c.Param("id")) + agentUUID, err := parseAgentUUIDParam(c) + if err != nil { + dtoRequest.ResponseError(c, 400, "uuid 非法", nil) + return + } + exist, err := h.srv.GetByUUID(c.Request.Context(), req.Env, tenantRef, agentUUID) if err != nil { - dtoRequest.ResponseError(c, 400, "id 非法", nil) + dtoRequest.ResponseError(c, 404, "未找到", err) return } in := &dbmodel.AgentSetting{ Env: req.Env, - AgentID: agentID, + AgentID: exist.ID, Provider: strings.TrimSpace(req.Provider), Model: strings.TrimSpace(req.Model), Params: req.Params, @@ -466,12 +475,17 @@ func (h *AgentHandler) DeleteAgentAISetting(c *gin.Context) { return } tenantRef := tenantCtx.UUIDPtr() - agentID, err := utils.ParseUintID(c.Param("id")) + agentUUID, err := parseAgentUUIDParam(c) + if err != nil { + dtoRequest.ResponseError(c, 400, "uuid 非法", nil) + return + } + exist, err := h.srv.GetByUUID(c.Request.Context(), env, tenantRef, agentUUID) if err != nil { - dtoRequest.ResponseError(c, 400, "id 非法", nil) + dtoRequest.ResponseError(c, 404, "未找到", err) return } - if err := h.srv.DeleteAgentAISetting(c.Request.Context(), env, tenantRef, agentID); err != nil { + if err := h.srv.DeleteAgentAISetting(c.Request.Context(), env, tenantRef, exist.ID); err != nil { dtoRequest.ResponseError(c, 400, err.Error(), nil) return } @@ -486,12 +500,17 @@ func (h *AgentHandler) AgentHealthCheck(c *gin.Context) { return } tenantRef := tenantCtx.UUIDPtr() - agentID, err := utils.ParseUintID(c.Param("id")) + agentUUID, err := parseAgentUUIDParam(c) if err != nil { - dtoRequest.ResponseError(c, 400, "id 非法", nil) + dtoRequest.ResponseError(c, 400, "uuid 非法", nil) + return + } + exist, err := h.srv.GetByUUID(c.Request.Context(), env, tenantRef, agentUUID) + if err != nil { + dtoRequest.ResponseError(c, 404, "未找到", err) return } - info, err := h.srv.HealthCheck(c.Request.Context(), env, tenantRef, agentID) + info, err := h.srv.HealthCheck(c.Request.Context(), env, tenantRef, exist.ID) if err != nil { dtoRequest.ResponseError(c, 400, "检查失败", err) return diff --git a/backend/internal/transport/http/admin/agent/agent_session_handler.go b/backend/internal/transport/http/admin/agent/agent_session_handler.go index 109a174a..ac0903a9 100644 --- a/backend/internal/transport/http/admin/agent/agent_session_handler.go +++ b/backend/internal/transport/http/admin/agent/agent_session_handler.go @@ -8,19 +8,23 @@ import ( dbmodel "github.com/ArtisanCloud/PowerX/internal/server/agent/persistence/model" agentSvc "github.com/ArtisanCloud/PowerX/internal/service/agent" dto "github.com/ArtisanCloud/PowerX/pkg/dto" + "github.com/ArtisanCloud/PowerX/pkg/corex/iam/reqctx" "github.com/ArtisanCloud/PowerX/pkg/utils" "github.com/gin-gonic/gin" + "github.com/google/uuid" "gorm.io/datatypes" ) // ===== Service holder ===== type AgentSessionHandler struct { his *agentSvc.ChatHistoryService + ag *agentSvc.AgentService } func NewAgentSessionHandler(dep *shared.Deps) *AgentSessionHandler { return &AgentSessionHandler{ his: agentSvc.NewChatHistoryService(dep.DB), + ag: agentSvc.NewAgentService(dep.DB), } } @@ -28,7 +32,8 @@ func NewAgentSessionHandler(dep *shared.Deps) *AgentSessionHandler { type createSessionReq struct { Env string `json:"env" validate:"required"` - AgentID uint64 `json:"agentId" validate:"required"` + AgentID uint64 `json:"agentId"` + AgentUUID string `json:"agentUuid"` Title string `json:"title"` UserID uint64 `json:"userId"` // 可选;没有就由后端取鉴权上下文(此处留空也行) Singleton *bool `json:"singleton,omitempty"` // 不传就按 Agent 策略;这里只作直传 @@ -73,6 +78,28 @@ func (h *AgentSessionHandler) CreateSession(c *gin.Context) { return } tenantRef := tenantCtx.UUIDPtr() + agentID := req.AgentID + if strings.TrimSpace(req.AgentUUID) != "" { + agentUUID, err := uuid.Parse(strings.TrimSpace(req.AgentUUID)) + if err != nil { + dto.ResponseError(c, 400, "agentUuid 非法", err) + return + } + exist, err := h.ag.GetByUUID(c.Request.Context(), req.Env, tenantRef, agentUUID) + if err != nil { + dto.ResponseError(c, 404, "未找到指定的 Agent", err) + return + } + agentID = exist.ID + } + if agentID == 0 { + dto.ResponseError(c, 400, "agentId/agentUuid 必填", nil) + return + } + userID := req.UserID + if userID == 0 { + userID = reqctx.GetUserID(c.Request.Context()) + } // 单例标志:如果没传,默认 false(可在上层读取 Agent 配置再传入) singleton := false @@ -88,7 +115,7 @@ func (h *AgentSessionHandler) CreateSession(c *gin.Context) { Meta: req.Meta, } - out, err := h.his.GetOrCreateSession(c.Request.Context(), req.Env, tenantRef, req.AgentID, req.UserID, singleton, &def) + out, err := h.his.GetOrCreateSession(c.Request.Context(), req.Env, tenantRef, agentID, userID, singleton, &def) if err != nil { dto.ResponseError(c, 400, err.Error(), nil) return @@ -105,10 +132,26 @@ func (h *AgentSessionHandler) ListSessions(c *gin.Context) { return } tenantRef := tenantCtx.UUIDPtr() - agentID, err := utils.ParseUintID(c.DefaultQuery("agent_id", "0")) - if err != nil || agentID == 0 { - dto.ResponseError(c, 400, "agent_id 必填", nil) - return + var agentID uint64 + if agentUUIDStr := strings.TrimSpace(c.Query("agent_uuid")); agentUUIDStr != "" { + agentUUID, err := uuid.Parse(agentUUIDStr) + if err != nil { + dto.ResponseError(c, 400, "agent_uuid 非法", err) + return + } + exist, err := h.ag.GetByUUID(c.Request.Context(), env, tenantRef, agentUUID) + if err != nil { + dto.ResponseError(c, 404, "未找到指定的 Agent", err) + return + } + agentID = exist.ID + } else { + id, err := utils.ParseUintID(c.DefaultQuery("agent_id", "0")) + if err != nil || id == 0 { + dto.ResponseError(c, 400, "agent_uuid/agent_id 必填", nil) + return + } + agentID = id } var statuses []string diff --git a/backend/internal/transport/http/admin/agent/api.go b/backend/internal/transport/http/admin/agent/api.go index 59ac5826..26e50cdf 100644 --- a/backend/internal/transport/http/admin/agent/api.go +++ b/backend/internal/transport/http/admin/agent/api.go @@ -58,20 +58,20 @@ func RegisterAPIRoutes(publicGroup *gin.RouterGroup, protectedGroup *gin.RouterG // 智能体 CRUD agentAdminGroup.POST("", agentH.CreateAgent) agentAdminGroup.GET("", agentH.ListAgents) - agentAdminGroup.GET("/:id", agentH.GetAgent) - agentAdminGroup.PATCH("/:id", agentH.UpdateAgent) - agentAdminGroup.POST("/:id/enable", agentH.EnableAgent) - agentAdminGroup.POST("/:id/disable", agentH.DisableAgent) + agentAdminGroup.GET("/:uuid", agentH.GetAgent) + agentAdminGroup.PATCH("/:uuid", agentH.UpdateAgent) + agentAdminGroup.POST("/:uuid/enable", agentH.EnableAgent) + agentAdminGroup.POST("/:uuid/disable", agentH.DisableAgent) - agentAdminGroup.POST("/:id/shares", shareH.CreateShare) + agentAdminGroup.POST("/:uuid/shares", shareH.CreateShare) agentAdminGroup.POST("/shares/:share_id/revoke", shareH.RevokeShare) - agentAdminGroup.DELETE("/:id", agentH.DeleteAgent) + agentAdminGroup.DELETE("/:uuid", agentH.DeleteAgent) // 智能体 AI 配置 - agentAdminGroup.GET("/:id/ai-setting", agentH.GetAgentAISetting) - agentAdminGroup.PUT("/:id/ai-setting", agentH.UpsertAgentAISetting) - agentAdminGroup.DELETE("/:id/ai-setting", agentH.DeleteAgentAISetting) - agentAdminGroup.POST("/:id/health-check", agentH.AgentHealthCheck) + agentAdminGroup.GET("/:uuid/ai-setting", agentH.GetAgentAISetting) + agentAdminGroup.PUT("/:uuid/ai-setting", agentH.UpsertAgentAISetting) + agentAdminGroup.DELETE("/:uuid/ai-setting", agentH.DeleteAgentAISetting) + agentAdminGroup.POST("/:uuid/health-check", agentH.AgentHealthCheck) tenantFormsGroup := agentAdminGroup.Group("/tenant/forms") { diff --git a/backend/internal/transport/http/admin/agent/chat_handler.go b/backend/internal/transport/http/admin/agent/chat_handler.go index 8903df6e..a16ad4b5 100644 --- a/backend/internal/transport/http/admin/agent/chat_handler.go +++ b/backend/internal/transport/http/admin/agent/chat_handler.go @@ -5,12 +5,12 @@ import ( "context" "encoding/json" "fmt" - "github.com/ArtisanCloud/PowerX/internal/server/agent/runtime" "strings" "time" "github.com/ArtisanCloud/PowerX/internal/app/shared" "github.com/ArtisanCloud/PowerX/internal/server/agent" + "github.com/ArtisanCloud/PowerX/internal/server/agent/runtime" dbmodel "github.com/ArtisanCloud/PowerX/internal/server/agent/persistence/model" agentschema "github.com/ArtisanCloud/PowerX/internal/server/agent/schemas" agentSvc "github.com/ArtisanCloud/PowerX/internal/service/agent" @@ -19,17 +19,20 @@ import ( dto "github.com/ArtisanCloud/PowerX/pkg/dto" "github.com/ArtisanCloud/PowerX/pkg/utils" "github.com/gin-gonic/gin" + "github.com/google/uuid" ) type AgentChatHandler struct { his *agentSvc.ChatHistoryService cfgResolver *agentSvc.ChatConfigResolver + ag *agentSvc.AgentService } func NewAgentChatHandler(dep *shared.Deps) *AgentChatHandler { return &AgentChatHandler{ his: agentSvc.NewChatHistoryService(dep.DB), cfgResolver: agentSvc.NewChatConfigResolver(dep.DB), + ag: agentSvc.NewAgentService(dep.DB), } } @@ -153,7 +156,6 @@ func (h *AgentChatHandler) StreamSSE(c *gin.Context) { dto.ResponseError(c, 400, "缺少 q(消息内容)", nil) return } - agentID, _ := utils.ParseUintID(strings.TrimSpace(c.Query("agent_id"))) tenantCtx, err := requireTenantContext(c) if err != nil { dto.ResponseError(c, 400, err.Error(), nil) @@ -161,6 +163,27 @@ func (h *AgentChatHandler) StreamSSE(c *gin.Context) { } tenantRef := tenantCtx.UUIDPtr() uid := reqctx.GetUserID(c.Request.Context()) + var agentID uint64 + if agentUUIDStr := strings.TrimSpace(c.Query("agent_uuid")); agentUUIDStr != "" { + agentUUID, err := uuid.Parse(agentUUIDStr) + if err != nil { + dto.ResponseError(c, 400, "agent_uuid 非法", err) + return + } + exist, err := h.ag.GetByUUID(c.Request.Context(), env, tenantRef, agentUUID) + if err != nil { + dto.ResponseError(c, 404, "未找到指定的 Agent", err) + return + } + agentID = exist.ID + } else { + id, _ := utils.ParseUintID(strings.TrimSpace(c.Query("agent_id"))) + agentID = id + } + if agentID == 0 { + dto.ResponseError(c, 400, "agent_uuid 必填", nil) + return + } // 会话:session_id 优先,否则 sticky var sess *dbmodel.AgentChatSession @@ -177,6 +200,11 @@ func (h *AgentChatHandler) StreamSSE(c *gin.Context) { return } } + // 若会话标题为空,则用首个问题生成标题(ChatGPT 风格) + if sess != nil && strings.TrimSpace(sess.Title) == "" && strings.TrimSpace(q) != "" { + title := runtime.MakeDefaultSessionTitle(q, 24) + _ = h.his.RenameSession(c, env, tenantRef, sess.ID, title) + } // 3) 适配器 + Engine runtime.SetSSEHeaders(c) @@ -226,6 +254,12 @@ func (h *AgentChatHandler) StreamSSE(c *gin.Context) { _ = histSink.Emit(dto.EventEnd, map[string]any{"success": false}) return } + // 让前端/排障能看到“实际执行用的 provider/model”,避免把模型自报当成事实。 + _ = histSink.Emit(dto.EventMeta, map[string]any{ + "env": env, + "llm_provider": strings.TrimSpace(cfg.Provider), + "llm_model": strings.TrimSpace(cfg.ModelName), + }) _ = runtime.NewEngine().Run(c.Request.Context(), q, cfg, "", histSink) // explicitFlow 传空,交给意图/plan 选择 } diff --git a/backend/internal/transport/http/admin/menu/api.go b/backend/internal/transport/http/admin/menu/api.go index 23c256b0..14b7bb04 100644 --- a/backend/internal/transport/http/admin/menu/api.go +++ b/backend/internal/transport/http/admin/menu/api.go @@ -5,6 +5,7 @@ import "github.com/gin-gonic/gin" func RegisterAPIRoutes(publicGroup *gin.RouterGroup, protectedGroup *gin.RouterGroup) { menuGroup := protectedGroup.Group("/admin/menus") { - menuGroup.GET("/", AdminMenusHandler) + // 用 "" 避免 Gin 对 "/admin/menus" -> "/admin/menus/" 的 301 重定向,减少前端代理/缓存场景下的 404 误判 + menuGroup.GET("", AdminMenusHandler) } } diff --git a/backend/internal/transport/http/admin/menu/merge_handler.go b/backend/internal/transport/http/admin/menu/merge_handler.go index 415fd967..fc7a02fd 100644 --- a/backend/internal/transport/http/admin/menu/merge_handler.go +++ b/backend/internal/transport/http/admin/menu/merge_handler.go @@ -609,6 +609,8 @@ func indexSystemSlots(sys []admdto.AdminMenuItem) map[plugin_mgr.MenuKey]*admdto idx[plugin_mgr.KeyDashboard] = it case plugin_mgr.KeyWorkflow: idx[plugin_mgr.KeyWorkflow] = it + case plugin_mgr.KeyMedia: + idx[plugin_mgr.KeyMedia] = it case plugin_mgr.KeyKnowledgeSpace: idx[plugin_mgr.KeyKnowledgeSpace] = it case plugin_mgr.KeyAgent: @@ -657,7 +659,7 @@ func groupAsCategories(sys []admdto.AdminMenuItem, i18n []admdto.MenuI18nPackage item := origin if item.Origin == plugin_mgr.OriginSystem { switch item.Key { - case plugin_mgr.KeyAgent, plugin_mgr.KeyKnowledgeSpace, plugin_mgr.KeyWorkflow, plugin_mgr.KeyDashboard: + case plugin_mgr.KeyAgent, plugin_mgr.KeyKnowledgeSpace, plugin_mgr.KeyWorkflow, plugin_mgr.KeyMedia, plugin_mgr.KeyDashboard: byID[catPinnedKey].Children = append(byID[catPinnedKey].Children, item) case plugin_mgr.KeyPlugins: byID[plugin_mgr.KeySettings].Children = append(byID[plugin_mgr.KeySettings].Children, item) diff --git a/backend/internal/transport/http/admin/menu/system_menus_handler.go b/backend/internal/transport/http/admin/menu/system_menus_handler.go index 0a0e3060..3e3de67c 100644 --- a/backend/internal/transport/http/admin/menu/system_menus_handler.go +++ b/backend/internal/transport/http/admin/menu/system_menus_handler.go @@ -37,20 +37,31 @@ func BuildSystemMenus() []admdto.AdminMenuItem { Origin: plugin_mgr.OriginSystem, Slot: plugin_mgr.SlotRoot, }, + { + Key: plugin_mgr.KeyMedia, + Title: "menu.media", + Icon: "i-heroicons-photo", + URL: "/content/media", + Order: 4, + Visible: true, + Origin: plugin_mgr.OriginSystem, + Slot: plugin_mgr.SlotRoot, + }, { Key: plugin_mgr.KeyDashboard, Title: "menu.dashboard", Icon: "i-heroicons-arrow-trending-up", URL: "/dashboard", - Order: 4, + Order: 5, Visible: true, Origin: plugin_mgr.OriginSystem, + Slot: plugin_mgr.SlotRoot, }, { Key: plugin_mgr.KeyPlugins, Title: "menu.openMarket", Icon: "i-heroicons-puzzle-piece", - Order: 4, + Order: 6, Visible: true, Origin: plugin_mgr.OriginSystem, Slot: plugin_mgr.SlotRoot, @@ -84,7 +95,7 @@ func BuildSystemMenus() []admdto.AdminMenuItem { Key: plugin_mgr.KeySettings, Title: "menu.settings", Icon: "i-heroicons-cog-6-tooth", - Order: 6, + Order: 7, Visible: true, Origin: plugin_mgr.OriginSystem, Children: []admdto.AdminMenuItem{ diff --git a/backend/pkg/plugin_mgr/types.go b/backend/pkg/plugin_mgr/types.go index 3c74dfc0..643b180a 100644 --- a/backend/pkg/plugin_mgr/types.go +++ b/backend/pkg/plugin_mgr/types.go @@ -64,6 +64,7 @@ const ( KeySettings MenuKey = "settings" KeyDashboard MenuKey = "dashboard" KeyWorkflow MenuKey = "workflow" + KeyMedia MenuKey = "media" KeyAgent MenuKey = "agent" KeyKnowledgeSpace MenuKey = "knowledge_space" diff --git a/docs/plan/content/media.md b/docs/plan/content/media.md new file mode 100644 index 00000000..47e9ab07 --- /dev/null +++ b/docs/plan/content/media.md @@ -0,0 +1,207 @@ +# 媒体管理 UI(Web Admin)开发方案 + +本文档用于指导在 `web-admin`(Nuxt)中搭建“媒体库/媒体管理”UI,以管理 PowerX 底座(CoreX)媒体资产(MediaAsset)。 + +> 适用范围:管理员/运营在控制台内进行上传、检索、编辑、下线与分发链接(预签名/资源链接)管理。 + +--- + +## 1. 背景与目标 + +### 1.1 背景 + +后端已提供 MediaAsset 的 Admin/OpenAPI 能力(创建、列表、详情、更新、删除、预签名、资源访问),以及本地驱动的“预签名写入端点”用于文件落盘与元数据回填。 + +### 1.2 目标(UI) + +- 在 Web Admin 提供统一的“媒体库”入口,覆盖: + - 媒体资产列表与筛选(分页、关键字、标签、状态、驱动、回收站) + - 资产详情查看与编辑(名称、描述、标签、业务状态、扩展元数据) + - 上传/入库(优先走预签名上传闭环;支持外链入库) + - 下载/预览、复制链接(可控的资源访问方式) + - 批量操作(第一期可先支持批量删除/批量加标签) + +### 1.3 非目标(第一期) + +- 不做复杂的素材编目体系(文件夹树、智能标签、相似度检索、OCR、转码、缩略图服务等) +- 不做媒体恢复/物理删除(当前后端未提供 Admin HTTP 的 restore/force-delete 接口;可在后续迭代补齐) +- 不实现“direct_upload”文件直传(后端 gRPC proto 有 `file_content/file_name` 字段,但当前 HTTP handler 未实现该上传方式的二进制直传) + +--- + +## 2. 依赖能力与接口映射 + +### 2.1 认证与租户上下文 + +- Web Admin 通过 `Authorization: Bearer ` 登录态访问管理接口 +- 请求需携带租户上下文头:`X-Tenant-UUID: ` + - `web-admin/app/composables/api/index.ts` 已默认注入 `Authorization` 与 `X-Tenant-UUID`(来自本地存储/上下文) + +### 2.2 后端接口(Admin) + +统一前缀:`/api`(由后端 `server.api_prefix` 决定,默认配置为 `/api`;部分契约文档中示例为 `/api/v1`,以实际部署为准) + +- 列表:`GET /api/admin/media/assets` +- 创建:`POST /api/admin/media/assets` +- 详情:`GET /api/admin/media/assets/:uuid` +- 更新:`PATCH /api/admin/media/assets/:uuid` +- 删除(软删):`DELETE /api/admin/media/assets/:uuid` +- 预签名:`POST /api/admin/media/assets/:uuid/presign` +- 资源(鉴权):`GET /api/admin/media/assets/:uuid/resource?disposition=inline|attachment` +- 本地上传写入端点(鉴权):`PUT /api/admin/media/assets/:uuid` + - 需透传预签名返回的头:`X-CoreX-Upload-Expires`、`X-CoreX-Upload-Token`(若配置了 `storage.local.upload_token_secret`) + +### 2.3 资源公开入口(安全提醒) + +后端存在匿名资源入口:`GET /media/:uuid/resource`。这意味着“知道 uuid 即可访问”。 +UI 第一阶段建议 **默认走鉴权资源接口**(`/api/admin/.../resource`)进行预览/下载,避免误用匿名入口导致泄露风险。 + +--- + +## 3. 信息架构(IA)与路由 + +建议挂在现有“内容管理(Content)”模块下(`/content`): + +- `GET /content/media`:媒体库(列表/网格 + 筛选 + 上传) +- `GET /content/media/:uuid`:资产详情(预览 + 编辑) +- `GET /content/media?onlyDeleted=1`:回收站视图(仅展示 + 批量清理提示) + +> 当前 `web-admin/app/pages/content/index.vue` 已存在“媒体库”入口,但未实现对应页面,需要补齐 `web-admin/app/pages/content/media/*`。 + +--- + +## 4. 页面与交互设计 + +### 4.1 媒体库(/content/media) + +#### 4.1.1 视图与布局 + +- 顶部:标题 + 上传按钮 + 视图切换(网格/表格) +- 筛选条(可折叠高级项) +- 内容区: + - 网格:以“预览卡片”展示(图片可直接用资源 URL;其他 mime 显示 icon) + - 表格:信息密度高(名称、类型、大小、标签、状态、更新时间、操作) +- 右侧抽屉:上传/入库(见 4.3) + +#### 4.1.2 筛选项(后端已支持) + +- `keyword`:按名称模糊匹配 +- `businessStatus[]`:draft/under_review/published/archived(字符串) +- `driver`:local/s3(可先写死 options,也可从配置/后续接口拉取) +- `tags[]`:全包含语义(AND) +- `includeDeleted` / `onlyDeleted`:包含软删、仅软删(回收站) +- 高级(可选):`ownerSubjectType`、`ownerSubjectId` + +#### 4.1.3 列表操作 + +单条: +- 打开详情 +- 复制链接(默认复制鉴权资源 URL;若启用公开入口,可复制公开 URL) +- 下载(`disposition=attachment`) +- 改状态(按状态机约束) +- 删除(软删) + +批量(第一期建议): +- 批量删除(软删) +- 批量打标签/移除标签(如果后端暂不支持批量,可先前端串行调用 PATCH) + +### 4.2 资产详情(/content/media/:uuid) + +#### 4.2.1 预览区域 + +- 图片:`` 预览(默认走鉴权资源;可用 `fetch(blob)` + `URL.createObjectURL`) +- 视频/音频:HTML5 播放器(同上) +- 其他:文件卡片 + 下载按钮 + +#### 4.2.2 详情与编辑 + +基础字段: +- `name`(必填) +- `description`(写入 meta.description) +- `tags[]` +- `businessStatus`(必须遵循后端状态机,避免无效流转) + +存储信息(只读): +- `driver`、`bucket`、`objectKey(storageKey)`、`sizeBytes`、`mimeType` +- `externalUrl`(外链入库) +- `downloadUrl`/`downloadExpiredAt`(后端可能记录最近一次预签名信息) + +扩展元数据: +- 展示 `metadata/meta`(key-value) +- 第一阶段建议:只读展示 + “添加/删除键值”轻量编辑(调用 PATCH metadata) + +### 4.3 上传/入库(Upload) + +第一阶段建议提供 3 种入口,但只启用其中 2 种: + +#### 4.3.1 预签名上传(推荐,闭环完整) + +1) 创建资产(进入 draft) +`POST /api/admin/media/assets`,`uploadMethod=presign_upload` + +2) 生成上传预签名 +`POST /api/admin/media/assets/:uuid/presign` +请求:`{ action: 'upload', method: 'PUT', content_type: file.type, expiresInSeconds?: number }` + +3) 执行上传 +- 若返回 URL 为站内相对路径(例如 `/api/admin/media/assets/:uuid`): + - 以 `PUT` 上传文件内容,合并预签名返回的 `headers`(包含 `X-CoreX-Upload-*`)并带上 Authorization +- 若返回 URL 为外部对象存储(S3/MinIO)绝对地址: + - 使用返回的 `method + headers` 直传,不附加自定义 Authorization + +4) 成功后刷新详情/列表 +本地驱动落盘后会回写 `sizeBytes/mimeType`(由上传中间件探测并同步) + +#### 4.3.2 外链入库(External Link) + +`POST /api/admin/media/assets`,`uploadMethod=external_link` + `externalUrl` +后端会探测 size/mime(HEAD/Range GET),UI 直接展示即可。 + +#### 4.3.3 直传(Direct Upload) + +目前不建议在 UI 中开放(后端未提供可用的 HTTP 二进制直传实现);UI 可显示为“未启用/预留能力”。 + +--- + +## 5. 组件拆分建议(Web Admin) + +- `MediaFilterBar`:筛选条(keyword/status/tags/driver/回收站) +- `MediaUploadDrawer`:上传抽屉(预签名上传/外链入库) +- `MediaGrid` / `MediaTable`:列表视图 +- `MediaAssetCard`:网格卡片 +- `MediaPreview`:按 mime 渲染预览 +- `MediaAssetDetailPanel`:编辑区域(name/desc/tags/status/metadata) + +--- + +## 6. 状态、错误与可观测性 + +### 6.1 状态管理 + +- 列表查询:分页加载、筛选变更触发刷新、空态/错误态 +- 上传流程:分步状态(创建 → 预签名 → 上传 → 回填/刷新),需展示进度与失败可重试 + +### 6.2 错误呈现 + +- 统一显示后端 `message/error`(web-admin 的 api client 已做错误归一化) +- 对常见错误做明确提示: + - `invalid media asset status transition`:提示“状态流转不合法” + - 413:提示“文件过大(受驱动限制)” + - 403:提示“上传 token 失效/无权限” + +### 6.3 安全与默认策略 + +- 默认使用鉴权资源接口进行预览/下载 +- 复制链接默认复制鉴权 URL;若后续确认公开入口安全可控,再提供“公开链接”复制 + +--- + +## 7. 验收清单(UI) + +- 能在 `/content/media` 浏览媒体列表并筛选(keyword/tags/status/driver/回收站) +- 能创建资产并通过“预签名上传”完成文件上传,上传完成后 size/mime 有回填 +- 能通过“外链入库”创建资产并可预览/跳转 +- 能在详情页编辑 name/description/tags/status 并正确保存 +- 能删除资产(软删)并在回收站筛选到记录 +- 默认不依赖匿名 `/media/:uuid/resource` 进行预览/下载 + diff --git a/make_files/database.mk b/make_files/database.mk index 754a4693..fec60a98 100644 --- a/make_files/database.mk +++ b/make_files/database.mk @@ -42,6 +42,8 @@ db-seed: .PHONY: db-refresh db-refresh: + @echo "⚠️ 将刷新数据库(回滚+迁移+种子),该操作会清空当前数据库数据。" + @printf "确认继续?[y/N] " ; read ans; case "$$ans" in y|Y|yes|YES) echo "继续执行...";; *) echo "已取消"; exit 1;; esac @echo "刷新数据库(回滚+迁移+种子)..." @cd backend && go run ./cmd/database refresh diff --git a/specs/001-docs-media-storage/contracts/http-admin.yaml b/specs/001-media-storage/contracts/http-admin.yaml similarity index 100% rename from specs/001-docs-media-storage/contracts/http-admin.yaml rename to specs/001-media-storage/contracts/http-admin.yaml diff --git a/specs/001-docs-media-storage/contracts/http-openapi.yaml b/specs/001-media-storage/contracts/http-openapi.yaml similarity index 100% rename from specs/001-docs-media-storage/contracts/http-openapi.yaml rename to specs/001-media-storage/contracts/http-openapi.yaml diff --git a/specs/001-docs-media-storage/contracts/tests/grpc_media_asset_test.md b/specs/001-media-storage/contracts/tests/grpc_media_asset_test.md similarity index 100% rename from specs/001-docs-media-storage/contracts/tests/grpc_media_asset_test.md rename to specs/001-media-storage/contracts/tests/grpc_media_asset_test.md diff --git a/specs/001-docs-media-storage/contracts/tests/http_media_asset_test.md b/specs/001-media-storage/contracts/tests/http_media_asset_test.md similarity index 100% rename from specs/001-docs-media-storage/contracts/tests/http_media_asset_test.md rename to specs/001-media-storage/contracts/tests/http_media_asset_test.md diff --git a/specs/001-docs-media-storage/contracts/tests/http_openapi_test.md b/specs/001-media-storage/contracts/tests/http_openapi_test.md similarity index 81% rename from specs/001-docs-media-storage/contracts/tests/http_openapi_test.md rename to specs/001-media-storage/contracts/tests/http_openapi_test.md index 8a7471a7..a1e53066 100644 --- a/specs/001-docs-media-storage/contracts/tests/http_openapi_test.md +++ b/specs/001-media-storage/contracts/tests/http_openapi_test.md @@ -1,6 +1,6 @@ # 契约测试计划:公开版 Media OpenAPI -用于校验 `specs/001-docs-media-storage/contracts/http-openapi.yaml` 的对外能力接口,测试覆盖宿主/插件通过 `/api/v1/media/assets` 调用的关键断言。 +用于校验 `specs/001-media-storage/contracts/http-openapi.yaml` 的对外能力接口,测试覆盖宿主/插件通过 `/api/v1/media/assets` 调用的关键断言。 ## POST /media/assets - 期望 201 响应,字段 `uuid`、`driver`、`businessStatus` 存在。 diff --git a/specs/001-docs-media-storage/data-model.md b/specs/001-media-storage/data-model.md similarity index 100% rename from specs/001-docs-media-storage/data-model.md rename to specs/001-media-storage/data-model.md diff --git a/specs/001-docs-media-storage/plan.md b/specs/001-media-storage/plan.md similarity index 85% rename from specs/001-docs-media-storage/plan.md rename to specs/001-media-storage/plan.md index 182131c4..01739f88 100644 --- a/specs/001-docs-media-storage/plan.md +++ b/specs/001-media-storage/plan.md @@ -1,10 +1,10 @@ ```markdown # Implementation Plan: Media Asset Admin Capabilities (CoreX Module) -**Branch**: `001-docs-media-storage` +**Branch**: `001-media-storage` **Date**: 2025-10-10 -**Spec**: `specs/001-docs-media-storage/spec.md` -**Input**: Feature specification from `specs/001-docs-media-storage/spec.md` +**Spec**: `specs/001-media-storage/spec.md` +**Input**: Feature specification from `specs/001-media-storage/spec.md` > 本计划为 **CoreX 内核模块** 的实现方案(非插件)。所有路径均为**相对仓库根目录**,不再使用绝对路径。 @@ -41,6 +41,8 @@ 为后台运营人员提供统一的媒体资产管理能力,支持上传、筛选、详情、业务属性变更、软删除与 **12 小时**预签名链接。 **实现位置**:作为 **PowerX CoreX 内核模块** 落地,代码直接进入主工程(非 plugins 目录),沿用 CoreX 的多租户、RBAC、审计与迁移框架。 +同时,为 Web Admin 控制台补齐“媒体库(Media Library)”UI 页面与上传闭环(预签名上传/外链入库),UI 设计与页面流落盘于:`docs/plan/content/media.md`。 + --- ## Technical Context @@ -60,9 +62,9 @@ *GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* -- **HTTP_PRESENT ✅**:内部管理端 REST 契约放置于 `specs/001-docs-media-storage/contracts/http-admin.yaml` +- **HTTP_PRESENT ✅**:内部管理端 REST 契约放置于 `specs/001-media-storage/contracts/http-admin.yaml` (**行动**:将现有 `http-openapi.yaml` 重命名为 `http-admin.yaml`,仅保留内部接口,`servers.url` 使用 `/api/v1` 或 `/admin/...`) -- **GRPC_PRESENT ✅**:gRPC 契约位于 `specs/001-docs-media-storage/contracts/grpc-media-asset.proto`(服务名示例:`media.v1.MediaAssetAdminService`) +- **GRPC_PRESENT ✅**:gRPC 契约位于 `specs/001-media-storage/contracts/grpc-media-asset.proto`(服务名示例:`media.v1.MediaAssetAdminService`) - **PROTOBUF_DEFINED ✅**:`buf.yaml`/`buf.gen.yaml` 在主工程下维护,`go_package_prefix` 指向 `api/grpc/gen` - **SERVER_DEFINED ✅**:HTTP/GRPC 入口在 **主工程**(非插件)下: - HTTP:`internal/transport/http/admin/media/` @@ -78,7 +80,7 @@ ``` -specs/001-docs-media-storage/ +specs/001-media-storage/ ├── plan.md # 本文件(/plan 输出) ├── research.md # Phase 0(/plan 输出) ├── data-model.md # Phase 1(/plan 输出) @@ -88,6 +90,9 @@ specs/001-docs-media-storage/ ├── grpc-media-asset.proto └── tests/ +docs/plan/content/ +└── media.md # Web Admin 媒体库 UI 方案与页面流 + ``` ### Source Code (CoreX module in repo root) @@ -137,6 +142,9 @@ internal/transport/grpc/auth/middleware/ api/grpc/contracts/powerx/media/v1/media_asset.proto Makefile # proto-gen / contracts-test / etc. +web-admin/ +└── app/pages/content/media/ # 媒体库与详情页面(新增) + ``` **Structure Decision**:媒体资产作为 **CoreX 内核能力** 常驻主进程;统一复用主工程的鉴权、租户、审计、迁移与观测组件;不创建插件工程,不在 `plugins/` 目录下放置任何实现代码。 @@ -149,7 +157,7 @@ Makefile # proto-gen / contracts-test / etc. - 在 `research.md` 归档 **Decision / Rationale / Alternatives** - 结论:采用 PostgreSQL + 软删除 + JSONB 元数据;MediaManager 统一驱动;预签名默认 12h,可配置 -**Output**: `specs/001-docs-media-storage/research.md` +**Output**: `specs/001-media-storage/research.md` --- @@ -179,9 +187,17 @@ Makefile # proto-gen / contracts-test / etc. - 本地启动、迁移、样例上传/预签名/筛选验证步骤 **Output**: -- `specs/001-docs-media-storage/data-model.md` -- `specs/001-docs-media-storage/contracts/` -- `specs/001-docs-media-storage/quickstart.md` +- `specs/001-media-storage/data-model.md` +- `specs/001-media-storage/contracts/` +- `specs/001-media-storage/quickstart.md` + +### Phase 1.5: Web Admin UI Design (Content/Media) + +- 将“媒体库 UI 设计方案”落盘为:`docs/plan/content/media.md` +- UI 与现有 Admin API 的映射策略: + - 列表/详情/编辑/删除:走 `/api/admin/media/assets...` + - 上传:以 “create → presign → upload → refresh” 为主流程 + - 资源预览/下载:默认走鉴权资源入口(Admin `/resource`),避免误用匿名 `/media/:uuid/resource` --- diff --git a/specs/001-docs-media-storage/quickstart.md b/specs/001-media-storage/quickstart.md similarity index 75% rename from specs/001-docs-media-storage/quickstart.md rename to specs/001-media-storage/quickstart.md index 09494f40..037e07fd 100644 --- a/specs/001-docs-media-storage/quickstart.md +++ b/specs/001-media-storage/quickstart.md @@ -69,6 +69,28 @@ go run ./cmd/media_tool cleanup --dry-run --before=24h --limit=50 生产环境建议去掉 `--dry-run` 参数,并结合 Cron/任务编排按需执行。运行 `go run ./cmd/media_tool help` 查看更多子命令与参数说明。 +## Web Admin 手工验收(UI) + +> 适用于本仓库 `web-admin`(Nuxt)控制台页面:`/content/media` + +1. 启动后端:`make dev`(默认 `http://localhost:8077`),并确保已能登录管理端拿到 `access_token`。 +2. 启动 Web Admin:在 `web-admin/` 执行 `npm run dev`。 +3. 打开媒体库: + - 进入 `内容管理 → 媒体库`(路由:`/content/media`) + - 验证列表请求能返回并渲染(支持 keyword/状态/驱动/标签/回收站筛选,分页可切换) +4. 预签名上传(推荐): + - 点击“上传” → 选择“预签名上传” + - 选择文件 → 填写名称(可默认文件名)→ 开始上传 + - 预期:创建成功后列表出现新资产;点击进入详情页能预览/下载(本地上传会回填 size/mime) +5. 外链入库: + - 点击“上传” → 选择“外链入库” + - 填写名称与外链 URL → 创建 + - 预期:详情页显示外链并可跳转打开 +6. 编辑与删除: + - 进入详情页(路由:`/content/media/{uuid}`) + - 修改名称/描述/标签/业务状态并保存 + - 点击删除(软删)后可在列表勾选“回收站(仅软删)”筛选到该记录 + ## 回滚策略 1. 通过 `DELETE {APIPrefix}/admin/media/assets/{uuid}` 恢复至软删除状态再重试。 diff --git a/specs/001-docs-media-storage/research.md b/specs/001-media-storage/research.md similarity index 100% rename from specs/001-docs-media-storage/research.md rename to specs/001-media-storage/research.md diff --git a/specs/001-docs-media-storage/spec.md b/specs/001-media-storage/spec.md similarity index 79% rename from specs/001-docs-media-storage/spec.md rename to specs/001-media-storage/spec.md index da0ed4c5..57bf034f 100644 --- a/specs/001-docs-media-storage/spec.md +++ b/specs/001-media-storage/spec.md @@ -1,6 +1,6 @@ # Feature Specification: Media Asset Admin Capabilities -**Feature Branch**: `001-docs-media-storage` +**Feature Branch**: `001-media-storage` **Created**: 2025-10-07 **Status**: Draft **Input**: User description: "Refer to docs/media_storage_design.md docs/media_storage_admin_api.md" @@ -15,6 +15,7 @@ As an ops/content admin, I want to upload, search, update, and retire media asse 1. **Given** the admin has console access and storage drivers are configured, **When** the admin uploads a local file or provides an external file URL, **Then** the system validates the request, persists media metadata, and returns an accessible resource payload. 2. **Given** the admin needs to locate historical assets, **When** the admin filters the media list by keyword, storage driver, or owner subject, **Then** the system returns a paginated result set with total count and allows viewing full details. +3. **Given** the admin uses the Web Admin console, **When** the admin completes a presigned upload flow (create → presign → upload → refresh), **Then** the console shows the updated asset (including size/mime) and supports preview/download via controlled resource access. ### Edge Cases @@ -35,6 +36,17 @@ As an ops/content admin, I want to upload, search, update, and retire media asse - **FR-007**: Generate time-limited presigned links for an existing or to-be-uploaded resource; links must expire automatically after 12 hours by default and only authorized admins may generate them. - **FR-008**: Persist auditable trails for upload/update/delete/presign operations to trace actor, source, and parameters. +### Console UI Requirements (Web Admin) + +> UI 设计与页面流详见:`docs/plan/content/media.md` + +- **FR-UI-001**: Provide a “Media Library” entry in the admin console for tenant-scoped media asset management (list / detail / edit / delete). +- **FR-UI-002**: Support search and filtering in the console by keyword, tags (AND semantics), business status, storage driver, and recycle-bin mode (only deleted / include deleted). +- **FR-UI-003**: Support presigned upload in the console as the primary upload path (create asset → presign → upload → refresh), including progress, retry, and clear failure messages. +- **FR-UI-004**: Support external-link ingestion in the console (register an asset by URL and preview/open it safely). +- **FR-UI-005**: Provide safe preview/download behaviors by default (prefer controlled, authenticated resource access; avoid accidental exposure through public anonymous endpoints). +- **FR-UI-006**: Provide basic batch operations in the console (minimum: batch soft-delete; optional: batch tag edits). + ### Key Entities - **Media Asset**: A single file or external resource managed by the platform, including name, storage driver, access URL, size, owner subject, tags, business status (Draft / Under Review / Published / Archived), timestamps, and soft-delete marker. @@ -48,6 +60,7 @@ As an ops/content admin, I want to upload, search, update, and retire media asse - Unified tagging and owner subject standards exist; asset creation/update must comply with them. - An operations-maintained scheduled task will process soft-deleted assets for physical removal according to retention policies. - Maximum upload size is governed by each storage driver; the admin console will not impose an additional global cap. +- The Web Admin console can resolve tenant context and attach it to requests consistently (tenant isolation is mandatory). ## Clarifications diff --git a/specs/001-docs-media-storage/tasks.md b/specs/001-media-storage/tasks.md similarity index 73% rename from specs/001-docs-media-storage/tasks.md rename to specs/001-media-storage/tasks.md index 7dbf9afb..67d58f84 100644 --- a/specs/001-docs-media-storage/tasks.md +++ b/specs/001-media-storage/tasks.md @@ -1,6 +1,6 @@ # Tasks: Media Asset Admin Capabilities (CoreX Module) -**Input**: 设计资产 `specs/001-docs-media-storage/`(plan.md、research.md、data-model.md、contracts/、quickstart.md) +**Input**: 设计资产 `specs/001-media-storage/`(plan.md、research.md、data-model.md、contracts/、quickstart.md) **目标**: 依据最新计划,将媒体管理功能落地于 CoreX 内核 ## 执行节奏 @@ -15,8 +15,8 @@ ## Phase 3: 任务列表 -- [X] **T001** 将 `specs/001-docs-media-storage/contracts/http-openapi.yaml` 重命名并整理为 `specs/001-docs-media-storage/contracts/http-admin.yaml`,`servers.url` 与 `server.api_prefix` 保持一致(默认 `/api/v1`)并同步标签说明及引用链接。 -- [X] **T002** 将 `specs/001-docs-media-storage/contracts/grpc-media-asset.proto` 移入 `api/grpc/contracts/powerx/media/v1/media_asset.proto`,修改 package 为 `powerx.media.v1`,设置 `go_package = github.com/ArtisanCloud/PowerX/internal/transport/grpc/gen/powerx/media/v1;corexmediav1`。 +- [X] **T001** 将 `specs/001-media-storage/contracts/http-openapi.yaml` 重命名并整理为 `specs/001-media-storage/contracts/http-admin.yaml`,`servers.url` 与 `server.api_prefix` 保持一致(默认 `/api/v1`)并同步标签说明及引用链接。 +- [X] **T002** 将 `specs/001-media-storage/contracts/grpc-media-asset.proto` 移入 `api/grpc/contracts/powerx/media/v1/media_asset.proto`,修改 package 为 `powerx.media.v1`,设置 `go_package = github.com/ArtisanCloud/PowerX/internal/transport/grpc/gen/powerx/media/v1;corexmediav1`。 - [X] **T003** 在仓库根目录新增/更新 `buf.yaml`、`buf.gen.yaml`,纳入 `api/grpc/contracts/powerx`,并在 `Makefile` 添加 `proto-gen`、`proto-lint`、`proto-clean`、`contracts-test` 目标(含 CI 钩子)。 - [X] **T004 [P]** 在 `internal/transport/http/admin/media/contract_media_asset_test.go` 编写失败的 HTTP 契约测试(6 个端点),使用 `httpexpect` 校验状态码与响应体。 - [X] **T005 [P]** 在 `internal/transport/grpc/media/contract_media_asset_test.go` 编写失败的 gRPC 契约测试,覆盖 `MediaAssetAdminService` 六个 RPC(`bufconn` + `testify`)。 @@ -46,12 +46,29 @@ - [X] **T029** 新建 `cmd/media_tool/main.go`,实现媒资工具集入口,包含软删除清理子命令:扫描过期资产、调用驱动删除、写审计事件。 - [X] **T030 [P]** 在 `internal/infra/media/manager/manager_test.go` 编写单元测试,验证驱动注册、默认回退、错误冒泡。 - [X] **T031 [P]** 在 `internal/service/media/service_test.go` 编写单元测试,覆盖状态流转、RBAC 拒绝、审计记录。 -- [X] **T032** 执行 `make proto-gen && make contracts-test && make unit-test`,收集日志并更新 `specs/001-docs-media-storage/quickstart.md` 的命令示例/说明。 +- [X] **T032** 执行 `make proto-gen && make contracts-test && make unit-test`,收集日志并更新 `specs/001-media-storage/quickstart.md` 的命令示例/说明。 - [X] **T033** 扩展 `internal/infra/media/driver/local/local.go` 预签名逻辑,支持上传动作(PUT)、HMAC Token、限流配置。 - [X] **T034** 在 `internal/http` 新增本地读写端点 `GET/PUT /media/*objectKey`,校验 Token/过期时间、限制 `Content-Length`,并确保目录与 `local.public_base_url`/`base_path` 一致。 - [X] **T035** 调整 `POST /admin/media/assets/{uuid}/presign` 请求/响应契约,接入 `content_type`/`expires_in` 字段并返回统一 `storageKey`、`expiresAt` 信息。 - [X] **T036** 强化媒资下载安全:在 `external_link`/本地上传的元数据校验基础上,再新增统一受控的资源访问入口(REST `GET /api/v1/media/assets/{uuid}/resource` 及 Admin 对应端点),禁止直接暴露 `/media/*` 给外部。该 Handler 必须复用 `MediaService` 读取资产记录,检查权限、业务状态、`mimeType`/`sizeBytes` 白名单,动态设置 `Content-Type`、`Content-Disposition` 并根据 `uploadMethod` 选择读取本地驱动或 302 到 `externalUrl`。同步更新 contracts、docs、quickstart,说明 `/media/*` 仅用于开发调试,正式访问都走 `resource` 接口,可选支持 `attachment`/`inline` header。 +## Phase 4: Web Admin UI(内容管理 / 媒体库) + +> UI 方案与页面流:`docs/plan/content/media.md` + +- [X] **T037** 新增 `web-admin/app/pages/content/media/index.vue`:媒体库列表(网格/表格切换、筛选条、分页、回收站开关)。 +- [X] **T038** 新增 `web-admin/app/pages/content/media/[uuid].vue`:媒体资产详情页(预览、编辑、状态流转、删除/下载/复制链接)。 +- [X] **T039** 新增 `web-admin/app/composables/api/services/mediaAssetService.ts`:封装 Admin 媒体资产 API(list/create/get/update/delete/presign/resource)。 +- [X] **T040 [P]** 新增 UI 组件:`MediaFilterBar`、`MediaUploadDrawer`、`MediaGrid`/`MediaTable`、`MediaPreview`、`MediaAssetDetailPanel`(按 `docs/plan/content/media.md` 拆分)。 +- [X] **T041** 实现“预签名上传”闭环:create(asset) → presign(upload) → upload(file) → refresh(list/detail),包含进度/失败重试与错误提示。 +- [X] **T042** 实现“外链入库”:通过 external URL 创建资产并在详情页可预览/跳转。 +- [X] **T043** 实现“安全预览/下载默认策略”:默认走鉴权资源入口(Admin `/resource`)进行预览/下载;提供复制链接策略(鉴权链接/公开链接可配置)。 +- [X] **T044 [P]** 增加测试: + - 最低要求:`mediaAssetService` 的单测(参数拼装、错误透传)。 + - 可选:Playwright e2e(登录态 fixture → 打开媒体库 → 触发列表请求并渲染空态/列表)。 +- [X] **T045** 更新文档:在 `specs/001-media-storage/quickstart.md` 增加“Web Admin 手工验收步骤”(路由、上传、筛选、详情编辑、删除)。 +- [X] **T046** 后台系统菜单追加“媒体”入口:`/content/media`,并确保在 Admin `GET /admin/menus` 的置顶分组中稳定出现在“流程”和“仪表盘”之间(按 `order` 排序)。 + ## 依赖关系 - T001–T003 必须首先完成,确保契约/生成配置与 Makefile 同步。 diff --git a/web-admin/.gitignore b/web-admin/.gitignore index 1a85a505..3b74701f 100644 --- a/web-admin/.gitignore +++ b/web-admin/.gitignore @@ -17,6 +17,7 @@ logs .DS_Store .fleet .idea +.vitest-cache # Local env files .env diff --git a/web-admin/app/components/agent/AgentPluginRenderView.vue b/web-admin/app/components/agent/AgentPluginRenderView.vue index d6d51e10..1cec01ea 100644 --- a/web-admin/app/components/agent/AgentPluginRenderView.vue +++ b/web-admin/app/components/agent/AgentPluginRenderView.vue @@ -13,7 +13,7 @@ const currentAgentId = chatSessions.currentAgentId; const selectedAgent = computed(() => { return ( - agents.value.find((agent) => agent.id === currentAgentId.value) || null + agents.value.find((agent) => agent.uuid === currentAgentId.value) || null ); }); @@ -40,7 +40,7 @@ const togglePluginPanel = () => { const chatSessions = useChatSessions(); watch( - () => selectedAgent.value?.id, + () => selectedAgent.value?.uuid, () => { isPluginPanelCollapsed.value = false; } @@ -50,7 +50,7 @@ const togglePluginPanel = () => { const selectedAgent = computed(() => { return ( - agents.value.find((agent) => agent.id === currentAgentId.value) || null + agents.value.find((agent) => agent.uuid === currentAgentId.value) || null ); }); }; diff --git a/web-admin/app/components/agent/AgentSelector.vue b/web-admin/app/components/agent/AgentSelector.vue index 9111ea52..0ddd6f22 100644 --- a/web-admin/app/components/agent/AgentSelector.vue +++ b/web-admin/app/components/agent/AgentSelector.vue @@ -13,38 +13,38 @@ export interface ChatSession { interface Props { agents?: Agent[]; - currentAgentId?: number; + currentAgentId?: string; loading?: boolean; // ✅ 外部传入的会话数据/状态(推荐做"单一事实来源") - sessionsByAgent?: Record; - sessionsLoadingByAgent?: Record; - hasMoreByAgent?: Record; + sessionsByAgent?: Record; + sessionsLoadingByAgent?: Record; + hasMoreByAgent?: Record; // (可选)如果你希望在子组件里直接触发加载,也可传入这个函数 - fetchSessions?: (agentId: number) => Promise; + fetchSessions?: (agentId: string) => Promise; } interface Emits { - (e: "select", agentId: number): void; + (e: "select", agentId: string): void; (e: "create"): void; - (e: "edit", agentId: number): void; - (e: "delete", agentId: number): void; + (e: "edit", agentId: string): void; + (e: "delete", agentId: string): void; // ✅ 新增会话相关事件 ( e: "select-session", - payload: { agentId: number; sessionId: number | string } + payload: { agentId: string; sessionId: number | string } ): void; - (e: "create-session", agentId: number): void; + (e: "create-session", agentId: string): void; ( e: "delete-session", - payload: { agentId: number; sessionId: number | string } + payload: { agentId: string; sessionId: number | string } ): void; - (e: "load-sessions", agentId: number): void; // 若不传 fetchSessions,用这个让父组件去拉 - (e: "load-more-sessions", agentId: number): void; // 翻页 + (e: "load-sessions", agentId: string): void; // 若不传 fetchSessions,用这个让父组件去拉 + (e: "load-more-sessions", agentId: string): void; // 翻页 } const props = withDefaults(defineProps(), { agents: () => [], loading: false, - currentAgentId: 0, + currentAgentId: "", sessionsByAgent: () => ({}), sessionsLoadingByAgent: () => ({}), hasMoreByAgent: () => ({}), @@ -85,7 +85,7 @@ const groupedAgents = computed(() => { return { active, inactive }; }); -const selectAgent = async (id: number) => { +const selectAgent = async (id: string) => { emit("select", id); // 选中即展开 if (!isExpanded(id)) { @@ -99,7 +99,7 @@ const selectAgent = async (id: number) => { const getStatusColor = (agent: Agent) => { if (agent.status === "inactive") return "neutral"; - if (agent.id === props.currentAgentId) return "primary"; + if (agent.uuid === props.currentAgentId) return "primary"; return "success"; }; @@ -140,7 +140,7 @@ const makeMenuItems = (agent: Agent): any[][] => { { label: t("agent.selector.edit"), icon: "i-heroicons-pencil", - onSelect: () => emit("edit", agent.id), + onSelect: () => emit("edit", agent.uuid), }, ], ]; @@ -154,7 +154,7 @@ const makeMenuItems = (agent: Agent): any[][] => { onSelect: (e?: Event) => { // 可选:阻止某些默认行为,比如复用快捷键时 e?.preventDefault?.(); - emit("delete", agent.id); + emit("delete", agent.uuid); }, }, ]); @@ -164,16 +164,16 @@ const makeMenuItems = (agent: Agent): any[][] => { const onDropdownSelect = (item: any, agent: Agent) => { console.log(item); - if (item?.value === "edit") emit("edit", agent.id); - if (item?.value === "delete") emit("delete", agent.id); + if (item?.value === "edit") emit("edit", agent.uuid); + if (item?.value === "delete") emit("delete", agent.uuid); }; // ✅ 记录哪些行处于展开态 -const expandedIds = ref>(new Set()); +const expandedIds = ref>(new Set()); -const isExpanded = (id: number) => expandedIds.value.has(id); +const isExpanded = (id: string) => expandedIds.value.has(id); -const toggleExpand = async (id: number) => { +const toggleExpand = async (id: string) => { // 用新 Set 触发响应式 const s = new Set(expandedIds.value); s.has(id) ? s.delete(id) : s.add(id); @@ -185,15 +185,15 @@ const toggleExpand = async (id: number) => { }; // 工具:取会话/加载/更多标识 -const getSessions = (agentId: number): ChatSession[] => +const getSessions = (agentId: string): ChatSession[] => props.sessionsByAgent?.[agentId] ?? []; -const isSessionsLoading = (agentId: number): boolean => +const isSessionsLoading = (agentId: string): boolean => !!props.sessionsLoadingByAgent?.[agentId]; -const hasMoreSessions = (agentId: number): boolean => +const hasMoreSessions = (agentId: string): boolean => !!props.hasMoreByAgent?.[agentId]; // 首次展开时加载 -async function ensureSessionsLoaded(agentId: number) { +async function ensureSessionsLoaded(agentId: string) { if (getSessions(agentId)?.length) return; // 已有缓存 if (props.fetchSessions) { try { @@ -291,15 +291,15 @@ watch(
@@ -325,8 +325,8 @@ watch( size="xs" variant="ghost" class="transition-transform ml-1" - :class="{ 'rotate-180': isExpanded(agent.id) }" - @click.stop="toggleExpand(agent.id)" + :class="{ 'rotate-180': isExpanded(agent.uuid) }" + @click.stop="toggleExpand(agent.uuid)" /> @@ -337,7 +337,7 @@ watch( class="whitespace-nowrap min-w-fit" > {{ - agent.id === currentAgentId + agent.uuid === currentAgentId ? t("agent.selector.current") : t("agent.selector.available") }} @@ -349,7 +349,7 @@ watch( size="xs" variant="outline" class="hidden sm:inline-flex" - @click.stop="emit('edit', agent.id)" + @click.stop="emit('edit', agent.uuid)" /> @@ -367,7 +367,7 @@ watch(

{{ agent.description }} @@ -375,7 +375,7 @@ watch(

{{ agent.description }}

@@ -401,7 +401,7 @@ watch( size="xs" icon="i-heroicons-pencil" variant="outline" - @click.stop="emit('edit', agent.id)" + @click.stop="emit('edit', agent.uuid)" > {{ t("agent.selector.edit") }} @@ -410,7 +410,7 @@ watch( size="xs" icon="i-heroicons-trash" variant="outline" - @click.stop="emit('delete', agent.id)" + @click.stop="emit('delete', agent.uuid)" > {{ t("agent.selector.delete") }} @@ -428,13 +428,13 @@ watch( size="xs" variant="ghost" icon="i-heroicons-plus" - @click.stop="emit('create-session', agent.id)" + @click.stop="emit('create-session', agent.uuid)" > {{ t("agent.selector.newSession") || "新建会话" }}
-
+
{{ t("agent.selector.noSessions") || "暂无会话" }} @@ -455,12 +455,12 @@ watch( class="divide-y divide-gray-200 rounded-md bg-white" >
  • -
    +
    {{ t("common.loadMore") || "加载更多" }} @@ -533,10 +533,10 @@ watch(
    @@ -562,8 +562,8 @@ watch( size="xs" variant="ghost" class="transition-transform ml-1" - :class="{ 'rotate-180': isExpanded(agent.id) }" - @click.stop="toggleExpand(agent.id)" + :class="{ 'rotate-180': isExpanded(agent.uuid) }" + @click.stop="toggleExpand(agent.uuid)" /> @@ -578,7 +578,7 @@ watch( size="xs" variant="outline" class="hidden sm:inline-flex" - @click.stop="emit('edit', agent.id)" + @click.stop="emit('edit', agent.uuid)" /> @@ -599,7 +599,7 @@ watch(

    {{ agent.description }} @@ -607,7 +607,7 @@ watch(

    {{ agent.description }}

    @@ -633,7 +633,7 @@ watch( size="xs" icon="i-heroicons-pencil" variant="outline" - @click.stop="emit('edit', agent.id)" + @click.stop="emit('edit', agent.uuid)" > {{ t("agent.selector.edit") }} @@ -642,7 +642,7 @@ watch( size="xs" icon="i-heroicons-trash" variant="outline" - @click.stop="emit('delete', agent.id)" + @click.stop="emit('delete', agent.uuid)" > {{ t("agent.selector.delete") }} @@ -660,13 +660,13 @@ watch( size="xs" variant="ghost" icon="i-heroicons-plus" - @click.stop="emit('create-session', agent.id)" + @click.stop="emit('create-session', agent.uuid)" > {{ t("agent.selector.newSession") || "新建会话" }}
    -
    +
    {{ t("agent.selector.noSessions") || "暂无会话" }} @@ -687,12 +687,12 @@ watch( class="divide-y divide-gray-200 rounded-md bg-white" >
  • -
    +
    {{ t("common.loadMore") || "加载更多" }} diff --git a/web-admin/app/components/agent/AgentSidebar.vue b/web-admin/app/components/agent/AgentSidebar.vue index c83dee6b..2bd42de1 100644 --- a/web-admin/app/components/agent/AgentSidebar.vue +++ b/web-admin/app/components/agent/AgentSidebar.vue @@ -13,60 +13,64 @@ export interface ChatSession { interface Props { agents?: Agent[]; - currentAgentId?: number | null; + currentAgentId?: string | null; currentSessionId?: number | string; // ✅ 高亮当前会话 loading?: boolean; + busy?: boolean; // 会话数据(外部单一事实来源) - sessionsByAgent?: Record; - sessionsLoadingByAgent?: Record; - hasMoreByAgent?: Record; + sessionsByAgent?: Record; + sessionsLoadingByAgent?: Record; + hasMoreByAgent?: Record; // 可选:子组件内部触发加载 - fetchSessions?: (agentId: number) => Promise; + fetchSessions?: (agentId: string) => Promise; } const emit = defineEmits<{ - select: [agentId: number]; + select: [agentId: string]; "create-agent": []; - "edit-agent": [agentId: number]; + "edit-agent": [agentId: string]; "new-session": []; - "select-session": [payload: { agentId: number; sessionId: number | string }]; - "delete-session": [payload: { agentId: number; sessionId: number | string }]; - "load-sessions": [agentId: number]; - "load-more-sessions": [agentId: number]; + "clear-sessions": [agentId: string]; + "select-session": [payload: { agentId: string; sessionId: number | string }]; + "delete-session": [payload: { agentId: string; sessionId: number | string }]; + "load-sessions": [agentId: string]; + "load-more-sessions": [agentId: string]; "pin-session": [ - payload: { agentId: number; sessionId: number | string; pinned: boolean }, + payload: { agentId: string; sessionId: number | string; pinned: boolean }, ]; "rename-session": [ - payload: { agentId: number; sessionId: number | string; title: string }, + payload: { agentId: string; sessionId: number | string; title: string }, ]; }>(); const props = withDefaults(defineProps(), { agents: () => [], loading: false, - currentAgentId: 0, + currentAgentId: "", currentSessionId: undefined, + busy: false, sessionsByAgent: () => ({}), sessionsLoadingByAgent: () => ({}), hasMoreByAgent: () => ({}), }); const { t } = useI18n(); +const isBusy = computed(() => !!props.busy); /* ---------- 顶部:Agent 选择 + 新建 ---------- */ const agentOptions = computed(() => (props.agents || []).map((a) => ({ label: a.name, - value: a.id, + value: a.uuid, })) ); const agentOptionsWithIcon = computed(() => (props.agents || []).map((a) => ({ label: a.name, - value: a.id, + value: a.uuid, icon: getAgentIcon(a), })) ); @@ -84,7 +88,7 @@ function getAgentIcon(agent: Agent) { // ✅ 选中项改为 SelectOption(对象) const selectedAgent = computed({ get: () => { - const id = props.currentAgentId || 0; + const id = props.currentAgentId || ""; const found = agentOptions.value.find((o) => o.value === id) ?? ({ @@ -94,7 +98,7 @@ const selectedAgent = computed({ return found; }, set: (opt) => { - const id = (opt?.value as number) || 0; + const id = String(opt?.value || "").trim(); if (!id) return; emit("select", id); ensureSessionsLoaded(id, { force: true }); @@ -103,21 +107,30 @@ const selectedAgent = computed({ // 当前选中 Agent 的图标 const currentIcon = computed(() => { - const val = selectedAgent.value?.value as number | string | null; + const val = selectedAgent.value?.value as string | null; const hit = agentOptionsWithIcon.value.find((o) => o.value === val); return hit?.icon || "i-heroicons-cpu-chip"; }); function createSession() { + if (isBusy.value) return; if (!props.currentAgentId) return; emit("new-session"); } +function clearSessions() { + if (isBusy.value) return; + if (!props.currentAgentId) return; + emit("clear-sessions", props.currentAgentId); +} + function createAgent() { + if (isBusy.value) return; emit("create-agent"); } function editAgent() { + if (isBusy.value) return; if (!props.currentAgentId) return; emit("edit-agent", props.currentAgentId); } @@ -126,19 +139,24 @@ function editAgent() { const searchQuery = ref(""); /* ---------- 列表数据:置顶 + 最近 ---------- */ -function getSessions(agentId?: number): ChatSession[] { +function getSessions(agentId?: string): ChatSession[] { if (!agentId) return []; return props.sessionsByAgent?.[agentId] ?? []; } -function isSessionsLoading(agentId?: number) { +function isSessionsLoading(agentId?: string) { if (!agentId) return false; return !!props.sessionsLoadingByAgent?.[agentId]; } -function hasMore(agentId?: number) { +function hasMore(agentId?: string) { if (!agentId) return false; return !!props.hasMoreByAgent?.[agentId]; } +const hasAnySessions = computed(() => { + if (!props.currentAgentId) return false; + return (getSessions(props.currentAgentId) || []).length > 0; +}); + function norm(ts?: string | number | Date) { if (!ts) return 0; return new Date(ts).getTime() || 0; @@ -163,7 +181,7 @@ const filteredSessions = computed(() => { /* ---------- 首次/切换时加载 ---------- */ async function ensureSessionsLoaded( - agentId: number, + agentId: string, opts: { force?: boolean } = {} ) { if (!opts.force && getSessions(agentId)?.length) return; @@ -186,14 +204,17 @@ watch( /* ---------- 交互动作 ---------- */ function onClickSession(sid: number | string) { + if (isBusy.value) return; if (!props.currentAgentId) return; emit("select-session", { agentId: props.currentAgentId, sessionId: sid }); } function onDeleteSession(sid: number | string) { + if (isBusy.value) return; if (!props.currentAgentId) return; emit("delete-session", { agentId: props.currentAgentId, sessionId: sid }); } function onTogglePin(sid: number | string, pinned: boolean) { + if (isBusy.value) return; if (!props.currentAgentId) return; emit("pin-session", { agentId: props.currentAgentId, @@ -202,6 +223,7 @@ function onTogglePin(sid: number | string, pinned: boolean) { }); } async function onRenameSession(sid: number | string) { + if (isBusy.value) return; if (!props.currentAgentId) return; const title = prompt(t("agent.selector.renameSession") || "重命名会话"); if (title && title.trim()) { @@ -236,6 +258,7 @@ function fmtTime(ts?: string | number | Date) { option-attribute="label" value-attribute="value" searchable + :disabled="isBusy" class="flex-1" >