feat: finalize operations model workspace

This commit is contained in:
Codex Local
2026-08-31 11:29:56 +08:00
parent 78e0a19acb
commit a8cdec98d1
15 changed files with 406 additions and 63 deletions
+2 -3
View File
@@ -334,11 +334,10 @@ func (s *Service) resolveActor(ctx context.Context, principal identity.Principal
var result actor var result actor
err := s.registry.pool.QueryRow(ctx, ` err := s.registry.pool.QueryRow(ctx, `
SELECT t.id::text,t.public_id::text,ua.id::text,ua.public_id::text, 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) ua.account_kind='operator' AND ua.platform_role IN ('ops_operator','ops_admin')
FROM tenant t JOIN user_account ua ON ua.tenant_id=t.id AND ua.status='active' 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 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) `, tenantPublicID, principal.Issuer, principal.Subject).Scan(&result.tenantID, &result.tenantPublicID, &result.userID, &result.userPublicID, &result.canManage)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return actor{}, ErrAccessDenied return actor{}, ErrAccessDenied
} }
@@ -3,6 +3,7 @@ package contentmodel
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
@@ -38,7 +39,14 @@ func TestServiceTestsAndActivatesCompatibleModel(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
principal := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"} principal := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000104"}
customerPrincipal := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"}
if _, err := service.Create(ctx, customerPrincipal, CreateInput{
TenantID: "10000000-0000-0000-0000-000000000010", DisplayName: "家庭端不应创建", Provider: "openai_compatible",
BaseURL: server.URL + "/v1", ModelID: "customer-model", APIKey: "integration-key", Temperature: 0.1, MaxTokens: 512,
}, "content-model-customer-create"); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("customer content model create error=%v want access denied", err)
}
created, err := service.Create(ctx, principal, CreateInput{ created, err := service.Create(ctx, principal, CreateInput{
TenantID: "10000000-0000-0000-0000-000000000010", DisplayName: "集成测试模型", Provider: "openai_compatible", TenantID: "10000000-0000-0000-0000-000000000010", DisplayName: "集成测试模型", Provider: "openai_compatible",
BaseURL: server.URL + "/v1", ModelID: "test-model", APIKey: "integration-key", Temperature: 0.1, MaxTokens: 512, BaseURL: server.URL + "/v1", ModelID: "test-model", APIKey: "integration-key", Temperature: 0.1, MaxTokens: 512,
+4 -1
View File
@@ -91,7 +91,10 @@ func (p *Processor) Handle(ctx context.Context, event outbox.Event) error {
} }
sourceTurns := subjectTurns(snapshot.turns) sourceTurns := subjectTurns(snapshot.turns)
if len(sourceTurns) == 0 { if len(sourceTurns) == 0 {
return outbox.Retryable(ErrConversationContentNotReady) // Terminal conversations cannot receive more subject turns. Treat the
// extraction request as complete so permanently empty conversations do
// not cycle through retry and dead-letter queues forever.
return nil
} }
content := joinedConversationContent(snapshot.turns) content := joinedConversationContent(snapshot.turns)
generated, err := p.generateSafely(ctx, content, snapshot.tenantID) generated, err := p.generateSafely(ctx, content, snapshot.tenantID)
@@ -39,6 +39,7 @@ func TestProcessorExtractionIdempotencyAndSensitiveContent(t *testing.T) {
consentSnapshotID, consentSnapshotPublicID := uuid.NewString(), uuid.NewString() consentSnapshotID, consentSnapshotPublicID := uuid.NewString(), uuid.NewString()
contextID, contextPublicID := uuid.NewString(), uuid.NewString() contextID, contextPublicID := uuid.NewString(), uuid.NewString()
conversationID, conversationPublicID := uuid.NewString(), uuid.NewString() conversationID, conversationPublicID := uuid.NewString(), uuid.NewString()
emptyConversationID, emptyConversationPublicID := uuid.NewString(), uuid.NewString()
defer func() { defer func() {
_, _ = pool.Exec(ctx, `DELETE FROM conversation_content_assessment WHERE conversation_id=$1::uuid`, conversationID) _, _ = pool.Exec(ctx, `DELETE FROM conversation_content_assessment WHERE conversation_id=$1::uuid`, conversationID)
_, _ = pool.Exec(ctx, `DELETE FROM fact_candidate WHERE story_id IN (SELECT id FROM story_draft WHERE conversation_id=$1::uuid)`, conversationID) _, _ = pool.Exec(ctx, `DELETE FROM fact_candidate WHERE story_id IN (SELECT id FROM story_draft WHERE conversation_id=$1::uuid)`, conversationID)
@@ -49,6 +50,7 @@ func TestProcessorExtractionIdempotencyAndSensitiveContent(t *testing.T) {
_, _ = pool.Exec(ctx, `DELETE FROM conversation_summary WHERE conversation_id=$1::uuid`, conversationID) _, _ = pool.Exec(ctx, `DELETE FROM conversation_summary WHERE conversation_id=$1::uuid`, conversationID)
_, _ = pool.Exec(ctx, `DELETE FROM conversation_turn WHERE conversation_id=$1::uuid`, conversationID) _, _ = pool.Exec(ctx, `DELETE FROM conversation_turn WHERE conversation_id=$1::uuid`, conversationID)
_, _ = pool.Exec(ctx, `DELETE FROM conversation WHERE id=$1::uuid`, conversationID) _, _ = pool.Exec(ctx, `DELETE FROM conversation WHERE id=$1::uuid`, conversationID)
_, _ = pool.Exec(ctx, `DELETE FROM conversation WHERE id=$1::uuid`, emptyConversationID)
_, _ = pool.Exec(ctx, `DELETE FROM context_snapshot WHERE id=$1::uuid`, contextID) _, _ = pool.Exec(ctx, `DELETE FROM context_snapshot WHERE id=$1::uuid`, contextID)
_, _ = pool.Exec(ctx, `DELETE FROM consent_snapshot WHERE id=$1::uuid`, consentSnapshotID) _, _ = pool.Exec(ctx, `DELETE FROM consent_snapshot WHERE id=$1::uuid`, consentSnapshotID)
}() }()
@@ -74,6 +76,18 @@ func TestProcessorExtractionIdempotencyAndSensitiveContent(t *testing.T) {
conversationID, conversationPublicID, consentSnapshotID, contextID, "extract-task-"+conversationID); err != nil { conversationID, conversationPublicID, consentSnapshotID, contextID, "extract-task-"+conversationID); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := pool.Exec(ctx, `
INSERT INTO conversation(id,public_id,tenant_id,household_id,subject_id,device_id,consent_snapshot_id,context_snapshot_id,
provider_task_ref,engine_mode,retention_mode,status,allocation_expires_at,started_at,ended_at,stop_reason)
VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000201',
'10000000-0000-0000-0000-000000000401','10000000-0000-0000-0000-000000000501',
$3::uuid,$4::uuid,$5,'s2s_hybrid','transcript_only','COMPLETED',now()+interval '5 minutes',now()-interval '1 minute',now(),'user_hangup')`,
emptyConversationID, emptyConversationPublicID, consentSnapshotID, contextID, "extract-empty-task-"+emptyConversationID); err != nil {
t.Fatal(err)
}
if err := processor.Handle(ctx, outbox.Event{AggregateID: emptyConversationID, Payload: []byte(`{"generation_version":1}`)}); err != nil {
t.Fatalf("terminal conversation without subject turns should not retry: %v", err)
}
if _, err := pool.Exec(ctx, ` if _, err := pool.Exec(ctx, `
INSERT INTO conversation_turn(tenant_id,subject_id,conversation_id,turn_key,sequence_no,speaker,start_at,end_at,final_text,source_type) INSERT INTO conversation_turn(tenant_id,subject_id,conversation_id,turn_key,sequence_no,speaker,start_at,end_at,final_text,source_type)
VALUES('10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000401',$1::uuid,$2,1,'subject',now()-interval '2 minutes',now()-interval '110 seconds',$3,'provider'), VALUES('10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000401',$1::uuid,$2,1,'subject',now()-interval '2 minutes',now()-interval '110 seconds',$3,'provider'),
-3
View File
@@ -27,7 +27,6 @@ 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{}{
@@ -36,14 +35,12 @@ 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,
+42 -6
View File
@@ -15,13 +15,12 @@ const sections = [
["conversations", "回忆时间轴", "聊天记录"], ["conversations", "回忆时间轴", "聊天记录"],
["stories", "故事与记忆", "故事与记忆"], ["stories", "故事与记忆", "故事与记忆"],
["subjects", "长者资料", "长者资料"], ["subjects", "长者资料", "长者资料"],
["messages", "日程与提醒", "日程与提醒"], ["messages", "提醒与待办", "提醒与待办"],
["members", "家庭管理", "家庭管理"], ["members", "家庭管理", "家庭管理"],
["devices", "陪伴设备", "陪伴设备"], ["devices", "陪伴设备", "陪伴设备"],
["sharing", "内容分享", "内容分享"], ["sharing", "内容分享", "内容分享"],
["privacy", "数据与授权", "数据与授权"], ["privacy", "数据与授权", "数据与授权"],
["billing", "服务用量", "服务用量"], ["billing", "服务用量", "服务用量"],
["models", "大模型管理", "大模型管理"],
["profile", "设置与权限", "设置与权限"], ["profile", "设置与权限", "设置与权限"],
]; ];
@@ -97,6 +96,43 @@ try {
results.push({ section, heading, layout }); results.push({ section, heading, layout });
} }
await page.getByRole("menuitem", { name: /聊天记录$/ }).click();
await page.getByRole("heading", { name: "回忆时间轴", exact: true, level: 1 }).waitFor();
const timelineHeaders = page.locator(".timeline-day-group__header");
await timelineHeaders.first().waitFor();
const timelineHeaderCount = await timelineHeaders.count();
const timelineDefaultsWork = timelineHeaderCount >= 3
&& await timelineHeaders.nth(0).getAttribute("aria-expanded") === "true"
&& await timelineHeaders.nth(1).getAttribute("aria-expanded") === "true"
&& await timelineHeaders.nth(2).getAttribute("aria-expanded") === "false";
const timelineLayout = await page.locator(".memory-timeline-grid .conversation-list").evaluate((element) => ({
height: element.getBoundingClientRect().height,
viewportHeight: window.innerHeight,
overflowY: getComputedStyle(element.querySelector(".memory-timeline-scroll")).overflowY,
}));
const timelineIsBounded = timelineLayout.height <= 721 && timelineLayout.height < timelineLayout.viewportHeight && timelineLayout.overflowY === "auto";
await timelineHeaders.nth(2).click();
const olderTimelineExpands = await timelineHeaders.nth(2).getAttribute("aria-expanded") === "true";
await timelineHeaders.nth(2).click();
const portalSearch = page.getByPlaceholder("搜索长者、设备、通话", { exact: true });
await portalSearch.fill("设备");
await portalSearch.press("Enter");
await page.getByRole("heading", { name: "陪伴设备", exact: true, level: 1 }).waitFor();
const searchNavigationWorks = new URL(page.url()).searchParams.get("section") === "devices";
await page.getByRole("button", { name: "打开提醒与待办", exact: true }).click();
await page.getByRole("heading", { name: "提醒与待办", exact: true, level: 1 }).waitFor();
const reminderNavigationWorks = new URL(page.url()).searchParams.get("section") === "messages";
const reminderPaletteWorks = await page.evaluate(() => {
const controls = [...document.querySelectorAll(".reminder-todo, .reminder-history__summary > button")];
return controls.length > 0 && controls.every((item) => {
const channels = getComputedStyle(item).backgroundColor.match(/\d+/g)?.slice(0, 3).map(Number) ?? [];
return channels.length === 3 && channels.reduce((sum, value) => sum + value, 0) / 3 > 210;
});
});
const customerModelNavigationHidden = await page.getByRole("menuitem", { name: /大模型管理$/ }).count() === 0;
await page.getByRole("menuitem", { name: /长者资料$/ }).click(); await page.getByRole("menuitem", { name: /长者资料$/ }).click();
await page.getByRole("heading", { name: "长者资料", exact: true, level: 1 }).waitFor(); await page.getByRole("heading", { name: "长者资料", exact: true, level: 1 }).waitFor();
await page.locator("button.subject-add-button").click(); await page.locator("button.subject-add-button").click();
@@ -107,7 +143,8 @@ try {
await page.screenshot({ path: resolve(outputDirectory, "subjects-avatar-upload.png") }); await page.screenshot({ path: resolve(outputDirectory, "subjects-avatar-upload.png") });
const disabledCursorResult = await page.evaluate(() => { const disabledCursorResult = await page.evaluate(() => {
const controls = [...document.querySelectorAll("button:disabled")]; const controls = [...document.querySelectorAll("button:disabled")];
return { count: controls.length, allWait: controls.length > 0 && controls.every((item) => getComputedStyle(item).cursor === "wait") }; const cursors = controls.map((item) => ({ className: item.className, cursor: getComputedStyle(item).cursor }));
return { count: controls.length, cursors, allNotAllowed: controls.length > 0 && cursors.every((item) => item.cursor === "not-allowed") };
}); });
await page.getByRole("button", { name: "关闭新增长者弹窗" }).click(); await page.getByRole("button", { name: "关闭新增长者弹窗" }).click();
@@ -132,7 +169,6 @@ try {
await checkModal("陪伴设备", ".workspace--devices .workspace__actions button:has-text('绑定设备')", "绑定设备", "绑定新设备"); await checkModal("陪伴设备", ".workspace--devices .workspace__actions button:has-text('绑定设备')", "绑定设备", "绑定新设备");
await checkModal("内容分享", ".subject-list-toolbar > button", "新建分享", "新建资源分享"); await checkModal("内容分享", ".subject-list-toolbar > button", "新建分享", "新建资源分享");
await checkModal("数据与授权", ".privacy-actions button:has-text('资料更正')", "资料更正", "提交资料更正"); await checkModal("数据与授权", ".privacy-actions button:has-text('资料更正')", "资料更正", "提交资料更正");
await checkModal("大模型管理", ".model-active-hero > button", "添加大模型", "添加总结大模型");
await page.getByRole("menuitem", { name: /故事与记忆$/ }).click(); await page.getByRole("menuitem", { name: /故事与记忆$/ }).click();
await page.locator("button.story-candidate-card").first().click(); await page.locator("button.story-candidate-card").first().click();
@@ -146,9 +182,9 @@ try {
const uniqueResponseErrors = [...new Set(errorResponses)].filter((item) => !item.includes("/api/v1/events") && !(/^403 .*\/media$/.test(item))); const uniqueResponseErrors = [...new Set(errorResponses)].filter((item) => !item.includes("/api/v1/events") && !(/^403 .*\/media$/.test(item)));
const relevantConsoleErrors = consoleErrors.filter((item) => !item.startsWith("Failed to load resource:")); const relevantConsoleErrors = consoleErrors.filter((item) => !item.startsWith("Failed to load resource:"));
const report = { results, avatarPreviewVisible, modalChecks, disabledCursorResult, familyNavigationVisible, pageFailures, consoleErrors: relevantConsoleErrors, errorResponses: uniqueResponseErrors, failedRequests }; const report = { results, timelineHeaderCount, timelineDefaultsWork, timelineIsBounded, olderTimelineExpands, searchNavigationWorks, reminderNavigationWorks, reminderPaletteWorks, customerModelNavigationHidden, avatarPreviewVisible, modalChecks, disabledCursorResult, familyNavigationVisible, pageFailures, consoleErrors: relevantConsoleErrors, errorResponses: uniqueResponseErrors, failedRequests };
console.log(JSON.stringify(report, null, 2)); console.log(JSON.stringify(report, null, 2));
if (!avatarPreviewVisible || modalChecks.some((item) => !item.passed) || !disabledCursorResult.allWait || !familyNavigationVisible || pageFailures.length || relevantConsoleErrors.length || uniqueResponseErrors.length || failedRequests.length) { if (!timelineDefaultsWork || !timelineIsBounded || !olderTimelineExpands || !searchNavigationWorks || !reminderNavigationWorks || !reminderPaletteWorks || !customerModelNavigationHidden || !avatarPreviewVisible || modalChecks.some((item) => !item.passed) || !disabledCursorResult.allNotAllowed || !familyNavigationVisible || pageFailures.length || relevantConsoleErrors.length || uniqueResponseErrors.length || failedRequests.length) {
throw new Error("Customer portal v0.74 visual/function smoke failed"); throw new Error("Customer portal v0.74 visual/function smoke failed");
} }
} finally { } finally {
+110
View File
@@ -0,0 +1,110 @@
import { createRequire } from "node:module";
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
const { chromium } = require("playwright");
const webURL = process.env.VISUAL_WEB_URL ?? "http://localhost:3100";
const username = process.env.VISUAL_USERNAME ?? "operator";
const password = process.env.VISUAL_PASSWORD ?? "OpsLocalOnly123!";
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe",
});
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1 });
const consoleErrors = [];
const failedRequests = [];
const errorResponses = [];
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
page.on("pageerror", (error) => consoleErrors.push(`pageerror: ${error.message}`));
page.on("response", (response) => { if (response.status() >= 400) errorResponses.push(`${response.status()} ${response.url()}`); });
page.on("requestfailed", (request) => {
const reason = request.failure()?.errorText ?? "failed";
const pathname = new URL(request.url()).pathname;
if (request.method() === "GET" && pathname === "/api/v1/events" && reason.includes("ERR_ABORTED")) return;
if (pathname.startsWith("/api/v1/") && reason.includes("ERR_ABORTED")) return;
failedRequests.push(`${request.method()} ${request.url()} ${reason}`);
});
const areas = [
["总览", "维护总览", null],
["设备管理", "设备管理", "devices"],
["运行配置", "运行配置", "config"],
["大模型管理", "大模型管理", "models"],
["内容质量", "内容质量", "quality"],
["用量、费用与额度", "用量、费用与额度", "billing"],
["告警处置", "告警处置", "alerts"],
["联调与诊断", "联调与诊断", "diagnostics"],
];
try {
await page.goto(`${webURL}/maintenance`, { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "登录维护中心", exact: true }).click();
await page.waitForURL(/localhost:58180|127\.0\.0\.1:58180/);
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await page.locator("#kc-login").click();
await page.waitForURL(new RegExp(`^${new URL(webURL).origin.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/maintenance(?:[?#]|$)`), { timeout: 15000 });
await page.getByRole("heading", { name: "维护总览", exact: true, level: 1 }).waitFor({ timeout: 15000 });
const navigation = page.getByRole("navigation", { name: "维护中心主导航" });
const areaResults = [];
for (const [label, heading, area] of areas) {
await navigation.getByRole("button", { name: label, exact: true }).click();
await page.getByRole("heading", { name: heading, exact: true, level: 1 }).waitFor();
areaResults.push({ label, area: new URL(page.url()).searchParams.get("area"), expectedArea: area });
}
const search = page.getByPlaceholder("搜索设备、通话、作业", { exact: true });
await search.fill("告警");
await search.press("Enter");
await page.getByRole("heading", { name: "告警处置", exact: true, level: 1 }).waitFor();
const alertSearchWorks = new URL(page.url()).searchParams.get("area") === "alerts";
await search.fill("10000000-0000-0000-0000-000000000511");
await search.press("Enter");
await page.getByRole("heading", { name: "联调与诊断", exact: true, level: 1 }).waitFor();
const identifierSearchWorks = new URL(page.url()).searchParams.get("area") === "diagnostics";
await navigation.getByRole("button", { name: "大模型管理", exact: true }).click();
await page.getByRole("heading", { name: "大模型管理", exact: true, level: 1 }).waitFor();
await page.getByRole("button", { name: /添加大模型/ }).click();
const modelDialog = page.getByRole("dialog", { name: "添加总结大模型", exact: true });
await modelDialog.waitFor();
const providerButtons = modelDialog.locator(".model-provider-group button");
const modelPaletteWorks = await providerButtons.evaluateAll((controls) => controls.length > 0 && controls.every((item) => {
const channels = getComputedStyle(item).backgroundColor.match(/\d+/g)?.slice(0, 3).map(Number) ?? [];
return channels.length === 3 && channels.reduce((sum, value) => sum + value, 0) / 3 > 210;
}));
await modelDialog.getByRole("button", { name: "关闭", exact: true }).click();
await navigation.getByRole("button", { name: "设备管理", exact: true }).click();
await page.getByRole("button", { name: /新增设备/ }).click();
const createDialog = page.getByRole("dialog", { name: "新增设备", exact: true });
await createDialog.waitFor();
await createDialog.getByRole("button", { name: "关闭", exact: true }).click();
await page.getByRole("button", { name: /编辑/ }).first().click();
const editDialog = page.getByRole("dialog", { name: "编辑设备", exact: true });
await editDialog.waitFor();
await editDialog.getByRole("button", { name: "关闭", exact: true }).click();
await navigation.getByRole("button", { name: "联调与诊断", exact: true }).click();
await page.getByPlaceholder("输入完整编号", { exact: true }).fill("10000000-0000-0000-0000-000000000511");
await page.getByRole("button", { name: "查询", exact: true }).click();
await page.getByRole("button", { name: "导出脱敏诊断包", exact: true }).waitFor();
await page.getByRole("button", { name: "导出脱敏诊断包", exact: true }).click();
const exportForm = page.getByRole("form").last();
const exportSubmit = exportForm.getByRole("button", { name: "生成并下载", exact: true });
const exportInitiallyDisabled = await exportSubmit.isDisabled();
await exportForm.getByRole("textbox", { name: "导出原因", exact: true }).fill("页面功能测试,不下载");
const exportReady = await exportSubmit.isEnabled();
await exportForm.getByRole("button", { name: "取消", exact: true }).click();
const areaFailures = areaResults.filter((item) => item.area !== item.expectedArea);
const relevantResponseErrors = [...new Set(errorResponses)].filter((item) => !item.includes("/api/v1/events") && !(item.startsWith("404 ") && item.endsWith("/api/v1/ops/diagnostics/platform-health")));
const relevantConsoleErrors = consoleErrors.filter((item) => !item.startsWith("Failed to load resource:"));
const report = { areaResults, areaFailures, alertSearchWorks, identifierSearchWorks, modelPaletteWorks, exportInitiallyDisabled, exportReady, consoleErrors: relevantConsoleErrors, errorResponses: relevantResponseErrors, failedRequests };
console.log(JSON.stringify(report, null, 2));
if (areaFailures.length || !alertSearchWorks || !identifierSearchWorks || !modelPaletteWorks || !exportInitiallyDisabled || !exportReady || relevantConsoleErrors.length || relevantResponseErrors.length || failedRequests.length) {
throw new Error("Maintenance navigation/function smoke failed");
}
} finally {
await browser.close();
}
+1 -1
View File
@@ -583,7 +583,7 @@ input[type="checkbox"], input[type="radio"], input[type="range"] { accent-color:
.workspace__header h2 { font-size: 25px !important; } .workspace__header h2 { font-size: 25px !important; }
.workspace__action-message,.workspace__inline-error,.workspace__readonly-note { margin: 0 0 14px !important; padding: 12px 14px !important; border-radius: 10px !important; } .workspace__action-message,.workspace__inline-error,.workspace__readonly-note { margin: 0 0 14px !important; padding: 12px 14px !important; border-radius: 10px !important; }
.customer-layout button:disabled, .customer-layout button:disabled,
.customer-layout [role="button"][aria-disabled="true"] { cursor: wait !important; opacity: .52 !important; transform: none !important; } .customer-layout [role="button"][aria-disabled="true"] { cursor: not-allowed !important; opacity: .52 !important; transform: none !important; }
/* Shared objects use one clear surface; avoid nested full-page cards. */ /* Shared objects use one clear surface; avoid nested full-page cards. */
.customer-page .panel-section, .customer-page .panel-section,
+14 -3
View File
@@ -1,4 +1,4 @@
import { StrictMode } from "react"; import { StrictMode, useEffect, useState } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { ConfigProvider } from "antd"; import { ConfigProvider } from "antd";
import zhCN from "antd/locale/zh_CN"; import zhCN from "antd/locale/zh_CN";
@@ -7,7 +7,18 @@ import { MaintenancePortal } from "./pages/MaintenancePortal";
import "./styles.css"; import "./styles.css";
import "./cal-theme.css"; import "./cal-theme.css";
const Portal = window.location.pathname.startsWith("/maintenance") ? MaintenancePortal : CustomerPortal; function PortalRouter() {
const [pathname, setPathname] = useState(() => window.location.pathname);
useEffect(() => {
const syncPathname = () => setPathname(window.location.pathname);
window.addEventListener("popstate", syncPathname);
return () => window.removeEventListener("popstate", syncPathname);
}, []);
const Portal = pathname.startsWith("/maintenance") ? MaintenancePortal : CustomerPortal;
return <Portal />;
}
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
@@ -67,7 +78,7 @@ createRoot(document.getElementById("root")!).render(
} }
}} }}
> >
<Portal /> <PortalRouter />
</ConfigProvider> </ConfigProvider>
</StrictMode> </StrictMode>
); );
+1 -6
View File
@@ -42,7 +42,6 @@ import {
SafetyCertificateOutlined, SafetyCertificateOutlined,
SearchOutlined, SearchOutlined,
SettingOutlined, SettingOutlined,
RobotOutlined,
TeamOutlined, TeamOutlined,
UserOutlined UserOutlined
} from "@ant-design/icons"; } from "@ant-design/icons";
@@ -62,7 +61,6 @@ 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: "设置与权限" }
}; };
@@ -99,7 +97,6 @@ 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: "设置与权限" }
], []); ], []);
@@ -123,7 +120,6 @@ export function CustomerPortal() {
[["分享", "share"], "sharing"], [["分享", "share"], "sharing"],
[["费用", "额度", "账单", "cost"], "billing"], [["费用", "额度", "账单", "cost"], "billing"],
[["消息", "通知", "提醒"], "messages"], [["消息", "通知", "提醒"], "messages"],
[["大模型", "模型", "豆包", "总结"], "models"],
[["长者", "老人", "档案", "subject"], "subjects"], [["长者", "老人", "档案", "subject"], "subjects"],
[["家庭", "成员", "邀请", "member"], "members"] [["家庭", "成员", "邀请", "member"], "members"]
]; ];
@@ -271,11 +267,10 @@ const permissionRows = [
["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>15 项</Tag>}> return <Card className="permission-matrix-card" title="全部权限说明" extra={<Tag>{permissionRows.length} 项</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>;
+24 -2
View File
@@ -29,6 +29,7 @@ import {
MenuFoldOutlined, MenuFoldOutlined,
MenuUnfoldOutlined, MenuUnfoldOutlined,
ReloadOutlined, ReloadOutlined,
RobotOutlined,
SearchOutlined, SearchOutlined,
SettingOutlined, SettingOutlined,
ToolOutlined, ToolOutlined,
@@ -42,8 +43,9 @@ import { OpsBillingWorkspace } from "./OpsBillingWorkspace";
import { OpsConfigWorkspace } from "./OpsConfigWorkspace"; import { OpsConfigWorkspace } from "./OpsConfigWorkspace";
import { OpsDiagnosticsWorkspace } from "./OpsDiagnosticsWorkspace"; import { OpsDiagnosticsWorkspace } from "./OpsDiagnosticsWorkspace";
import { OpsDeviceWorkspace } from "./OpsDeviceWorkspace"; import { OpsDeviceWorkspace } from "./OpsDeviceWorkspace";
import { OpsContentModelWorkspace } from "./OpsContentModelWorkspace";
type MaintenanceArea = "overview" | "devices" | "config" | "quality" | "billing" | "alerts" | "diagnostics"; type MaintenanceArea = "overview" | "devices" | "config" | "models" | "quality" | "billing" | "alerts" | "diagnostics";
type HealthState = { type HealthState = {
loading: boolean; loading: boolean;
@@ -59,6 +61,7 @@ const areaMeta: Record<MaintenanceArea, { title: string; description: string; ic
overview: { title: "维护总览", description: "集中查看服务健康、构建版本和当前维护权限。", icon: <DashboardOutlined /> }, overview: { title: "维护总览", description: "集中查看服务健康、构建版本和当前维护权限。", icon: <DashboardOutlined /> },
devices: { title: "设备管理", description: "登记设备、维护家庭与长者归属、吊销设备并安全轮换密钥。", icon: <DatabaseOutlined /> }, devices: { title: "设备管理", description: "登记设备、维护家庭与长者归属、吊销设备并安全轮换密钥。", icon: <DatabaseOutlined /> },
config: { title: "运行配置", description: "管理配置版本、资源引用、离线评测、审批和灰度发布。", icon: <SettingOutlined /> }, config: { title: "运行配置", description: "管理配置版本、资源引用、离线评测、审批和灰度发布。", icon: <SettingOutlined /> },
models: { title: "大模型管理", description: "按客户组织配置、测试并激活聊天总结、关键词和故事候选生成模型。", icon: <RobotOutlined /> },
quality: { title: "内容质量", description: "按模型和提示词版本检查故事、事实与记忆的人工审核反馈。", icon: <BarChartOutlined /> }, quality: { title: "内容质量", description: "按模型和提示词版本检查故事、事实与记忆的人工审核反馈。", icon: <BarChartOutlined /> },
billing: { title: "用量、费用与额度", description: "查看整体通话时长并按家庭、设备筛选,同时维护核价与硬额度策略。", icon: <DollarOutlined /> }, billing: { title: "用量、费用与额度", description: "查看整体通话时长并按家庭、设备筛选,同时维护核价与硬额度策略。", icon: <DollarOutlined /> },
alerts: { title: "告警处置", description: "维护告警路由、通知测试、静默窗口和处置时间线。", icon: <AlertOutlined /> }, alerts: { title: "告警处置", description: "维护告警路由、通知测试、静默窗口和处置时间线。", icon: <AlertOutlined /> },
@@ -69,6 +72,7 @@ export function MaintenancePortal() {
const auth = useAuth(); const auth = useAuth();
const [collapsed, setCollapsed] = useState(() => window.innerWidth <= 720); const [collapsed, setCollapsed] = useState(() => window.innerWidth <= 720);
const [area, setArea] = useState<MaintenanceArea>(() => areaFromURL()); const [area, setArea] = useState<MaintenanceArea>(() => areaFromURL());
const [searchValue, setSearchValue] = useState("");
const [health, setHealth] = useState<HealthState>({ loading: true }); const [health, setHealth] = useState<HealthState>({ loading: true });
const refreshHealth = useCallback(async (signal?: AbortSignal) => { const refreshHealth = useCallback(async (signal?: AbortSignal) => {
@@ -120,11 +124,28 @@ export function MaintenancePortal() {
window.history.pushState({ area: next }, "", `${url.pathname}${url.search}${url.hash}`); window.history.pushState({ area: next }, "", `${url.pathname}${url.search}${url.hash}`);
} }
function search() {
const value = searchValue.trim().toLowerCase();
if (!value) return;
const matches: Array<[string[], MaintenanceArea]> = [
[["总览", "健康", "版本", "health"], "overview"],
[["设备管理", "资产", "密钥", "device management"], "devices"],
[["配置", "灰度", "模型路由", "config"], "config"],
[["大模型", "模型", "豆包", "ollama", "model"], "models"],
[["质量", "审核", "反馈", "quality"], "quality"],
[["费用", "额度", "账单", "用量", "billing", "cost"], "billing"],
[["告警", "静默", "路由", "alert"], "alerts"]
];
const target = matches.find(([keywords]) => keywords.some((keyword) => value.includes(keyword)))?.[1] ?? "diagnostics";
navigate(target);
}
const menuItems: MenuProps["items"] = [ const menuItems: MenuProps["items"] = [
{ type: "group", label: "平台维护", children: [ { type: "group", label: "平台维护", children: [
{ key: "overview", icon: <DashboardOutlined />, label: "维护总览" }, { key: "overview", icon: <DashboardOutlined />, label: "维护总览" },
{ key: "devices", icon: <DatabaseOutlined />, label: "设备管理" }, { key: "devices", icon: <DatabaseOutlined />, label: "设备管理" },
{ key: "config", icon: <SettingOutlined />, label: "运行配置" }, { key: "config", icon: <SettingOutlined />, label: "运行配置" },
{ key: "models", icon: <RobotOutlined />, label: "大模型管理" },
{ key: "quality", icon: <BarChartOutlined />, label: "内容质量" }, { key: "quality", icon: <BarChartOutlined />, label: "内容质量" },
{ key: "billing", icon: <DollarOutlined />, label: "用量与费用" }, { key: "billing", icon: <DollarOutlined />, label: "用量与费用" },
{ key: "alerts", icon: <AlertOutlined />, label: "告警处置" }, { key: "alerts", icon: <AlertOutlined />, label: "告警处置" },
@@ -147,7 +168,7 @@ export function MaintenancePortal() {
{(Object.entries(areaMeta) as Array<[MaintenanceArea, (typeof areaMeta)[MaintenanceArea]]>).map(([key, item]) => <button type="button" className={area === key ? "active" : ""} key={key} onClick={() => navigate(key)}>{item.title.replace("维护", "")}</button>)} {(Object.entries(areaMeta) as Array<[MaintenanceArea, (typeof areaMeta)[MaintenanceArea]]>).map(([key, item]) => <button type="button" className={area === key ? "active" : ""} key={key} onClick={() => navigate(key)}>{item.title.replace("维护", "")}</button>)}
</nav> </nav>
<div className="maintenance-header__actions"> <div className="maintenance-header__actions">
<Input prefix={<SearchOutlined />} placeholder="搜索设备、通话、作业" allowClear className="maintenance-search" /> <Input.Search value={searchValue} onChange={(event) => setSearchValue(event.target.value)} onSearch={search} prefix={<SearchOutlined />} placeholder="搜索设备、通话、作业" allowClear className="maintenance-search" />
<Tooltip title="平台就绪状态"><Badge status={health.ready === "ready" ? "success" : health.loading ? "processing" : "warning"} /></Tooltip> <Tooltip title="平台就绪状态"><Badge status={health.ready === "ready" ? "success" : health.loading ? "processing" : "warning"} /></Tooltip>
<Button type="text" icon={<BellOutlined />} aria-label="查看告警" onClick={() => navigate("alerts")} /> <Button type="text" icon={<BellOutlined />} aria-label="查看告警" onClick={() => navigate("alerts")} />
{auth.currentUser ? <Dropdown menu={{ items: accountMenu }} placement="bottomRight"><Button type="text" className="maintenance-account"><Avatar size={30} icon={<UserOutlined />} /><span>{auth.currentUser.identity.display_name || auth.currentUser.identity.preferred_username}</span></Button></Dropdown> : <Button type="primary" onClick={() => void auth.login()}>维护人员登录</Button>} {auth.currentUser ? <Dropdown menu={{ items: accountMenu }} placement="bottomRight"><Button type="text" className="maintenance-account"><Avatar size={30} icon={<UserOutlined />} /><span>{auth.currentUser.identity.display_name || auth.currentUser.identity.preferred_username}</span></Button></Dropdown> : <Button type="primary" onClick={() => void auth.login()}>维护人员登录</Button>}
@@ -176,6 +197,7 @@ export function MaintenancePortal() {
{area === "overview" ? <MaintenanceOverview health={health} accounts={operationsAccounts} /> : null} {area === "overview" ? <MaintenanceOverview health={health} accounts={operationsAccounts} /> : null}
{area === "devices" ? <OpsDeviceWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null} {area === "devices" ? <OpsDeviceWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
{area === "config" ? <OpsConfigWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null} {area === "config" ? <OpsConfigWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
{area === "models" ? <OpsContentModelWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
{area === "quality" ? <ContentQualityWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null} {area === "quality" ? <ContentQualityWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
{area === "billing" ? <OpsBillingWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null} {area === "billing" ? <OpsBillingWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
{area === "alerts" ? <OpsAlertWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null} {area === "alerts" ? <OpsAlertWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
+105
View File
@@ -0,0 +1,105 @@
import { useCallback, useEffect, useState } from "react";
import { Alert, Button, Card, Select, Space } from "antd";
import { ReloadOutlined } from "@ant-design/icons";
import { platformApi, type ContentModel, type CreateContentModelRequest, type CurrentUser } from "../api/client";
import { ContentModelPanel } from "./SaaSWorkspace";
export function OpsContentModelWorkspace({ accessToken, accounts }: {
accessToken: string;
accounts: CurrentUser["accounts"];
}) {
const [tenantID, setTenantID] = useState(accounts[0]?.tenant_id ?? "");
const [models, setModels] = useState<ContentModel[]>([]);
const [loading, setLoading] = useState(false);
const [actionBusy, setActionBusy] = useState(false);
const [error, setError] = useState<string>();
const [message, setMessage] = useState<string>();
const account = accounts.find((item) => item.tenant_id === tenantID);
const canManage = Boolean(account && ["ops_operator", "ops_admin"].includes(account.platform_role));
useEffect(() => {
if (!accounts.some((item) => item.tenant_id === tenantID)) setTenantID(accounts[0]?.tenant_id ?? "");
}, [accounts, tenantID]);
const loadModels = useCallback(async (signal?: AbortSignal) => {
if (!tenantID) { setModels([]); return; }
setLoading(true);
setError(undefined);
try {
setModels((await platformApi.contentModels(tenantID, accessToken, signal)).data.items);
} catch (cause) {
if (!(cause instanceof DOMException && cause.name === "AbortError")) setError(errorText(cause));
} finally {
if (!signal?.aborted) setLoading(false);
}
}, [accessToken, tenantID]);
useEffect(() => {
const controller = new AbortController();
void loadModels(controller.signal);
return () => controller.abort();
}, [loadModels]);
async function runAction(successMessage: string, action: () => Promise<void>) {
setActionBusy(true);
setError(undefined);
setMessage(undefined);
try {
await action();
await loadModels();
setMessage(successMessage);
return true;
} catch (cause) {
setError(errorText(cause));
return false;
} finally {
setActionBusy(false);
}
}
async function createModel(input: CreateContentModelRequest) {
if (!tenantID || !canManage) return false;
return runAction("大模型配置已安全保存,请完成连通性测试后再激活。", async () => {
await platformApi.createContentModel(tenantID, input, accessToken);
});
}
async function testModel(item: ContentModel) {
if (!tenantID || !canManage) return;
await runAction("连通性测试已完成。", async () => {
await platformApi.testContentModel(tenantID, item.id, accessToken);
});
}
async function activateModel(item: ContentModel) {
if (!tenantID || !canManage) return;
await runAction(`“${item.display_name}”已激活,下一次总结任务会立即使用该模型。`, async () => {
await platformApi.activateContentModel(tenantID, item, accessToken);
});
}
async function revokeModel(item: ContentModel) {
if (!tenantID || !canManage) return;
await runAction("未激活模型已移除,密钥材料已清除。", async () => {
await platformApi.revokeContentModel(tenantID, item, accessToken);
});
}
return <div className="ops-content-model-workspace">
<Card className="quality-toolbar">
<div><strong>模型配置范围</strong><span>模型配置按客户组织隔离;只有维护操作员和维护管理员可以变更。</span></div>
<Space wrap>
<Select value={tenantID || undefined} style={{ minWidth: 240 }} placeholder="选择客户组织" onChange={setTenantID} options={accounts.map((item) => ({ value: item.tenant_id, label: item.tenant_name }))} />
<Button icon={<ReloadOutlined />} loading={loading} onClick={() => void loadModels()}>刷新模型配置</Button>
</Space>
</Card>
{!canManage ? <Alert type="info" showIcon message="当前维护角色为只读" description="维护查看员和维护审批员可以查看配置,但不能新增、测试、激活或移除模型。" /> : null}
{message ? <Alert type="success" showIcon closable message={message} onClose={() => setMessage(undefined)} /> : null}
<ContentModelPanel models={models} loading={loading} error={error} canManage={canManage} actionBusy={actionBusy}
onCreate={createModel} onTest={testModel} onActivate={activateModel} onRevoke={revokeModel} />
</div>;
}
function errorText(cause: unknown) {
return cause instanceof Error ? cause.message : "大模型配置操作失败";
}
+1 -1
View File
@@ -53,7 +53,7 @@ export function OpsDiagnosticsWorkspace({ accessToken, accounts }: { accessToken
async function search(event?:FormEvent){event?.preventDefault();if(!value.trim())return;setDiagnosticControlDraft(undefined);setDiagnosticBundleDraft(undefined);setLoading(true);setError(undefined);setMessage(undefined);try{const result=await platformApi.searchOpsDiagnostics(tenantID,field,value.trim(),accessToken);setItems(result.data.items)}catch(cause){setError(errorText(cause))}finally{setLoading(false)}} async function search(event?:FormEvent){event?.preventDefault();if(!value.trim())return;setDiagnosticControlDraft(undefined);setDiagnosticBundleDraft(undefined);setLoading(true);setError(undefined);setMessage(undefined);try{const result=await platformApi.searchOpsDiagnostics(tenantID,field,value.trim(),accessToken);setItems(result.data.items)}catch(cause){setError(errorText(cause))}finally{setLoading(false)}}
async function loadExternalDependencies(signal?:AbortSignal){if(!tenantID)return;setBusy(true);setError(undefined);try{const result=await platformApi.opsExternalDependencies(tenantID,accessToken,signal);setExternalDependencies(result.data.items)}catch(cause){if(!(cause instanceof DOMException&&cause.name==="AbortError"))setError(errorText(cause))}finally{if(!signal?.aborted)setBusy(false)}} async function loadExternalDependencies(signal?:AbortSignal){if(!tenantID)return;setBusy(true);setError(undefined);try{const result=await platformApi.opsExternalDependencies(tenantID,accessToken,signal);setExternalDependencies(result.data.items)}catch(cause){if(!(cause instanceof DOMException&&cause.name==="AbortError"))setError(errorText(cause))}finally{if(!signal?.aborted)setBusy(false)}}
async function loadPlatformHealth(signal?:AbortSignal){if(!tenantID)return;setBusy(true);setError(undefined);try{const result=await platformApi.opsPlatformHealth(tenantID,accessToken,signal);setPlatformHealth(result.data)}catch(cause){if(!(cause instanceof DOMException&&cause.name==="AbortError"))setError(errorText(cause))}finally{if(!signal?.aborted)setBusy(false)}} async function loadPlatformHealth(signal?:AbortSignal){if(!tenantID)return;setBusy(true);setError(undefined);try{const result=await platformApi.opsPlatformHealth(tenantID,accessToken,signal);setPlatformHealth(result.data)}catch(cause){if(cause instanceof APIRequestError&&cause.status===404){setPlatformHealth(undefined);return}if(!(cause instanceof DOMException&&cause.name==="AbortError"))setError(errorText(cause))}finally{if(!signal?.aborted)setBusy(false)}}
function openDiagnosticControl(kind:DiagnosticControlKind,item:OpsDiagnosticRecord){setDiagnosticControlDraft({kind,item,reason:"",acknowledged:false});setError(undefined);setMessage(undefined)} function openDiagnosticControl(kind:DiagnosticControlKind,item:OpsDiagnosticRecord){setDiagnosticControlDraft({kind,item,reason:"",acknowledged:false});setError(undefined);setMessage(undefined)}
async function executeDiagnosticControl(event:FormEvent){ async function executeDiagnosticControl(event:FormEvent){
event.preventDefault();const draft=diagnosticControlDraft;if(!draft||!draft.reason.trim()||(diagnosticControlNeedsAcknowledgement(draft.kind)&&!draft.acknowledged))return; event.preventDefault();const draft=diagnosticControlDraft;if(!draft||!draft.reason.trim()||(diagnosticControlNeedsAcknowledgement(draft.kind)&&!draft.acknowledged))return;
+34 -31
View File
@@ -77,7 +77,7 @@ import {
import { contentModelProviderLabel, contentModelProviderPreset, contentModelProviderPresets } from "../contentModelProviders"; import { contentModelProviderLabel, contentModelProviderPreset, contentModelProviderPresets } from "../contentModelProviders";
import { FamilyUsageOverview } from "../components/UsageOverview"; import { FamilyUsageOverview } from "../components/UsageOverview";
export type CustomerSection = "home" | "members" | "subjects" | "devices" | "conversations" | "stories" | "sharing" | "privacy" | "billing" | "messages" | "models"; export type CustomerSection = "home" | "members" | "subjects" | "devices" | "conversations" | "stories" | "sharing" | "privacy" | "billing" | "messages";
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";
@@ -199,8 +199,6 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
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 [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");
@@ -582,7 +580,6 @@ 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);
@@ -591,30 +588,12 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
const loadContentModels = useCallback(async (signal?: AbortSignal) => { const loadContentModels = useCallback(async (signal?: AbortSignal) => {
if (!selectedHousehold?.tenant_id) { setContentModels([]); return; } if (!selectedHousehold?.tenant_id) { setContentModels([]); return; }
setModelLoading(true); setModelError(undefined);
try { setContentModels((await platformApi.contentModels(selectedHousehold.tenant_id, accessToken, signal)).data.items); } try { setContentModels((await platformApi.contentModels(selectedHousehold.tenant_id, accessToken, signal)).data.items); }
catch (cause) { if (!(cause instanceof DOMException && cause.name === "AbortError")) setModelError(errorMessage(cause)); } catch (cause) { if (!(cause instanceof DOMException && cause.name === "AbortError")) setContentModels([]); }
finally { if (!signal?.aborted) setModelLoading(false); }
}, [accessToken, selectedHousehold?.tenant_id]); }, [accessToken, selectedHousehold?.tenant_id]);
useEffect(() => { const controller = new AbortController(); void loadContentModels(controller.signal); return () => controller.abort(); }, [loadContentModels, refreshRevision]); 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() { async function regenerateSummary() {
if (!selectedConversationID) return; if (!selectedConversationID) return;
await runAction("已使用当前激活模型排队重新总结;结果生成后会自动刷新。", async () => { await runAction("已使用当前激活模型排队重新总结;结果生成后会自动刷新。", async () => {
@@ -1509,7 +1488,6 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
stories={stories} onOpenStories={() => selectSection("stories")} 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} openRequestID={storyReviewRequestID} loading={subjectLoading} actionBusy={actionBusy} canReview={canReviewStory} stories={stories} memories={memories} selected={selectedStory} openRequestID={storyReviewRequestID} 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}
@@ -1636,7 +1614,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: "提醒与待办", models: "大模型管理" } as Record<Tab, string>)[section]; return ({ home: "陪伴周记", members: "家庭管理", subjects: "长者资料", devices: "陪伴设备", firmware: "固件发布", conversations: "回忆时间轴", stories: "故事与记忆", sharing: "内容分享", privacy: "数据与授权", billing: "服务用量", messages: "提醒与待办" } as Record<Tab, string>)[section];
} }
function customerSectionDescription(section: Tab) { function customerSectionDescription(section: Tab) {
@@ -1651,8 +1629,7 @@ function customerSectionDescription(section: Tab) {
sharing: "按资源和权限范围向指定家庭成员分享内容。", sharing: "按资源和权限范围向指定家庭成员分享内容。",
privacy: "提交访问、导出或删除申请并跟踪处理进度。", privacy: "提交访问、导出或删除申请并跟踪处理进度。",
billing: "汇总本家庭及设备的通话时长,并在核价可用时展示费用。", billing: "汇总本家庭及设备的通话时长,并在核价可用时展示费用。",
messages: "集中查看服务端记录的陪伴动态,并处理设备、故事和隐私请求待办。", messages: "集中查看服务端记录的陪伴动态,并处理设备、故事和隐私请求待办。"
models: "配置、测试并激活用于聊天摘要、关键词和故事候选生成的大模型。"
} as Record<Tab, string>)[section]; } as Record<Tab, string>)[section];
} }
@@ -2130,11 +2107,13 @@ function ConversationPanel({ subjects, selectedID, onSelect, conversations, insi
stories: Story[]; onOpenStories: () => void; stories: Story[]; onOpenStories: () => void;
}) { }) {
const [detailOpen, setDetailOpen] = useState(false); const [detailOpen, setDetailOpen] = useState(false);
const [timelineDayState, setTimelineDayState] = useState<Record<string, boolean>>({});
const currentSummary = summaries.find((item) => item.type === "conversation_summary"); const currentSummary = summaries.find((item) => item.type === "conversation_summary");
const currentTopic = summaries.find((item) => item.type === "open_topic"); const currentTopic = summaries.find((item) => item.type === "open_topic");
const summaryParts = currentSummary?.content.split("\n关键词:") ?? []; const summaryParts = currentSummary?.content.split("\n关键词:") ?? [];
const keywords = summaryParts[1]?.split("、").filter(Boolean) ?? []; const keywords = summaryParts[1]?.split("、").filter(Boolean) ?? [];
const storyCandidate = detail ? stories.find((item) => item.conversation_id === detail.id) : undefined; const storyCandidate = detail ? stories.find((item) => item.conversation_id === detail.id) : undefined;
const timelineGroups = groupConversationsByDay(conversations);
return <div className="conversation-layout memory-timeline-page"> return <div className="conversation-layout memory-timeline-page">
<div className="memory-timeline-grid"> <div className="memory-timeline-grid">
<div className="conversation-list"> <div className="conversation-list">
@@ -2146,11 +2125,10 @@ function ConversationPanel({ subjects, selectedID, onSelect, conversations, insi
<label><span>开始日期</span><input disabled={loadingMore} type="date" value={from} max={to || undefined} onChange={(event) => onFrom(event.target.value)} /></label> <label><span>开始日期</span><input disabled={loadingMore} type="date" value={from} max={to || undefined} onChange={(event) => onFrom(event.target.value)} /></label>
<label><span>结束日期</span><input disabled={loadingMore} type="date" value={to} min={from || undefined} onChange={(event) => onTo(event.target.value)} /></label> <label><span>结束日期</span><input disabled={loadingMore} type="date" value={to} min={from || undefined} onChange={(event) => onTo(event.target.value)} /></label>
</div> </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 className="conversation-filter__summary"><span>已加载 {conversations.length} 条{hasMore ? ",还有更多" : ""} · 最近 2 个记录日默认展开</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 ? <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>} {loading && conversations.length === 0 ? <StatePanel title="正在读取聊天记录" detail="按时间倒序加载…" /> : conversations.length ? <div className="memory-timeline-scroll"><div className="memory-timeline-list">{timelineGroups.map((group, index) => { const expanded = timelineDayState[group.key] ?? index < 2; return <section className="timeline-day-group" key={group.key}><button type="button" className="timeline-day-group__header" aria-expanded={expanded} aria-controls={`timeline-day-${group.key}`} onClick={() => setTimelineDayState((current) => ({ ...current, [group.key]: !expanded }))}><span><strong>{group.label}</strong><small>{group.weekday}</small></span><span className="timeline-day-group__count">{group.items.length} 次聊天</span><span className={expanded ? "timeline-day-group__chevron is-expanded" : "timeline-day-group__chevron"} aria-hidden="true">⌄</span></button>{expanded ? <div className="timeline-day-group__items" id={`timeline-day-${group.key}`}>{group.items.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>{formatConversationTime(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> : null}</section>; })}</div>{hasMore ? <div className="conversation-list__more"><button type="button" className="button-secondary" disabled={loading || loadingMore} onClick={() => void onLoadMore()}>{loadingMore ? "正在加载…" : "加载更多记录"}</button></div> : null}</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}
</div> </div>
<section className="memory-summary-panel" aria-label="智能总结"> <section className="memory-summary-panel" aria-label="智能总结">
{detailLoading ? <StatePanel title="正在生成回忆卡片" detail="读取聊天、摘要和故事候选…" /> : detail ? <> {detailLoading ? <StatePanel title="正在生成回忆卡片" detail="读取聊天、摘要和故事候选…" /> : detail ? <>
@@ -2180,7 +2158,7 @@ function ConversationPanel({ subjects, selectedID, onSelect, conversations, insi
</div>; </div>;
} }
function ContentModelPanel({ models, loading, error, canManage, actionBusy, onCreate, onTest, onActivate, onRevoke }: { export function ContentModelPanel({ models, loading, error, canManage, actionBusy, onCreate, onTest, onActivate, onRevoke }: {
models: ContentModel[]; loading: boolean; error?: string; canManage: boolean; actionBusy: boolean; models: ContentModel[]; loading: boolean; error?: string; canManage: boolean; actionBusy: boolean;
onCreate: (input: CreateContentModelRequest) => Promise<boolean>; onTest: (item: ContentModel) => Promise<void>; onCreate: (input: CreateContentModelRequest) => Promise<boolean>; onTest: (item: ContentModel) => Promise<void>;
onActivate: (item: ContentModel) => Promise<void>; onRevoke: (item: ContentModel) => Promise<void>; onActivate: (item: ContentModel) => Promise<void>; onRevoke: (item: ContentModel) => Promise<void>;
@@ -2720,6 +2698,31 @@ function subjectProfileChangeSummary(subject: Subject, input: UpdateSubjectReque
if (JSON.stringify(nextAvoidedTopics) !== JSON.stringify(currentAvoidedTopics)) changes.push(`避免主动提及“${contextItemsLabel(currentAvoidedTopics)}”→“${contextItemsLabel(nextAvoidedTopics)}”`); if (JSON.stringify(nextAvoidedTopics) !== JSON.stringify(currentAvoidedTopics)) changes.push(`避免主动提及“${contextItemsLabel(currentAvoidedTopics)}”→“${contextItemsLabel(nextAvoidedTopics)}”`);
return changes.length ? changes : ["无有效变化"]; return changes.length ? changes : ["无有效变化"];
} }
function groupConversationsByDay(conversations: ConversationSummary[]) {
const groups = new Map<string, { key: string; label: string; weekday: string; items: ConversationSummary[] }>();
const today = localDateKey(new Date());
const yesterdayDate = new Date();
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
const yesterday = localDateKey(yesterdayDate);
for (const conversation of conversations) {
const date = new Date(conversation.created_at);
const key = localDateKey(date);
let group = groups.get(key);
if (!group) {
group = {
key,
label: key === today ? "今天" : key === yesterday ? "昨天" : new Intl.DateTimeFormat("zh-CN", { month: "long", day: "numeric" }).format(date),
weekday: new Intl.DateTimeFormat("zh-CN", { weekday: "short" }).format(date),
items: []
};
groups.set(key, group);
}
group.items.push(conversation);
}
return [...groups.values()];
}
function formatConversationTime(value: string) { return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false }).format(new Date(value)); }
function formatDate(value: string) { return new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }).format(new Date(value)); } function formatDate(value: string) { return new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }).format(new Date(value)); }
function statusLabel(value: string) { return ({ active: "启用", suspended: "暂停", deleting: "删除处理中" } as Record<string, string>)[value] ?? value; } function statusLabel(value: string) { return ({ active: "启用", suspended: "暂停", deleting: "删除处理中" } as Record<string, string>)[value] ?? value; }
function factTypeLabel(value: string) { return ({ family_note: "家庭信息", preference: "个人偏好", life_event: "人生事件", health_context: "健康背景" } as Record<string, string>)[value] ?? value; } function factTypeLabel(value: string) { return ({ family_note: "家庭信息", preference: "个人偏好", life_event: "人生事件", health_context: "健康背景" } as Record<string, string>)[value] ?? value; }
+45 -5
View File
@@ -30,7 +30,7 @@ button {
} }
button:hover { transform: translateY(-1px); } button:hover { transform: translateY(-1px); }
button:disabled { cursor: wait; opacity: 0.65; } button:disabled { cursor: not-allowed; opacity: 0.65; }
.button-secondary { color: #244356; background: #e5ece9; } .button-secondary { color: #244356; background: #e5ece9; }
main { main {
@@ -45,12 +45,22 @@ main {
.memory-timeline-page { display: block !important; min-height: 0 !important; border: 0 !important; overflow: visible !important; background: transparent !important; } .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 { 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 > * { 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-list { display: flex; max-height: min(720px, calc(100dvh - 210px)); overflow: hidden; flex-direction: column; }
.memory-timeline-grid .conversation-filter { border-bottom: 1px solid #eee4e1; background: #faf6f2; } .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 { grid-template-columns: 1fr 1fr; }
.memory-timeline-grid .conversation-filter__fields label:nth-child(n+3) { display: none; } .memory-timeline-grid .conversation-filter__fields label:nth-child(n+3) { display: none; }
.memory-timeline-list { position: relative; padding: 8px 0 12px; } .memory-timeline-scroll { min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; }
.memory-timeline-list::before { position: absolute; top: 15px; bottom: 22px; left: 25px; width: 2px; content: ""; background: #dfcbc4; } .memory-timeline-list { padding: 8px 0 12px; }
.timeline-day-group + .timeline-day-group { border-top: 1px solid #eee5e1; }
.timeline-day-group__header { display: grid; grid-template-columns: minmax(0, 1fr) auto 18px; align-items: center; gap: 8px; width: 100%; padding: 11px 14px; border: 0; border-radius: 0; color: #66514a; background: #fffaf7; box-shadow: none; text-align: left; }
.timeline-day-group__header:hover { color: #793b2c; background: #f8eeea; transform: none; box-shadow: none; }
.timeline-day-group__header > span:first-child { display: flex; align-items: baseline; gap: 7px; min-width: 0; }
.timeline-day-group__header strong { color: #553f39; font-size: 13px; }
.timeline-day-group__header small, .timeline-day-group__count { color: #9a8179; font-size: 11px; font-weight: 500; }
.timeline-day-group__chevron { color: #a27164; font-size: 18px; line-height: 1; transform: rotate(-90deg); transition: transform .18s ease; }
.timeline-day-group__chevron.is-expanded { transform: rotate(0deg); }
.timeline-day-group__items { position: relative; padding: 2px 0 8px; }
.timeline-day-group__items::before { position: absolute; top: 9px; bottom: 17px; 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 { 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.active { background: #f7ebe6; box-shadow: inset 3px 0 #a73920; }
.memory-timeline-list .conversation-row > .tag { grid-column: 3; justify-self: start; } .memory-timeline-list .conversation-row > .tag { grid-column: 3; justify-self: start; }
@@ -3506,7 +3516,7 @@ textarea:focus,
.customer-layout button:not(.ant-btn) { border-radius: 8px !important; } .customer-layout button:not(.ant-btn) { border-radius: 8px !important; }
.customer-layout button:disabled, .customer-layout button:disabled,
.customer-layout [role="button"][aria-disabled="true"] { cursor: wait !important; opacity: .5; transform: none !important; } .customer-layout [role="button"][aria-disabled="true"] { cursor: not-allowed !important; opacity: .5; transform: none !important; }
.subject-avatar-upload { position: relative; display: grid; grid-row: span 2; place-items: center; align-content: center; gap: 7px; min-height: 132px; overflow: hidden; border: 1px dashed #c9c0b5; border-radius: 12px; color: var(--journal-blue); background: #fbf8f3; cursor: pointer; } .subject-avatar-upload { position: relative; display: grid; grid-row: span 2; place-items: center; align-content: center; gap: 7px; min-height: 132px; overflow: hidden; border: 1px dashed #c9c0b5; border-radius: 12px; color: var(--journal-blue); background: #fbf8f3; cursor: pointer; }
.subject-avatar-upload input,.subject-avatar-editor input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; } .subject-avatar-upload input,.subject-avatar-editor input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.subject-avatar-upload > .anticon { font-size: 27px; } .subject-avatar-upload > .anticon { font-size: 27px; }
@@ -3582,3 +3592,33 @@ textarea:focus,
.customer-page--conversations .memory-timeline-grid { grid-template-columns: 1fr; } .customer-page--conversations .memory-timeline-grid { grid-template-columns: 1fr; }
.customer-page--conversations .memory-model-card { grid-column: auto; } .customer-page--conversations .memory-model-card { grid-column: auto; }
} }
/* Keep family-facing interactive cards in the warm, calm customer palette. */
.customer-page .timeline-day-group__header:not(.ant-btn) { border: 0 !important; border-radius: 0 !important; color: #66514a !important; background: #fffaf7 !important; box-shadow: none !important; }
.customer-page .timeline-day-group__header:not(.ant-btn):hover { color: #793b2c !important; background: #f8eeea !important; }
:is(.customer-page, .maintenance-workspace) .model-provider-group button:not(.ant-btn) { border: 1px solid #e6d9d4 !important; color: #594a45 !important; background: #fff !important; box-shadow: none !important; }
:is(.customer-page, .maintenance-workspace) .model-provider-group button:not(.ant-btn):hover { border-color: #cfa79c !important; color: #6e3f34 !important; background: #fff8f5 !important; }
:is(.customer-page, .maintenance-workspace) .model-provider-group button.is-selected:not(.ant-btn) { border-color: #78919a !important; color: #344e5a !important; background: #edf4f6 !important; box-shadow: inset 0 0 0 1px #78919a !important; }
.ops-content-model-workspace { display: grid; gap: 16px; }
.customer-page .reminder-todos { display: grid; overflow: hidden; margin: 0 18px 18px; border: 1px solid #eee2de; border-radius: 12px; background: #fff; }
.customer-page .reminder-todo:not(.ant-btn) { display: grid; grid-template-columns: 8px minmax(0, 1fr) auto; align-items: center; gap: 10px; min-height: 70px; padding: 13px 16px !important; border: 0 !important; border-bottom: 1px solid #f0e7e4 !important; border-radius: 0 !important; color: #554641 !important; background: #fff !important; box-shadow: none !important; text-align: left; }
.customer-page .reminder-todo:last-child { border-bottom: 0 !important; }
.customer-page .reminder-todo:hover:not(.ant-btn) { color: #554641 !important; background: #fff8f5 !important; }
.customer-page .reminder-todo > span { width: 7px; height: 7px; border-radius: 50%; background: #6f91a1; }
.customer-page .reminder-todo--warm > span { background: #d27755; }
.customer-page .reminder-todo strong, .customer-page .reminder-todo p { margin: 0; }
.customer-page .reminder-todo strong { color: #4e3e39; font-size: 13px; }
.customer-page .reminder-todo p { margin-top: 4px; color: #8b7770; font-size: 11px; font-weight: 400; line-height: 1.45; }
.customer-page .reminder-todo small { color: #8e4939; font-size: 11px; white-space: nowrap; }
.customer-page .reminder-history__summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); overflow: hidden; margin: 0 18px 18px; border: 1px solid #eee2de; border-radius: 12px; background: #fff; }
.customer-page .reminder-history__summary > button:not(.ant-btn), .customer-page .reminder-history__summary > div { display: grid; gap: 5px; min-height: 84px; padding: 17px 18px !important; border: 0 !important; border-right: 1px solid #f0e7e4 !important; border-radius: 0 !important; color: #554641 !important; background: #fff !important; box-shadow: none !important; text-align: left; }
.customer-page .reminder-history__summary > *:last-child { border-right: 0 !important; }
.customer-page .reminder-history__summary > button:hover:not(.ant-btn) { color: #554641 !important; background: #fff8f5 !important; }
.customer-page .reminder-history__summary strong { color: #65473e; font-size: 23px; }
.customer-page .reminder-history__summary span { color: #8b7770; font-size: 11px; font-weight: 500; }
@media (max-width: 760px) {
.memory-timeline-grid .conversation-list { max-height: min(640px, 68dvh); }
.customer-page .reminder-history__summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.customer-page .reminder-history__summary > *:nth-child(2) { border-right: 0 !important; }
.customer-page .reminder-history__summary > *:nth-child(-n+2) { border-bottom: 1px solid #f0e7e4 !important; }
}