diff --git a/compose.yaml b/compose.yaml index 1a6e165..ea97044 100644 --- a/compose.yaml +++ b/compose.yaml @@ -36,6 +36,7 @@ x-backend-environment: &backend-environment PROVIDER_MEMORY_TIMEOUT: ${PROVIDER_MEMORY_TIMEOUT:-5s} DEVICE_CLOCK_SKEW: 5m DEVICE_CREDENTIAL_MASTER_KEY: ${DEVICE_CREDENTIAL_MASTER_KEY:-000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f} + MODEL_CREDENTIAL_MASTER_KEY: ${MODEL_CREDENTIAL_MASTER_KEY:-1f1e1d1c1b1a191817161514131211100f0e0d0c0b0a09080706050403020100} RTC_APP_ID: local-public-rtc-app RTC_APP_KEY: ${RTC_APP_KEY:-} RTC_TOKEN_ISSUER: ${RTC_TOKEN_ISSUER:-local} diff --git a/deploy/server/bootstrap-secrets.sh b/deploy/server/bootstrap-secrets.sh index 57eeddb..f58aea0 100644 --- a/deploy/server/bootstrap-secrets.sh +++ b/deploy/server/bootstrap-secrets.sh @@ -34,6 +34,7 @@ umask 077 printf 'KEYCLOAK_ADMIN_PASSWORD=%s\n' "$(openssl rand -hex 24)" printf 'DEVICE_LOCAL_SECRET=%s\n' "$(openssl rand -hex 32)" printf 'DEVICE_CREDENTIAL_MASTER_KEY=%s\n' "$(openssl rand -hex 32)" + printf 'MODEL_CREDENTIAL_MASTER_KEY=%s\n' "$(openssl rand -hex 32)" printf 'RTC_TOKEN_SIGNING_SECRET=%s\n' "$(openssl rand -hex 32)" printf 'CURSOR_SIGNING_SECRET=%s\n' "$(openssl rand -hex 32)" printf 'SHARE_TOKEN_SIGNING_SECRET=%s\n' "$(openssl rand -hex 32)" diff --git a/deploy/server/compose.override.yaml b/deploy/server/compose.override.yaml index 3f4657c..eac5696 100644 --- a/deploy/server/compose.override.yaml +++ b/deploy/server/compose.override.yaml @@ -21,6 +21,7 @@ x-server-backend-environment: &server-backend-environment SHARE_TOKEN_SIGNING_SECRET: ${SHARE_TOKEN_SIGNING_SECRET} MEMORY_TOOL_BASE_URL: ${PUBLIC_ORIGIN}/internal/v1/tools/memory-search MEMORY_TOOL_SIGNING_SECRET: ${MEMORY_TOOL_SIGNING_SECRET} + MODEL_CREDENTIAL_MASTER_KEY: ${MODEL_CREDENTIAL_MASTER_KEY} EMBEDDING_PROVIDER: local VOICE_PROVIDER: ${VOICE_PROVIDER:-mock} diff --git a/design-qa.md b/design-qa.md index 7e908cb..a8f639b 100644 --- a/design-qa.md +++ b/design-qa.md @@ -1,60 +1,46 @@ -# Design QA — 陪伴周记 +# 回忆时间轴设计 QA -- source visual truth path: `E:\Codex\story\platform\.design-evidence\selected-conversation-journal.png` -- implementation screenshot path: `E:\Codex\story\platform\.design-evidence\implementation-home-final.png` -- comparison image path: `E:\Codex\story\platform\.design-evidence\design-comparison-final.png` -- responsive evidence: `E:\Codex\story\platform\.design-evidence\implementation-home-mobile.png` -- viewport: desktop 1280 × 720 CSS px; mobile 390 × 844 CSS px -- source pixels: 1488 × 1058 -- implementation pixels: 1265 × 720 for the desktop browser content capture; device scale factor 1 -- normalization: source was downsampled to 1265 px wide and cropped to the top 720 px; implementation remained at its native capture size. The two normalized images were placed in one 2530 × 720 comparison image. -- state: authenticated family owner, one real seeded elder selected, completed-conversation data loaded, no dialog open +- source visual truth: `E:\Codex\story\platform\.design-evidence\selected-conversation-timeline.png` +- implementation screenshot: `E:\Codex\story\platform\.design-evidence\implementation-conversation-timeline-final.png` +- normalized comparison: `selected-conversation-timeline-normalized.png` and `implementation-conversation-timeline-normalized.png` +- browser viewport: 1265 × 712 CSS px, device scale factor 1 +- source pixels: 1487 × 1058; implementation full-page pixels: 1265 × 2150 +- density normalization: source was resized and implementation was top-cropped to 1265 × 900 for an equal-size comparison +- state: authenticated owner, conversations route, one completed conversation selected, no custom model active ## Full-view comparison evidence -The final comparison shows the same information architecture and visual hierarchy as the selected direction: warm paper background, white header and navigation, family tabs, portrait-based elder selection, a seven-day conversation/mood journal, latest-chat summary, family-attention card, and recent activity. The implementation uses real seeded data, so the number of elders, dates, durations, summary availability, and account text intentionally differ from the concept image. - -## Focused region evidence - -- Home journal: weekly duration columns, highlighted current day, mood markers, topic chips, summary card, and the non-diagnostic mood disclaimer were inspected in the desktop comparison. -- Avatar flow: `.design-evidence/avatar-upload-preview.png` records the file-picker result and generated WebP preview; the browser assertion confirmed a `data:image/webp` preview with no error. -- Permission matrix: `.design-evidence/permission-matrix.png` records all 14 capabilities across four family roles, including the two separately granted recording permissions. -- Mobile: `.design-evidence/implementation-home-mobile.png` records the 390 px layout; browser measurement confirmed no document-level horizontal overflow. +The equal-size comparison preserves the source's main information architecture: fixed family navigation, family tab, timeline title, left conversation stream, central AI memory note, and right model status. The implementation intentionally uses live test data and a compact filter header instead of the mock's date navigator and grouped demo dates. This does not remove any core task. ## Required fidelity surfaces -- Fonts and typography: Chinese system sans-serif hierarchy matches the source's clear editorial treatment; headings, small metadata, labels, and long summary text remain readable and do not truncate important content. -- Spacing and layout rhythm: sidebar/content proportions, family tabs, elder strip, two-column journal, lower cards, borders, and radii align with the source after two spacing corrections. Mobile cards stack without page overflow. -- Colors and tokens: ivory paper, warm white cards, dusty blue interaction color, coral attention cue, muted green positive cue, and warm hairlines match the selected palette. No decorative gradient is used. -- Image quality and asset fidelity: real supplied portrait assets are used with circular crops; uploaded avatars are center-cropped and resized to a 320 × 320 WebP. Ant Design icons supply all UI symbols. -- Copy and content: customer-organization and platform-role jargon were removed from customer pages. “本次交流目标” became “长期陪伴方向”; pace help text states that the setting is sent to Doubao at the start of every new chat. Mood copy explicitly describes an inference from authorized summaries and not a health diagnosis. +- Fonts and typography: Chinese system sans-serif fallbacks, headline/body weights, line heights, hierarchy, wrapping, and muted metadata are consistent and readable. No clipping or truncation affects core content. +- Spacing and layout rhythm: the three-column frame, card padding, border radii, section gaps, selected state, and vertical rhythm follow the source. The narrower implementation viewport appropriately compresses the timeline and model columns. +- Colors and tokens: warm neutral page background, white/cream cards, restrained green status color, peach story surface, borders, and shadows map to the selected direction with accessible contrast. +- Image quality and assets: the selected live test record has no uploaded portrait, so the implementation uses the product's Ant Design icon system rather than inventing a decorative asset. No target logo or product image was replaced by CSS art or custom SVG. +- Copy and content: all labels stand alone in the product context. The implementation adds clear privacy wording for story candidates and identifies the exact model/result version when available. +- Interaction and accessibility: timeline items, full-chat action, story-review action, filters, family tabs, model management entry, and refresh controls are semantic buttons/controls. Disabled buttons use the required wait cursor. Form controls are labelled, and the selected/active/status states are not communicated by color alone. + +Focused region comparison was not required: at 1265 × 900 both the source and implementation expose readable labels, typography, icon alignment, card spacing, selected states, and status treatments without zoom ambiguity. ## Comparison history -### Iteration 1 — blocked +1. Earlier P2 — the implementation capture started below the family tab and page title because a legacy workspace rule hid the heading and the page retained scroll position. Fixed by adding section-specific workspace header overrides and recapturing a full-page state. Post-fix evidence shows the family tab, `回忆时间轴` title, status and refresh action above the three-column layout. +2. Earlier P1 — continuous SSE backlog events triggered all workspace REST effects every two seconds, eventually showing a rate-limit alert and preventing a selected conversation from rendering. Fixed by debouncing authoritative event refreshes to a 15-second minimum and loading recent summaries sequentially. Post-fix browser evidence shows 19 records loaded, a selected completed conversation, generated summary content, and no rate-limit alert. +3. Final comparison — no actionable P0, P1, or P2 mismatch remains. Live-data differences from the mock (no portrait, fewer generated-version comparisons, compact date filtering) are expected product-state differences and do not block the core journey. -- P1: the authenticated home still had excessive top padding and a nested workspace card, pushing the journal well below the selected composition. -- P2: the 390 px layout expanded to 570 px because the seven-day grid contributed a fixed min-content width. -- Fixes: removed customer-page/workspace padding with specificity above the legacy theme, tightened the family/elder strip, contained journal cards and the weekly grid, and corrected the mobile recent-activity grid. -- Post-fix evidence: `.design-evidence/implementation-home-v2.png` and `.design-evidence/implementation-home-mobile.png`. +## Findings -### Iteration 2 — passed +No actionable P0/P1/P2 findings. -- Earlier findings are resolved: family tabs now begin directly below the header, the journal occupies the intended above-the-fold area, and mobile has no document-level horizontal overflow. -- Real-data differences (one elder rather than four, seeded durations, pending summary) are expected product constraints and do not change the design grammar. -- No actionable P0, P1, or P2 visual issue remains. +P3 follow-up polish: add a dedicated generation-version comparison drawer once multiple production model outputs are available for the same conversation. -## Browser and interaction verification +## Implementation checklist -- Tested every customer section at 1280 px: home, conversations, stories, subjects, messages, members, devices, sharing, privacy, billing, and profile. -- Confirmed every section has no horizontal overflow and no visible “客户组织” copy. -- Confirmed all disabled buttons report `cursor: wait`. -- Opened the new-elder dialog, uploaded a real PNG through the browser file chooser, and confirmed the WebP preview. -- Confirmed the profile renders 14 permission rows. -- Confirmed the final home state has no visible request error and browser logs contain no error-level entry (only Vite reconnect/debug messages from the intentional test-server restart). - -## Follow-up polish - -- P3: when a family contains only one elder, the portrait row naturally has more empty horizontal space than the four-person concept. This is an accurate data state and can later be enriched with a compact “添加长者” affordance if desired. +- [x] Three-column memory timeline implemented. +- [x] Summary, keywords, story candidate and next-topic states implemented. +- [x] Current model and generation provenance shown. +- [x] Core interactions and empty/disabled states verified in the in-app browser. +- [x] Rate-limit regression removed and browser console-visible page errors checked. final result: passed diff --git a/internal/app/app.go b/internal/app/app.go index e2581a8..55302a4 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -14,6 +14,7 @@ import ( "data-terminal-cloud/internal/billing" "data-terminal-cloud/internal/cache" "data-terminal-cloud/internal/config" + "data-terminal-cloud/internal/contentmodel" "data-terminal-cloud/internal/contentpolicy" "data-terminal-cloud/internal/database" "data-terminal-cloud/internal/dataexport" @@ -137,6 +138,21 @@ func Run(ctx context.Context, serviceName string) error { var opsDeviceService *opsdevice.Service var memoryToolService *memorytool.Service var memoryToolSigner *memorytool.TokenSigner + var contentModelService *contentmodel.Service + var contentModelRegistry *contentmodel.Registry + if serviceName == "api" || serviceName == "worker" { + var fallback extraction.Generator = extraction.DeterministicGenerator{} + if cfg.ContentGenerator == "ark" { + fallback, err = extraction.NewArkGenerator(cfg.ArkBaseURL, cfg.ArkAPIKey, cfg.ArkContentModel, cfg.ArkRequestTimeout) + if err != nil { + return fmt.Errorf("create fallback Ark content generator: %w", err) + } + } + contentModelRegistry, err = contentmodel.NewRegistry(databasePool, cfg.ContentModelMasterKey, fallback) + if err != nil { + return fmt.Errorf("create content model registry: %w", err) + } + } if serviceName == "api" || serviceName == "callbacks" { diagnosticService, err = diagnostics.NewService(databasePool, idempotency.NewExecutor(databasePool), audit.NewRepository()) if err != nil { @@ -144,6 +160,10 @@ func Run(ctx context.Context, serviceName string) error { } } if serviceName == "api" { + contentModelService, err = contentmodel.NewService(contentModelRegistry, audit.NewRepository()) + if err != nil { + return fmt.Errorf("create content model service: %w", err) + } memoryToolSigner, err = memorytool.NewTokenSigner([]byte(cfg.MemoryToolSigningSecret)) if err != nil { return fmt.Errorf("create memory tool signer: %w", err) @@ -354,14 +374,7 @@ func Run(ctx context.Context, serviceName string) error { } outboxWorker.Register("conversation.agent_stop_requested", stopProcessor.Handle) outboxWorker.Register("conversation.agent_stop_reconcile_requested", stopProcessor.Handle) - var contentGenerator extraction.Generator = extraction.DeterministicGenerator{} - if cfg.ContentGenerator == "ark" { - arkGenerator, generatorErr := extraction.NewArkGenerator(cfg.ArkBaseURL, cfg.ArkAPIKey, cfg.ArkContentModel, cfg.ArkRequestTimeout) - if generatorErr != nil { - return fmt.Errorf("create Ark content generator: %w", generatorErr) - } - contentGenerator = arkGenerator - } + var contentGenerator extraction.Generator = contentModelRegistry var contentSafety contentpolicy.Classifier = contentpolicy.LocalClassifier{} if cfg.ContentSafetyProvider == "managed_http" { managedSafety, safetyErr := contentpolicy.NewManagedClassifier(cfg.ContentSafetyEndpoint, cfg.ContentSafetyAPIKey, cfg.ContentSafetyPolicyRef, cfg.ContentSafetyTimeout) @@ -476,6 +489,7 @@ func Run(ctx context.Context, serviceName string) error { DiagnosticService: diagnosticService, OpsAccessService: opsAccessService, OpsDeviceService: opsDeviceService, + ContentModelService: contentModelService, ConversationCreator: conversationCreator, ConversationActivator: conversationCreator, DeviceConversationStateReader: conversationCreator, diff --git a/internal/config/config.go b/internal/config/config.go index 7978cdf..c2abd64 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -48,6 +48,7 @@ type Config struct { ProviderMaxActiveTasks int DeviceLocalSecret string DeviceCredentialMasterKey string + ContentModelMasterKey string DeviceClockSkew time.Duration RTCAppID string RTCAppKey string @@ -226,7 +227,7 @@ func Load(serviceName string) (Config, error) { } environment := stringEnv("APP_ENV", EnvironmentLocal) secretValues, err := loadSecretValues( - "DATABASE_URL", "REDIS_PASSWORD", "DEVICE_LOCAL_SECRET", "DEVICE_CREDENTIAL_MASTER_KEY", "RTC_APP_KEY", "RTC_TOKEN_SIGNING_SECRET", + "DATABASE_URL", "REDIS_PASSWORD", "DEVICE_LOCAL_SECRET", "DEVICE_CREDENTIAL_MASTER_KEY", "MODEL_CREDENTIAL_MASTER_KEY", "RTC_APP_KEY", "RTC_TOKEN_SIGNING_SECRET", "CURSOR_SIGNING_SECRET", "SHARE_TOKEN_SIGNING_SECRET", "PROVIDER_CALLBACK_SECRET", "PLATFORM_HEALTH_CALLBACK_SECRET", "MOCK_PROVIDER_API_KEY", "VOLC_ACCESS_KEY_ID", "VOLC_SECRET_ACCESS_KEY", "VOLC_SESSION_TOKEN", "OBJECT_STORE_ACCESS_KEY", "OBJECT_STORE_SECRET_KEY", "ALERT_WEBHOOK_SECRET", "ARK_API_KEY", "CONTENT_SAFETY_API_KEY", "MEMORY_TOOL_SIGNING_SECRET", "PROVIDER_MEMORY_API_KEY", @@ -236,6 +237,7 @@ func Load(serviceName string) (Config, error) { } deviceLocalSecret := secretValues["DEVICE_LOCAL_SECRET"] deviceCredentialMasterKey := secretValues["DEVICE_CREDENTIAL_MASTER_KEY"] + contentModelMasterKey := secretValues["MODEL_CREDENTIAL_MASTER_KEY"] rtcTokenSecret := secretValues["RTC_TOKEN_SIGNING_SECRET"] cursorSigningSecret := secretValues["CURSOR_SIGNING_SECRET"] shareTokenSecret := secretValues["SHARE_TOKEN_SIGNING_SECRET"] @@ -252,6 +254,9 @@ func Load(serviceName string) (Config, error) { if deviceCredentialMasterKey == "" && serviceName == "api" && (environment == EnvironmentLocal || environment == EnvironmentTest) { deviceCredentialMasterKey = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" } + if contentModelMasterKey == "" && (serviceName == "api" || serviceName == "worker") && (environment == EnvironmentLocal || environment == EnvironmentTest) { + contentModelMasterKey = "1f1e1d1c1b1a191817161514131211100f0e0d0c0b0a09080706050403020100" + } if rtcTokenSecret == "" && serviceName == "api" && (environment == EnvironmentLocal || environment == EnvironmentTest) { rtcTokenSecret = "local-simulated-rtc-token-secret-32-bytes-minimum" } @@ -311,6 +316,7 @@ func Load(serviceName string) (Config, error) { RecoveryStoppingAfter: recoveryStoppingAfter, DeviceLocalSecret: deviceLocalSecret, DeviceCredentialMasterKey: deviceCredentialMasterKey, + ContentModelMasterKey: contentModelMasterKey, DeviceClockSkew: deviceClockSkew, RTCAppID: stringEnv("RTC_APP_ID", "local-public-rtc-app"), RTCAppKey: secretValues["RTC_APP_KEY"], @@ -478,6 +484,12 @@ func (c Config) Validate() error { return errors.New("DEVICE_CREDENTIAL_MASTER_KEY must be 32 bytes encoded as 64 hexadecimal characters") } } + if c.ServiceName == "api" || c.ServiceName == "worker" { + decodedModelKey, err := hex.DecodeString(c.ContentModelMasterKey) + if err != nil || len(decodedModelKey) != 32 { + return errors.New("MODEL_CREDENTIAL_MASTER_KEY must be 32 bytes encoded as 64 hexadecimal characters") + } + } if c.DeviceClockSkew <= 0 { return errors.New("DEVICE_CLOCK_SKEW must be positive") } diff --git a/internal/contentmodel/service.go b/internal/contentmodel/service.go new file mode 100644 index 0000000..201d88e --- /dev/null +++ b/internal/contentmodel/service.go @@ -0,0 +1,409 @@ +package contentmodel + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io" + "net/url" + "strings" + "time" + + "data-terminal-cloud/internal/audit" + "data-terminal-cloud/internal/database" + "data-terminal-cloud/internal/extraction" + "data-terminal-cloud/internal/identity" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ( + ErrAccessDenied = errors.New("content model access denied") + ErrNotFound = errors.New("content model not found") + ErrInvalid = errors.New("invalid content model configuration") + ErrVersion = errors.New("content model version conflict") + ErrTestRequired = errors.New("content model must pass a connection test before activation") + ErrActiveRevoke = errors.New("active content model must be replaced before revocation") +) + +type Model struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + DisplayName string `json:"display_name"` + Provider string `json:"provider"` + BaseURL string `json:"base_url"` + ModelID string `json:"model_id"` + CredentialHint string `json:"credential_hint"` + Temperature float64 `json:"temperature"` + MaxTokens int `json:"max_tokens"` + Status string `json:"status"` + TestStatus string `json:"test_status"` + LastTestedAt *time.Time `json:"last_tested_at,omitempty"` + LastErrorCode string `json:"last_error_code,omitempty"` + ActivatedAt *time.Time `json:"activated_at,omitempty"` + Version int64 `json:"version"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type CreateInput struct { + TenantID string `json:"tenant_id"` + DisplayName string `json:"display_name"` + Provider string `json:"provider"` + BaseURL string `json:"base_url"` + ModelID string `json:"model_id"` + APIKey string `json:"api_key"` + Temperature float64 `json:"temperature"` + MaxTokens int `json:"max_tokens"` +} + +type Registry struct { + pool *pgxpool.Pool + aead cipher.AEAD + fallback extraction.Generator +} + +type Service struct { + registry *Registry + audit *audit.Repository +} + +type storedModel struct { + Model + tenantInternalID string + encrypted []byte + nonce []byte + keyVersion int64 +} + +type actor struct { + tenantID, tenantPublicID, userID, userPublicID string + canManage bool +} + +func NewRegistry(pool *pgxpool.Pool, masterKeyHex string, fallback extraction.Generator) (*Registry, error) { + if pool == nil || fallback == nil { + return nil, errors.New("content model database and fallback generator are required") + } + key, err := hex.DecodeString(strings.TrimSpace(masterKeyHex)) + if err != nil || len(key) != 32 { + return nil, errors.New("content model master key must be 32 bytes encoded as 64 hexadecimal characters") + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("create content model cipher: %w", err) + } + aead, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("create content model AEAD: %w", err) + } + return &Registry{pool: pool, aead: aead, fallback: fallback}, nil +} + +func NewService(registry *Registry, auditRepository *audit.Repository) (*Service, error) { + if registry == nil || auditRepository == nil { + return nil, errors.New("content model registry and audit repository are required") + } + return &Service{registry: registry, audit: auditRepository}, nil +} + +// Generate resolves the active tenant model at execution time. This makes an +// activation change effective for the next summary job without restarting the worker. +func (r *Registry) Generate(ctx context.Context, input extraction.GenerationInput) (extraction.GenerationResult, error) { + if strings.TrimSpace(input.TenantID) == "" { + return r.fallback.Generate(ctx, input) + } + item, err := r.active(ctx, input.TenantID) + if errors.Is(err, pgx.ErrNoRows) { + return r.fallback.Generate(ctx, input) + } + if err != nil { + return extraction.GenerationResult{}, err + } + generator, err := r.generator(item) + if err != nil { + return extraction.GenerationResult{}, err + } + return generator.Generate(ctx, input) +} + +func (s *Service) List(ctx context.Context, principal identity.Principal, tenantPublicID string) ([]Model, error) { + current, err := s.resolveActor(ctx, principal, tenantPublicID) + if err != nil { + return nil, err + } + rows, err := s.registry.pool.Query(ctx, modelSelect+` WHERE cm.tenant_id=$1::uuid AND cm.status<>'revoked' ORDER BY (cm.status='active') DESC,cm.updated_at DESC`, current.tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Model{} + for rows.Next() { + item, scanErr := scanModel(rows) + if scanErr != nil { + return nil, scanErr + } + items = append(items, item.Model) + } + return items, rows.Err() +} + +func (s *Service) Create(ctx context.Context, principal identity.Principal, input CreateInput, requestID string) (Model, error) { + normalizeInput(&input) + if err := validateInput(input); err != nil { + return Model{}, err + } + current, err := s.resolveActor(ctx, principal, input.TenantID) + if err != nil { + return Model{}, err + } + if !current.canManage { + return Model{}, ErrAccessDenied + } + var result Model + err = database.Transact(ctx, s.registry.pool, func(tx pgx.Tx) error { + publicID := uuid.NewString() + nonce := make([]byte, s.registry.aead.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return err + } + const keyVersion int64 = 1 + encrypted := s.registry.aead.Seal(nil, nonce, []byte(input.APIKey), modelAAD(current.tenantID, publicID, keyVersion)) + row := tx.QueryRow(ctx, ` + INSERT INTO content_model_config(public_id,tenant_id,display_name,provider,base_url,model_id,encrypted_api_key,nonce,key_version,credential_hint,temperature,max_tokens,created_by,updated_by) + VALUES($1::uuid,$2::uuid,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13::uuid,$13::uuid) + RETURNING public_id::text,$14::text,display_name,provider,base_url,model_id,credential_hint,temperature::float8,max_tokens,status,test_status,last_tested_at,COALESCE(last_error_code,''),activated_at,version,created_at,updated_at,tenant_id::text,encrypted_api_key,nonce,key_version`, + publicID, current.tenantID, input.DisplayName, input.Provider, input.BaseURL, input.ModelID, encrypted, nonce, keyVersion, credentialHint(input.APIKey), input.Temperature, input.MaxTokens, current.userID, current.tenantPublicID) + item, scanErr := scanModel(row) + if scanErr != nil { + return scanErr + } + result = item.Model + resourceID := result.ID + return s.audit.Record(ctx, tx, audit.Event{TenantID: ¤t.tenantID, ActorType: "user", ActorPublicID: ¤t.userPublicID, Action: "content_model.create", ResourceType: "content_model", ResourceID: &resourceID, RequestID: requestID, Purpose: "配置聊天内容总结模型", Result: "success", Detail: map[string]any{"provider": result.Provider, "model_id": result.ModelID}}) + }) + return result, err +} + +func (s *Service) Test(ctx context.Context, principal identity.Principal, tenantPublicID, publicID, requestID string) (Model, error) { + current, err := s.resolveActor(ctx, principal, tenantPublicID) + if err != nil || !current.canManage { + return Model{}, ErrAccessDenied + } + item, err := s.get(ctx, current.tenantID, publicID) + if err != nil { + return Model{}, err + } + generator, err := s.registry.generator(item) + testStatus, errorCode := "passed", "" + if err == nil { + _, err = generator.Generate(ctx, extraction.GenerationInput{TenantID: current.tenantID, Content: "模型连通性测试,不包含长者或家庭数据。"}) + } + if err != nil { + testStatus, errorCode = "failed", normalizedErrorCode(err) + } + var result Model + updateErr := database.Transact(ctx, s.registry.pool, func(tx pgx.Tx) error { + row := tx.QueryRow(ctx, modelUpdateReturning+` WHERE cm.tenant_id=$1::uuid AND cm.public_id=$2::uuid`, current.tenantID, publicID, testStatus, errorCode, current.userID, current.tenantPublicID) + updated, scanErr := scanModel(row) + if scanErr != nil { + return scanErr + } + result = updated.Model + resourceID := result.ID + return s.audit.Record(ctx, tx, audit.Event{TenantID: ¤t.tenantID, ActorType: "user", ActorPublicID: ¤t.userPublicID, Action: "content_model.test", ResourceType: "content_model", ResourceID: &resourceID, RequestID: requestID, Purpose: "验证总结模型连通性", Result: testStatus, Detail: map[string]any{"error_code": errorCode}}) + }) + if updateErr != nil { + return Model{}, updateErr + } + return result, nil +} + +func (s *Service) Activate(ctx context.Context, principal identity.Principal, tenantPublicID, publicID, requestID string, expectedVersion int64) (Model, error) { + current, err := s.resolveActor(ctx, principal, tenantPublicID) + if err != nil || !current.canManage { + return Model{}, ErrAccessDenied + } + var result Model + err = database.Transact(ctx, s.registry.pool, func(tx pgx.Tx) error { + var internalID string + var version int64 + var testStatus, status string + if err := tx.QueryRow(ctx, `SELECT id::text,version,test_status,status FROM content_model_config WHERE tenant_id=$1::uuid AND public_id=$2::uuid FOR UPDATE`, current.tenantID, publicID).Scan(&internalID, &version, &testStatus, &status); errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } else if err != nil { + return err + } + if version != expectedVersion { + return ErrVersion + } + if status == "revoked" { + return ErrNotFound + } + if testStatus != "passed" { + return ErrTestRequired + } + if _, err := tx.Exec(ctx, `UPDATE content_model_config SET status='inactive',activated_at=NULL,updated_at=now(),version=version+1,updated_by=$2::uuid WHERE tenant_id=$1::uuid AND status='active' AND id<>$3::uuid`, current.tenantID, current.userID, internalID); err != nil { + return err + } + row := tx.QueryRow(ctx, `UPDATE content_model_config cm SET status='active',activated_at=now(),updated_at=now(),version=version+1,updated_by=$3::uuid WHERE tenant_id=$1::uuid AND public_id=$2::uuid RETURNING `+modelReturning, current.tenantID, publicID, current.userID, current.tenantPublicID) + updated, scanErr := scanModel(row) + if scanErr != nil { + return scanErr + } + result = updated.Model + resourceID := result.ID + return s.audit.Record(ctx, tx, audit.Event{TenantID: ¤t.tenantID, ActorType: "user", ActorPublicID: ¤t.userPublicID, Action: "content_model.activate", ResourceType: "content_model", ResourceID: &resourceID, RequestID: requestID, Purpose: "激活聊天总结模型", Result: "success", Detail: map[string]any{"model_id": result.ModelID}}) + }) + return result, err +} + +func (s *Service) Revoke(ctx context.Context, principal identity.Principal, tenantPublicID, publicID, requestID string, expectedVersion int64) (Model, error) { + current, err := s.resolveActor(ctx, principal, tenantPublicID) + if err != nil || !current.canManage { + return Model{}, ErrAccessDenied + } + var result Model + err = database.Transact(ctx, s.registry.pool, func(tx pgx.Tx) error { + item, lockErr := s.getTx(ctx, tx, current.tenantID, publicID, true) + if lockErr != nil { + return lockErr + } + if item.Version != expectedVersion { + return ErrVersion + } + if item.Status == "active" { + return ErrActiveRevoke + } + row := tx.QueryRow(ctx, `UPDATE content_model_config cm SET status='revoked',encrypted_api_key=decode('','hex'),nonce=decode('','hex'),updated_at=now(),version=version+1,updated_by=$3::uuid WHERE tenant_id=$1::uuid AND public_id=$2::uuid RETURNING `+modelReturning, current.tenantID, publicID, current.userID, current.tenantPublicID) + updated, scanErr := scanModel(row) + if scanErr != nil { + return scanErr + } + result = updated.Model + resourceID := result.ID + return s.audit.Record(ctx, tx, audit.Event{TenantID: ¤t.tenantID, ActorType: "user", ActorPublicID: ¤t.userPublicID, Action: "content_model.revoke", ResourceType: "content_model", ResourceID: &resourceID, RequestID: requestID, Purpose: "移除未激活的总结模型", Result: "success"}) + }) + return result, err +} + +func (r *Registry) active(ctx context.Context, tenantID string) (storedModel, error) { + row := r.pool.QueryRow(ctx, modelSelect+` WHERE cm.tenant_id=$1::uuid AND cm.status='active'`, tenantID) + return scanModel(row) +} + +func (r *Registry) generator(item storedModel) (extraction.Generator, error) { + key, err := r.aead.Open(nil, item.nonce, item.encrypted, modelAAD(item.tenantInternalID, item.ID, item.keyVersion)) + if err != nil || len(key) < 8 { + return nil, errors.New("content model credential cannot be decrypted") + } + return extraction.NewCompatibleGenerator(item.Provider, item.BaseURL, string(key), item.ModelID, item.Temperature, item.MaxTokens, 30*time.Second) +} + +func (s *Service) get(ctx context.Context, tenantID, publicID string) (storedModel, error) { + return s.getTx(ctx, s.registry.pool, tenantID, publicID, false) +} + +type rowQueryer interface { + QueryRow(context.Context, string, ...any) pgx.Row +} + +func (s *Service) getTx(ctx context.Context, q rowQueryer, tenantID, publicID string, lock bool) (storedModel, error) { + lockClause := "" + if lock { + lockClause = " FOR UPDATE OF cm" + } + item, err := scanModel(q.QueryRow(ctx, modelSelect+` WHERE cm.tenant_id=$1::uuid AND cm.public_id=$2::uuid`+lockClause, tenantID, publicID)) + if errors.Is(err, pgx.ErrNoRows) { + return storedModel{}, ErrNotFound + } + return item, err +} + +func (s *Service) resolveActor(ctx context.Context, principal identity.Principal, tenantPublicID string) (actor, error) { + var result actor + err := s.registry.pool.QueryRow(ctx, ` + SELECT t.id::text,t.public_id::text,ua.id::text,ua.public_id::text, + COALESCE(bool_or(hm.role IN ('owner','admin') AND hm.status='active'),false) + FROM tenant t JOIN user_account ua ON ua.tenant_id=t.id AND ua.status='active' + LEFT JOIN household_member hm ON hm.user_id=ua.id AND hm.tenant_id=t.id + WHERE t.public_id=$1::uuid AND t.status='active' AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 + GROUP BY t.id,t.public_id,ua.id,ua.public_id`, tenantPublicID, principal.Issuer, principal.Subject).Scan(&result.tenantID, &result.tenantPublicID, &result.userID, &result.userPublicID, &result.canManage) + if errors.Is(err, pgx.ErrNoRows) { + return actor{}, ErrAccessDenied + } + return result, err +} + +func normalizeInput(input *CreateInput) { + input.TenantID = strings.TrimSpace(input.TenantID) + input.DisplayName = strings.TrimSpace(input.DisplayName) + input.Provider = strings.ToLower(strings.TrimSpace(input.Provider)) + input.BaseURL = strings.TrimRight(strings.TrimSpace(input.BaseURL), "/") + input.ModelID = strings.TrimSpace(input.ModelID) + input.APIKey = strings.TrimSpace(input.APIKey) + if input.Temperature == 0 { + input.Temperature = 0.2 + } + if input.MaxTokens == 0 { + input.MaxTokens = 1200 + } +} + +func validateInput(input CreateInput) error { + parsed, err := url.Parse(input.BaseURL) + validProvider := input.Provider == "ark" || input.Provider == "openai_compatible" + validURL := err == nil && parsed.Host != "" && (parsed.Scheme == "https" || (parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1"))) + if !validUUID(input.TenantID) || len([]rune(input.DisplayName)) < 1 || len([]rune(input.DisplayName)) > 120 || !validProvider || !validURL || input.ModelID == "" || len(input.APIKey) < 8 || input.Temperature < 0 || input.Temperature > 2 || input.MaxTokens < 256 || input.MaxTokens > 8192 { + return ErrInvalid + } + return nil +} + +func credentialHint(key string) string { + runes := []rune(strings.TrimSpace(key)) + if len(runes) <= 4 { + return "••••" + } + return "••••" + string(runes[len(runes)-4:]) +} + +func normalizedErrorCode(err error) string { + value := strings.ToLower(err.Error()) + switch { + case strings.Contains(value, "401"), strings.Contains(value, "403"): + return "AUTHENTICATION_FAILED" + case strings.Contains(value, "429"): + return "RATE_LIMITED" + case strings.Contains(value, "timeout"), strings.Contains(value, "deadline"): + return "TIMEOUT" + case strings.Contains(value, "validation"), strings.Contains(value, "decode"): + return "INVALID_RESPONSE" + default: + return "CONNECTION_FAILED" + } +} + +func modelAAD(tenantID, publicID string, version int64) []byte { + return []byte(fmt.Sprintf("content-model-credential-v1\n%s\n%s\n%d", tenantID, publicID, version)) +} + +func validUUID(value string) bool { _, err := uuid.Parse(value); return err == nil } + +const modelSelect = `SELECT cm.public_id::text,t.public_id::text,cm.display_name,cm.provider,cm.base_url,cm.model_id,cm.credential_hint,cm.temperature::float8,cm.max_tokens,cm.status,cm.test_status,cm.last_tested_at,COALESCE(cm.last_error_code,''),cm.activated_at,cm.version,cm.created_at,cm.updated_at,cm.tenant_id::text,cm.encrypted_api_key,cm.nonce,cm.key_version FROM content_model_config cm JOIN tenant t ON t.id=cm.tenant_id` +const modelReturning = `cm.public_id::text,$4::text,cm.display_name,cm.provider,cm.base_url,cm.model_id,cm.credential_hint,cm.temperature::float8,cm.max_tokens,cm.status,cm.test_status,cm.last_tested_at,COALESCE(cm.last_error_code,''),cm.activated_at,cm.version,cm.created_at,cm.updated_at,cm.tenant_id::text,cm.encrypted_api_key,cm.nonce,cm.key_version` +const modelUpdateReturning = `UPDATE content_model_config cm SET test_status=$3,last_tested_at=now(),last_error_code=NULLIF($4,''),updated_at=now(),version=version+1,updated_by=$5::uuid RETURNING cm.public_id::text,$6::text,cm.display_name,cm.provider,cm.base_url,cm.model_id,cm.credential_hint,cm.temperature::float8,cm.max_tokens,cm.status,cm.test_status,cm.last_tested_at,COALESCE(cm.last_error_code,''),cm.activated_at,cm.version,cm.created_at,cm.updated_at,cm.tenant_id::text,cm.encrypted_api_key,cm.nonce,cm.key_version` + +type rowScanner interface{ Scan(...any) error } + +func scanModel(row rowScanner) (storedModel, error) { + var item storedModel + err := row.Scan(&item.ID, &item.TenantID, &item.DisplayName, &item.Provider, &item.BaseURL, &item.ModelID, &item.CredentialHint, &item.Temperature, &item.MaxTokens, &item.Status, &item.TestStatus, &item.LastTestedAt, &item.LastErrorCode, &item.ActivatedAt, &item.Version, &item.CreatedAt, &item.UpdatedAt, &item.tenantInternalID, &item.encrypted, &item.nonce, &item.keyVersion) + return item, err +} diff --git a/internal/extraction/generator.go b/internal/extraction/generator.go index 0fa253d..9786c31 100644 --- a/internal/extraction/generator.go +++ b/internal/extraction/generator.go @@ -18,7 +18,8 @@ import ( ) type GenerationInput struct { - Content string + TenantID string + Content string } type GenerationResult struct { @@ -89,13 +90,21 @@ func safePolicyToken(value string) string { } type ArkGenerator struct { - endpoint string - apiKey string - model string - client *http.Client + endpoint string + apiKey string + model string + provider string + temperature float64 + maxTokens int + client *http.Client } func NewArkGenerator(baseURL, apiKey, model string, timeout time.Duration) (*ArkGenerator, error) { + return NewCompatibleGenerator("ark", baseURL, apiKey, model, 0.2, 1200, timeout) +} + +func NewCompatibleGenerator(provider, baseURL, apiKey, model string, temperature float64, maxTokens int, timeout time.Duration) (*ArkGenerator, error) { + provider = strings.ToLower(strings.TrimSpace(provider)) baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") apiKey = strings.TrimSpace(apiKey) model = strings.TrimSpace(model) @@ -103,11 +112,15 @@ func NewArkGenerator(baseURL, apiKey, model string, timeout time.Duration) (*Ark if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { return nil, errors.New("Ark base URL must be an absolute HTTP(S) URL") } - if apiKey == "" || model == "" || timeout <= 0 { - return nil, errors.New("Ark API key, content model and positive timeout are required") + if provider != "ark" && provider != "openai_compatible" { + return nil, errors.New("content model provider must be ark or openai_compatible") + } + if apiKey == "" || model == "" || timeout <= 0 || temperature < 0 || temperature > 2 || maxTokens < 256 || maxTokens > 8192 { + return nil, errors.New("content model credentials and valid generation parameters are required") } return &ArkGenerator{ endpoint: baseURL + "/chat/completions", apiKey: apiKey, model: model, + provider: provider, temperature: temperature, maxTokens: maxTokens, client: &http.Client{Timeout: timeout}, }, nil } @@ -123,8 +136,8 @@ func (g *ArkGenerator) Generate(ctx context.Context, input GenerationInput) (Gen {"role": "system", "content": "你是长者口述内容整理助手。只依据输入字幕生成可人工核对的候选,不补充未出现的事实,不把推断写成事实。必须返回JSON对象,字段为summary、keywords、open_topic、story_title、story_content、fact_content、confidence。summary概括本次内容,keywords是1至8个简短关键词字符串组成的数组,open_topic给出一个温和的后续话题,story和fact保留原意,confidence取0到1。"}, {"role": "user", "content": content}, }, - "temperature": 0.2, - "max_tokens": 1200, + "temperature": g.temperature, + "max_tokens": g.maxTokens, "response_format": map[string]string{"type": "json_object"}, } encoded, err := json.Marshal(requestBody) @@ -181,8 +194,8 @@ func (g *ArkGenerator) Generate(ctx context.Context, input GenerationInput) (Gen if !validGeneratedContent(result) { return GenerationResult{}, outbox.Retryable(errors.New("Ark structured content failed validation")) } - result.ModelRef = "ark:" + g.model - result.PromptVersion = "story-ark-json-v1" + result.ModelRef = g.provider + ":" + g.model + result.PromptVersion = "story-" + g.provider + "-json-v2" result.AllowStory = true return result, nil } diff --git a/internal/extraction/processor.go b/internal/extraction/processor.go index de8dbea..e3579dc 100644 --- a/internal/extraction/processor.go +++ b/internal/extraction/processor.go @@ -94,7 +94,7 @@ func (p *Processor) Handle(ctx context.Context, event outbox.Event) error { return outbox.Retryable(ErrConversationContentNotReady) } content := joinedTurnContent(sourceTurns) - generated, err := p.generateSafely(ctx, content) + generated, err := p.generateSafely(ctx, content, snapshot.tenantID) if err != nil { return err } @@ -125,13 +125,17 @@ func (p *Processor) Handle(ctx context.Context, event outbox.Event) error { return storeGeneration(ctx, tx, event.AggregateID, current, generated, generationVersion) } -func (p *Processor) generateSafely(ctx context.Context, content string) (GenerationResult, error) { +func (p *Processor) generateSafely(ctx context.Context, content string, tenantIDs ...string) (GenerationResult, error) { + tenantID := "" + if len(tenantIDs) > 0 { + tenantID = tenantIDs[0] + } decision, err := p.safety.Classify(ctx, content) if err != nil { return GenerationResult{}, err } if decision.AllowOrdinaryStory { - generated, generateErr := p.generator.Generate(ctx, GenerationInput{Content: content}) + generated, generateErr := p.generator.Generate(ctx, GenerationInput{TenantID: tenantID, Content: content}) if generateErr != nil { return GenerationResult{}, generateErr } diff --git a/internal/httpapi/content_model_handlers.go b/internal/httpapi/content_model_handlers.go new file mode 100644 index 0000000..c62d055 --- /dev/null +++ b/internal/httpapi/content_model_handlers.go @@ -0,0 +1,133 @@ +package httpapi + +import ( + "errors" + "net/http" + "strings" + + "data-terminal-cloud/internal/concurrency" + "data-terminal-cloud/internal/contentmodel" +) + +func ListContentModelsHandler(service *contentmodel.Service) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + principal, ok := webPrincipal(w, r) + if !ok { + return + } + tenantID := strings.TrimSpace(r.Header.Get("X-Tenant-Id")) + if !validUUID(tenantID) { + writeValidation(w, r, "租户参数不正确", FieldError{Field: "X-Tenant-Id", Message: "必须是有效UUID"}) + return + } + items, err := service.List(r.Context(), principal, tenantID) + if err != nil { + writeContentModelError(w, r, err) + return + } + w.Header().Set("Cache-Control", "no-store") + WriteJSON(w, r, http.StatusOK, map[string]any{"items": items}) + }) +} + +func CreateContentModelHandler(service *contentmodel.Service) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + principal, ok := webPrincipal(w, r) + if !ok { + return + } + var input contentmodel.CreateInput + if !decodeWebJSON(w, r, &input, "大模型配置参数不正确") { + return + } + input.TenantID = strings.TrimSpace(r.Header.Get("X-Tenant-Id")) + item, err := service.Create(r.Context(), principal, input, RequestID(r.Context())) + if err != nil { + writeContentModelError(w, r, err) + return + } + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("ETag", concurrency.FormatETag(item.Version)) + WriteJSON(w, r, http.StatusCreated, item) + }) +} + +func TestContentModelHandler(service *contentmodel.Service) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + principal, ok := resourcePrincipal(w, r, "id") + if !ok { + return + } + item, err := service.Test(r.Context(), principal, strings.TrimSpace(r.Header.Get("X-Tenant-Id")), r.PathValue("id"), RequestID(r.Context())) + if err != nil { + writeContentModelError(w, r, err) + return + } + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("ETag", concurrency.FormatETag(item.Version)) + WriteJSON(w, r, http.StatusOK, item) + }) +} + +func ActivateContentModelHandler(service *contentmodel.Service) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + principal, ok := resourcePrincipal(w, r, "id") + if !ok { + return + } + version, err := concurrency.ParseIfMatch(r) + if err != nil { + writeETagError(w, r, err) + return + } + item, err := service.Activate(r.Context(), principal, strings.TrimSpace(r.Header.Get("X-Tenant-Id")), r.PathValue("id"), RequestID(r.Context()), version) + if err != nil { + writeContentModelError(w, r, err) + return + } + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("ETag", concurrency.FormatETag(item.Version)) + WriteJSON(w, r, http.StatusOK, item) + }) +} + +func RevokeContentModelHandler(service *contentmodel.Service) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + principal, ok := resourcePrincipal(w, r, "id") + if !ok { + return + } + version, err := concurrency.ParseIfMatch(r) + if err != nil { + writeETagError(w, r, err) + return + } + item, err := service.Revoke(r.Context(), principal, strings.TrimSpace(r.Header.Get("X-Tenant-Id")), r.PathValue("id"), RequestID(r.Context()), version) + if err != nil { + writeContentModelError(w, r, err) + return + } + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("ETag", concurrency.FormatETag(item.Version)) + WriteJSON(w, r, http.StatusOK, item) + }) +} + +func writeContentModelError(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, contentmodel.ErrInvalid): + writeValidation(w, r, "大模型配置参数不正确", FieldError{Field: "model", Message: "请检查供应商、地址、模型ID、密钥和生成参数"}) + case errors.Is(err, contentmodel.ErrAccessDenied): + WriteError(w, r, http.StatusForbidden, "CONTENT_MODEL_ACCESS_DENIED", "只有家庭负责人或管理员可以管理总结模型") + case errors.Is(err, contentmodel.ErrNotFound): + WriteError(w, r, http.StatusNotFound, "RESOURCE_NOT_FOUND", "大模型配置不存在") + case errors.Is(err, contentmodel.ErrVersion): + WriteError(w, r, http.StatusPreconditionFailed, "VERSION_CONFLICT", "配置已更新,请刷新后重试") + case errors.Is(err, contentmodel.ErrTestRequired): + WriteError(w, r, http.StatusConflict, "MODEL_TEST_REQUIRED", "请先通过连通性测试再激活") + case errors.Is(err, contentmodel.ErrActiveRevoke): + WriteError(w, r, http.StatusConflict, "ACTIVE_MODEL_CANNOT_REMOVE", "请先激活另一个模型,再移除当前模型") + default: + WriteError(w, r, http.StatusInternalServerError, "INTERNAL_ERROR", "大模型配置处理失败") + } +} diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go index 862e89b..52e52b4 100644 --- a/internal/httpapi/router.go +++ b/internal/httpapi/router.go @@ -8,6 +8,7 @@ import ( "data-terminal-cloud/internal/alerts" "data-terminal-cloud/internal/billing" "data-terminal-cloud/internal/buildinfo" + "data-terminal-cloud/internal/contentmodel" "data-terminal-cloud/internal/device" "data-terminal-cloud/internal/diagnostics" "data-terminal-cloud/internal/health" @@ -42,6 +43,7 @@ type RouterOptions struct { DiagnosticService *diagnostics.Service OpsAccessService *opsaccess.Service OpsDeviceService *opsdevice.Service + ContentModelService *contentmodel.Service ConversationCreator ConversationCreator ConversationActivator ConversationActivator DeviceConversationStateReader DeviceConversationStateReader @@ -164,6 +166,13 @@ func NewRouter(options RouterOptions) http.Handler { mux.Handle("POST /api/v1/ops/devices/{id}/credentials/rotate", authenticated(RotateOpsDeviceCredentialHandler(options.OpsDeviceService))) mux.Handle("POST /api/v1/ops/devices/{id}/claims", authenticated(IssueOpsDeviceClaimHandler(options.OpsDeviceService))) } + if options.ContentModelService != nil { + mux.Handle("GET /api/v1/content-models", authenticated(ListContentModelsHandler(options.ContentModelService))) + mux.Handle("POST /api/v1/content-models", authenticated(CreateContentModelHandler(options.ContentModelService))) + mux.Handle("POST /api/v1/content-models/{id}/test", authenticated(TestContentModelHandler(options.ContentModelService))) + mux.Handle("POST /api/v1/content-models/{id}/activate", authenticated(ActivateContentModelHandler(options.ContentModelService))) + mux.Handle("DELETE /api/v1/content-models/{id}", authenticated(RevokeContentModelHandler(options.ContentModelService))) + } mux.Handle("GET /api/v1/households", authenticated(ListHouseholdsHandler(options.SaaSService))) mux.Handle("POST /api/v1/households", authenticated(CreateHouseholdHandler(options.SaaSService))) mux.Handle("GET /api/v1/households/{id}", authenticated(GetHouseholdHandler(options.SaaSService))) @@ -226,6 +235,7 @@ func NewRouter(options RouterOptions) http.Handler { } mux.Handle("GET /api/v1/conversations/{id}/turns", authenticated(ListConversationTurnsHandler(options.SaaSService))) mux.Handle("GET /api/v1/conversations/{id}/summaries", authenticated(ListConversationSummariesHandler(options.SaaSService))) + mux.Handle("POST /api/v1/conversations/{id}/summaries:regenerate", authenticated(RegenerateConversationSummaryHandler(options.SaaSService))) mux.Handle("GET /api/v1/subjects/{id}/stories", authenticated(ListSubjectStoriesHandler(options.SaaSService))) mux.Handle("GET /api/v1/stories/{id}", authenticated(GetStoryHandler(options.SaaSService))) mux.Handle("PATCH /api/v1/stories/{id}", authenticated(PatchStoryHandler(options.SaaSService))) diff --git a/internal/httpapi/saas_handlers.go b/internal/httpapi/saas_handlers.go index aa26df4..5aedfaf 100644 --- a/internal/httpapi/saas_handlers.go +++ b/internal/httpapi/saas_handlers.go @@ -1044,6 +1044,21 @@ func ListConversationSummariesHandler(service *saas.Service) http.Handler { }) } +func RegenerateConversationSummaryHandler(service *saas.Service) http.Handler { + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + principal, ok := resourcePrincipal(writer, request, "id") + if !ok { + return + } + version, err := service.RegenerateConversationSummary(request.Context(), principal, request.PathValue("id"), RequestID(request.Context())) + if err != nil { + writeSaaSError(writer, request, err) + return + } + WriteJSON(writer, request, http.StatusAccepted, map[string]any{"generation_version": version, "status": "queued"}) + }) +} + func ListSubjectStoriesHandler(service *saas.Service) http.Handler { return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { principal, ok := resourcePrincipal(writer, request, "id") @@ -1921,6 +1936,8 @@ func writeSaaSError(writer http.ResponseWriter, request *http.Request, err error WriteError(writer, request, http.StatusConflict, "STORY_REVIEW_STATE_CONFLICT", "当前故事审核状态不允许此操作") case errors.Is(err, saas.ErrFactReviewStateConflict): WriteError(writer, request, http.StatusConflict, "FACT_REVIEW_STATE_CONFLICT", "当前事实候选状态不允许此操作") + case errors.Is(err, saas.ErrConversationSummaryNotReady): + WriteError(writer, request, http.StatusConflict, "CONVERSATION_SUMMARY_NOT_READY", "聊天尚未结束或没有可用于总结的文字内容") case errors.Is(err, pagination.ErrInvalidCursor): writeValidation(writer, request, "分页游标无效", FieldError{Field: "cursor", Message: "游标无效、已损坏或不是本服务签发"}) default: diff --git a/internal/identity/permissions.go b/internal/identity/permissions.go index f823c5c..6ce2679 100644 --- a/internal/identity/permissions.go +++ b/internal/identity/permissions.go @@ -27,6 +27,7 @@ const ( CapabilityRecordingDownload Capability = "recording.download" CapabilityPrivacyRead Capability = "privacy.read" CapabilityPrivacyManage Capability = "privacy.manage" + CapabilityModelManage Capability = "model.manage" ) var roleCapabilities = map[Role]map[Capability]struct{}{ @@ -35,12 +36,14 @@ var roleCapabilities = map[Role]map[Capability]struct{}{ CapabilityOwnershipTransfer, CapabilitySubjectRead, CapabilitySubjectWrite, CapabilityConversationRead, CapabilityStoryReview, CapabilityDeviceManage, CapabilityFirmwareManage, CapabilityPrivacyRead, CapabilityPrivacyManage, + CapabilityModelManage, ), RoleAdmin: capabilitySet( CapabilityHouseholdRead, CapabilityHouseholdManage, CapabilityMemberManage, CapabilitySubjectRead, CapabilitySubjectWrite, CapabilityConversationRead, CapabilityStoryReview, CapabilityDeviceManage, CapabilityFirmwareManage, CapabilityPrivacyRead, + CapabilityModelManage, ), RoleEditor: capabilitySet( CapabilityHouseholdRead, CapabilitySubjectRead, CapabilitySubjectWrite, diff --git a/internal/saas/content_service.go b/internal/saas/content_service.go index 9a62dba..234f9a9 100644 --- a/internal/saas/content_service.go +++ b/internal/saas/content_service.go @@ -24,6 +24,7 @@ var ( ErrPersonalizedMemoryConsentNeeded = errors.New("personalized memory consent is required") ErrReviewStateConflict = errors.New("content review state does not allow this operation") ErrFactReviewStateConflict = errors.New("fact candidate review state does not allow this operation") + ErrConversationSummaryNotReady = errors.New("conversation is not ready for summary generation") ) type ConversationTurn struct { @@ -216,6 +217,35 @@ func (s *Service) ListConversationContentSummaries(ctx context.Context, principa return items, rows.Err() } +func (s *Service) RegenerateConversationSummary(ctx context.Context, principal identity.Principal, conversationPublicID, requestID string) (int64, error) { + var generationVersion int64 + err := database.Transact(ctx, s.pool, func(tx pgx.Tx) error { + access, conversationID, err := s.resolveConversationContent(ctx, tx, principal, conversationPublicID, identity.CapabilityStoryReview, true) + if err != nil { + return err + } + var status string + var maxSequence int64 + if err := tx.QueryRow(ctx, `SELECT c.status,(SELECT COALESCE(MAX(sequence_no),0) FROM conversation_turn ct WHERE ct.conversation_id=c.id AND ct.tenant_id=c.tenant_id) FROM conversation c WHERE c.id=$1::uuid`, conversationID).Scan(&status, &maxSequence); err != nil { + return err + } + if (status != "COMPLETED" && status != "FAILED") || maxSequence == 0 { + return ErrConversationSummaryNotReady + } + if err := tx.QueryRow(ctx, `SELECT GREATEST($2::bigint,COALESCE(MAX(generation_version),0))+1 FROM conversation_summary WHERE tenant_id=$1::uuid AND conversation_id=$3::uuid`, access.tenantID, maxSequence, conversationID).Scan(&generationVersion); err != nil { + return err + } + payload, _ := json.Marshal(map[string]any{"generation_version": generationVersion, "reason": "manual_regeneration"}) + dedupe := fmt.Sprintf("content-regenerate:%s:%d", conversationID, generationVersion) + if _, err := outbox.NewStore(s.pool).Enqueue(ctx, tx, outbox.NewEvent{TenantID: &access.tenantID, AggregateType: "conversation", AggregateID: conversationID, EventType: "conversation.content_extraction_requested", DeduplicationKey: &dedupe, Payload: payload}); err != nil { + return err + } + resourceID := conversationPublicID + return s.audit.Record(ctx, tx, audit.Event{TenantID: &access.tenantID, ActorType: "user", ActorPublicID: &access.userPublicID, Action: "conversation.summary_regenerate", ResourceType: "conversation", ResourceID: &resourceID, RequestID: requestID, Purpose: "使用当前激活的大模型重新总结聊天", Result: "queued", Detail: map[string]any{"generation_version": generationVersion}}) + }) + return generationVersion, err +} + func (s *Service) ListSubjectStories(ctx context.Context, principal identity.Principal, subjectPublicID, status string) ([]Story, error) { access, _, err := resolveSubject(ctx, s.pool, principal, subjectPublicID, identity.CapabilitySubjectRead, false) if err != nil { diff --git a/migrations/000043_content_model_management.down.sql b/migrations/000043_content_model_management.down.sql new file mode 100644 index 0000000..da40ee2 --- /dev/null +++ b/migrations/000043_content_model_management.down.sql @@ -0,0 +1,3 @@ +BEGIN; +DROP TABLE IF EXISTS content_model_config; +COMMIT; diff --git a/migrations/000043_content_model_management.up.sql b/migrations/000043_content_model_management.up.sql new file mode 100644 index 0000000..eeb8ff8 --- /dev/null +++ b/migrations/000043_content_model_management.up.sql @@ -0,0 +1,37 @@ +BEGIN; + +CREATE TABLE content_model_config ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + public_id uuid NOT NULL UNIQUE DEFAULT gen_random_uuid(), + tenant_id uuid NOT NULL REFERENCES tenant(id), + display_name varchar(120) NOT NULL CHECK (length(trim(display_name)) BETWEEN 1 AND 120), + provider varchar(32) NOT NULL CHECK (provider IN ('ark','openai_compatible')), + base_url varchar(500) NOT NULL CHECK (length(trim(base_url)) BETWEEN 8 AND 500), + model_id varchar(200) NOT NULL CHECK (length(trim(model_id)) BETWEEN 1 AND 200), + encrypted_api_key bytea NOT NULL, + nonce bytea NOT NULL, + key_version bigint NOT NULL DEFAULT 1 CHECK (key_version > 0), + credential_hint varchar(16) NOT NULL, + temperature numeric(3,2) NOT NULL DEFAULT 0.20 CHECK (temperature >= 0 AND temperature <= 2), + max_tokens integer NOT NULL DEFAULT 1200 CHECK (max_tokens BETWEEN 256 AND 8192), + status varchar(16) NOT NULL DEFAULT 'inactive' CHECK (status IN ('inactive','active','revoked')), + test_status varchar(16) NOT NULL DEFAULT 'untested' CHECK (test_status IN ('untested','passed','failed')), + last_tested_at timestamptz, + last_error_code varchar(80), + activated_at timestamptz, + created_by uuid NOT NULL, + updated_by uuid NOT NULL, + version bigint NOT NULL DEFAULT 1 CHECK (version > 0), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT content_model_creator_tenant_fk FOREIGN KEY (created_by,tenant_id) REFERENCES user_account(id,tenant_id), + CONSTRAINT content_model_updater_tenant_fk FOREIGN KEY (updated_by,tenant_id) REFERENCES user_account(id,tenant_id), + CONSTRAINT content_model_identity_unique UNIQUE (tenant_id,provider,base_url,model_id) +); + +CREATE UNIQUE INDEX content_model_one_active_per_tenant + ON content_model_config(tenant_id) WHERE status='active'; +CREATE INDEX content_model_list_idx + ON content_model_config(tenant_id,status,updated_at DESC); + +COMMIT; diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index db0d8dc..de87e01 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: 数据采集终端—云端系统 API - version: 0.70.0 + version: 0.73.0 description: 工程框架、SaaS核心业务、设备v2能力协商与智能体启动状态查询、首次通话就绪与最低固件门禁、背景资料准备失败闭环、活动通话授权撤回安全停止、语音内容闭环、终端终稿补传、批量设备遥测、录音上传队列、隐私对账、固件、双轨录音、状态事件、具体资源分享、受控运营配置、Agent组合组件引用、质量证据门禁、运营自定义版本族投放、新通话运行时冻结与供应商启动传递、带并发版本校验的配置投放暂停恢复、带严重度和验收证据门禁的联调问题闭环、含阻断告警定位、当前主机健康及生产部署标记、实际应用环境、身份服务模式、异地备份、外部告警与模拟服务强制条件的发布候选技术验收快照、绑定候选版本与构建摘要并按业务时区生成的可审计试点日/周运行证据、可审计且不生成正式告警的接收器联通测试、外部依赖预检台账、证书到期清单,以及带价格版本冻结、供应商用量去重和硬额度控制的费用治理接口。 servers: - url: http://localhost:58080 @@ -36,6 +36,53 @@ paths: '200': { description: 按生成模型和提示词版本聚合的候选确认、修改、拒绝和质量问题类型, content: { application/json: { schema: { $ref: '#/components/schemas/ContentQualityListResponse' } } } } '401': { $ref: '#/components/responses/AuthenticationError' } '403': { $ref: '#/components/responses/AccessDeniedError' } + /api/v1/content-models: + get: + operationId: listContentModels + tags: [content-review] + security: [{ oidcBearer: [] }] + parameters: [{ $ref: '#/components/parameters/RequiredTenantSelectionHeader' }] + responses: + '200': { description: 当前账号空间可用的总结模型;不返回密钥明文, content: { application/json: { schema: { $ref: '#/components/schemas/ContentModelListResponse' } } } } + '403': { $ref: '#/components/responses/AccessDeniedError' } + post: + operationId: createContentModel + tags: [content-review] + security: [{ oidcBearer: [] }] + parameters: [{ $ref: '#/components/parameters/RequiredTenantSelectionHeader' }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/CreateContentModelRequest' } } } } + responses: + '201': { description: 模型配置和加密凭据已保存, headers: { ETag: { $ref: '#/components/headers/StrongETag' } }, content: { application/json: { schema: { $ref: '#/components/schemas/ContentModelResponse' } } } } + '400': { $ref: '#/components/responses/ValidationError' } + '403': { $ref: '#/components/responses/AccessDeniedError' } + /api/v1/content-models/{id}/test: + post: + operationId: testContentModel + tags: [content-review] + security: [{ oidcBearer: [] }] + parameters: [{ $ref: '#/components/parameters/ResourceID' }, { $ref: '#/components/parameters/RequiredTenantSelectionHeader' }] + responses: + '200': { description: 使用无家庭数据的固定文本完成连通性测试, headers: { ETag: { $ref: '#/components/headers/StrongETag' } }, content: { application/json: { schema: { $ref: '#/components/schemas/ContentModelResponse' } } } } + '403': { $ref: '#/components/responses/AccessDeniedError' } + /api/v1/content-models/{id}/activate: + post: + operationId: activateContentModel + tags: [content-review] + security: [{ oidcBearer: [] }] + parameters: [{ $ref: '#/components/parameters/ResourceID' }, { $ref: '#/components/parameters/RequiredTenantSelectionHeader' }, { $ref: '#/components/parameters/IfMatchHeader' }] + responses: + '200': { description: 已通过测试的模型成为该账号空间唯一活动总结模型, headers: { ETag: { $ref: '#/components/headers/StrongETag' } }, content: { application/json: { schema: { $ref: '#/components/schemas/ContentModelResponse' } } } } + '409': { $ref: '#/components/responses/ConflictError' } + '412': { $ref: '#/components/responses/VersionConflictError' } + /api/v1/content-models/{id}: + delete: + operationId: revokeContentModel + tags: [content-review] + security: [{ oidcBearer: [] }] + parameters: [{ $ref: '#/components/parameters/ResourceID' }, { $ref: '#/components/parameters/RequiredTenantSelectionHeader' }, { $ref: '#/components/parameters/IfMatchHeader' }] + responses: + '200': { description: 未激活配置已撤销且密钥材料已清除, content: { application/json: { schema: { $ref: '#/components/schemas/ContentModelResponse' } } } } + '409': { $ref: '#/components/responses/ConflictError' } /api/v1/ops/config-versions: get: operationId: listOpsConfigVersions @@ -1956,6 +2003,16 @@ paths: content: { application/json: { schema: { $ref: '#/components/schemas/ConversationContentSummaryListResponse' } } } '401': { $ref: '#/components/responses/AuthenticationError' } '403': { $ref: '#/components/responses/AccessDeniedError' } + /api/v1/conversations/{id}/summaries:regenerate: + parameters: [{ $ref: '#/components/parameters/ResourceID' }] + post: + operationId: regenerateConversationSummary + tags: [content-review] + security: [{ oidcBearer: [] }] + responses: + '202': { description: 已按当前激活模型排队生成新的版本化摘要, content: { application/json: { schema: { $ref: '#/components/schemas/ConversationSummaryRegenerationResponse' } } } } + '403': { $ref: '#/components/responses/AccessDeniedError' } + '409': { $ref: '#/components/responses/ConflictError' } /api/v1/subjects/{id}/stories: parameters: [{ $ref: '#/components/parameters/ResourceID' }] get: @@ -5603,6 +5660,40 @@ components: generation_version: { type: integer, format: int64, minimum: 1 } status: { type: string, enum: [PROCESSING, READY, FAILED, SUPERSEDED] } created_at: { type: string, format: date-time } + ContentModel: + type: object + additionalProperties: false + required: [id, tenant_id, display_name, provider, base_url, model_id, credential_hint, temperature, max_tokens, status, test_status, version, created_at, updated_at] + properties: + id: { type: string, format: uuid } + tenant_id: { type: string, format: uuid } + display_name: { type: string, minLength: 1, maxLength: 120 } + provider: { type: string, enum: [ark, openai_compatible] } + base_url: { type: string, format: uri } + model_id: { type: string, minLength: 1, maxLength: 200 } + credential_hint: { type: string, description: 仅包含掩码和密钥末四位。 } + temperature: { type: number, minimum: 0, maximum: 2 } + max_tokens: { type: integer, minimum: 256, maximum: 8192 } + status: { type: string, enum: [inactive, active, revoked] } + test_status: { type: string, enum: [untested, passed, failed] } + last_tested_at: { type: string, format: date-time } + last_error_code: { type: string } + activated_at: { type: string, format: date-time } + version: { type: integer, format: int64, minimum: 1 } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + CreateContentModelRequest: + type: object + additionalProperties: false + required: [display_name, provider, base_url, model_id, api_key, temperature, max_tokens] + properties: + display_name: { type: string, minLength: 1, maxLength: 120 } + provider: { type: string, enum: [ark, openai_compatible] } + base_url: { type: string, format: uri, maxLength: 500 } + model_id: { type: string, minLength: 1, maxLength: 200 } + api_key: { type: string, minLength: 8, maxLength: 1000, writeOnly: true } + temperature: { type: number, minimum: 0, maximum: 2 } + max_tokens: { type: integer, minimum: 256, maximum: 8192 } StoryStatus: type: string enum: [CANDIDATE, IN_REVIEW, CONFIRMED, REJECTED, SUPERSEDED] @@ -6627,6 +6718,33 @@ components: required: [items] properties: items: { type: array, items: { $ref: '#/components/schemas/ConversationContentSummary' } } + ConversationSummaryRegenerationResponse: + type: object + required: [request_id, data] + properties: + request_id: { type: string } + data: + type: object + required: [generation_version, status] + properties: + generation_version: { type: integer, format: int64, minimum: 1 } + status: { type: string, enum: [queued] } + ContentModelResponse: + type: object + required: [request_id, data] + properties: + request_id: { type: string } + data: { $ref: '#/components/schemas/ContentModel' } + ContentModelListResponse: + type: object + required: [request_id, data] + properties: + request_id: { type: string } + data: + type: object + required: [items] + properties: + items: { type: array, items: { $ref: '#/components/schemas/ContentModel' } } StoryResponse: type: object required: [request_id, data] diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 13ac915..726102b 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -34,6 +34,13 @@ export type ConversationDetail = components["schemas"]["ConversationDetail"]; export type ConversationUsage = components["schemas"]["ConversationUsage"]; export type ConversationTurn = components["schemas"]["ConversationTurn"]; export type ConversationContentSummary = components["schemas"]["ConversationContentSummary"]; +export type ContentModel = { + id: string; tenant_id: string; display_name: string; provider: "ark" | "openai_compatible"; + base_url: string; model_id: string; credential_hint: string; temperature: number; max_tokens: number; + status: "inactive" | "active" | "revoked"; test_status: "untested" | "passed" | "failed"; + last_tested_at?: string; last_error_code?: string; activated_at?: string; version: number; created_at: string; updated_at: string; +}; +export type CreateContentModelRequest = { display_name: string; provider: "ark" | "openai_compatible"; base_url: string; model_id: string; api_key: string; temperature: number; max_tokens: number }; export type ConversationMedia = components["schemas"]["ConversationMedia"]; export type MediaSegment = components["schemas"]["MediaSegment"]; export type RecordingAccessGrant = components["schemas"]["RecordingAccessGrant"]; @@ -306,6 +313,18 @@ export const platformApi = { authenticatedJSON>("GET", `/api/v1/conversations/${conversationID}/turns?limit=100`, accessToken, signal), conversationSummaries: (conversationID: string, accessToken: string, signal?: AbortSignal) => authenticatedJSON>("GET", `/api/v1/conversations/${conversationID}/summaries`, accessToken, signal), + regenerateConversationSummary: (conversationID: string, accessToken: string) => + authenticatedJSON>("POST", `/api/v1/conversations/${conversationID}/summaries:regenerate`, accessToken), + contentModels: (tenantID: string, accessToken: string, signal?: AbortSignal) => + authenticatedJSON>("GET", "/api/v1/content-models", accessToken, signal, undefined, { "X-Tenant-Id": tenantID }), + createContentModel: (tenantID: string, input: CreateContentModelRequest, accessToken: string) => + authenticatedJSON>("POST", "/api/v1/content-models", accessToken, undefined, input, { "X-Tenant-Id": tenantID }), + testContentModel: (tenantID: string, modelID: string, accessToken: string) => + authenticatedJSON>("POST", `/api/v1/content-models/${modelID}/test`, accessToken, undefined, undefined, { "X-Tenant-Id": tenantID }), + activateContentModel: (tenantID: string, model: ContentModel, accessToken: string) => + authenticatedJSON>("POST", `/api/v1/content-models/${model.id}/activate`, accessToken, undefined, undefined, { "X-Tenant-Id": tenantID, "If-Match": `"v${model.version}"` }), + revokeContentModel: (tenantID: string, model: ContentModel, accessToken: string) => + authenticatedJSON>("DELETE", `/api/v1/content-models/${model.id}`, accessToken, undefined, undefined, { "X-Tenant-Id": tenantID, "If-Match": `"v${model.version}"` }), conversationMedia: (conversationID: string, accessToken: string, signal?: AbortSignal) => authenticatedJSON>("GET", `/api/v1/conversations/${conversationID}/media`, accessToken, signal), recordingAccessGrants: (subjectID: string, accessToken: string, signal?: AbortSignal) => diff --git a/web/src/api/schema.d.ts b/web/src/api/schema.d.ts index b251969..61a1cae 100644 --- a/web/src/api/schema.d.ts +++ b/web/src/api/schema.d.ts @@ -37,6 +37,70 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/content-models": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["listContentModels"]; + put?: never; + post: operations["createContentModel"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/content-models/{id}/test": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["testContentModel"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/content-models/{id}/activate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["activateContentModel"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/content-models/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["revokeContentModel"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/ops/config-versions": { parameters: { query?: never; @@ -1892,6 +1956,24 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/conversations/{id}/summaries:regenerate": { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ResourceID"]; + }; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["regenerateConversationSummary"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/subjects/{id}/stories": { parameters: { query?: never; @@ -5259,6 +5341,48 @@ export interface components { /** Format: date-time */ created_at: string; }; + ContentModel: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + tenant_id: string; + display_name: string; + /** @enum {string} */ + provider: "ark" | "openai_compatible"; + /** Format: uri */ + base_url: string; + model_id: string; + /** @description 仅包含掩码和密钥末四位。 */ + credential_hint: string; + temperature: number; + max_tokens: number; + /** @enum {string} */ + status: "inactive" | "active" | "revoked"; + /** @enum {string} */ + test_status: "untested" | "passed" | "failed"; + /** Format: date-time */ + last_tested_at?: string; + last_error_code?: string; + /** Format: date-time */ + activated_at?: string; + /** Format: int64 */ + version: number; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + updated_at: string; + }; + CreateContentModelRequest: { + display_name: string; + /** @enum {string} */ + provider: "ark" | "openai_compatible"; + /** Format: uri */ + base_url: string; + model_id: string; + api_key: string; + temperature: number; + max_tokens: number; + }; /** @enum {string} */ StoryStatus: "CANDIDATE" | "IN_REVIEW" | "CONFIRMED" | "REJECTED" | "SUPERSEDED"; StorySource: { @@ -6104,6 +6228,25 @@ export interface components { items: components["schemas"]["ConversationContentSummary"][]; }; }; + ConversationSummaryRegenerationResponse: { + request_id: string; + data: { + /** Format: int64 */ + generation_version: number; + /** @enum {string} */ + status: "queued"; + }; + }; + ContentModelResponse: { + request_id: string; + data: components["schemas"]["ContentModel"]; + }; + ContentModelListResponse: { + request_id: string; + data: { + items: components["schemas"]["ContentModel"][]; + }; + }; StoryResponse: { request_id: string; data: components["schemas"]["Story"]; @@ -6366,6 +6509,145 @@ export interface operations { 403: components["responses"]["AccessDeniedError"]; }; }; + listContentModels: { + parameters: { + query?: never; + header: { + /** @description 待创建资源所属的公开客户组织编号,不能是数据库内部主键。 */ + "X-Tenant-Id": components["parameters"]["RequiredTenantSelectionHeader"]; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 当前账号空间可用的总结模型;不返回密钥明文 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ContentModelListResponse"]; + }; + }; + 403: components["responses"]["AccessDeniedError"]; + }; + }; + createContentModel: { + parameters: { + query?: never; + header: { + /** @description 待创建资源所属的公开客户组织编号,不能是数据库内部主键。 */ + "X-Tenant-Id": components["parameters"]["RequiredTenantSelectionHeader"]; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateContentModelRequest"]; + }; + }; + responses: { + /** @description 模型配置和加密凭据已保存 */ + 201: { + headers: { + ETag: components["headers"]["StrongETag"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ContentModelResponse"]; + }; + }; + 400: components["responses"]["ValidationError"]; + 403: components["responses"]["AccessDeniedError"]; + }; + }; + testContentModel: { + parameters: { + query?: never; + header: { + /** @description 待创建资源所属的公开客户组织编号,不能是数据库内部主键。 */ + "X-Tenant-Id": components["parameters"]["RequiredTenantSelectionHeader"]; + }; + path: { + id: components["parameters"]["ResourceID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 使用无家庭数据的固定文本完成连通性测试 */ + 200: { + headers: { + ETag: components["headers"]["StrongETag"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ContentModelResponse"]; + }; + }; + 403: components["responses"]["AccessDeniedError"]; + }; + }; + activateContentModel: { + parameters: { + query?: never; + header: { + /** @description 待创建资源所属的公开客户组织编号,不能是数据库内部主键。 */ + "X-Tenant-Id": components["parameters"]["RequiredTenantSelectionHeader"]; + /** @description 最近一次读取返回的强ETag,不接受通配符或弱ETag。 */ + "If-Match": components["parameters"]["IfMatchHeader"]; + }; + path: { + id: components["parameters"]["ResourceID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 已通过测试的模型成为该账号空间唯一活动总结模型 */ + 200: { + headers: { + ETag: components["headers"]["StrongETag"]; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ContentModelResponse"]; + }; + }; + 409: components["responses"]["ConflictError"]; + 412: components["responses"]["VersionConflictError"]; + }; + }; + revokeContentModel: { + parameters: { + query?: never; + header: { + /** @description 待创建资源所属的公开客户组织编号,不能是数据库内部主键。 */ + "X-Tenant-Id": components["parameters"]["RequiredTenantSelectionHeader"]; + /** @description 最近一次读取返回的强ETag,不接受通配符或弱ETag。 */ + "If-Match": components["parameters"]["IfMatchHeader"]; + }; + path: { + id: components["parameters"]["ResourceID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 未激活配置已撤销且密钥材料已清除 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ContentModelResponse"]; + }; + }; + 409: components["responses"]["ConflictError"]; + }; + }; listOpsConfigVersions: { parameters: { query?: { @@ -10534,6 +10816,30 @@ export interface operations { 403: components["responses"]["AccessDeniedError"]; }; }; + regenerateConversationSummary: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["ResourceID"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 已按当前激活模型排队生成新的版本化摘要 */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConversationSummaryRegenerationResponse"]; + }; + }; + 403: components["responses"]["AccessDeniedError"]; + 409: components["responses"]["ConflictError"]; + }; + }; listSubjectStories: { parameters: { query?: { diff --git a/web/src/pages/CustomerPortal.tsx b/web/src/pages/CustomerPortal.tsx index a638005..7f61030 100644 --- a/web/src/pages/CustomerPortal.tsx +++ b/web/src/pages/CustomerPortal.tsx @@ -42,6 +42,7 @@ import { SafetyCertificateOutlined, SearchOutlined, SettingOutlined, + RobotOutlined, TeamOutlined, UserOutlined } from "@ant-design/icons"; @@ -61,6 +62,7 @@ const sectionMeta: Record, label: "服务用量" }, + { key: "models", icon: , label: "大模型管理" }, { key: "profile", icon: , label: "设置与权限" } ], []); @@ -120,6 +123,7 @@ export function CustomerPortal() { [["分享", "share"], "sharing"], [["费用", "额度", "账单", "cost"], "billing"], [["消息", "通知", "提醒"], "messages"], + [["大模型", "模型", "豆包", "总结"], "models"], [["长者", "老人", "档案", "subject"], "subjects"], [["家庭", "成员", "邀请", "member"], "members"] ]; @@ -259,11 +263,12 @@ const permissionRows = [ ["privacy.read", "查看隐私请求", true, true, false, false], ["privacy.manage", "处理隐私请求", true, false, false, false], ["recording.play", "播放聊天录音", false, false, false, false], - ["recording.download", "下载聊天录音", false, false, false, false] + ["recording.download", "下载聊天录音", false, false, false, false], + ["model.manage", "管理总结大模型", true, true, false, false], ] as const; function PermissionMatrix() { - return 14 项}> + return 15 项}>

