feat: add managed llm summaries and memory timeline
ci / verify (push) Canceled after 0s

This commit is contained in:
Codex Local
2026-08-30 10:51:32 +08:00
parent 47f8a164fa
commit 962dd1a063
22 changed files with 1417 additions and 86 deletions
+1
View File
@@ -36,6 +36,7 @@ x-backend-environment: &backend-environment
PROVIDER_MEMORY_TIMEOUT: ${PROVIDER_MEMORY_TIMEOUT:-5s} PROVIDER_MEMORY_TIMEOUT: ${PROVIDER_MEMORY_TIMEOUT:-5s}
DEVICE_CLOCK_SKEW: 5m DEVICE_CLOCK_SKEW: 5m
DEVICE_CREDENTIAL_MASTER_KEY: ${DEVICE_CREDENTIAL_MASTER_KEY:-000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f} 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_ID: local-public-rtc-app
RTC_APP_KEY: ${RTC_APP_KEY:-} RTC_APP_KEY: ${RTC_APP_KEY:-}
RTC_TOKEN_ISSUER: ${RTC_TOKEN_ISSUER:-local} RTC_TOKEN_ISSUER: ${RTC_TOKEN_ISSUER:-local}
+1
View File
@@ -34,6 +34,7 @@ umask 077
printf 'KEYCLOAK_ADMIN_PASSWORD=%s\n' "$(openssl rand -hex 24)" printf 'KEYCLOAK_ADMIN_PASSWORD=%s\n' "$(openssl rand -hex 24)"
printf 'DEVICE_LOCAL_SECRET=%s\n' "$(openssl rand -hex 32)" printf 'DEVICE_LOCAL_SECRET=%s\n' "$(openssl rand -hex 32)"
printf 'DEVICE_CREDENTIAL_MASTER_KEY=%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 'RTC_TOKEN_SIGNING_SECRET=%s\n' "$(openssl rand -hex 32)"
printf 'CURSOR_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)" printf 'SHARE_TOKEN_SIGNING_SECRET=%s\n' "$(openssl rand -hex 32)"
+1
View File
@@ -21,6 +21,7 @@ x-server-backend-environment: &server-backend-environment
SHARE_TOKEN_SIGNING_SECRET: ${SHARE_TOKEN_SIGNING_SECRET} SHARE_TOKEN_SIGNING_SECRET: ${SHARE_TOKEN_SIGNING_SECRET}
MEMORY_TOOL_BASE_URL: ${PUBLIC_ORIGIN}/internal/v1/tools/memory-search MEMORY_TOOL_BASE_URL: ${PUBLIC_ORIGIN}/internal/v1/tools/memory-search
MEMORY_TOOL_SIGNING_SECRET: ${MEMORY_TOOL_SIGNING_SECRET} MEMORY_TOOL_SIGNING_SECRET: ${MEMORY_TOOL_SIGNING_SECRET}
MODEL_CREDENTIAL_MASTER_KEY: ${MODEL_CREDENTIAL_MASTER_KEY}
EMBEDDING_PROVIDER: local EMBEDDING_PROVIDER: local
VOICE_PROVIDER: ${VOICE_PROVIDER:-mock} VOICE_PROVIDER: ${VOICE_PROVIDER:-mock}
+29 -43
View File
@@ -1,60 +1,46 @@
# Design QA — 陪伴周记 # 回忆时间轴设计 QA
- source visual truth path: `E:\Codex\story\platform\.design-evidence\selected-conversation-journal.png` - source visual truth: `E:\Codex\story\platform\.design-evidence\selected-conversation-timeline.png`
- implementation screenshot path: `E:\Codex\story\platform\.design-evidence\implementation-home-final.png` - implementation screenshot: `E:\Codex\story\platform\.design-evidence\implementation-conversation-timeline-final.png`
- comparison image path: `E:\Codex\story\platform\.design-evidence\design-comparison-final.png` - normalized comparison: `selected-conversation-timeline-normalized.png` and `implementation-conversation-timeline-normalized.png`
- responsive evidence: `E:\Codex\story\platform\.design-evidence\implementation-home-mobile.png` - browser viewport: 1265 × 712 CSS px, device scale factor 1
- viewport: desktop 1280 × 720 CSS px; mobile 390 × 844 CSS px - source pixels: 1487 × 1058; implementation full-page pixels: 1265 × 2150
- source pixels: 1488 × 1058 - density normalization: source was resized and implementation was top-cropped to 1265 × 900 for an equal-size comparison
- implementation pixels: 1265 × 720 for the desktop browser content capture; device scale factor 1 - state: authenticated owner, conversations route, one completed conversation selected, no custom model active
- 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
## Full-view comparison evidence ## 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. 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.
## 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.
## Required fidelity surfaces ## 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. - 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: 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. - 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: 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. - 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 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. - 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: 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. - 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 ## 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. ## Findings
- 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`.
### 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. P3 follow-up polish: add a dedicated generation-version comparison drawer once multiple production model outputs are available for the same conversation.
- 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.
## 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. - [x] Three-column memory timeline implemented.
- Confirmed every section has no horizontal overflow and no visible “客户组织” copy. - [x] Summary, keywords, story candidate and next-topic states implemented.
- Confirmed all disabled buttons report `cursor: wait`. - [x] Current model and generation provenance shown.
- Opened the new-elder dialog, uploaded a real PNG through the browser file chooser, and confirmed the WebP preview. - [x] Core interactions and empty/disabled states verified in the in-app browser.
- Confirmed the profile renders 14 permission rows. - [x] Rate-limit regression removed and browser console-visible page errors checked.
- 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.
final result: passed final result: passed
+22 -8
View File
@@ -14,6 +14,7 @@ import (
"data-terminal-cloud/internal/billing" "data-terminal-cloud/internal/billing"
"data-terminal-cloud/internal/cache" "data-terminal-cloud/internal/cache"
"data-terminal-cloud/internal/config" "data-terminal-cloud/internal/config"
"data-terminal-cloud/internal/contentmodel"
"data-terminal-cloud/internal/contentpolicy" "data-terminal-cloud/internal/contentpolicy"
"data-terminal-cloud/internal/database" "data-terminal-cloud/internal/database"
"data-terminal-cloud/internal/dataexport" "data-terminal-cloud/internal/dataexport"
@@ -137,6 +138,21 @@ func Run(ctx context.Context, serviceName string) error {
var opsDeviceService *opsdevice.Service var opsDeviceService *opsdevice.Service
var memoryToolService *memorytool.Service var memoryToolService *memorytool.Service
var memoryToolSigner *memorytool.TokenSigner 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" { if serviceName == "api" || serviceName == "callbacks" {
diagnosticService, err = diagnostics.NewService(databasePool, idempotency.NewExecutor(databasePool), audit.NewRepository()) diagnosticService, err = diagnostics.NewService(databasePool, idempotency.NewExecutor(databasePool), audit.NewRepository())
if err != nil { if err != nil {
@@ -144,6 +160,10 @@ func Run(ctx context.Context, serviceName string) error {
} }
} }
if serviceName == "api" { 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)) memoryToolSigner, err = memorytool.NewTokenSigner([]byte(cfg.MemoryToolSigningSecret))
if err != nil { if err != nil {
return fmt.Errorf("create memory tool signer: %w", err) 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_requested", stopProcessor.Handle)
outboxWorker.Register("conversation.agent_stop_reconcile_requested", stopProcessor.Handle) outboxWorker.Register("conversation.agent_stop_reconcile_requested", stopProcessor.Handle)
var contentGenerator extraction.Generator = extraction.DeterministicGenerator{} var contentGenerator extraction.Generator = contentModelRegistry
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 contentSafety contentpolicy.Classifier = contentpolicy.LocalClassifier{} var contentSafety contentpolicy.Classifier = contentpolicy.LocalClassifier{}
if cfg.ContentSafetyProvider == "managed_http" { if cfg.ContentSafetyProvider == "managed_http" {
managedSafety, safetyErr := contentpolicy.NewManagedClassifier(cfg.ContentSafetyEndpoint, cfg.ContentSafetyAPIKey, cfg.ContentSafetyPolicyRef, cfg.ContentSafetyTimeout) 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, DiagnosticService: diagnosticService,
OpsAccessService: opsAccessService, OpsAccessService: opsAccessService,
OpsDeviceService: opsDeviceService, OpsDeviceService: opsDeviceService,
ContentModelService: contentModelService,
ConversationCreator: conversationCreator, ConversationCreator: conversationCreator,
ConversationActivator: conversationCreator, ConversationActivator: conversationCreator,
DeviceConversationStateReader: conversationCreator, DeviceConversationStateReader: conversationCreator,
+13 -1
View File
@@ -48,6 +48,7 @@ type Config struct {
ProviderMaxActiveTasks int ProviderMaxActiveTasks int
DeviceLocalSecret string DeviceLocalSecret string
DeviceCredentialMasterKey string DeviceCredentialMasterKey string
ContentModelMasterKey string
DeviceClockSkew time.Duration DeviceClockSkew time.Duration
RTCAppID string RTCAppID string
RTCAppKey string RTCAppKey string
@@ -226,7 +227,7 @@ func Load(serviceName string) (Config, error) {
} }
environment := stringEnv("APP_ENV", EnvironmentLocal) environment := stringEnv("APP_ENV", EnvironmentLocal)
secretValues, err := loadSecretValues( 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", "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", "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", "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"] deviceLocalSecret := secretValues["DEVICE_LOCAL_SECRET"]
deviceCredentialMasterKey := secretValues["DEVICE_CREDENTIAL_MASTER_KEY"] deviceCredentialMasterKey := secretValues["DEVICE_CREDENTIAL_MASTER_KEY"]
contentModelMasterKey := secretValues["MODEL_CREDENTIAL_MASTER_KEY"]
rtcTokenSecret := secretValues["RTC_TOKEN_SIGNING_SECRET"] rtcTokenSecret := secretValues["RTC_TOKEN_SIGNING_SECRET"]
cursorSigningSecret := secretValues["CURSOR_SIGNING_SECRET"] cursorSigningSecret := secretValues["CURSOR_SIGNING_SECRET"]
shareTokenSecret := secretValues["SHARE_TOKEN_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) { if deviceCredentialMasterKey == "" && serviceName == "api" && (environment == EnvironmentLocal || environment == EnvironmentTest) {
deviceCredentialMasterKey = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" deviceCredentialMasterKey = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
} }
if contentModelMasterKey == "" && (serviceName == "api" || serviceName == "worker") && (environment == EnvironmentLocal || environment == EnvironmentTest) {
contentModelMasterKey = "1f1e1d1c1b1a191817161514131211100f0e0d0c0b0a09080706050403020100"
}
if rtcTokenSecret == "" && serviceName == "api" && (environment == EnvironmentLocal || environment == EnvironmentTest) { if rtcTokenSecret == "" && serviceName == "api" && (environment == EnvironmentLocal || environment == EnvironmentTest) {
rtcTokenSecret = "local-simulated-rtc-token-secret-32-bytes-minimum" rtcTokenSecret = "local-simulated-rtc-token-secret-32-bytes-minimum"
} }
@@ -311,6 +316,7 @@ func Load(serviceName string) (Config, error) {
RecoveryStoppingAfter: recoveryStoppingAfter, RecoveryStoppingAfter: recoveryStoppingAfter,
DeviceLocalSecret: deviceLocalSecret, DeviceLocalSecret: deviceLocalSecret,
DeviceCredentialMasterKey: deviceCredentialMasterKey, DeviceCredentialMasterKey: deviceCredentialMasterKey,
ContentModelMasterKey: contentModelMasterKey,
DeviceClockSkew: deviceClockSkew, DeviceClockSkew: deviceClockSkew,
RTCAppID: stringEnv("RTC_APP_ID", "local-public-rtc-app"), RTCAppID: stringEnv("RTC_APP_ID", "local-public-rtc-app"),
RTCAppKey: secretValues["RTC_APP_KEY"], 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") 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 { if c.DeviceClockSkew <= 0 {
return errors.New("DEVICE_CLOCK_SKEW must be positive") return errors.New("DEVICE_CLOCK_SKEW must be positive")
} }
+409
View File
@@ -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: &current.tenantID, ActorType: "user", ActorPublicID: &current.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: &current.tenantID, ActorType: "user", ActorPublicID: &current.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: &current.tenantID, ActorType: "user", ActorPublicID: &current.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: &current.tenantID, ActorType: "user", ActorPublicID: &current.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
}
+19 -6
View File
@@ -18,6 +18,7 @@ import (
) )
type GenerationInput struct { type GenerationInput struct {
TenantID string
Content string Content string
} }
@@ -92,10 +93,18 @@ type ArkGenerator struct {
endpoint string endpoint string
apiKey string apiKey string
model string model string
provider string
temperature float64
maxTokens int
client *http.Client client *http.Client
} }
func NewArkGenerator(baseURL, apiKey, model string, timeout time.Duration) (*ArkGenerator, error) { 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), "/") baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
apiKey = strings.TrimSpace(apiKey) apiKey = strings.TrimSpace(apiKey)
model = strings.TrimSpace(model) 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") { 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") return nil, errors.New("Ark base URL must be an absolute HTTP(S) URL")
} }
if apiKey == "" || model == "" || timeout <= 0 { if provider != "ark" && provider != "openai_compatible" {
return nil, errors.New("Ark API key, content model and positive timeout are required") 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{ return &ArkGenerator{
endpoint: baseURL + "/chat/completions", apiKey: apiKey, model: model, endpoint: baseURL + "/chat/completions", apiKey: apiKey, model: model,
provider: provider, temperature: temperature, maxTokens: maxTokens,
client: &http.Client{Timeout: timeout}, client: &http.Client{Timeout: timeout},
}, nil }, 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": "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}, {"role": "user", "content": content},
}, },
"temperature": 0.2, "temperature": g.temperature,
"max_tokens": 1200, "max_tokens": g.maxTokens,
"response_format": map[string]string{"type": "json_object"}, "response_format": map[string]string{"type": "json_object"},
} }
encoded, err := json.Marshal(requestBody) encoded, err := json.Marshal(requestBody)
@@ -181,8 +194,8 @@ func (g *ArkGenerator) Generate(ctx context.Context, input GenerationInput) (Gen
if !validGeneratedContent(result) { if !validGeneratedContent(result) {
return GenerationResult{}, outbox.Retryable(errors.New("Ark structured content failed validation")) return GenerationResult{}, outbox.Retryable(errors.New("Ark structured content failed validation"))
} }
result.ModelRef = "ark:" + g.model result.ModelRef = g.provider + ":" + g.model
result.PromptVersion = "story-ark-json-v1" result.PromptVersion = "story-" + g.provider + "-json-v2"
result.AllowStory = true result.AllowStory = true
return result, nil return result, nil
} }
+7 -3
View File
@@ -94,7 +94,7 @@ func (p *Processor) Handle(ctx context.Context, event outbox.Event) error {
return outbox.Retryable(ErrConversationContentNotReady) return outbox.Retryable(ErrConversationContentNotReady)
} }
content := joinedTurnContent(sourceTurns) content := joinedTurnContent(sourceTurns)
generated, err := p.generateSafely(ctx, content) generated, err := p.generateSafely(ctx, content, snapshot.tenantID)
if err != nil { if err != nil {
return err 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) 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) decision, err := p.safety.Classify(ctx, content)
if err != nil { if err != nil {
return GenerationResult{}, err return GenerationResult{}, err
} }
if decision.AllowOrdinaryStory { 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 { if generateErr != nil {
return GenerationResult{}, generateErr return GenerationResult{}, generateErr
} }
+133
View File
@@ -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", "大模型配置处理失败")
}
}
+10
View File
@@ -8,6 +8,7 @@ import (
"data-terminal-cloud/internal/alerts" "data-terminal-cloud/internal/alerts"
"data-terminal-cloud/internal/billing" "data-terminal-cloud/internal/billing"
"data-terminal-cloud/internal/buildinfo" "data-terminal-cloud/internal/buildinfo"
"data-terminal-cloud/internal/contentmodel"
"data-terminal-cloud/internal/device" "data-terminal-cloud/internal/device"
"data-terminal-cloud/internal/diagnostics" "data-terminal-cloud/internal/diagnostics"
"data-terminal-cloud/internal/health" "data-terminal-cloud/internal/health"
@@ -42,6 +43,7 @@ type RouterOptions struct {
DiagnosticService *diagnostics.Service DiagnosticService *diagnostics.Service
OpsAccessService *opsaccess.Service OpsAccessService *opsaccess.Service
OpsDeviceService *opsdevice.Service OpsDeviceService *opsdevice.Service
ContentModelService *contentmodel.Service
ConversationCreator ConversationCreator ConversationCreator ConversationCreator
ConversationActivator ConversationActivator ConversationActivator ConversationActivator
DeviceConversationStateReader DeviceConversationStateReader 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}/credentials/rotate", authenticated(RotateOpsDeviceCredentialHandler(options.OpsDeviceService)))
mux.Handle("POST /api/v1/ops/devices/{id}/claims", authenticated(IssueOpsDeviceClaimHandler(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("GET /api/v1/households", authenticated(ListHouseholdsHandler(options.SaaSService)))
mux.Handle("POST /api/v1/households", authenticated(CreateHouseholdHandler(options.SaaSService))) mux.Handle("POST /api/v1/households", authenticated(CreateHouseholdHandler(options.SaaSService)))
mux.Handle("GET /api/v1/households/{id}", authenticated(GetHouseholdHandler(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}/turns", authenticated(ListConversationTurnsHandler(options.SaaSService)))
mux.Handle("GET /api/v1/conversations/{id}/summaries", authenticated(ListConversationSummariesHandler(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/subjects/{id}/stories", authenticated(ListSubjectStoriesHandler(options.SaaSService)))
mux.Handle("GET /api/v1/stories/{id}", authenticated(GetStoryHandler(options.SaaSService))) mux.Handle("GET /api/v1/stories/{id}", authenticated(GetStoryHandler(options.SaaSService)))
mux.Handle("PATCH /api/v1/stories/{id}", authenticated(PatchStoryHandler(options.SaaSService))) mux.Handle("PATCH /api/v1/stories/{id}", authenticated(PatchStoryHandler(options.SaaSService)))
+17
View File
@@ -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 { func ListSubjectStoriesHandler(service *saas.Service) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
principal, ok := resourcePrincipal(writer, request, "id") 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", "当前故事审核状态不允许此操作") WriteError(writer, request, http.StatusConflict, "STORY_REVIEW_STATE_CONFLICT", "当前故事审核状态不允许此操作")
case errors.Is(err, saas.ErrFactReviewStateConflict): case errors.Is(err, saas.ErrFactReviewStateConflict):
WriteError(writer, request, http.StatusConflict, "FACT_REVIEW_STATE_CONFLICT", "当前事实候选状态不允许此操作") 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): case errors.Is(err, pagination.ErrInvalidCursor):
writeValidation(writer, request, "分页游标无效", FieldError{Field: "cursor", Message: "游标无效、已损坏或不是本服务签发"}) writeValidation(writer, request, "分页游标无效", FieldError{Field: "cursor", Message: "游标无效、已损坏或不是本服务签发"})
default: default:
+3
View File
@@ -27,6 +27,7 @@ const (
CapabilityRecordingDownload Capability = "recording.download" CapabilityRecordingDownload Capability = "recording.download"
CapabilityPrivacyRead Capability = "privacy.read" CapabilityPrivacyRead Capability = "privacy.read"
CapabilityPrivacyManage Capability = "privacy.manage" CapabilityPrivacyManage Capability = "privacy.manage"
CapabilityModelManage Capability = "model.manage"
) )
var roleCapabilities = map[Role]map[Capability]struct{}{ var roleCapabilities = map[Role]map[Capability]struct{}{
@@ -35,12 +36,14 @@ var roleCapabilities = map[Role]map[Capability]struct{}{
CapabilityOwnershipTransfer, CapabilitySubjectRead, CapabilitySubjectWrite, CapabilityOwnershipTransfer, CapabilitySubjectRead, CapabilitySubjectWrite,
CapabilityConversationRead, CapabilityStoryReview, CapabilityDeviceManage, CapabilityConversationRead, CapabilityStoryReview, CapabilityDeviceManage,
CapabilityFirmwareManage, CapabilityPrivacyRead, CapabilityPrivacyManage, CapabilityFirmwareManage, CapabilityPrivacyRead, CapabilityPrivacyManage,
CapabilityModelManage,
), ),
RoleAdmin: capabilitySet( RoleAdmin: capabilitySet(
CapabilityHouseholdRead, CapabilityHouseholdManage, CapabilityMemberManage, CapabilityHouseholdRead, CapabilityHouseholdManage, CapabilityMemberManage,
CapabilitySubjectRead, CapabilitySubjectWrite, CapabilityConversationRead, CapabilitySubjectRead, CapabilitySubjectWrite, CapabilityConversationRead,
CapabilityStoryReview, CapabilityDeviceManage, CapabilityStoryReview, CapabilityDeviceManage,
CapabilityFirmwareManage, CapabilityPrivacyRead, CapabilityFirmwareManage, CapabilityPrivacyRead,
CapabilityModelManage,
), ),
RoleEditor: capabilitySet( RoleEditor: capabilitySet(
CapabilityHouseholdRead, CapabilitySubjectRead, CapabilitySubjectWrite, CapabilityHouseholdRead, CapabilitySubjectRead, CapabilitySubjectWrite,
+30
View File
@@ -24,6 +24,7 @@ var (
ErrPersonalizedMemoryConsentNeeded = errors.New("personalized memory consent is required") ErrPersonalizedMemoryConsentNeeded = errors.New("personalized memory consent is required")
ErrReviewStateConflict = errors.New("content review state does not allow this operation") ErrReviewStateConflict = errors.New("content review state does not allow this operation")
ErrFactReviewStateConflict = errors.New("fact candidate 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 { type ConversationTurn struct {
@@ -216,6 +217,35 @@ func (s *Service) ListConversationContentSummaries(ctx context.Context, principa
return items, rows.Err() 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) { 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) access, _, err := resolveSubject(ctx, s.pool, principal, subjectPublicID, identity.CapabilitySubjectRead, false)
if err != nil { if err != nil {
@@ -0,0 +1,3 @@
BEGIN;
DROP TABLE IF EXISTS content_model_config;
COMMIT;
@@ -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;
+119 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0 openapi: 3.1.0
info: info:
title: 数据采集终端—云端系统 API title: 数据采集终端—云端系统 API
version: 0.70.0 version: 0.73.0
description: 工程框架、SaaS核心业务、设备v2能力协商与智能体启动状态查询、首次通话就绪与最低固件门禁、背景资料准备失败闭环、活动通话授权撤回安全停止、语音内容闭环、终端终稿补传、批量设备遥测、录音上传队列、隐私对账、固件、双轨录音、状态事件、具体资源分享、受控运营配置、Agent组合组件引用、质量证据门禁、运营自定义版本族投放、新通话运行时冻结与供应商启动传递、带并发版本校验的配置投放暂停恢复、带严重度和验收证据门禁的联调问题闭环、含阻断告警定位、当前主机健康及生产部署标记、实际应用环境、身份服务模式、异地备份、外部告警与模拟服务强制条件的发布候选技术验收快照、绑定候选版本与构建摘要并按业务时区生成的可审计试点日/周运行证据、可审计且不生成正式告警的接收器联通测试、外部依赖预检台账、证书到期清单,以及带价格版本冻结、供应商用量去重和硬额度控制的费用治理接口。 description: 工程框架、SaaS核心业务、设备v2能力协商与智能体启动状态查询、首次通话就绪与最低固件门禁、背景资料准备失败闭环、活动通话授权撤回安全停止、语音内容闭环、终端终稿补传、批量设备遥测、录音上传队列、隐私对账、固件、双轨录音、状态事件、具体资源分享、受控运营配置、Agent组合组件引用、质量证据门禁、运营自定义版本族投放、新通话运行时冻结与供应商启动传递、带并发版本校验的配置投放暂停恢复、带严重度和验收证据门禁的联调问题闭环、含阻断告警定位、当前主机健康及生产部署标记、实际应用环境、身份服务模式、异地备份、外部告警与模拟服务强制条件的发布候选技术验收快照、绑定候选版本与构建摘要并按业务时区生成的可审计试点日/周运行证据、可审计且不生成正式告警的接收器联通测试、外部依赖预检台账、证书到期清单,以及带价格版本冻结、供应商用量去重和硬额度控制的费用治理接口。
servers: servers:
- url: http://localhost:58080 - url: http://localhost:58080
@@ -36,6 +36,53 @@ paths:
'200': { description: 按生成模型和提示词版本聚合的候选确认、修改、拒绝和质量问题类型, content: { application/json: { schema: { $ref: '#/components/schemas/ContentQualityListResponse' } } } } '200': { description: 按生成模型和提示词版本聚合的候选确认、修改、拒绝和质量问题类型, content: { application/json: { schema: { $ref: '#/components/schemas/ContentQualityListResponse' } } } }
'401': { $ref: '#/components/responses/AuthenticationError' } '401': { $ref: '#/components/responses/AuthenticationError' }
'403': { $ref: '#/components/responses/AccessDeniedError' } '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: /api/v1/ops/config-versions:
get: get:
operationId: listOpsConfigVersions operationId: listOpsConfigVersions
@@ -1956,6 +2003,16 @@ paths:
content: { application/json: { schema: { $ref: '#/components/schemas/ConversationContentSummaryListResponse' } } } content: { application/json: { schema: { $ref: '#/components/schemas/ConversationContentSummaryListResponse' } } }
'401': { $ref: '#/components/responses/AuthenticationError' } '401': { $ref: '#/components/responses/AuthenticationError' }
'403': { $ref: '#/components/responses/AccessDeniedError' } '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: /api/v1/subjects/{id}/stories:
parameters: [{ $ref: '#/components/parameters/ResourceID' }] parameters: [{ $ref: '#/components/parameters/ResourceID' }]
get: get:
@@ -5603,6 +5660,40 @@ components:
generation_version: { type: integer, format: int64, minimum: 1 } generation_version: { type: integer, format: int64, minimum: 1 }
status: { type: string, enum: [PROCESSING, READY, FAILED, SUPERSEDED] } status: { type: string, enum: [PROCESSING, READY, FAILED, SUPERSEDED] }
created_at: { type: string, format: date-time } 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: StoryStatus:
type: string type: string
enum: [CANDIDATE, IN_REVIEW, CONFIRMED, REJECTED, SUPERSEDED] enum: [CANDIDATE, IN_REVIEW, CONFIRMED, REJECTED, SUPERSEDED]
@@ -6627,6 +6718,33 @@ components:
required: [items] required: [items]
properties: properties:
items: { type: array, items: { $ref: '#/components/schemas/ConversationContentSummary' } } 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: StoryResponse:
type: object type: object
required: [request_id, data] required: [request_id, data]
+19
View File
@@ -34,6 +34,13 @@ export type ConversationDetail = components["schemas"]["ConversationDetail"];
export type ConversationUsage = components["schemas"]["ConversationUsage"]; export type ConversationUsage = components["schemas"]["ConversationUsage"];
export type ConversationTurn = components["schemas"]["ConversationTurn"]; export type ConversationTurn = components["schemas"]["ConversationTurn"];
export type ConversationContentSummary = components["schemas"]["ConversationContentSummary"]; 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 ConversationMedia = components["schemas"]["ConversationMedia"];
export type MediaSegment = components["schemas"]["MediaSegment"]; export type MediaSegment = components["schemas"]["MediaSegment"];
export type RecordingAccessGrant = components["schemas"]["RecordingAccessGrant"]; export type RecordingAccessGrant = components["schemas"]["RecordingAccessGrant"];
@@ -306,6 +313,18 @@ export const platformApi = {
authenticatedJSON<Envelope<{ items: ConversationTurn[]; next_cursor?: string }>>("GET", `/api/v1/conversations/${conversationID}/turns?limit=100`, accessToken, signal), authenticatedJSON<Envelope<{ items: ConversationTurn[]; next_cursor?: string }>>("GET", `/api/v1/conversations/${conversationID}/turns?limit=100`, accessToken, signal),
conversationSummaries: (conversationID: string, accessToken: string, signal?: AbortSignal) => conversationSummaries: (conversationID: string, accessToken: string, signal?: AbortSignal) =>
authenticatedJSON<Envelope<{ items: ConversationContentSummary[] }>>("GET", `/api/v1/conversations/${conversationID}/summaries`, accessToken, signal), authenticatedJSON<Envelope<{ items: ConversationContentSummary[] }>>("GET", `/api/v1/conversations/${conversationID}/summaries`, accessToken, signal),
regenerateConversationSummary: (conversationID: string, accessToken: string) =>
authenticatedJSON<Envelope<{ generation_version: number; status: string }>>("POST", `/api/v1/conversations/${conversationID}/summaries:regenerate`, accessToken),
contentModels: (tenantID: string, accessToken: string, signal?: AbortSignal) =>
authenticatedJSON<Envelope<{ items: ContentModel[] }>>("GET", "/api/v1/content-models", accessToken, signal, undefined, { "X-Tenant-Id": tenantID }),
createContentModel: (tenantID: string, input: CreateContentModelRequest, accessToken: string) =>
authenticatedJSON<Envelope<ContentModel>>("POST", "/api/v1/content-models", accessToken, undefined, input, { "X-Tenant-Id": tenantID }),
testContentModel: (tenantID: string, modelID: string, accessToken: string) =>
authenticatedJSON<Envelope<ContentModel>>("POST", `/api/v1/content-models/${modelID}/test`, accessToken, undefined, undefined, { "X-Tenant-Id": tenantID }),
activateContentModel: (tenantID: string, model: ContentModel, accessToken: string) =>
authenticatedJSON<Envelope<ContentModel>>("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<Envelope<ContentModel>>("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) => conversationMedia: (conversationID: string, accessToken: string, signal?: AbortSignal) =>
authenticatedJSON<Envelope<ConversationMedia>>("GET", `/api/v1/conversations/${conversationID}/media`, accessToken, signal), authenticatedJSON<Envelope<ConversationMedia>>("GET", `/api/v1/conversations/${conversationID}/media`, accessToken, signal),
recordingAccessGrants: (subjectID: string, accessToken: string, signal?: AbortSignal) => recordingAccessGrants: (subjectID: string, accessToken: string, signal?: AbortSignal) =>
+306
View File
@@ -37,6 +37,70 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/api/v1/ops/config-versions": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1892,6 +1956,24 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/api/v1/subjects/{id}/stories": {
parameters: { parameters: {
query?: never; query?: never;
@@ -5259,6 +5341,48 @@ export interface components {
/** Format: date-time */ /** Format: date-time */
created_at: string; 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} */ /** @enum {string} */
StoryStatus: "CANDIDATE" | "IN_REVIEW" | "CONFIRMED" | "REJECTED" | "SUPERSEDED"; StoryStatus: "CANDIDATE" | "IN_REVIEW" | "CONFIRMED" | "REJECTED" | "SUPERSEDED";
StorySource: { StorySource: {
@@ -6104,6 +6228,25 @@ export interface components {
items: components["schemas"]["ConversationContentSummary"][]; 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: { StoryResponse: {
request_id: string; request_id: string;
data: components["schemas"]["Story"]; data: components["schemas"]["Story"];
@@ -6366,6 +6509,145 @@ export interface operations {
403: components["responses"]["AccessDeniedError"]; 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: { listOpsConfigVersions: {
parameters: { parameters: {
query?: { query?: {
@@ -10534,6 +10816,30 @@ export interface operations {
403: components["responses"]["AccessDeniedError"]; 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: { listSubjectStories: {
parameters: { parameters: {
query?: { query?: {
+7 -2
View File
@@ -42,6 +42,7 @@ import {
SafetyCertificateOutlined, SafetyCertificateOutlined,
SearchOutlined, SearchOutlined,
SettingOutlined, SettingOutlined,
RobotOutlined,
TeamOutlined, TeamOutlined,
UserOutlined UserOutlined
} from "@ant-design/icons"; } from "@ant-design/icons";
@@ -61,6 +62,7 @@ const sectionMeta: Record<PortalSection, { title: string; description: string; b
privacy: { title: "隐私请求", description: "申请访问、导出或删除个人数据,并跟踪处理状态。", breadcrumb: "授权与隐私 / 隐私请求" }, privacy: { title: "隐私请求", description: "申请访问、导出或删除个人数据,并跟踪处理状态。", breadcrumb: "授权与隐私 / 隐私请求" },
billing: { title: "用量与费用", description: "查看本家庭及设备的通话时长;存在已核价记录时可切换费用。", breadcrumb: "用量与费用" }, billing: { title: "用量与费用", description: "查看本家庭及设备的通话时长;存在已核价记录时可切换费用。", breadcrumb: "用量与费用" },
messages: { title: "日程与提醒", description: "查看陪伴动态、设备状态和需要处理的家庭提醒。", breadcrumb: "日程与提醒" }, messages: { title: "日程与提醒", description: "查看陪伴动态、设备状态和需要处理的家庭提醒。", breadcrumb: "日程与提醒" },
models: { title: "大模型管理", description: "配置、测试并激活用于聊天总结、关键词和故事候选生成的大模型。", breadcrumb: "设置与权限 / 大模型管理" },
profile: { title: "设置与权限", description: "查看登录身份、家庭角色和全部权限范围。", breadcrumb: "设置与权限" } profile: { title: "设置与权限", description: "查看登录身份、家庭角色和全部权限范围。", breadcrumb: "设置与权限" }
}; };
@@ -97,6 +99,7 @@ export function CustomerPortal() {
] ]
}, },
{ key: "billing", icon: <CreditCardOutlined />, label: "服务用量" }, { key: "billing", icon: <CreditCardOutlined />, label: "服务用量" },
{ key: "models", icon: <RobotOutlined />, label: "大模型管理" },
{ key: "profile", icon: <SettingOutlined />, label: "设置与权限" } { key: "profile", icon: <SettingOutlined />, label: "设置与权限" }
], []); ], []);
@@ -120,6 +123,7 @@ export function CustomerPortal() {
[["分享", "share"], "sharing"], [["分享", "share"], "sharing"],
[["费用", "额度", "账单", "cost"], "billing"], [["费用", "额度", "账单", "cost"], "billing"],
[["消息", "通知", "提醒"], "messages"], [["消息", "通知", "提醒"], "messages"],
[["大模型", "模型", "豆包", "总结"], "models"],
[["长者", "老人", "档案", "subject"], "subjects"], [["长者", "老人", "档案", "subject"], "subjects"],
[["家庭", "成员", "邀请", "member"], "members"] [["家庭", "成员", "邀请", "member"], "members"]
]; ];
@@ -259,11 +263,12 @@ const permissionRows = [
["privacy.read", "查看隐私请求", true, true, false, false], ["privacy.read", "查看隐私请求", true, true, false, false],
["privacy.manage", "处理隐私请求", true, false, false, false], ["privacy.manage", "处理隐私请求", true, false, false, false],
["recording.play", "播放聊天录音", false, 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; ] as const;
function PermissionMatrix() { function PermissionMatrix() {
return <Card className="permission-matrix-card" title="全部权限说明" extra={<Tag>14 项</Tag>}> return <Card className="permission-matrix-card" title="全部权限说明" extra={<Tag>15 项</Tag>}>
<p className="permission-matrix-note">家庭角色决定一般操作权限;播放和下载录音属于敏感权限,必须由所有者或管理员按长者、用途和有效期单独授权。</p> <p className="permission-matrix-note">家庭角色决定一般操作权限;播放和下载录音属于敏感权限,必须由所有者或管理员按长者、用途和有效期单独授权。</p>
<div className="permission-matrix-wrap"><table className="permission-matrix"><thead><tr><th>权限</th><th>所有者</th><th>管理员</th><th>编辑者</th><th>查看者</th></tr></thead><tbody>{permissionRows.map(([code, label, owner, admin, editor, viewer]) => <tr key={code}><th><strong>{label}</strong><small>{code}</small></th>{[owner, admin, editor, viewer].map((enabled, index) => <td key={index} className={enabled ? "is-enabled" : ""}>{enabled ? <CheckOutlined aria-label="包含" /> : <MinusOutlined aria-label="不包含" />}</td>)}</tr>)}</tbody></table></div> <div className="permission-matrix-wrap"><table className="permission-matrix"><thead><tr><th>权限</th><th>所有者</th><th>管理员</th><th>编辑者</th><th>查看者</th></tr></thead><tbody>{permissionRows.map(([code, label, owner, admin, editor, viewer]) => <tr key={code}><th><strong>{label}</strong><small>{code}</small></th>{[owner, admin, editor, viewer].map((enabled, index) => <td key={index} className={enabled ? "is-enabled" : ""}>{enabled ? <CheckOutlined aria-label="包含" /> : <MinusOutlined aria-label="不包含" />}</td>)}</tr>)}</tbody></table></div>
</Card>; </Card>;
+108 -13
View File
@@ -24,6 +24,8 @@ import {
UserAddOutlined, UserAddOutlined,
UserOutlined, UserOutlined,
UploadOutlined, UploadOutlined,
RobotOutlined,
CheckCircleOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { import {
APIRequestError, APIRequestError,
@@ -38,6 +40,8 @@ import {
type ConversationSummary, type ConversationSummary,
type ConversationTurn, type ConversationTurn,
type ConversationMedia, type ConversationMedia,
type ContentModel,
type CreateContentModelRequest,
type CreateSubjectFactRequest, type CreateSubjectFactRequest,
type CreateSubjectRequest, type CreateSubjectRequest,
type CuratedMemory, type CuratedMemory,
@@ -71,7 +75,7 @@ import {
} from "../api/client"; } from "../api/client";
import { FamilyUsageOverview } from "../components/UsageOverview"; 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 Tab = CustomerSection | "firmware";
type LoadState = "idle" | "loading" | "ready" | "error"; type LoadState = "idle" | "loading" | "ready" | "error";
type MediaLoadState = "idle" | "loading" | "ready" | "restricted" | "error"; type MediaLoadState = "idle" | "loading" | "ready" | "restricted" | "error";
@@ -191,6 +195,9 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
const [conversationUsage, setConversationUsage] = useState<ConversationUsage>(); const [conversationUsage, setConversationUsage] = useState<ConversationUsage>();
const [turns, setTurns] = useState<ConversationTurn[]>([]); const [turns, setTurns] = useState<ConversationTurn[]>([]);
const [contentSummaries, setContentSummaries] = useState<ConversationContentSummary[]>([]); const [contentSummaries, setContentSummaries] = useState<ConversationContentSummary[]>([]);
const [contentModels, setContentModels] = useState<ContentModel[]>([]);
const [modelLoading, setModelLoading] = useState(false);
const [modelError, setModelError] = useState<string>();
const [conversationInsights, setConversationInsights] = useState<Record<string, ConversationInsight>>({}); const [conversationInsights, setConversationInsights] = useState<Record<string, ConversationInsight>>({});
const [conversationMedia, setConversationMedia] = useState<ConversationMedia>(); const [conversationMedia, setConversationMedia] = useState<ConversationMedia>();
const [mediaState, setMediaState] = useState<MediaLoadState>("idle"); const [mediaState, setMediaState] = useState<MediaLoadState>("idle");
@@ -274,9 +281,13 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
} }
const scheduleEventRefresh = useCallback(() => { const scheduleEventRefresh = useCallback(() => {
if (eventRefreshTimer.current !== undefined) return; // Event streams can replay a backlog when the page reconnects. Debounce the
const minimumIntervalMS = 2000; // authoritative REST refresh until that burst settles; otherwise every
const delay = Math.max(0, minimumIntervalMS - (Date.now() - lastEventRefreshAt.current)); // 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 = window.setTimeout(() => {
eventRefreshTimer.current = undefined; eventRefreshTimer.current = undefined;
lastEventRefreshAt.current = Date.now(); lastEventRefreshAt.current = Date.now();
@@ -511,15 +522,21 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
return; return;
} }
const controller = new AbortController(); const controller = new AbortController();
void Promise.all(recent.map(async (conversation) => { void (async () => {
const items: Array<readonly [string, ConversationInsight]> = [];
// 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 { try {
const response = await platformApi.conversationSummaries(conversation.id, accessToken, controller.signal); const response = await platformApi.conversationSummaries(conversation.id, accessToken, controller.signal);
return [conversation.id, inferConversationInsight(response.data.items)] as const; items.push([conversation.id, inferConversationInsight(response.data.items)] as const);
} catch (cause) { } catch (cause) {
if (cause instanceof DOMException && cause.name === "AbortError") throw cause; if (cause instanceof DOMException && cause.name === "AbortError") throw cause;
return [conversation.id, inferConversationInsight([])] as const; items.push([conversation.id, inferConversationInsight([])] as const);
} }
})).then((items) => { }
return items;
})().then((items) => {
if (!controller.signal.aborted) setConversationInsights(Object.fromEntries(items)); if (!controller.signal.aborted) setConversationInsights(Object.fromEntries(items));
}).catch((cause) => { }).catch((cause) => {
if (!(cause instanceof DOMException && cause.name === "AbortError")) setConversationInsights({}); 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 canManageFirmware = enableFirmware && (selectedHousehold?.capabilities.includes("firmware.manage") ?? false);
const canManageRecordingAccess = selectedHousehold?.role === "owner" || selectedHousehold?.role === "admin"; const canManageRecordingAccess = selectedHousehold?.role === "owner" || selectedHousehold?.role === "admin";
const canManageSharing = 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 policyVersion = accounts.find((account) => account.tenant_id === selectedHousehold?.tenant_id)?.policy_version ?? "";
const consentNotice = `我已阅读隐私政策${policyVersion || "当前"}版本,并同意对后续新通话启用“${consentLabel(newConsentType)}”。${consentImpactLabel(newConsentType)}我知道该授权可以单独撤回。`; const consentNotice = `我已阅读隐私政策${policyVersion || "当前"}版本,并同意对后续新通话启用“${consentLabel(newConsentType)}”。${consentImpactLabel(newConsentType)}我知道该授权可以单独撤回。`;
const selectedConsentAlreadyEffective = consents.some((item) => item.type === newConsentType && item.effective); const selectedConsentAlreadyEffective = consents.some((item) => item.type === newConsentType && item.effective);
useEffect(() => { setFamilyControlDraft(undefined); setClaimCode(""); setAcceptInvitationToken(""); }, [tab, selectedHouseholdID, selectedSubjectID, selectedConversationID]); 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(() => { useEffect(() => {
if (!selectedSubjectID || !canManageRecordingAccess) { if (!selectedSubjectID || !canManageRecordingAccess) {
setRecordingGrants([]); setRecordingGrants([]);
@@ -1408,8 +1460,11 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
to={conversationTo} onTo={(value) => { clearConversationDetail(); setConversations([]); setConversationTo(value); }} to={conversationTo} onTo={(value) => { clearConversationDetail(); setConversations([]); setConversationTo(value); }}
hasMore={Boolean(conversationNextCursor)} onResetFilters={resetConversationFilters} onLoadMore={loadMoreConversations} hasMore={Boolean(conversationNextCursor)} onResetFilters={resetConversationFilters} onLoadMore={loadMoreConversations}
onOpen={openConversation} onGrant={grantRecordingAccess} onOpen={openConversation} onGrant={grantRecordingAccess}
activeModel={contentModels.find((item) => item.status === "active")} onRegenerate={regenerateSummary}
stories={stories} onOpenStories={() => selectSection("stories")}
onRevoke={revokeRecordingAccess} onAccess={accessRecording} /> onRevoke={revokeRecordingAccess} onAccess={accessRecording} />
) : null} ) : null}
{tab === "models" ? <ContentModelPanel models={contentModels} loading={modelLoading} error={modelError} canManage={canManageModels} actionBusy={actionBusy} onCreate={createContentModel} onTest={testContentModel} onActivate={activateContentModel} onRevoke={revokeContentModel} /> : null}
{tab === "stories" ? <StoryPanel selectedID={selectedSubjectID} {tab === "stories" ? <StoryPanel selectedID={selectedSubjectID}
stories={stories} memories={memories} selected={selectedStory} loading={subjectLoading} actionBusy={actionBusy} canReview={canReviewStory} stories={stories} memories={memories} selected={selectedStory} loading={subjectLoading} actionBusy={actionBusy} canReview={canReviewStory}
onOpen={openStory} onOpenConversation={openStoryConversation} onRevise={reviseStory} onConfirm={confirmStory} onReject={rejectStory} onUpdateFact={updateFactCandidate} onUpdateMemory={updateMemory} onDeleteMemory={deleteMemory} /> : null} onOpen={openStory} onOpenConversation={openStoryConversation} onRevise={reviseStory} onConfirm={confirmStory} onReject={rejectStory} onUpdateFact={updateFactCandidate} onUpdateMemory={updateMemory} onDeleteMemory={deleteMemory} /> : null}
@@ -1526,7 +1581,7 @@ function CustomerMessagePanel({ notices, devices, presence, stories, privacyRequ
} }
function customerSectionTitle(section: Tab) { function customerSectionTitle(section: Tab) {
return ({ home: "陪伴周记", members: "家人共享", subjects: "长者资料", devices: "陪伴设备", firmware: "固件发布", conversations: "聊天记录", stories: "故事与记忆", sharing: "内容分享", privacy: "隐私与授权", billing: "服务用量", messages: "日程与提醒" } as Record<Tab, string>)[section]; return ({ home: "陪伴周记", members: "家人共享", subjects: "长者资料", devices: "陪伴设备", firmware: "固件发布", conversations: "回忆时间轴", stories: "故事与记忆", sharing: "内容分享", privacy: "隐私与授权", billing: "服务用量", messages: "日程与提醒", models: "大模型管理" } as Record<Tab, string>)[section];
} }
function customerSectionDescription(section: Tab) { function customerSectionDescription(section: Tab) {
@@ -1541,7 +1596,8 @@ function customerSectionDescription(section: Tab) {
sharing: "按资源和权限范围向指定家庭成员分享内容。", sharing: "按资源和权限范围向指定家庭成员分享内容。",
privacy: "提交访问、导出或删除申请并跟踪处理进度。", privacy: "提交访问、导出或删除申请并跟踪处理进度。",
billing: "汇总本家庭及设备的通话时长,并在核价可用时展示费用。", billing: "汇总本家庭及设备的通话时长,并在核价可用时展示费用。",
messages: "集中查看陪伴动态、设备、故事和隐私请求的状态提醒。" messages: "集中查看陪伴动态、设备、故事和隐私请求的状态提醒。",
models: "配置、测试并激活用于聊天摘要、关键词和故事候选生成的大模型。"
} as Record<Tab, string>)[section]; } as Record<Tab, string>)[section];
} }
@@ -1991,7 +2047,7 @@ function deviceBindingBlockerLabel(value: string) {
return ({ target_subject_required: "请选择新的使用人", active_conversation: "设备仍有未结束通话", pending_device_commands: "设备仍有未完成命令", active_firmware_task: "设备仍有进行中的固件任务" } as Record<string, string>)[value] ?? value; return ({ target_subject_required: "请选择新的使用人", active_conversation: "设备仍有未结束通话", pending_device_commands: "设备仍有未完成命令", active_firmware_task: "设备仍有进行中的固件任务" } as Record<string, string>)[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<string, ConversationInsight>; subjects: Subject[]; selectedID?: string; onSelect: (id: string) => void; conversations: ConversationSummary[]; insights: Record<string, ConversationInsight>;
detail?: ConversationDetail; usage?: ConversationUsage; turns: ConversationTurn[]; summaries: ConversationContentSummary[]; detail?: ConversationDetail; usage?: ConversationUsage; turns: ConversationTurn[]; summaries: ConversationContentSummary[];
media?: ConversationMedia; mediaState: MediaLoadState; mediaError?: string; grants: RecordingAccessGrant[]; members: HouseholdMember[]; 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; onGrant: (input: RecordingGrantInput) => void;
onRevoke: (grant: RecordingAccessGrant) => Promise<void>; onRevoke: (grant: RecordingAccessGrant) => Promise<void>;
onAccess: (segment: RecordingSegment, action: "PLAY" | "DOWNLOAD", purpose: string) => void; onAccess: (segment: RecordingSegment, action: "PLAY" | "DOWNLOAD", purpose: string) => void;
activeModel?: ContentModel; onRegenerate: () => Promise<void>;
stories: Story[]; onOpenStories: () => void;
}) { }) {
const [detailOpen, setDetailOpen] = useState(false); const [detailOpen, setDetailOpen] = useState(false);
return <div className="conversation-layout"> 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 <div className="conversation-layout memory-timeline-page">
<div className="memory-timeline-grid">
<div className="conversation-list"> <div className="conversation-list">
<div className="conversation-filter"> <div className="conversation-filter">
<div className="conversation-filter__fields"> <div className="conversation-filter__fields">
@@ -2018,9 +2082,22 @@ function ConversationPanel({ subjects, selectedID, onSelect, conversations, insi
<div className="conversation-filter__summary"><span>已加载 {conversations.length} 条{hasMore ? ",还有更多" : ""}</span>{status || engineMode || from || to ? <button type="button" className="button-small button-secondary" onClick={onResetFilters}>清除筛选</button> : null}</div> <div className="conversation-filter__summary"><span>已加载 {conversations.length} 条{hasMore ? ",还有更多" : ""}</span>{status || engineMode || from || to ? <button type="button" className="button-small button-secondary" onClick={onResetFilters}>清除筛选</button> : null}</div>
</div> </div>
{error ? <div className="conversation-list__error" role="alert">{error}</div> : null} {error ? <div className="conversation-list__error" role="alert">{error}</div> : null}
{loading && conversations.length === 0 ? <StatePanel title="正在读取聊天记录" detail="按时间倒序加载…" /> : conversations.length ? conversations.map((item) => { const insight = insights[item.id]; return <button type="button" className={selectedConversationID === item.id ? "conversation-row active" : "conversation-row"} key={item.id} disabled={detailLoading && selectedConversationID === item.id} onClick={() => { setDetailOpen(true); void onOpen(item.id); }}><span className={`conversation-row__mood conversation-row__mood--${insight?.mood ?? "steady"}`}><SmileOutlined /></span><div><strong>{formatDate(item.created_at)} · {formatDuration(item.duration_ms)}</strong><span>{insight?.summary || conversationFailureLabel(item.failure_code) || "摘要整理中"}</span>{insight?.topics.length ? <small>{insight.topics.join(" · ")}</small> : null}</div><span className={`tag tag--${item.status.toLowerCase()}`}>{conversationStatusLabel(item.status)}</span></button>; }) : <p className="empty-copy padded">暂无聊天记录。</p>} {loading && conversations.length === 0 ? <StatePanel title="正在读取聊天记录" detail="按时间倒序加载…" /> : conversations.length ? <div className="memory-timeline-list">{conversations.map((item) => { const insight = insights[item.id]; return <button type="button" className={selectedConversationID === item.id ? "conversation-row active" : "conversation-row"} key={item.id} disabled={detailLoading && selectedConversationID === item.id} onClick={() => void onOpen(item.id)}><span className="memory-timeline-dot" /><span className={`conversation-row__mood conversation-row__mood--${insight?.mood ?? "steady"}`}><SmileOutlined /></span><div><strong>{formatDate(item.created_at)} · {formatDuration(item.duration_ms)}</strong><span>{insight?.summary || conversationFailureLabel(item.failure_code) || "摘要整理中"}</span>{insight?.topics.length ? <small>{insight.topics.join(" · ")}</small> : null}</div><span className={`tag tag--${item.status.toLowerCase()}`}>{conversationStatusLabel(item.status)}</span></button>; })}</div> : <p className="empty-copy padded">暂无聊天记录。</p>}
{hasMore ? <div className="conversation-list__more"><button type="button" className="button-secondary" disabled={loading || loadingMore} onClick={() => void onLoadMore()}>{loadingMore ? "正在加载…" : "加载更多记录"}</button></div> : null} {hasMore ? <div className="conversation-list__more"><button type="button" className="button-secondary" disabled={loading || loadingMore} onClick={() => void onLoadMore()}>{loadingMore ? "正在加载…" : "加载更多记录"}</button></div> : null}
</div> </div>
<section className="memory-summary-panel" aria-label="智能总结">
{detailLoading ? <StatePanel title="正在生成回忆卡片" detail="读取聊天、摘要和故事候选…" /> : detail ? <>
<header><div><p className="eyebrow">AI MEMORY NOTE</p><h3>这次聊天,长者想起了什么</h3></div><span className="memory-summary-panel__mood"><SmileOutlined /> {insights[detail.id]?.moodLabel ?? "平稳"}</span></header>
<div className="memory-summary-panel__meta"><span>{formatDate(detail.created_at)}</span><span>{formatDuration(detail.duration_ms)}</span><span>来源 {currentSummary?.source_turn_ids.length ?? turns.length} 条话轮</span></div>
<article className="memory-summary-block"><h4>聊天摘要</h4><p>{summaryParts[0] || insights[detail.id]?.summary || "大模型总结正在生成中。"}</p></article>
<article className="memory-summary-block"><h4>关键词</h4><div className="memory-keywords">{keywords.length ? keywords.map((keyword) => <span key={keyword}>{keyword}</span>) : <span>等待提取</span>}</div></article>
<article className="memory-summary-block memory-summary-block--story"><h4>故事候选(节选)</h4><strong>{storyCandidate?.title || "故事候选正在整理"}</strong><p>{storyCandidate?.content || "总结完成后,系统会生成一段可由家人审核的故事候选;未经确认不会写入长期记忆。"}</p></article>
<article className="memory-summary-block memory-summary-block--warm"><h4>下次可以接着聊</h4><p>{currentTopic?.content || "待总结完成后生成温和的后续话题。"}</p></article>
<footer><button type="button" className="button-secondary" onClick={() => setDetailOpen(true)}>查看完整聊天</button>{storyCandidate ? <button type="button" onClick={onOpenStories}>审核并保存为故事</button> : null}<button type="button" onClick={() => void onRegenerate()} disabled={actionBusy || !activeModel}>{actionBusy ? "正在提交…" : "重新生成总结"}</button></footer>
</> : <div className="memory-summary-empty"><BookOutlined /><h3>选择一段聊天</h3><p>这里会展示大模型生成的摘要、关键词、故事线索和后续话题。</p></div>}
</section>
<aside className="memory-model-card"><span className="memory-model-card__icon"><RobotOutlined /></span><p>当前总结模型</p><h3>{activeModel?.display_name || "系统默认模型"}</h3><small>{activeModel ? `${activeModel.provider === "ark" ? "火山方舟" : "OpenAI 兼容"} · ${activeModel.model_id}` : "尚未激活自定义模型,将使用部署环境默认配置"}</small><div><span className={activeModel ? "is-active" : ""} />{activeModel ? "已激活" : "默认回退"}</div>{currentSummary ? <p className="memory-model-card__history"><CheckCircleOutlined /> 最近结果由<br /><strong>{currentSummary.model_ref}</strong><br />生成版本 v{currentSummary.generation_version}</p> : null}</aside>
</div>
{detailOpen ? <div className="workspace-dialog-backdrop" role="presentation" onMouseDown={(event) => { if (event.currentTarget === event.target) setDetailOpen(false); }}><section className="conversation-detail workspace-dialog workspace-dialog--wide" role="dialog" aria-modal="true" aria-labelledby="conversation-detail-title"><header><div><h3 id="conversation-detail-title">通话详情</h3><p>查看文字内容、整理结果、语音授权与状态历史</p></div><button type="button" aria-label="关闭" onClick={() => setDetailOpen(false)}><CloseOutlined /></button></header><div className="workspace-dialog__body">{detailLoading ? <StatePanel title="正在读取通话详情" detail="正在核验状态、最终话轮、整理结果、用量和录音访问权限…" /> : detailError && selectedConversationID ? <StatePanel title="通话详情读取失败" detail={detailError} action={() => void onOpen(selectedConversationID)} /> : detail ? <> {detailOpen ? <div className="workspace-dialog-backdrop" role="presentation" onMouseDown={(event) => { if (event.currentTarget === event.target) setDetailOpen(false); }}><section className="conversation-detail workspace-dialog workspace-dialog--wide" role="dialog" aria-modal="true" aria-labelledby="conversation-detail-title"><header><div><h3 id="conversation-detail-title">通话详情</h3><p>查看文字内容、整理结果、语音授权与状态历史</p></div><button type="button" aria-label="关闭" onClick={() => setDetailOpen(false)}><CloseOutlined /></button></header><div className="workspace-dialog__body">{detailLoading ? <StatePanel title="正在读取通话详情" detail="正在核验状态、最终话轮、整理结果、用量和录音访问权限…" /> : detailError && selectedConversationID ? <StatePanel title="通话详情读取失败" detail={detailError} action={() => void onOpen(selectedConversationID)} /> : detail ? <>
<div className="resource-detail__head"><div><p className="eyebrow">CONVERSATION DETAIL</p><h3>{conversationStatusLabel(detail.status)}</h3><p>{formatDate(detail.created_at)} · {detail.duration_ms ? `${Math.round(detail.duration_ms / 1000)}秒` : "时长待确认"}</p></div></div> <div className="resource-detail__head"><div><p className="eyebrow">CONVERSATION DETAIL</p><h3>{conversationStatusLabel(detail.status)}</h3><p>{formatDate(detail.created_at)} · {detail.duration_ms ? `${Math.round(detail.duration_ms / 1000)}秒` : "时长待确认"}</p></div></div>
{detail.failure_code ? <p className="recording-notice recording-notice--error" role="alert"><strong>{conversationFailureLabel(detail.failure_code) ?? "通话准备失败"}</strong><br />设备收到的失败标识:{detail.failure_code};背景准备状态:{detail.snapshots.context_preparation_status}</p> : null} {detail.failure_code ? <p className="recording-notice recording-notice--error" role="alert"><strong>{conversationFailureLabel(detail.failure_code) ?? "通话准备失败"}</strong><br />设备收到的失败标识:{detail.failure_code};背景准备状态:{detail.snapshots.context_preparation_status}</p> : null}
@@ -2036,6 +2113,24 @@ function ConversationPanel({ subjects, selectedID, onSelect, conversations, insi
</div>; </div>;
} }
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<boolean>; onTest: (item: ContentModel) => Promise<void>;
onActivate: (item: ContentModel) => Promise<void>; onRevoke: (item: ContentModel) => Promise<void>;
}) {
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState<CreateContentModelRequest>({ 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 <div className="model-management-page">
<section className="model-active-hero"><div><span className="model-active-hero__icon"><RobotOutlined /></span><div><p>当前总结引擎</p><h3>{active?.display_name || "系统默认模型"}</h3><span>{active ? `${active.model_id} · 下一条总结任务立即生效` : "配置并激活模型后,聊天摘要、关键词和故事候选将由它生成"}</span></div></div>{canManage ? <button type="button" onClick={() => setOpen(true)}><PlusOutlined /> 添加大模型</button> : null}</section>
{error ? <div className="workspace__inline-error" role="alert">{error}</div> : null}
{loading ? <StatePanel title="正在读取模型配置" detail="密钥不会回显,仅展示末四位提示。" /> : <section className="model-card-grid">{models.length ? models.map((item) => <article className={item.status === "active" ? "model-config-card is-active" : "model-config-card"} key={item.id}><header><span><RobotOutlined /></span><div><h3>{item.display_name}</h3><p>{item.provider === "ark" ? "火山方舟 / 豆包" : "OpenAI 兼容服务"}</p></div><b>{item.status === "active" ? "正在使用" : "备用"}</b></header><dl><div><dt>模型 ID</dt><dd>{item.model_id}</dd></div><div><dt>接口地址</dt><dd>{item.base_url}</dd></div><div><dt>访问密钥</dt><dd>{item.credential_hint}</dd></div><div><dt>生成参数</dt><dd>温度 {item.temperature} · 最多 {item.max_tokens} tokens</dd></div></dl><footer><span className={`model-test-status model-test-status--${item.test_status}`}>{item.test_status === "passed" ? "连通性正常" : item.test_status === "failed" ? `测试失败 · ${item.last_error_code || "请检查配置"}` : "尚未测试"}</span>{canManage ? <div><button type="button" className="button-secondary" disabled={actionBusy} onClick={() => void onTest(item)}>测试连接</button>{item.status !== "active" ? <button type="button" disabled={actionBusy || item.test_status !== "passed"} onClick={() => void onActivate(item)}>设为当前模型</button> : null}{item.status !== "active" ? <button type="button" className="button-danger" disabled={actionBusy} onClick={() => void onRevoke(item)}>移除</button> : null}</div> : null}</footer></article>) : <div className="model-empty"><RobotOutlined /><h3>还没有自定义总结模型</h3><p>添加火山方舟或其他 OpenAI 兼容模型,测试通过后即可激活。</p>{canManage ? <button type="button" onClick={() => setOpen(true)}>添加第一个模型</button> : null}</div>}</section>}
<section className="model-security-note"><SafetyCertificateOutlined /><div><strong>密钥如何保存</strong><p>API Key 使用独立主密钥经 AES-256-GCM 加密后存入数据库,页面和接口都不会再次回显明文;每次激活、测试和移除都会写入审计记录。</p></div></section>
{open ? <div className="workspace-dialog-backdrop" role="presentation" onMouseDown={(event) => { if (event.currentTarget === event.target) setOpen(false); }}><form className="workspace-dialog model-create-dialog" role="dialog" aria-modal="true" onSubmit={(event) => void submit(event)}><header><div><h3>添加总结大模型</h3><p>该模型用于生成聊天摘要、关键词、故事和下次话题。</p></div><button type="button" aria-label="关闭" onClick={() => setOpen(false)}><CloseOutlined /></button></header><div className="workspace-dialog__body"><label><span>配置名称</span><input autoFocus required maxLength={120} value={draft.display_name} onChange={(event) => setDraft({ ...draft, display_name:event.target.value })} placeholder="例如:豆包聊天总结" /></label><label><span>服务类型</span><select value={draft.provider} onChange={(event) => setDraft({ ...draft, provider:event.target.value as ContentModel["provider"] })}><option value="ark">火山方舟 / 豆包</option><option value="openai_compatible">OpenAI 兼容接口</option></select></label><label><span>接口基础地址</span><input required type="url" value={draft.base_url} onChange={(event) => setDraft({ ...draft, base_url:event.target.value })} /></label><label><span>模型或 Endpoint ID</span><input required value={draft.model_id} onChange={(event) => setDraft({ ...draft, model_id:event.target.value })} placeholder="ep-xxxxxxxx" /></label><label><span>API Key</span><input required type="password" minLength={8} autoComplete="new-password" value={draft.api_key} onChange={(event) => setDraft({ ...draft, api_key:event.target.value })} placeholder="只在本次保存时使用" /></label><div className="model-parameter-row"><label><span>温度</span><input type="number" min="0" max="2" step="0.1" value={draft.temperature} onChange={(event) => setDraft({ ...draft, temperature:Number(event.target.value) })} /></label><label><span>最大输出</span><input type="number" min="256" max="8192" step="128" value={draft.max_tokens} onChange={(event) => setDraft({ ...draft, max_tokens:Number(event.target.value) })} /></label></div></div><footer><button type="button" className="button-secondary" onClick={() => setOpen(false)}>取消</button><button type="submit" disabled={actionBusy || !draft.display_name.trim() || !draft.model_id.trim() || draft.api_key.length < 8}>{actionBusy ? "正在加密保存…" : "保存配置"}</button></footer></form></div> : null}
</div>;
}
function RecordingMediaBlock({ retentionMode, media, state, error, grants, members, canManage, actionBusy, audioURLs, onGrant, onRevoke, onAccess }: { 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[]; retentionMode: string; media?: ConversationMedia; state: MediaLoadState; error?: string; grants: RecordingAccessGrant[]; members: HouseholdMember[];
canManage: boolean; actionBusy: boolean; audioURLs: Record<string, string>; canManage: boolean; actionBusy: boolean; audioURLs: Record<string, string>;
+114
View File
@@ -39,6 +39,100 @@ main {
padding: 5rem 0; 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 { .hero {
display: flex; display: flex;
align-items: end; align-items: end;
@@ -3442,3 +3536,23 @@ textarea:focus,
.recent-activity > div > button > .anticon { grid-column: 1; grid-row: 1 / 4; } .recent-activity > div > button > .anticon { grid-column: 1; grid-row: 1 / 4; }
.recent-activity time,.recent-activity strong,.recent-activity span { grid-column: 2; } .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; }
}