From a8cdec98d13211703a82c054a70a14ac01bb5993 Mon Sep 17 00:00:00 2001 From: Codex Local Date: Mon, 31 Aug 2026 11:29:56 +0800 Subject: [PATCH] feat: finalize operations model workspace --- internal/contentmodel/service.go | 5 +- .../contentmodel/service_integration_test.go | 10 +- internal/extraction/processor.go | 5 +- .../extraction/processor_integration_test.go | 14 +++ internal/identity/permissions.go | 3 - test/e2e/visual-customer-v074.mjs | 48 +++++++- test/e2e/visual-maintenance-navigation.mjs | 110 ++++++++++++++++++ web/src/cal-theme.css | 2 +- web/src/main.tsx | 17 ++- web/src/pages/CustomerPortal.tsx | 7 +- web/src/pages/MaintenancePortal.tsx | 26 ++++- web/src/pages/OpsContentModelWorkspace.tsx | 105 +++++++++++++++++ web/src/pages/OpsDiagnosticsWorkspace.tsx | 2 +- web/src/pages/SaaSWorkspace.tsx | 65 ++++++----- web/src/styles.css | 50 +++++++- 15 files changed, 406 insertions(+), 63 deletions(-) create mode 100644 test/e2e/visual-maintenance-navigation.mjs create mode 100644 web/src/pages/OpsContentModelWorkspace.tsx diff --git a/internal/contentmodel/service.go b/internal/contentmodel/service.go index bc9afb7..4f3776f 100644 --- a/internal/contentmodel/service.go +++ b/internal/contentmodel/service.go @@ -334,11 +334,10 @@ func (s *Service) resolveActor(ctx context.Context, principal identity.Principal 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) + 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' - 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) + `, 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 } diff --git a/internal/contentmodel/service_integration_test.go b/internal/contentmodel/service_integration_test.go index fee6e75..0d6d741 100644 --- a/internal/contentmodel/service_integration_test.go +++ b/internal/contentmodel/service_integration_test.go @@ -3,6 +3,7 @@ package contentmodel import ( "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "os" @@ -38,7 +39,14 @@ func TestServiceTestsAndActivatesCompatibleModel(t *testing.T) { if err != nil { 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{ 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, diff --git a/internal/extraction/processor.go b/internal/extraction/processor.go index c9cae3f..f658eff 100644 --- a/internal/extraction/processor.go +++ b/internal/extraction/processor.go @@ -91,7 +91,10 @@ func (p *Processor) Handle(ctx context.Context, event outbox.Event) error { } sourceTurns := subjectTurns(snapshot.turns) 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) generated, err := p.generateSafely(ctx, content, snapshot.tenantID) diff --git a/internal/extraction/processor_integration_test.go b/internal/extraction/processor_integration_test.go index 8d81ea4..4400bfb 100644 --- a/internal/extraction/processor_integration_test.go +++ b/internal/extraction/processor_integration_test.go @@ -39,6 +39,7 @@ func TestProcessorExtractionIdempotencyAndSensitiveContent(t *testing.T) { consentSnapshotID, consentSnapshotPublicID := uuid.NewString(), uuid.NewString() contextID, contextPublicID := uuid.NewString(), uuid.NewString() conversationID, conversationPublicID := uuid.NewString(), uuid.NewString() + emptyConversationID, emptyConversationPublicID := uuid.NewString(), uuid.NewString() defer func() { _, _ = 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) @@ -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_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`, emptyConversationID) _, _ = pool.Exec(ctx, `DELETE FROM context_snapshot WHERE id=$1::uuid`, contextID) _, _ = 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 { 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, ` 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'), diff --git a/internal/identity/permissions.go b/internal/identity/permissions.go index 6ce2679..f823c5c 100644 --- a/internal/identity/permissions.go +++ b/internal/identity/permissions.go @@ -27,7 +27,6 @@ const ( CapabilityRecordingDownload Capability = "recording.download" CapabilityPrivacyRead Capability = "privacy.read" CapabilityPrivacyManage Capability = "privacy.manage" - CapabilityModelManage Capability = "model.manage" ) var roleCapabilities = map[Role]map[Capability]struct{}{ @@ -36,14 +35,12 @@ var roleCapabilities = map[Role]map[Capability]struct{}{ CapabilityOwnershipTransfer, CapabilitySubjectRead, CapabilitySubjectWrite, CapabilityConversationRead, CapabilityStoryReview, CapabilityDeviceManage, CapabilityFirmwareManage, CapabilityPrivacyRead, CapabilityPrivacyManage, - CapabilityModelManage, ), RoleAdmin: capabilitySet( CapabilityHouseholdRead, CapabilityHouseholdManage, CapabilityMemberManage, CapabilitySubjectRead, CapabilitySubjectWrite, CapabilityConversationRead, CapabilityStoryReview, CapabilityDeviceManage, CapabilityFirmwareManage, CapabilityPrivacyRead, - CapabilityModelManage, ), RoleEditor: capabilitySet( CapabilityHouseholdRead, CapabilitySubjectRead, CapabilitySubjectWrite, diff --git a/test/e2e/visual-customer-v074.mjs b/test/e2e/visual-customer-v074.mjs index 6afa18e..58c5611 100644 --- a/test/e2e/visual-customer-v074.mjs +++ b/test/e2e/visual-customer-v074.mjs @@ -15,13 +15,12 @@ const sections = [ ["conversations", "回忆时间轴", "聊天记录"], ["stories", "故事与记忆", "故事与记忆"], ["subjects", "长者资料", "长者资料"], - ["messages", "日程与提醒", "日程与提醒"], + ["messages", "提醒与待办", "提醒与待办"], ["members", "家庭管理", "家庭管理"], ["devices", "陪伴设备", "陪伴设备"], ["sharing", "内容分享", "内容分享"], ["privacy", "数据与授权", "数据与授权"], ["billing", "服务用量", "服务用量"], - ["models", "大模型管理", "大模型管理"], ["profile", "设置与权限", "设置与权限"], ]; @@ -97,6 +96,43 @@ try { 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("heading", { name: "长者资料", exact: true, level: 1 }).waitFor(); await page.locator("button.subject-add-button").click(); @@ -107,7 +143,8 @@ try { await page.screenshot({ path: resolve(outputDirectory, "subjects-avatar-upload.png") }); const disabledCursorResult = await page.evaluate(() => { 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(); @@ -132,7 +169,6 @@ try { await checkModal("陪伴设备", ".workspace--devices .workspace__actions button:has-text('绑定设备')", "绑定设备", "绑定新设备"); await checkModal("内容分享", ".subject-list-toolbar > button", "新建分享", "新建资源分享"); await checkModal("数据与授权", ".privacy-actions button:has-text('资料更正')", "资料更正", "提交资料更正"); - await checkModal("大模型管理", ".model-active-hero > button", "添加大模型", "添加总结大模型"); await page.getByRole("menuitem", { name: /故事与记忆$/ }).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 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)); - 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"); } } finally { diff --git a/test/e2e/visual-maintenance-navigation.mjs b/test/e2e/visual-maintenance-navigation.mjs new file mode 100644 index 0000000..06d4924 --- /dev/null +++ b/test/e2e/visual-maintenance-navigation.mjs @@ -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(); +} diff --git a/web/src/cal-theme.css b/web/src/cal-theme.css index e133721..f52a281 100644 --- a/web/src/cal-theme.css +++ b/web/src/cal-theme.css @@ -583,7 +583,7 @@ input[type="checkbox"], input[type="radio"], input[type="range"] { accent-color: .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; } .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. */ .customer-page .panel-section, diff --git a/web/src/main.tsx b/web/src/main.tsx index 4f734bd..1bc674e 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,4 +1,4 @@ -import { StrictMode } from "react"; +import { StrictMode, useEffect, useState } from "react"; import { createRoot } from "react-dom/client"; import { ConfigProvider } from "antd"; import zhCN from "antd/locale/zh_CN"; @@ -7,7 +7,18 @@ import { MaintenancePortal } from "./pages/MaintenancePortal"; import "./styles.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 ; +} createRoot(document.getElementById("root")!).render( @@ -67,7 +78,7 @@ createRoot(document.getElementById("root")!).render( } }} > - + ); diff --git a/web/src/pages/CustomerPortal.tsx b/web/src/pages/CustomerPortal.tsx index 20a5a4d..cb6dfe9 100644 --- a/web/src/pages/CustomerPortal.tsx +++ b/web/src/pages/CustomerPortal.tsx @@ -42,7 +42,6 @@ import { SafetyCertificateOutlined, SearchOutlined, SettingOutlined, - RobotOutlined, TeamOutlined, UserOutlined } from "@ant-design/icons"; @@ -62,7 +61,6 @@ const sectionMeta: Record, label: "服务用量" }, - { key: "models", icon: , label: "大模型管理" }, { key: "profile", icon: , label: "设置与权限" } ], []); @@ -123,7 +120,6 @@ export function CustomerPortal() { [["分享", "share"], "sharing"], [["费用", "额度", "账单", "cost"], "billing"], [["消息", "通知", "提醒"], "messages"], - [["大模型", "模型", "豆包", "总结"], "models"], [["长者", "老人", "档案", "subject"], "subjects"], [["家庭", "成员", "邀请", "member"], "members"] ]; @@ -271,11 +267,10 @@ const permissionRows = [ ["privacy.manage", "处理隐私请求", true, false, false, false], ["recording.play", "播放聊天录音", false, false, false, false], ["recording.download", "下载聊天录音", false, false, false, false], - ["model.manage", "管理总结大模型", true, true, false, false], ] as const; function PermissionMatrix() { - return 15 项}> + return {permissionRows.length} 项}>

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

