feat: add managed llm summaries and memory timeline
This commit is contained in:
@@ -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}
|
||||||
|
|||||||
@@ -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)"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
package contentmodel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"data-terminal-cloud/internal/audit"
|
||||||
|
"data-terminal-cloud/internal/database"
|
||||||
|
"data-terminal-cloud/internal/extraction"
|
||||||
|
"data-terminal-cloud/internal/identity"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrAccessDenied = errors.New("content model access denied")
|
||||||
|
ErrNotFound = errors.New("content model not found")
|
||||||
|
ErrInvalid = errors.New("invalid content model configuration")
|
||||||
|
ErrVersion = errors.New("content model version conflict")
|
||||||
|
ErrTestRequired = errors.New("content model must pass a connection test before activation")
|
||||||
|
ErrActiveRevoke = errors.New("active content model must be replaced before revocation")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Model struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
TenantID string `json:"tenant_id"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
ModelID string `json:"model_id"`
|
||||||
|
CredentialHint string `json:"credential_hint"`
|
||||||
|
Temperature float64 `json:"temperature"`
|
||||||
|
MaxTokens int `json:"max_tokens"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
TestStatus string `json:"test_status"`
|
||||||
|
LastTestedAt *time.Time `json:"last_tested_at,omitempty"`
|
||||||
|
LastErrorCode string `json:"last_error_code,omitempty"`
|
||||||
|
ActivatedAt *time.Time `json:"activated_at,omitempty"`
|
||||||
|
Version int64 `json:"version"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateInput struct {
|
||||||
|
TenantID string `json:"tenant_id"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
ModelID string `json:"model_id"`
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
Temperature float64 `json:"temperature"`
|
||||||
|
MaxTokens int `json:"max_tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Registry struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
aead cipher.AEAD
|
||||||
|
fallback extraction.Generator
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
registry *Registry
|
||||||
|
audit *audit.Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
type storedModel struct {
|
||||||
|
Model
|
||||||
|
tenantInternalID string
|
||||||
|
encrypted []byte
|
||||||
|
nonce []byte
|
||||||
|
keyVersion int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type actor struct {
|
||||||
|
tenantID, tenantPublicID, userID, userPublicID string
|
||||||
|
canManage bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRegistry(pool *pgxpool.Pool, masterKeyHex string, fallback extraction.Generator) (*Registry, error) {
|
||||||
|
if pool == nil || fallback == nil {
|
||||||
|
return nil, errors.New("content model database and fallback generator are required")
|
||||||
|
}
|
||||||
|
key, err := hex.DecodeString(strings.TrimSpace(masterKeyHex))
|
||||||
|
if err != nil || len(key) != 32 {
|
||||||
|
return nil, errors.New("content model master key must be 32 bytes encoded as 64 hexadecimal characters")
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create content model cipher: %w", err)
|
||||||
|
}
|
||||||
|
aead, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create content model AEAD: %w", err)
|
||||||
|
}
|
||||||
|
return &Registry{pool: pool, aead: aead, fallback: fallback}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(registry *Registry, auditRepository *audit.Repository) (*Service, error) {
|
||||||
|
if registry == nil || auditRepository == nil {
|
||||||
|
return nil, errors.New("content model registry and audit repository are required")
|
||||||
|
}
|
||||||
|
return &Service{registry: registry, audit: auditRepository}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate resolves the active tenant model at execution time. This makes an
|
||||||
|
// activation change effective for the next summary job without restarting the worker.
|
||||||
|
func (r *Registry) Generate(ctx context.Context, input extraction.GenerationInput) (extraction.GenerationResult, error) {
|
||||||
|
if strings.TrimSpace(input.TenantID) == "" {
|
||||||
|
return r.fallback.Generate(ctx, input)
|
||||||
|
}
|
||||||
|
item, err := r.active(ctx, input.TenantID)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return r.fallback.Generate(ctx, input)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return extraction.GenerationResult{}, err
|
||||||
|
}
|
||||||
|
generator, err := r.generator(item)
|
||||||
|
if err != nil {
|
||||||
|
return extraction.GenerationResult{}, err
|
||||||
|
}
|
||||||
|
return generator.Generate(ctx, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) List(ctx context.Context, principal identity.Principal, tenantPublicID string) ([]Model, error) {
|
||||||
|
current, err := s.resolveActor(ctx, principal, tenantPublicID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := s.registry.pool.Query(ctx, modelSelect+` WHERE cm.tenant_id=$1::uuid AND cm.status<>'revoked' ORDER BY (cm.status='active') DESC,cm.updated_at DESC`, current.tenantID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
items := []Model{}
|
||||||
|
for rows.Next() {
|
||||||
|
item, scanErr := scanModel(rows)
|
||||||
|
if scanErr != nil {
|
||||||
|
return nil, scanErr
|
||||||
|
}
|
||||||
|
items = append(items, item.Model)
|
||||||
|
}
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Create(ctx context.Context, principal identity.Principal, input CreateInput, requestID string) (Model, error) {
|
||||||
|
normalizeInput(&input)
|
||||||
|
if err := validateInput(input); err != nil {
|
||||||
|
return Model{}, err
|
||||||
|
}
|
||||||
|
current, err := s.resolveActor(ctx, principal, input.TenantID)
|
||||||
|
if err != nil {
|
||||||
|
return Model{}, err
|
||||||
|
}
|
||||||
|
if !current.canManage {
|
||||||
|
return Model{}, ErrAccessDenied
|
||||||
|
}
|
||||||
|
var result Model
|
||||||
|
err = database.Transact(ctx, s.registry.pool, func(tx pgx.Tx) error {
|
||||||
|
publicID := uuid.NewString()
|
||||||
|
nonce := make([]byte, s.registry.aead.NonceSize())
|
||||||
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
const keyVersion int64 = 1
|
||||||
|
encrypted := s.registry.aead.Seal(nil, nonce, []byte(input.APIKey), modelAAD(current.tenantID, publicID, keyVersion))
|
||||||
|
row := tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO content_model_config(public_id,tenant_id,display_name,provider,base_url,model_id,encrypted_api_key,nonce,key_version,credential_hint,temperature,max_tokens,created_by,updated_by)
|
||||||
|
VALUES($1::uuid,$2::uuid,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13::uuid,$13::uuid)
|
||||||
|
RETURNING public_id::text,$14::text,display_name,provider,base_url,model_id,credential_hint,temperature::float8,max_tokens,status,test_status,last_tested_at,COALESCE(last_error_code,''),activated_at,version,created_at,updated_at,tenant_id::text,encrypted_api_key,nonce,key_version`,
|
||||||
|
publicID, current.tenantID, input.DisplayName, input.Provider, input.BaseURL, input.ModelID, encrypted, nonce, keyVersion, credentialHint(input.APIKey), input.Temperature, input.MaxTokens, current.userID, current.tenantPublicID)
|
||||||
|
item, scanErr := scanModel(row)
|
||||||
|
if scanErr != nil {
|
||||||
|
return scanErr
|
||||||
|
}
|
||||||
|
result = item.Model
|
||||||
|
resourceID := result.ID
|
||||||
|
return s.audit.Record(ctx, tx, audit.Event{TenantID: ¤t.tenantID, ActorType: "user", ActorPublicID: ¤t.userPublicID, Action: "content_model.create", ResourceType: "content_model", ResourceID: &resourceID, RequestID: requestID, Purpose: "配置聊天内容总结模型", Result: "success", Detail: map[string]any{"provider": result.Provider, "model_id": result.ModelID}})
|
||||||
|
})
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Test(ctx context.Context, principal identity.Principal, tenantPublicID, publicID, requestID string) (Model, error) {
|
||||||
|
current, err := s.resolveActor(ctx, principal, tenantPublicID)
|
||||||
|
if err != nil || !current.canManage {
|
||||||
|
return Model{}, ErrAccessDenied
|
||||||
|
}
|
||||||
|
item, err := s.get(ctx, current.tenantID, publicID)
|
||||||
|
if err != nil {
|
||||||
|
return Model{}, err
|
||||||
|
}
|
||||||
|
generator, err := s.registry.generator(item)
|
||||||
|
testStatus, errorCode := "passed", ""
|
||||||
|
if err == nil {
|
||||||
|
_, err = generator.Generate(ctx, extraction.GenerationInput{TenantID: current.tenantID, Content: "模型连通性测试,不包含长者或家庭数据。"})
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
testStatus, errorCode = "failed", normalizedErrorCode(err)
|
||||||
|
}
|
||||||
|
var result Model
|
||||||
|
updateErr := database.Transact(ctx, s.registry.pool, func(tx pgx.Tx) error {
|
||||||
|
row := tx.QueryRow(ctx, modelUpdateReturning+` WHERE cm.tenant_id=$1::uuid AND cm.public_id=$2::uuid`, current.tenantID, publicID, testStatus, errorCode, current.userID, current.tenantPublicID)
|
||||||
|
updated, scanErr := scanModel(row)
|
||||||
|
if scanErr != nil {
|
||||||
|
return scanErr
|
||||||
|
}
|
||||||
|
result = updated.Model
|
||||||
|
resourceID := result.ID
|
||||||
|
return s.audit.Record(ctx, tx, audit.Event{TenantID: ¤t.tenantID, ActorType: "user", ActorPublicID: ¤t.userPublicID, Action: "content_model.test", ResourceType: "content_model", ResourceID: &resourceID, RequestID: requestID, Purpose: "验证总结模型连通性", Result: testStatus, Detail: map[string]any{"error_code": errorCode}})
|
||||||
|
})
|
||||||
|
if updateErr != nil {
|
||||||
|
return Model{}, updateErr
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Activate(ctx context.Context, principal identity.Principal, tenantPublicID, publicID, requestID string, expectedVersion int64) (Model, error) {
|
||||||
|
current, err := s.resolveActor(ctx, principal, tenantPublicID)
|
||||||
|
if err != nil || !current.canManage {
|
||||||
|
return Model{}, ErrAccessDenied
|
||||||
|
}
|
||||||
|
var result Model
|
||||||
|
err = database.Transact(ctx, s.registry.pool, func(tx pgx.Tx) error {
|
||||||
|
var internalID string
|
||||||
|
var version int64
|
||||||
|
var testStatus, status string
|
||||||
|
if err := tx.QueryRow(ctx, `SELECT id::text,version,test_status,status FROM content_model_config WHERE tenant_id=$1::uuid AND public_id=$2::uuid FOR UPDATE`, current.tenantID, publicID).Scan(&internalID, &version, &testStatus, &status); errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return ErrNotFound
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if version != expectedVersion {
|
||||||
|
return ErrVersion
|
||||||
|
}
|
||||||
|
if status == "revoked" {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if testStatus != "passed" {
|
||||||
|
return ErrTestRequired
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `UPDATE content_model_config SET status='inactive',activated_at=NULL,updated_at=now(),version=version+1,updated_by=$2::uuid WHERE tenant_id=$1::uuid AND status='active' AND id<>$3::uuid`, current.tenantID, current.userID, internalID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
row := tx.QueryRow(ctx, `UPDATE content_model_config cm SET status='active',activated_at=now(),updated_at=now(),version=version+1,updated_by=$3::uuid WHERE tenant_id=$1::uuid AND public_id=$2::uuid RETURNING `+modelReturning, current.tenantID, publicID, current.userID, current.tenantPublicID)
|
||||||
|
updated, scanErr := scanModel(row)
|
||||||
|
if scanErr != nil {
|
||||||
|
return scanErr
|
||||||
|
}
|
||||||
|
result = updated.Model
|
||||||
|
resourceID := result.ID
|
||||||
|
return s.audit.Record(ctx, tx, audit.Event{TenantID: ¤t.tenantID, ActorType: "user", ActorPublicID: ¤t.userPublicID, Action: "content_model.activate", ResourceType: "content_model", ResourceID: &resourceID, RequestID: requestID, Purpose: "激活聊天总结模型", Result: "success", Detail: map[string]any{"model_id": result.ModelID}})
|
||||||
|
})
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Revoke(ctx context.Context, principal identity.Principal, tenantPublicID, publicID, requestID string, expectedVersion int64) (Model, error) {
|
||||||
|
current, err := s.resolveActor(ctx, principal, tenantPublicID)
|
||||||
|
if err != nil || !current.canManage {
|
||||||
|
return Model{}, ErrAccessDenied
|
||||||
|
}
|
||||||
|
var result Model
|
||||||
|
err = database.Transact(ctx, s.registry.pool, func(tx pgx.Tx) error {
|
||||||
|
item, lockErr := s.getTx(ctx, tx, current.tenantID, publicID, true)
|
||||||
|
if lockErr != nil {
|
||||||
|
return lockErr
|
||||||
|
}
|
||||||
|
if item.Version != expectedVersion {
|
||||||
|
return ErrVersion
|
||||||
|
}
|
||||||
|
if item.Status == "active" {
|
||||||
|
return ErrActiveRevoke
|
||||||
|
}
|
||||||
|
row := tx.QueryRow(ctx, `UPDATE content_model_config cm SET status='revoked',encrypted_api_key=decode('','hex'),nonce=decode('','hex'),updated_at=now(),version=version+1,updated_by=$3::uuid WHERE tenant_id=$1::uuid AND public_id=$2::uuid RETURNING `+modelReturning, current.tenantID, publicID, current.userID, current.tenantPublicID)
|
||||||
|
updated, scanErr := scanModel(row)
|
||||||
|
if scanErr != nil {
|
||||||
|
return scanErr
|
||||||
|
}
|
||||||
|
result = updated.Model
|
||||||
|
resourceID := result.ID
|
||||||
|
return s.audit.Record(ctx, tx, audit.Event{TenantID: ¤t.tenantID, ActorType: "user", ActorPublicID: ¤t.userPublicID, Action: "content_model.revoke", ResourceType: "content_model", ResourceID: &resourceID, RequestID: requestID, Purpose: "移除未激活的总结模型", Result: "success"})
|
||||||
|
})
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) active(ctx context.Context, tenantID string) (storedModel, error) {
|
||||||
|
row := r.pool.QueryRow(ctx, modelSelect+` WHERE cm.tenant_id=$1::uuid AND cm.status='active'`, tenantID)
|
||||||
|
return scanModel(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Registry) generator(item storedModel) (extraction.Generator, error) {
|
||||||
|
key, err := r.aead.Open(nil, item.nonce, item.encrypted, modelAAD(item.tenantInternalID, item.ID, item.keyVersion))
|
||||||
|
if err != nil || len(key) < 8 {
|
||||||
|
return nil, errors.New("content model credential cannot be decrypted")
|
||||||
|
}
|
||||||
|
return extraction.NewCompatibleGenerator(item.Provider, item.BaseURL, string(key), item.ModelID, item.Temperature, item.MaxTokens, 30*time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) get(ctx context.Context, tenantID, publicID string) (storedModel, error) {
|
||||||
|
return s.getTx(ctx, s.registry.pool, tenantID, publicID, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
type rowQueryer interface {
|
||||||
|
QueryRow(context.Context, string, ...any) pgx.Row
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) getTx(ctx context.Context, q rowQueryer, tenantID, publicID string, lock bool) (storedModel, error) {
|
||||||
|
lockClause := ""
|
||||||
|
if lock {
|
||||||
|
lockClause = " FOR UPDATE OF cm"
|
||||||
|
}
|
||||||
|
item, err := scanModel(q.QueryRow(ctx, modelSelect+` WHERE cm.tenant_id=$1::uuid AND cm.public_id=$2::uuid`+lockClause, tenantID, publicID))
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return storedModel{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return item, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) resolveActor(ctx context.Context, principal identity.Principal, tenantPublicID string) (actor, error) {
|
||||||
|
var result actor
|
||||||
|
err := s.registry.pool.QueryRow(ctx, `
|
||||||
|
SELECT t.id::text,t.public_id::text,ua.id::text,ua.public_id::text,
|
||||||
|
COALESCE(bool_or(hm.role IN ('owner','admin') AND hm.status='active'),false)
|
||||||
|
FROM tenant t JOIN user_account ua ON ua.tenant_id=t.id AND ua.status='active'
|
||||||
|
LEFT JOIN household_member hm ON hm.user_id=ua.id AND hm.tenant_id=t.id
|
||||||
|
WHERE t.public_id=$1::uuid AND t.status='active' AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3
|
||||||
|
GROUP BY t.id,t.public_id,ua.id,ua.public_id`, tenantPublicID, principal.Issuer, principal.Subject).Scan(&result.tenantID, &result.tenantPublicID, &result.userID, &result.userPublicID, &result.canManage)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return actor{}, ErrAccessDenied
|
||||||
|
}
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeInput(input *CreateInput) {
|
||||||
|
input.TenantID = strings.TrimSpace(input.TenantID)
|
||||||
|
input.DisplayName = strings.TrimSpace(input.DisplayName)
|
||||||
|
input.Provider = strings.ToLower(strings.TrimSpace(input.Provider))
|
||||||
|
input.BaseURL = strings.TrimRight(strings.TrimSpace(input.BaseURL), "/")
|
||||||
|
input.ModelID = strings.TrimSpace(input.ModelID)
|
||||||
|
input.APIKey = strings.TrimSpace(input.APIKey)
|
||||||
|
if input.Temperature == 0 {
|
||||||
|
input.Temperature = 0.2
|
||||||
|
}
|
||||||
|
if input.MaxTokens == 0 {
|
||||||
|
input.MaxTokens = 1200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateInput(input CreateInput) error {
|
||||||
|
parsed, err := url.Parse(input.BaseURL)
|
||||||
|
validProvider := input.Provider == "ark" || input.Provider == "openai_compatible"
|
||||||
|
validURL := err == nil && parsed.Host != "" && (parsed.Scheme == "https" || (parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1")))
|
||||||
|
if !validUUID(input.TenantID) || len([]rune(input.DisplayName)) < 1 || len([]rune(input.DisplayName)) > 120 || !validProvider || !validURL || input.ModelID == "" || len(input.APIKey) < 8 || input.Temperature < 0 || input.Temperature > 2 || input.MaxTokens < 256 || input.MaxTokens > 8192 {
|
||||||
|
return ErrInvalid
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func credentialHint(key string) string {
|
||||||
|
runes := []rune(strings.TrimSpace(key))
|
||||||
|
if len(runes) <= 4 {
|
||||||
|
return "••••"
|
||||||
|
}
|
||||||
|
return "••••" + string(runes[len(runes)-4:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizedErrorCode(err error) string {
|
||||||
|
value := strings.ToLower(err.Error())
|
||||||
|
switch {
|
||||||
|
case strings.Contains(value, "401"), strings.Contains(value, "403"):
|
||||||
|
return "AUTHENTICATION_FAILED"
|
||||||
|
case strings.Contains(value, "429"):
|
||||||
|
return "RATE_LIMITED"
|
||||||
|
case strings.Contains(value, "timeout"), strings.Contains(value, "deadline"):
|
||||||
|
return "TIMEOUT"
|
||||||
|
case strings.Contains(value, "validation"), strings.Contains(value, "decode"):
|
||||||
|
return "INVALID_RESPONSE"
|
||||||
|
default:
|
||||||
|
return "CONNECTION_FAILED"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func modelAAD(tenantID, publicID string, version int64) []byte {
|
||||||
|
return []byte(fmt.Sprintf("content-model-credential-v1\n%s\n%s\n%d", tenantID, publicID, version))
|
||||||
|
}
|
||||||
|
|
||||||
|
func validUUID(value string) bool { _, err := uuid.Parse(value); return err == nil }
|
||||||
|
|
||||||
|
const modelSelect = `SELECT cm.public_id::text,t.public_id::text,cm.display_name,cm.provider,cm.base_url,cm.model_id,cm.credential_hint,cm.temperature::float8,cm.max_tokens,cm.status,cm.test_status,cm.last_tested_at,COALESCE(cm.last_error_code,''),cm.activated_at,cm.version,cm.created_at,cm.updated_at,cm.tenant_id::text,cm.encrypted_api_key,cm.nonce,cm.key_version FROM content_model_config cm JOIN tenant t ON t.id=cm.tenant_id`
|
||||||
|
const modelReturning = `cm.public_id::text,$4::text,cm.display_name,cm.provider,cm.base_url,cm.model_id,cm.credential_hint,cm.temperature::float8,cm.max_tokens,cm.status,cm.test_status,cm.last_tested_at,COALESCE(cm.last_error_code,''),cm.activated_at,cm.version,cm.created_at,cm.updated_at,cm.tenant_id::text,cm.encrypted_api_key,cm.nonce,cm.key_version`
|
||||||
|
const modelUpdateReturning = `UPDATE content_model_config cm SET test_status=$3,last_tested_at=now(),last_error_code=NULLIF($4,''),updated_at=now(),version=version+1,updated_by=$5::uuid RETURNING cm.public_id::text,$6::text,cm.display_name,cm.provider,cm.base_url,cm.model_id,cm.credential_hint,cm.temperature::float8,cm.max_tokens,cm.status,cm.test_status,cm.last_tested_at,COALESCE(cm.last_error_code,''),cm.activated_at,cm.version,cm.created_at,cm.updated_at,cm.tenant_id::text,cm.encrypted_api_key,cm.nonce,cm.key_version`
|
||||||
|
|
||||||
|
type rowScanner interface{ Scan(...any) error }
|
||||||
|
|
||||||
|
func scanModel(row rowScanner) (storedModel, error) {
|
||||||
|
var item storedModel
|
||||||
|
err := row.Scan(&item.ID, &item.TenantID, &item.DisplayName, &item.Provider, &item.BaseURL, &item.ModelID, &item.CredentialHint, &item.Temperature, &item.MaxTokens, &item.Status, &item.TestStatus, &item.LastTestedAt, &item.LastErrorCode, &item.ActivatedAt, &item.Version, &item.CreatedAt, &item.UpdatedAt, &item.tenantInternalID, &item.encrypted, &item.nonce, &item.keyVersion)
|
||||||
|
return item, err
|
||||||
|
}
|
||||||
@@ -18,7 +18,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type GenerationInput struct {
|
type GenerationInput struct {
|
||||||
Content string
|
TenantID string
|
||||||
|
Content string
|
||||||
}
|
}
|
||||||
|
|
||||||
type GenerationResult struct {
|
type GenerationResult struct {
|
||||||
@@ -89,13 +90,21 @@ func safePolicyToken(value string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ArkGenerator struct {
|
type ArkGenerator struct {
|
||||||
endpoint string
|
endpoint string
|
||||||
apiKey string
|
apiKey string
|
||||||
model string
|
model string
|
||||||
client *http.Client
|
provider string
|
||||||
|
temperature float64
|
||||||
|
maxTokens int
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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", "大模型配置处理失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)))
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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]
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
Vendored
+306
@@ -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?: {
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
+112
-17
@@ -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 () => {
|
||||||
try {
|
const items: Array<readonly [string, ConversationInsight]> = [];
|
||||||
const response = await platformApi.conversationSummaries(conversation.id, accessToken, controller.signal);
|
// Keep the dashboard responsive without bursting seven requests into the
|
||||||
return [conversation.id, inferConversationInsight(response.data.items)] as const;
|
// per-user rate limiter at once. Results still arrive in timeline order.
|
||||||
} catch (cause) {
|
for (const conversation of recent) {
|
||||||
if (cause instanceof DOMException && cause.name === "AbortError") throw cause;
|
try {
|
||||||
return [conversation.id, inferConversationInsight([])] as const;
|
const response = await platformApi.conversationSummaries(conversation.id, accessToken, controller.signal);
|
||||||
|
items.push([conversation.id, inferConversationInsight(response.data.items)] as const);
|
||||||
|
} catch (cause) {
|
||||||
|
if (cause instanceof DOMException && cause.name === "AbortError") throw cause;
|
||||||
|
items.push([conversation.id, inferConversationInsight([])] as const);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})).then((items) => {
|
return items;
|
||||||
|
})().then((items) => {
|
||||||
if (!controller.signal.aborted) setConversationInsights(Object.fromEntries(items));
|
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>;
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user