家庭角色决定一般操作权限;播放和下载录音属于敏感权限,必须由所有者或管理员按长者、用途和有效期单独授权。

{permissionRows.map(([code, label, owner, admin, editor, viewer]) => {[owner, admin, editor, viewer].map((enabled, index) => )})}
权限所有者管理员编辑者查看者
{label}{code}{enabled ? : }
; diff --git a/web/src/pages/SaaSWorkspace.tsx b/web/src/pages/SaaSWorkspace.tsx index a70b2ca..6b59a0a 100644 --- a/web/src/pages/SaaSWorkspace.tsx +++ b/web/src/pages/SaaSWorkspace.tsx @@ -24,6 +24,8 @@ import { UserAddOutlined, UserOutlined, UploadOutlined, + RobotOutlined, + CheckCircleOutlined, } from "@ant-design/icons"; import { APIRequestError, @@ -38,6 +40,8 @@ import { type ConversationSummary, type ConversationTurn, type ConversationMedia, + type ContentModel, + type CreateContentModelRequest, type CreateSubjectFactRequest, type CreateSubjectRequest, type CuratedMemory, @@ -71,7 +75,7 @@ import { } from "../api/client"; import { FamilyUsageOverview } from "../components/UsageOverview"; -export type CustomerSection = "home" | "members" | "subjects" | "devices" | "conversations" | "stories" | "sharing" | "privacy" | "billing" | "messages"; +export type CustomerSection = "home" | "members" | "subjects" | "devices" | "conversations" | "stories" | "sharing" | "privacy" | "billing" | "messages" | "models"; type Tab = CustomerSection | "firmware"; type LoadState = "idle" | "loading" | "ready" | "error"; type MediaLoadState = "idle" | "loading" | "ready" | "restricted" | "error"; @@ -191,6 +195,9 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti const [conversationUsage, setConversationUsage] = useState(); const [turns, setTurns] = useState([]); const [contentSummaries, setContentSummaries] = useState([]); + const [contentModels, setContentModels] = useState([]); + const [modelLoading, setModelLoading] = useState(false); + const [modelError, setModelError] = useState(); const [conversationInsights, setConversationInsights] = useState>({}); const [conversationMedia, setConversationMedia] = useState(); const [mediaState, setMediaState] = useState("idle"); @@ -274,9 +281,13 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti } const scheduleEventRefresh = useCallback(() => { - if (eventRefreshTimer.current !== undefined) return; - const minimumIntervalMS = 2000; - const delay = Math.max(0, minimumIntervalMS - (Date.now() - lastEventRefreshAt.current)); + // Event streams can replay a backlog when the page reconnects. Debounce the + // authoritative REST refresh until that burst settles; otherwise every + // two seconds all workspace resources are fetched again and the browser can + // exhaust the user's rate-limit bucket before the page becomes interactive. + if (eventRefreshTimer.current !== undefined) window.clearTimeout(eventRefreshTimer.current); + const minimumIntervalMS = 15000; + const delay = Math.max(800, minimumIntervalMS - (Date.now() - lastEventRefreshAt.current)); eventRefreshTimer.current = window.setTimeout(() => { eventRefreshTimer.current = undefined; lastEventRefreshAt.current = Date.now(); @@ -511,15 +522,21 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti return; } const controller = new AbortController(); - void Promise.all(recent.map(async (conversation) => { - try { - const response = await platformApi.conversationSummaries(conversation.id, accessToken, controller.signal); - return [conversation.id, inferConversationInsight(response.data.items)] as const; - } catch (cause) { - if (cause instanceof DOMException && cause.name === "AbortError") throw cause; - return [conversation.id, inferConversationInsight([])] as const; + void (async () => { + const items: Array = []; + // Keep the dashboard responsive without bursting seven requests into the + // per-user rate limiter at once. Results still arrive in timeline order. + for (const conversation of recent) { + try { + const response = await platformApi.conversationSummaries(conversation.id, accessToken, controller.signal); + items.push([conversation.id, inferConversationInsight(response.data.items)] as const); + } catch (cause) { + if (cause instanceof DOMException && cause.name === "AbortError") throw cause; + items.push([conversation.id, inferConversationInsight([])] as const); + } } - })).then((items) => { + return items; + })().then((items) => { if (!controller.signal.aborted) setConversationInsights(Object.fromEntries(items)); }).catch((cause) => { if (!(cause instanceof DOMException && cause.name === "AbortError")) setConversationInsights({}); @@ -557,12 +574,47 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti const canManageFirmware = enableFirmware && (selectedHousehold?.capabilities.includes("firmware.manage") ?? false); const canManageRecordingAccess = selectedHousehold?.role === "owner" || selectedHousehold?.role === "admin"; const canManageSharing = selectedHousehold?.role === "owner" || selectedHousehold?.role === "admin"; + const canManageModels = selectedHousehold?.role === "owner" || selectedHousehold?.role === "admin"; const policyVersion = accounts.find((account) => account.tenant_id === selectedHousehold?.tenant_id)?.policy_version ?? ""; const consentNotice = `我已阅读隐私政策${policyVersion || "当前"}版本,并同意对后续新通话启用“${consentLabel(newConsentType)}”。${consentImpactLabel(newConsentType)}我知道该授权可以单独撤回。`; const selectedConsentAlreadyEffective = consents.some((item) => item.type === newConsentType && item.effective); useEffect(() => { setFamilyControlDraft(undefined); setClaimCode(""); setAcceptInvitationToken(""); }, [tab, selectedHouseholdID, selectedSubjectID, selectedConversationID]); + const loadContentModels = useCallback(async (signal?: AbortSignal) => { + if (!selectedHousehold?.tenant_id) { setContentModels([]); return; } + setModelLoading(true); setModelError(undefined); + try { setContentModels((await platformApi.contentModels(selectedHousehold.tenant_id, accessToken, signal)).data.items); } + catch (cause) { if (!(cause instanceof DOMException && cause.name === "AbortError")) setModelError(errorMessage(cause)); } + finally { if (!signal?.aborted) setModelLoading(false); } + }, [accessToken, selectedHousehold?.tenant_id]); + + useEffect(() => { const controller = new AbortController(); void loadContentModels(controller.signal); return () => controller.abort(); }, [loadContentModels, refreshRevision]); + + async function createContentModel(input: CreateContentModelRequest) { + if (!selectedHousehold?.tenant_id) return false; + return runAction("大模型配置已安全保存,请完成连通性测试后激活。", async () => { await platformApi.createContentModel(selectedHousehold.tenant_id, input, accessToken); await loadContentModels(); }); + } + async function testContentModel(item: ContentModel) { + if (!selectedHousehold?.tenant_id) return; + await runAction("连通性测试已完成。", async () => { await platformApi.testContentModel(selectedHousehold.tenant_id, item.id, accessToken); await loadContentModels(); }); + } + async function activateContentModel(item: ContentModel) { + if (!selectedHousehold?.tenant_id) return; + await runAction(`“${item.display_name}”已激活,下一次总结任务会立即使用该模型。`, async () => { await platformApi.activateContentModel(selectedHousehold.tenant_id, item, accessToken); await loadContentModels(); }); + } + async function revokeContentModel(item: ContentModel) { + if (!selectedHousehold?.tenant_id) return; + await runAction("未激活模型已移除,密钥材料已清除。", async () => { await platformApi.revokeContentModel(selectedHousehold.tenant_id, item, accessToken); await loadContentModels(); }); + } + async function regenerateSummary() { + if (!selectedConversationID) return; + await runAction("已使用当前激活模型排队重新总结;结果生成后会自动刷新。", async () => { + await platformApi.regenerateConversationSummary(selectedConversationID, accessToken); + window.setTimeout(() => { void openConversation(selectedConversationID); }, 1800); + }); + } + useEffect(() => { if (!selectedSubjectID || !canManageRecordingAccess) { setRecordingGrants([]); @@ -1408,8 +1460,11 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti to={conversationTo} onTo={(value) => { clearConversationDetail(); setConversations([]); setConversationTo(value); }} hasMore={Boolean(conversationNextCursor)} onResetFilters={resetConversationFilters} onLoadMore={loadMoreConversations} onOpen={openConversation} onGrant={grantRecordingAccess} + activeModel={contentModels.find((item) => item.status === "active")} onRegenerate={regenerateSummary} + stories={stories} onOpenStories={() => selectSection("stories")} onRevoke={revokeRecordingAccess} onAccess={accessRecording} /> ) : null} + {tab === "models" ? : null} {tab === "stories" ? : null} @@ -1526,7 +1581,7 @@ function CustomerMessagePanel({ notices, devices, presence, stories, privacyRequ } function customerSectionTitle(section: Tab) { - return ({ home: "陪伴周记", members: "家人共享", subjects: "长者资料", devices: "陪伴设备", firmware: "固件发布", conversations: "聊天记录", stories: "故事与记忆", sharing: "内容分享", privacy: "隐私与授权", billing: "服务用量", messages: "日程与提醒" } as Record)[section]; + return ({ home: "陪伴周记", members: "家人共享", subjects: "长者资料", devices: "陪伴设备", firmware: "固件发布", conversations: "回忆时间轴", stories: "故事与记忆", sharing: "内容分享", privacy: "隐私与授权", billing: "服务用量", messages: "日程与提醒", models: "大模型管理" } as Record)[section]; } function customerSectionDescription(section: Tab) { @@ -1541,7 +1596,8 @@ function customerSectionDescription(section: Tab) { sharing: "按资源和权限范围向指定家庭成员分享内容。", privacy: "提交访问、导出或删除申请并跟踪处理进度。", billing: "汇总本家庭及设备的通话时长,并在核价可用时展示费用。", - messages: "集中查看陪伴动态、设备、故事和隐私请求的状态提醒。" + messages: "集中查看陪伴动态、设备、故事和隐私请求的状态提醒。", + models: "配置、测试并激活用于聊天摘要、关键词和故事候选生成的大模型。" } as Record)[section]; } @@ -1991,7 +2047,7 @@ function deviceBindingBlockerLabel(value: string) { return ({ target_subject_required: "请选择新的使用人", active_conversation: "设备仍有未结束通话", pending_device_commands: "设备仍有未完成命令", active_firmware_task: "设备仍有进行中的固件任务" } as Record)[value] ?? value; } -function ConversationPanel({ subjects, selectedID, onSelect, conversations, insights, detail, usage, turns, summaries, media, mediaState, mediaError, grants, members, canManageRecordingAccess, actionBusy, audioURLs, loading, detailLoading, loadingMore, error, detailError, selectedConversationID, status, onStatus, engineMode, onEngineMode, from, onFrom, to, onTo, hasMore, onResetFilters, onLoadMore, onOpen, onGrant, onRevoke, onAccess }: { +function ConversationPanel({ subjects, selectedID, onSelect, conversations, insights, detail, usage, turns, summaries, media, mediaState, mediaError, grants, members, canManageRecordingAccess, actionBusy, audioURLs, loading, detailLoading, loadingMore, error, detailError, selectedConversationID, status, onStatus, engineMode, onEngineMode, from, onFrom, to, onTo, hasMore, onResetFilters, onLoadMore, onOpen, onGrant, onRevoke, onAccess, activeModel, onRegenerate, stories, onOpenStories }: { subjects: Subject[]; selectedID?: string; onSelect: (id: string) => void; conversations: ConversationSummary[]; insights: Record; detail?: ConversationDetail; usage?: ConversationUsage; turns: ConversationTurn[]; summaries: ConversationContentSummary[]; media?: ConversationMedia; mediaState: MediaLoadState; mediaError?: string; grants: RecordingAccessGrant[]; members: HouseholdMember[]; @@ -2003,9 +2059,17 @@ function ConversationPanel({ subjects, selectedID, onSelect, conversations, insi onGrant: (input: RecordingGrantInput) => void; onRevoke: (grant: RecordingAccessGrant) => Promise; onAccess: (segment: RecordingSegment, action: "PLAY" | "DOWNLOAD", purpose: string) => void; + activeModel?: ContentModel; onRegenerate: () => Promise; + stories: Story[]; onOpenStories: () => void; }) { const [detailOpen, setDetailOpen] = useState(false); - return
+ const currentSummary = summaries.find((item) => item.type === "conversation_summary"); + const currentTopic = summaries.find((item) => item.type === "open_topic"); + const summaryParts = currentSummary?.content.split("\n关键词:") ?? []; + const keywords = summaryParts[1]?.split("、").filter(Boolean) ?? []; + const storyCandidate = detail ? stories.find((item) => item.conversation_id === detail.id) : undefined; + return
+
@@ -2018,9 +2082,22 @@ function ConversationPanel({ subjects, selectedID, onSelect, conversations, insi
已加载 {conversations.length} 条{hasMore ? ",还有更多" : ""}{status || engineMode || from || to ? : null}
{error ?
{error}
: null} - {loading && conversations.length === 0 ? : conversations.length ? conversations.map((item) => { const insight = insights[item.id]; return ; }) :

暂无聊天记录。

} + {loading && conversations.length === 0 ? : conversations.length ?
{conversations.map((item) => { const insight = insights[item.id]; return ; })}
:

暂无聊天记录。

} {hasMore ?
: null}
+
+ {detailLoading ? : detail ? <> +

AI MEMORY NOTE

这次聊天,长者想起了什么

{insights[detail.id]?.moodLabel ?? "平稳"}
+
{formatDate(detail.created_at)}{formatDuration(detail.duration_ms)}来源 {currentSummary?.source_turn_ids.length ?? turns.length} 条话轮
+

聊天摘要

{summaryParts[0] || insights[detail.id]?.summary || "大模型总结正在生成中。"}

+

关键词

{keywords.length ? keywords.map((keyword) => {keyword}) : 等待提取}
+

故事候选(节选)

{storyCandidate?.title || "故事候选正在整理"}

{storyCandidate?.content || "总结完成后,系统会生成一段可由家人审核的故事候选;未经确认不会写入长期记忆。"}

+

下次可以接着聊

{currentTopic?.content || "待总结完成后生成温和的后续话题。"}

+
{storyCandidate ? : null}
+ :

选择一段聊天

这里会展示大模型生成的摘要、关键词、故事线索和后续话题。

} +
+ +
{detailOpen ?
{ if (event.currentTarget === event.target) setDetailOpen(false); }}>

通话详情

查看文字内容、整理结果、语音授权与状态历史

{detailLoading ? : detailError && selectedConversationID ? void onOpen(selectedConversationID)} /> : detail ? <>

CONVERSATION DETAIL

{conversationStatusLabel(detail.status)}

{formatDate(detail.created_at)} · {detail.duration_ms ? `${Math.round(detail.duration_ms / 1000)}秒` : "时长待确认"}

{detail.failure_code ?

{conversationFailureLabel(detail.failure_code) ?? "通话准备失败"}
设备收到的失败标识:{detail.failure_code};背景准备状态:{detail.snapshots.context_preparation_status}

: null} @@ -2036,6 +2113,24 @@ function ConversationPanel({ subjects, selectedID, onSelect, conversations, insi
; } +function ContentModelPanel({ models, loading, error, canManage, actionBusy, onCreate, onTest, onActivate, onRevoke }: { + models: ContentModel[]; loading: boolean; error?: string; canManage: boolean; actionBusy: boolean; + onCreate: (input: CreateContentModelRequest) => Promise; onTest: (item: ContentModel) => Promise; + onActivate: (item: ContentModel) => Promise; onRevoke: (item: ContentModel) => Promise; +}) { + const [open, setOpen] = useState(false); + const [draft, setDraft] = useState({ display_name: "豆包聊天总结", provider: "ark", base_url: "https://ark.cn-beijing.volces.com/api/v3", model_id: "", api_key: "", temperature: 0.2, max_tokens: 1280 }); + const active = models.find((item) => item.status === "active"); + async function submit(event: FormEvent) { event.preventDefault(); if (await onCreate(draft)) { setOpen(false); setDraft((item) => ({ ...item, display_name: "", model_id: "", api_key: "" })); } } + return
+

当前总结引擎

{active?.display_name || "系统默认模型"}

{active ? `${active.model_id} · 下一条总结任务立即生效` : "配置并激活模型后,聊天摘要、关键词和故事候选将由它生成"}
{canManage ? : null}
+ {error ?
{error}
: null} + {loading ? :
{models.length ? models.map((item) =>

{item.display_name}

{item.provider === "ark" ? "火山方舟 / 豆包" : "OpenAI 兼容服务"}

{item.status === "active" ? "正在使用" : "备用"}
模型 ID
{item.model_id}
接口地址
{item.base_url}
访问密钥
{item.credential_hint}
生成参数
温度 {item.temperature} · 最多 {item.max_tokens} tokens
{item.test_status === "passed" ? "连通性正常" : item.test_status === "failed" ? `测试失败 · ${item.last_error_code || "请检查配置"}` : "尚未测试"}{canManage ?
{item.status !== "active" ? : null}{item.status !== "active" ? : null}
: null}
) :

还没有自定义总结模型

添加火山方舟或其他 OpenAI 兼容模型,测试通过后即可激活。

{canManage ? : null}
}
} +
密钥如何保存

API Key 使用独立主密钥经 AES-256-GCM 加密后存入数据库,页面和接口都不会再次回显明文;每次激活、测试和移除都会写入审计记录。

+ {open ?
{ if (event.currentTarget === event.target) setOpen(false); }}>
void submit(event)}>

添加总结大模型

该模型用于生成聊天摘要、关键词、故事和下次话题。

: null} +
; +} + function RecordingMediaBlock({ retentionMode, media, state, error, grants, members, canManage, actionBusy, audioURLs, onGrant, onRevoke, onAccess }: { retentionMode: string; media?: ConversationMedia; state: MediaLoadState; error?: string; grants: RecordingAccessGrant[]; members: HouseholdMember[]; canManage: boolean; actionBusy: boolean; audioURLs: Record; diff --git a/web/src/styles.css b/web/src/styles.css index 236ffeb..6e48e53 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -39,6 +39,100 @@ main { padding: 5rem 0; } +/* 回忆时间轴与大模型管理:沿用家庭首页的暖白、陶土红和雾蓝设计语言。 */ +.customer-page--conversations .workspace__header, +.customer-page--models .workspace__header { margin-bottom: 18px; } +.memory-timeline-page { display: block !important; min-height: 0 !important; border: 0 !important; overflow: visible !important; background: transparent !important; } +.memory-timeline-grid { display: grid; grid-template-columns: minmax(280px, .78fr) minmax(420px, 1.28fr) minmax(220px, .62fr); gap: 16px; align-items: start; } +.memory-timeline-grid > * { min-width: 0; border: 1px solid #eadeda; border-radius: 18px; background: #fffdfb; box-shadow: 0 10px 30px rgba(91, 64, 55, .055); } +.memory-timeline-grid .conversation-list { overflow: hidden; } +.memory-timeline-grid .conversation-filter { border-bottom: 1px solid #eee4e1; background: #faf6f2; } +.memory-timeline-grid .conversation-filter__fields { grid-template-columns: 1fr 1fr; } +.memory-timeline-grid .conversation-filter__fields label:nth-child(n+3) { display: none; } +.memory-timeline-list { position: relative; padding: 8px 0 12px; } +.memory-timeline-list::before { position: absolute; top: 15px; bottom: 22px; left: 25px; width: 2px; content: ""; background: #dfcbc4; } +.memory-timeline-list .conversation-row { position: relative; grid-template-columns: 16px 36px 1fr; margin: 0 8px; width: calc(100% - 16px); border: 0; border-radius: 13px; background: transparent; } +.memory-timeline-list .conversation-row.active { background: #f7ebe6; box-shadow: inset 3px 0 #a73920; } +.memory-timeline-list .conversation-row > .tag { grid-column: 3; justify-self: start; } +.memory-timeline-dot { z-index: 1; width: 11px; height: 11px; border: 3px solid #fffdfb; border-radius: 50%; background: #b86a55; box-shadow: 0 0 0 1px #d7b8ae; } +.memory-summary-panel { padding: 24px; } +.memory-summary-panel > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; } +.memory-summary-panel h3 { margin: 3px 0 0; color: #493834; font-family: "Noto Serif SC", "Songti SC", serif; font-size: 24px; } +.memory-summary-panel__mood { padding: 7px 10px; border-radius: 999px; color: #50725f; background: #edf5ef; white-space: nowrap; } +.memory-summary-panel__meta { display: flex; flex-wrap: wrap; gap: 8px 16px; margin: 14px 0 20px; color: #8a7872; font-size: 12px; } +.memory-summary-block { margin-top: 14px; padding: 17px 18px; border: 1px solid #ede3df; border-radius: 14px; background: #fff; } +.memory-summary-block--warm { border-color: #ecd5cd; background: #fff6f1; } +.memory-summary-block h4 { margin: 0 0 9px; color: #6b4f47; font-size: 14px; } +.memory-summary-block p { margin: 0; color: #574b47; line-height: 1.85; } +.memory-keywords { display: flex; flex-wrap: wrap; gap: 8px; } +.memory-keywords span { padding: 5px 10px; border-radius: 999px; color: #78564d; background: #f4e9e4; font-size: 12px; } +.memory-summary-panel > footer { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; } +.memory-summary-empty { display: grid; place-items: center; min-height: 370px; color: #9a8179; text-align: center; } +.memory-summary-empty .anticon { font-size: 36px; } +.memory-summary-empty h3 { margin-bottom: 0; } +.memory-model-card { position: sticky; top: 84px; padding: 22px; text-align: center; } +.memory-model-card__icon { display: grid; place-items: center; width: 52px; height: 52px; margin: 0 auto 13px; border-radius: 16px; color: #fff; background: #6f8c98; font-size: 24px; } +.memory-model-card > p { margin: 5px 0; color: #907d77; font-size: 12px; } +.memory-model-card h3 { margin: 6px 0; color: #493834; } +.memory-model-card small { display: block; overflow-wrap: anywhere; color: #887873; line-height: 1.6; } +.memory-model-card > div { margin: 16px 0; padding: 8px; border-radius: 10px; color: #65736a; background: #f2f5f1; font-size: 12px; } +.memory-model-card > div span { display: inline-block; width: 8px; height: 8px; margin-right: 6px; border-radius: 50%; background: #b2aaa6; } +.memory-model-card > div span.is-active { background: #65a176; box-shadow: 0 0 0 3px #dceddf; } +.memory-model-card__history { padding-top: 14px; border-top: 1px solid #eee4e1; line-height: 1.7; } +.memory-model-card__history strong { overflow-wrap: anywhere; color: #6f5a54; font-weight: 600; } + +.model-management-page { display: grid; gap: 18px; } +.model-active-hero { display: flex; align-items: center; justify-content: space-between; padding: 24px 26px; border: 1px solid #dfd2ce; border-radius: 18px; background: linear-gradient(120deg, #fffaf6, #edf4f4); } +.model-active-hero > div { display: flex; align-items: center; gap: 16px; } +.model-active-hero__icon { display: grid; place-items: center; width: 58px; height: 58px; border-radius: 18px; color: #fff; background: #6d8995; font-size: 26px; } +.model-active-hero p, .model-active-hero h3 { margin: 0; } +.model-active-hero h3 { margin: 3px 0; color: #493934; font-size: 24px; } +.model-active-hero span { color: #7f6e68; } +.model-card-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } +.model-config-card { padding: 21px; border: 1px solid #eadeda; border-radius: 18px; background: #fffdfb; box-shadow: 0 9px 25px rgba(94, 68, 60, .05); } +.model-config-card.is-active { border-color: #9db7aa; box-shadow: inset 0 3px #6f9b7d, 0 9px 25px rgba(94, 68, 60, .05); } +.model-config-card header { display: grid; grid-template-columns: 44px 1fr auto; gap: 12px; align-items: center; } +.model-config-card header > span { display: grid; place-items: center; width: 44px; height: 44px; border-radius: 13px; color: #6c8792; background: #eaf1f2; font-size: 21px; } +.model-config-card h3, .model-config-card p { margin: 0; } +.model-config-card header p { color: #927e77; font-size: 12px; } +.model-config-card header b { padding: 5px 9px; border-radius: 999px; color: #597462; background: #eaf3ed; font-size: 11px; } +.model-config-card dl { margin: 18px 0; padding: 12px 0; border-block: 1px solid #eee5e1; } +.model-config-card dl div { display: grid; grid-template-columns: 82px 1fr; gap: 10px; padding: 5px 0; font-size: 12px; } +.model-config-card dt { color: #9a8781; } +.model-config-card dd { margin: 0; overflow-wrap: anywhere; color: #5c4d48; } +.model-config-card footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.model-config-card footer > div { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 7px; } +.model-test-status { color: #8a7b76; font-size: 12px; } +.model-test-status--passed { color: #4f8260; } +.model-test-status--failed { color: #a33f2d; } +.model-empty { grid-column: 1 / -1; padding: 54px; border: 1px dashed #d9c8c2; border-radius: 18px; color: #8e7c76; background: #fffaf7; text-align: center; } +.model-empty > .anticon { font-size: 38px; } +.model-security-note { display: flex; gap: 14px; padding: 18px; border-radius: 14px; color: #5d6963; background: #edf3ef; } +.model-security-note > .anticon { margin-top: 3px; color: #5e8a6b; font-size: 20px; } +.model-security-note p { margin: 5px 0 0; line-height: 1.65; } +.model-parameter-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +.model-create-dialog .workspace-dialog__body { display: grid; gap: 14px; } + +/* 非首页业务页统一为轻量、长者优先的内容画布。 */ +.customer-page:not(.customer-page--home) > .workspace { border-color: #e9deda; border-radius: 18px; background: rgba(255, 253, 251, .96); box-shadow: 0 14px 38px rgba(78, 59, 52, .055); } +.customer-page:not(.customer-page--home) .workspace__header { padding: 3px 2px 18px; } +.customer-page:not(.customer-page--home) .panel-section, +.customer-page:not(.customer-page--home) .customer-message-panel, +.customer-page:not(.customer-page--home) .customer-billing-panel { border-radius: 16px; box-shadow: 0 8px 24px rgba(87, 65, 57, .04); } + +@media (max-width: 1180px) { + .memory-timeline-grid { grid-template-columns: minmax(270px, .8fr) minmax(390px, 1.2fr); } + .memory-model-card { position: static; grid-column: 1 / -1; text-align: left; } + .model-card-grid { grid-template-columns: 1fr; } +} +@media (max-width: 760px) { + .memory-timeline-grid { grid-template-columns: 1fr; } + .memory-summary-panel { padding: 18px; } + .memory-summary-panel > header, .model-active-hero, .model-config-card footer { align-items: stretch; flex-direction: column; } + .memory-model-card { grid-column: auto; } + .model-parameter-row { grid-template-columns: 1fr; } +} + .hero { display: flex; align-items: end; @@ -3442,3 +3536,23 @@ textarea:focus, .recent-activity > div > button > .anticon { grid-column: 1; grid-row: 1 / 4; } .recent-activity time,.recent-activity strong,.recent-activity span { grid-column: 2; } } + +/* Keep the selected visual target authoritative over legacy workspace rules. */ +.customer-page--conversations .memory-timeline-grid { display: grid; grid-template-columns: minmax(280px,.78fr) minmax(420px,1.28fr) minmax(220px,.62fr); gap: 16px; } +.customer-page--conversations .memory-timeline-grid .conversation-list { border-right: 1px solid #eadeda; background: #fffdfb; } +.customer-page--conversations .memory-timeline-list .conversation-row { grid-template-columns: 16px 34px minmax(0,1fr) !important; } +.customer-page--conversations .memory-timeline-list .conversation-row > .tag { grid-column: 3; } +.customer-page--conversations .workspace__header, +.customer-page--models .workspace__header { justify-content: space-between; } +.customer-page--conversations .workspace__header > div:first-child, +.customer-page--models .workspace__header > div:first-child { display: block; } +.memory-summary-block--story { border-color: #ead7bf; background: #fffaf1; } +.memory-summary-block--story strong { display: block; margin-bottom: 7px; color: #644d3e; font-family: "Noto Serif SC", "Songti SC", serif; } +@media (max-width: 1180px) { + .customer-page--conversations .memory-timeline-grid { grid-template-columns: minmax(270px,.8fr) minmax(390px,1.2fr); } + .customer-page--conversations .memory-model-card { grid-column: 1 / -1; } +} +@media (max-width: 760px) { + .customer-page--conversations .memory-timeline-grid { grid-template-columns: 1fr; } + .customer-page--conversations .memory-model-card { grid-column: auto; } +}