{permissionRows.map(([code, label, owner, admin, editor, viewer]) => {[owner, admin, editor, viewer].map((enabled, index) => )})}
权限所有者管理员编辑者查看者
{label}{code}{enabled ? : }
; diff --git a/web/src/pages/MaintenancePortal.tsx b/web/src/pages/MaintenancePortal.tsx index 68b7aa0..c0327cb 100644 --- a/web/src/pages/MaintenancePortal.tsx +++ b/web/src/pages/MaintenancePortal.tsx @@ -29,6 +29,7 @@ import { MenuFoldOutlined, MenuUnfoldOutlined, ReloadOutlined, + RobotOutlined, SearchOutlined, SettingOutlined, ToolOutlined, @@ -42,8 +43,9 @@ import { OpsBillingWorkspace } from "./OpsBillingWorkspace"; import { OpsConfigWorkspace } from "./OpsConfigWorkspace"; import { OpsDiagnosticsWorkspace } from "./OpsDiagnosticsWorkspace"; 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 = { loading: boolean; @@ -59,6 +61,7 @@ const areaMeta: Record }, devices: { title: "设备管理", description: "登记设备、维护家庭与长者归属、吊销设备并安全轮换密钥。", icon: }, config: { title: "运行配置", description: "管理配置版本、资源引用、离线评测、审批和灰度发布。", icon: }, + models: { title: "大模型管理", description: "按客户组织配置、测试并激活聊天总结、关键词和故事候选生成模型。", icon: }, quality: { title: "内容质量", description: "按模型和提示词版本检查故事、事实与记忆的人工审核反馈。", icon: }, billing: { title: "用量、费用与额度", description: "查看整体通话时长并按家庭、设备筛选,同时维护核价与硬额度策略。", icon: }, alerts: { title: "告警处置", description: "维护告警路由、通知测试、静默窗口和处置时间线。", icon: }, @@ -69,6 +72,7 @@ export function MaintenancePortal() { const auth = useAuth(); const [collapsed, setCollapsed] = useState(() => window.innerWidth <= 720); const [area, setArea] = useState(() => areaFromURL()); + const [searchValue, setSearchValue] = useState(""); const [health, setHealth] = useState({ loading: true }); const refreshHealth = useCallback(async (signal?: AbortSignal) => { @@ -120,11 +124,28 @@ export function MaintenancePortal() { 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"] = [ { type: "group", label: "平台维护", children: [ { key: "overview", icon: , label: "维护总览" }, { key: "devices", icon: , label: "设备管理" }, { key: "config", icon: , label: "运行配置" }, + { key: "models", icon: , label: "大模型管理" }, { key: "quality", icon: , label: "内容质量" }, { key: "billing", icon: , label: "用量与费用" }, { key: "alerts", icon: , label: "告警处置" }, @@ -147,7 +168,7 @@ export function MaintenancePortal() { {(Object.entries(areaMeta) as Array<[MaintenanceArea, (typeof areaMeta)[MaintenanceArea]]>).map(([key, item]) => )}
- } placeholder="搜索设备、通话、作业" allowClear className="maintenance-search" /> + setSearchValue(event.target.value)} onSearch={search} prefix={} placeholder="搜索设备、通话、作业" allowClear className="maintenance-search" /> : } @@ -176,6 +197,7 @@ export function MaintenancePortal() { {area === "overview" ? : null} {area === "devices" ? : null} {area === "config" ? : null} + {area === "models" ? : null} {area === "quality" ? : null} {area === "billing" ? : null} {area === "alerts" ? : null} diff --git a/web/src/pages/OpsContentModelWorkspace.tsx b/web/src/pages/OpsContentModelWorkspace.tsx new file mode 100644 index 0000000..19de65a --- /dev/null +++ b/web/src/pages/OpsContentModelWorkspace.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const [actionBusy, setActionBusy] = useState(false); + const [error, setError] = useState(); + const [message, setMessage] = useState(); + 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) { + 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
+ +
模型配置范围模型配置按客户组织隔离;只有维护操作员和维护管理员可以变更。
+ + onFrom(event.target.value)} />
-
已加载 {conversations.length} 条{hasMore ? ",还有更多" : ""}{status || engineMode || from || to ? : null}
+
已加载 {conversations.length} 条{hasMore ? ",还有更多" : ""} · 最近 2 个记录日默认展开{status || engineMode || from || to ? : null}
{error ?
{error}
: null} - {loading && conversations.length === 0 ? : conversations.length ?
{conversations.map((item) => { const insight = insights[item.id]; return ; })}
:

暂无聊天记录。

} - {hasMore ?
: null} + {loading && conversations.length === 0 ? : conversations.length ?
{timelineGroups.map((group, index) => { const expanded = timelineDayState[group.key] ?? index < 2; return
{expanded ?
{group.items.map((item) => { const insight = insights[item.id]; return ; })}
: null}
; })}
{hasMore ?
: null}
:

暂无聊天记录。

}
{detailLoading ? : detail ? <> @@ -2180,7 +2158,7 @@ function ConversationPanel({ subjects, selectedID, onSelect, conversations, insi ; } -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; onCreate: (input: CreateContentModelRequest) => Promise; onTest: (item: ContentModel) => Promise; onActivate: (item: ContentModel) => Promise; onRevoke: (item: ContentModel) => Promise; @@ -2720,6 +2698,31 @@ function subjectProfileChangeSummary(subject: Subject, input: UpdateSubjectReque if (JSON.stringify(nextAvoidedTopics) !== JSON.stringify(currentAvoidedTopics)) changes.push(`避免主动提及“${contextItemsLabel(currentAvoidedTopics)}”→“${contextItemsLabel(nextAvoidedTopics)}”`); return changes.length ? changes : ["无有效变化"]; } +function groupConversationsByDay(conversations: ConversationSummary[]) { + const groups = new Map(); + 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 statusLabel(value: string) { return ({ active: "启用", suspended: "暂停", deleting: "删除处理中" } as Record)[value] ?? value; } function factTypeLabel(value: string) { return ({ family_note: "家庭信息", preference: "个人偏好", life_event: "人生事件", health_context: "健康背景" } as Record)[value] ?? value; } diff --git a/web/src/styles.css b/web/src/styles.css index 8bfeb06..4e17ceb 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -30,7 +30,7 @@ button { } 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; } 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-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-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__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-scroll { min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; } +.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.active { background: #f7ebe6; box-shadow: inset 3px 0 #a73920; } .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: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 input,.subject-avatar-editor input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; } .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-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; } +}