This commit is contained in:
@@ -34,6 +34,13 @@ export type ConversationDetail = components["schemas"]["ConversationDetail"];
|
||||
export type ConversationUsage = components["schemas"]["ConversationUsage"];
|
||||
export type ConversationTurn = components["schemas"]["ConversationTurn"];
|
||||
export type ConversationContentSummary = components["schemas"]["ConversationContentSummary"];
|
||||
export type ContentModel = {
|
||||
id: string; tenant_id: string; display_name: string; provider: "ark" | "openai_compatible";
|
||||
base_url: string; model_id: string; credential_hint: string; temperature: number; max_tokens: number;
|
||||
status: "inactive" | "active" | "revoked"; test_status: "untested" | "passed" | "failed";
|
||||
last_tested_at?: string; last_error_code?: string; activated_at?: string; version: number; created_at: string; updated_at: string;
|
||||
};
|
||||
export type CreateContentModelRequest = { display_name: string; provider: "ark" | "openai_compatible"; base_url: string; model_id: string; api_key: string; temperature: number; max_tokens: number };
|
||||
export type ConversationMedia = components["schemas"]["ConversationMedia"];
|
||||
export type MediaSegment = components["schemas"]["MediaSegment"];
|
||||
export type RecordingAccessGrant = components["schemas"]["RecordingAccessGrant"];
|
||||
@@ -306,6 +313,18 @@ export const platformApi = {
|
||||
authenticatedJSON<Envelope<{ items: ConversationTurn[]; next_cursor?: string }>>("GET", `/api/v1/conversations/${conversationID}/turns?limit=100`, accessToken, signal),
|
||||
conversationSummaries: (conversationID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<{ items: ConversationContentSummary[] }>>("GET", `/api/v1/conversations/${conversationID}/summaries`, accessToken, signal),
|
||||
regenerateConversationSummary: (conversationID: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<{ generation_version: number; status: string }>>("POST", `/api/v1/conversations/${conversationID}/summaries:regenerate`, accessToken),
|
||||
contentModels: (tenantID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<{ items: ContentModel[] }>>("GET", "/api/v1/content-models", accessToken, signal, undefined, { "X-Tenant-Id": tenantID }),
|
||||
createContentModel: (tenantID: string, input: CreateContentModelRequest, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<ContentModel>>("POST", "/api/v1/content-models", accessToken, undefined, input, { "X-Tenant-Id": tenantID }),
|
||||
testContentModel: (tenantID: string, modelID: string, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<ContentModel>>("POST", `/api/v1/content-models/${modelID}/test`, accessToken, undefined, undefined, { "X-Tenant-Id": tenantID }),
|
||||
activateContentModel: (tenantID: string, model: ContentModel, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<ContentModel>>("POST", `/api/v1/content-models/${model.id}/activate`, accessToken, undefined, undefined, { "X-Tenant-Id": tenantID, "If-Match": `"v${model.version}"` }),
|
||||
revokeContentModel: (tenantID: string, model: ContentModel, accessToken: string) =>
|
||||
authenticatedJSON<Envelope<ContentModel>>("DELETE", `/api/v1/content-models/${model.id}`, accessToken, undefined, undefined, { "X-Tenant-Id": tenantID, "If-Match": `"v${model.version}"` }),
|
||||
conversationMedia: (conversationID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
authenticatedJSON<Envelope<ConversationMedia>>("GET", `/api/v1/conversations/${conversationID}/media`, accessToken, signal),
|
||||
recordingAccessGrants: (subjectID: string, accessToken: string, signal?: AbortSignal) =>
|
||||
|
||||
Vendored
+306
@@ -37,6 +37,70 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/content-models": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listContentModels"];
|
||||
put?: never;
|
||||
post: operations["createContentModel"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/content-models/{id}/test": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["testContentModel"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/content-models/{id}/activate": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["activateContentModel"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/content-models/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete: operations["revokeContentModel"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/ops/config-versions": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1892,6 +1956,24 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/conversations/{id}/summaries:regenerate": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: components["parameters"]["ResourceID"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["regenerateConversationSummary"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/subjects/{id}/stories": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -5259,6 +5341,48 @@ export interface components {
|
||||
/** Format: date-time */
|
||||
created_at: string;
|
||||
};
|
||||
ContentModel: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
/** Format: uuid */
|
||||
tenant_id: string;
|
||||
display_name: string;
|
||||
/** @enum {string} */
|
||||
provider: "ark" | "openai_compatible";
|
||||
/** Format: uri */
|
||||
base_url: string;
|
||||
model_id: string;
|
||||
/** @description 仅包含掩码和密钥末四位。 */
|
||||
credential_hint: string;
|
||||
temperature: number;
|
||||
max_tokens: number;
|
||||
/** @enum {string} */
|
||||
status: "inactive" | "active" | "revoked";
|
||||
/** @enum {string} */
|
||||
test_status: "untested" | "passed" | "failed";
|
||||
/** Format: date-time */
|
||||
last_tested_at?: string;
|
||||
last_error_code?: string;
|
||||
/** Format: date-time */
|
||||
activated_at?: string;
|
||||
/** Format: int64 */
|
||||
version: number;
|
||||
/** Format: date-time */
|
||||
created_at: string;
|
||||
/** Format: date-time */
|
||||
updated_at: string;
|
||||
};
|
||||
CreateContentModelRequest: {
|
||||
display_name: string;
|
||||
/** @enum {string} */
|
||||
provider: "ark" | "openai_compatible";
|
||||
/** Format: uri */
|
||||
base_url: string;
|
||||
model_id: string;
|
||||
api_key: string;
|
||||
temperature: number;
|
||||
max_tokens: number;
|
||||
};
|
||||
/** @enum {string} */
|
||||
StoryStatus: "CANDIDATE" | "IN_REVIEW" | "CONFIRMED" | "REJECTED" | "SUPERSEDED";
|
||||
StorySource: {
|
||||
@@ -6104,6 +6228,25 @@ export interface components {
|
||||
items: components["schemas"]["ConversationContentSummary"][];
|
||||
};
|
||||
};
|
||||
ConversationSummaryRegenerationResponse: {
|
||||
request_id: string;
|
||||
data: {
|
||||
/** Format: int64 */
|
||||
generation_version: number;
|
||||
/** @enum {string} */
|
||||
status: "queued";
|
||||
};
|
||||
};
|
||||
ContentModelResponse: {
|
||||
request_id: string;
|
||||
data: components["schemas"]["ContentModel"];
|
||||
};
|
||||
ContentModelListResponse: {
|
||||
request_id: string;
|
||||
data: {
|
||||
items: components["schemas"]["ContentModel"][];
|
||||
};
|
||||
};
|
||||
StoryResponse: {
|
||||
request_id: string;
|
||||
data: components["schemas"]["Story"];
|
||||
@@ -6366,6 +6509,145 @@ export interface operations {
|
||||
403: components["responses"]["AccessDeniedError"];
|
||||
};
|
||||
};
|
||||
listContentModels: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header: {
|
||||
/** @description 待创建资源所属的公开客户组织编号,不能是数据库内部主键。 */
|
||||
"X-Tenant-Id": components["parameters"]["RequiredTenantSelectionHeader"];
|
||||
};
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description 当前账号空间可用的总结模型;不返回密钥明文 */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ContentModelListResponse"];
|
||||
};
|
||||
};
|
||||
403: components["responses"]["AccessDeniedError"];
|
||||
};
|
||||
};
|
||||
createContentModel: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header: {
|
||||
/** @description 待创建资源所属的公开客户组织编号,不能是数据库内部主键。 */
|
||||
"X-Tenant-Id": components["parameters"]["RequiredTenantSelectionHeader"];
|
||||
};
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["CreateContentModelRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description 模型配置和加密凭据已保存 */
|
||||
201: {
|
||||
headers: {
|
||||
ETag: components["headers"]["StrongETag"];
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ContentModelResponse"];
|
||||
};
|
||||
};
|
||||
400: components["responses"]["ValidationError"];
|
||||
403: components["responses"]["AccessDeniedError"];
|
||||
};
|
||||
};
|
||||
testContentModel: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header: {
|
||||
/** @description 待创建资源所属的公开客户组织编号,不能是数据库内部主键。 */
|
||||
"X-Tenant-Id": components["parameters"]["RequiredTenantSelectionHeader"];
|
||||
};
|
||||
path: {
|
||||
id: components["parameters"]["ResourceID"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description 使用无家庭数据的固定文本完成连通性测试 */
|
||||
200: {
|
||||
headers: {
|
||||
ETag: components["headers"]["StrongETag"];
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ContentModelResponse"];
|
||||
};
|
||||
};
|
||||
403: components["responses"]["AccessDeniedError"];
|
||||
};
|
||||
};
|
||||
activateContentModel: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header: {
|
||||
/** @description 待创建资源所属的公开客户组织编号,不能是数据库内部主键。 */
|
||||
"X-Tenant-Id": components["parameters"]["RequiredTenantSelectionHeader"];
|
||||
/** @description 最近一次读取返回的强ETag,不接受通配符或弱ETag。 */
|
||||
"If-Match": components["parameters"]["IfMatchHeader"];
|
||||
};
|
||||
path: {
|
||||
id: components["parameters"]["ResourceID"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description 已通过测试的模型成为该账号空间唯一活动总结模型 */
|
||||
200: {
|
||||
headers: {
|
||||
ETag: components["headers"]["StrongETag"];
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ContentModelResponse"];
|
||||
};
|
||||
};
|
||||
409: components["responses"]["ConflictError"];
|
||||
412: components["responses"]["VersionConflictError"];
|
||||
};
|
||||
};
|
||||
revokeContentModel: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header: {
|
||||
/** @description 待创建资源所属的公开客户组织编号,不能是数据库内部主键。 */
|
||||
"X-Tenant-Id": components["parameters"]["RequiredTenantSelectionHeader"];
|
||||
/** @description 最近一次读取返回的强ETag,不接受通配符或弱ETag。 */
|
||||
"If-Match": components["parameters"]["IfMatchHeader"];
|
||||
};
|
||||
path: {
|
||||
id: components["parameters"]["ResourceID"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description 未激活配置已撤销且密钥材料已清除 */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ContentModelResponse"];
|
||||
};
|
||||
};
|
||||
409: components["responses"]["ConflictError"];
|
||||
};
|
||||
};
|
||||
listOpsConfigVersions: {
|
||||
parameters: {
|
||||
query?: {
|
||||
@@ -10534,6 +10816,30 @@ export interface operations {
|
||||
403: components["responses"]["AccessDeniedError"];
|
||||
};
|
||||
};
|
||||
regenerateConversationSummary: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: components["parameters"]["ResourceID"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description 已按当前激活模型排队生成新的版本化摘要 */
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ConversationSummaryRegenerationResponse"];
|
||||
};
|
||||
};
|
||||
403: components["responses"]["AccessDeniedError"];
|
||||
409: components["responses"]["ConflictError"];
|
||||
};
|
||||
};
|
||||
listSubjectStories: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
SafetyCertificateOutlined,
|
||||
SearchOutlined,
|
||||
SettingOutlined,
|
||||
RobotOutlined,
|
||||
TeamOutlined,
|
||||
UserOutlined
|
||||
} from "@ant-design/icons";
|
||||
@@ -61,6 +62,7 @@ const sectionMeta: Record<PortalSection, { title: string; description: string; b
|
||||
privacy: { title: "隐私请求", description: "申请访问、导出或删除个人数据,并跟踪处理状态。", breadcrumb: "授权与隐私 / 隐私请求" },
|
||||
billing: { title: "用量与费用", description: "查看本家庭及设备的通话时长;存在已核价记录时可切换费用。", breadcrumb: "用量与费用" },
|
||||
messages: { title: "日程与提醒", description: "查看陪伴动态、设备状态和需要处理的家庭提醒。", breadcrumb: "日程与提醒" },
|
||||
models: { title: "大模型管理", description: "配置、测试并激活用于聊天总结、关键词和故事候选生成的大模型。", breadcrumb: "设置与权限 / 大模型管理" },
|
||||
profile: { title: "设置与权限", description: "查看登录身份、家庭角色和全部权限范围。", breadcrumb: "设置与权限" }
|
||||
};
|
||||
|
||||
@@ -97,6 +99,7 @@ export function CustomerPortal() {
|
||||
]
|
||||
},
|
||||
{ key: "billing", icon: <CreditCardOutlined />, label: "服务用量" },
|
||||
{ key: "models", icon: <RobotOutlined />, label: "大模型管理" },
|
||||
{ key: "profile", icon: <SettingOutlined />, label: "设置与权限" }
|
||||
], []);
|
||||
|
||||
@@ -120,6 +123,7 @@ export function CustomerPortal() {
|
||||
[["分享", "share"], "sharing"],
|
||||
[["费用", "额度", "账单", "cost"], "billing"],
|
||||
[["消息", "通知", "提醒"], "messages"],
|
||||
[["大模型", "模型", "豆包", "总结"], "models"],
|
||||
[["长者", "老人", "档案", "subject"], "subjects"],
|
||||
[["家庭", "成员", "邀请", "member"], "members"]
|
||||
];
|
||||
@@ -259,11 +263,12 @@ const permissionRows = [
|
||||
["privacy.read", "查看隐私请求", true, true, false, false],
|
||||
["privacy.manage", "处理隐私请求", true, false, false, false],
|
||||
["recording.play", "播放聊天录音", false, false, false, false],
|
||||
["recording.download", "下载聊天录音", false, false, false, false]
|
||||
["recording.download", "下载聊天录音", false, false, false, false],
|
||||
["model.manage", "管理总结大模型", true, true, false, false],
|
||||
] as const;
|
||||
|
||||
function PermissionMatrix() {
|
||||
return <Card className="permission-matrix-card" title="全部权限说明" extra={<Tag>14 项</Tag>}>
|
||||
return <Card className="permission-matrix-card" title="全部权限说明" extra={<Tag>15 项</Tag>}>
|
||||
<p className="permission-matrix-note">家庭角色决定一般操作权限;播放和下载录音属于敏感权限,必须由所有者或管理员按长者、用途和有效期单独授权。</p>
|
||||
<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>;
|
||||
|
||||
+112
-17
@@ -24,6 +24,8 @@ import {
|
||||
UserAddOutlined,
|
||||
UserOutlined,
|
||||
UploadOutlined,
|
||||
RobotOutlined,
|
||||
CheckCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
APIRequestError,
|
||||
@@ -38,6 +40,8 @@ import {
|
||||
type ConversationSummary,
|
||||
type ConversationTurn,
|
||||
type ConversationMedia,
|
||||
type ContentModel,
|
||||
type CreateContentModelRequest,
|
||||
type CreateSubjectFactRequest,
|
||||
type CreateSubjectRequest,
|
||||
type CuratedMemory,
|
||||
@@ -71,7 +75,7 @@ import {
|
||||
} from "../api/client";
|
||||
import { FamilyUsageOverview } from "../components/UsageOverview";
|
||||
|
||||
export type CustomerSection = "home" | "members" | "subjects" | "devices" | "conversations" | "stories" | "sharing" | "privacy" | "billing" | "messages";
|
||||
export type CustomerSection = "home" | "members" | "subjects" | "devices" | "conversations" | "stories" | "sharing" | "privacy" | "billing" | "messages" | "models";
|
||||
type Tab = CustomerSection | "firmware";
|
||||
type LoadState = "idle" | "loading" | "ready" | "error";
|
||||
type MediaLoadState = "idle" | "loading" | "ready" | "restricted" | "error";
|
||||
@@ -191,6 +195,9 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
|
||||
const [conversationUsage, setConversationUsage] = useState<ConversationUsage>();
|
||||
const [turns, setTurns] = useState<ConversationTurn[]>([]);
|
||||
const [contentSummaries, setContentSummaries] = useState<ConversationContentSummary[]>([]);
|
||||
const [contentModels, setContentModels] = useState<ContentModel[]>([]);
|
||||
const [modelLoading, setModelLoading] = useState(false);
|
||||
const [modelError, setModelError] = useState<string>();
|
||||
const [conversationInsights, setConversationInsights] = useState<Record<string, ConversationInsight>>({});
|
||||
const [conversationMedia, setConversationMedia] = useState<ConversationMedia>();
|
||||
const [mediaState, setMediaState] = useState<MediaLoadState>("idle");
|
||||
@@ -274,9 +281,13 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
|
||||
}
|
||||
|
||||
const scheduleEventRefresh = useCallback(() => {
|
||||
if (eventRefreshTimer.current !== undefined) return;
|
||||
const minimumIntervalMS = 2000;
|
||||
const delay = Math.max(0, minimumIntervalMS - (Date.now() - lastEventRefreshAt.current));
|
||||
// Event streams can replay a backlog when the page reconnects. Debounce the
|
||||
// authoritative REST refresh until that burst settles; otherwise every
|
||||
// two seconds all workspace resources are fetched again and the browser can
|
||||
// exhaust the user's rate-limit bucket before the page becomes interactive.
|
||||
if (eventRefreshTimer.current !== undefined) window.clearTimeout(eventRefreshTimer.current);
|
||||
const minimumIntervalMS = 15000;
|
||||
const delay = Math.max(800, minimumIntervalMS - (Date.now() - lastEventRefreshAt.current));
|
||||
eventRefreshTimer.current = window.setTimeout(() => {
|
||||
eventRefreshTimer.current = undefined;
|
||||
lastEventRefreshAt.current = Date.now();
|
||||
@@ -511,15 +522,21 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
void Promise.all(recent.map(async (conversation) => {
|
||||
try {
|
||||
const response = await platformApi.conversationSummaries(conversation.id, accessToken, controller.signal);
|
||||
return [conversation.id, inferConversationInsight(response.data.items)] as const;
|
||||
} catch (cause) {
|
||||
if (cause instanceof DOMException && cause.name === "AbortError") throw cause;
|
||||
return [conversation.id, inferConversationInsight([])] as const;
|
||||
void (async () => {
|
||||
const items: Array<readonly [string, ConversationInsight]> = [];
|
||||
// Keep the dashboard responsive without bursting seven requests into the
|
||||
// per-user rate limiter at once. Results still arrive in timeline order.
|
||||
for (const conversation of recent) {
|
||||
try {
|
||||
const response = await platformApi.conversationSummaries(conversation.id, accessToken, controller.signal);
|
||||
items.push([conversation.id, inferConversationInsight(response.data.items)] as const);
|
||||
} catch (cause) {
|
||||
if (cause instanceof DOMException && cause.name === "AbortError") throw cause;
|
||||
items.push([conversation.id, inferConversationInsight([])] as const);
|
||||
}
|
||||
}
|
||||
})).then((items) => {
|
||||
return items;
|
||||
})().then((items) => {
|
||||
if (!controller.signal.aborted) setConversationInsights(Object.fromEntries(items));
|
||||
}).catch((cause) => {
|
||||
if (!(cause instanceof DOMException && cause.name === "AbortError")) setConversationInsights({});
|
||||
@@ -557,12 +574,47 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
|
||||
const canManageFirmware = enableFirmware && (selectedHousehold?.capabilities.includes("firmware.manage") ?? false);
|
||||
const canManageRecordingAccess = selectedHousehold?.role === "owner" || selectedHousehold?.role === "admin";
|
||||
const canManageSharing = selectedHousehold?.role === "owner" || selectedHousehold?.role === "admin";
|
||||
const canManageModels = selectedHousehold?.role === "owner" || selectedHousehold?.role === "admin";
|
||||
const policyVersion = accounts.find((account) => account.tenant_id === selectedHousehold?.tenant_id)?.policy_version ?? "";
|
||||
const consentNotice = `我已阅读隐私政策${policyVersion || "当前"}版本,并同意对后续新通话启用“${consentLabel(newConsentType)}”。${consentImpactLabel(newConsentType)}我知道该授权可以单独撤回。`;
|
||||
const selectedConsentAlreadyEffective = consents.some((item) => item.type === newConsentType && item.effective);
|
||||
|
||||
useEffect(() => { setFamilyControlDraft(undefined); setClaimCode(""); setAcceptInvitationToken(""); }, [tab, selectedHouseholdID, selectedSubjectID, selectedConversationID]);
|
||||
|
||||
const loadContentModels = useCallback(async (signal?: AbortSignal) => {
|
||||
if (!selectedHousehold?.tenant_id) { setContentModels([]); return; }
|
||||
setModelLoading(true); setModelError(undefined);
|
||||
try { setContentModels((await platformApi.contentModels(selectedHousehold.tenant_id, accessToken, signal)).data.items); }
|
||||
catch (cause) { if (!(cause instanceof DOMException && cause.name === "AbortError")) setModelError(errorMessage(cause)); }
|
||||
finally { if (!signal?.aborted) setModelLoading(false); }
|
||||
}, [accessToken, selectedHousehold?.tenant_id]);
|
||||
|
||||
useEffect(() => { const controller = new AbortController(); void loadContentModels(controller.signal); return () => controller.abort(); }, [loadContentModels, refreshRevision]);
|
||||
|
||||
async function createContentModel(input: CreateContentModelRequest) {
|
||||
if (!selectedHousehold?.tenant_id) return false;
|
||||
return runAction("大模型配置已安全保存,请完成连通性测试后激活。", async () => { await platformApi.createContentModel(selectedHousehold.tenant_id, input, accessToken); await loadContentModels(); });
|
||||
}
|
||||
async function testContentModel(item: ContentModel) {
|
||||
if (!selectedHousehold?.tenant_id) return;
|
||||
await runAction("连通性测试已完成。", async () => { await platformApi.testContentModel(selectedHousehold.tenant_id, item.id, accessToken); await loadContentModels(); });
|
||||
}
|
||||
async function activateContentModel(item: ContentModel) {
|
||||
if (!selectedHousehold?.tenant_id) return;
|
||||
await runAction(`“${item.display_name}”已激活,下一次总结任务会立即使用该模型。`, async () => { await platformApi.activateContentModel(selectedHousehold.tenant_id, item, accessToken); await loadContentModels(); });
|
||||
}
|
||||
async function revokeContentModel(item: ContentModel) {
|
||||
if (!selectedHousehold?.tenant_id) return;
|
||||
await runAction("未激活模型已移除,密钥材料已清除。", async () => { await platformApi.revokeContentModel(selectedHousehold.tenant_id, item, accessToken); await loadContentModels(); });
|
||||
}
|
||||
async function regenerateSummary() {
|
||||
if (!selectedConversationID) return;
|
||||
await runAction("已使用当前激活模型排队重新总结;结果生成后会自动刷新。", async () => {
|
||||
await platformApi.regenerateConversationSummary(selectedConversationID, accessToken);
|
||||
window.setTimeout(() => { void openConversation(selectedConversationID); }, 1800);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSubjectID || !canManageRecordingAccess) {
|
||||
setRecordingGrants([]);
|
||||
@@ -1408,8 +1460,11 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
|
||||
to={conversationTo} onTo={(value) => { clearConversationDetail(); setConversations([]); setConversationTo(value); }}
|
||||
hasMore={Boolean(conversationNextCursor)} onResetFilters={resetConversationFilters} onLoadMore={loadMoreConversations}
|
||||
onOpen={openConversation} onGrant={grantRecordingAccess}
|
||||
activeModel={contentModels.find((item) => item.status === "active")} onRegenerate={regenerateSummary}
|
||||
stories={stories} onOpenStories={() => selectSection("stories")}
|
||||
onRevoke={revokeRecordingAccess} onAccess={accessRecording} />
|
||||
) : null}
|
||||
{tab === "models" ? <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}
|
||||
stories={stories} memories={memories} selected={selectedStory} loading={subjectLoading} actionBusy={actionBusy} canReview={canReviewStory}
|
||||
onOpen={openStory} onOpenConversation={openStoryConversation} onRevise={reviseStory} onConfirm={confirmStory} onReject={rejectStory} onUpdateFact={updateFactCandidate} onUpdateMemory={updateMemory} onDeleteMemory={deleteMemory} /> : null}
|
||||
@@ -1526,7 +1581,7 @@ function CustomerMessagePanel({ notices, devices, presence, stories, privacyRequ
|
||||
}
|
||||
|
||||
function customerSectionTitle(section: Tab) {
|
||||
return ({ home: "陪伴周记", members: "家人共享", subjects: "长者资料", devices: "陪伴设备", firmware: "固件发布", conversations: "聊天记录", stories: "故事与记忆", sharing: "内容分享", privacy: "隐私与授权", billing: "服务用量", messages: "日程与提醒" } as Record<Tab, string>)[section];
|
||||
return ({ home: "陪伴周记", members: "家人共享", subjects: "长者资料", devices: "陪伴设备", firmware: "固件发布", conversations: "回忆时间轴", stories: "故事与记忆", sharing: "内容分享", privacy: "隐私与授权", billing: "服务用量", messages: "日程与提醒", models: "大模型管理" } as Record<Tab, string>)[section];
|
||||
}
|
||||
|
||||
function customerSectionDescription(section: Tab) {
|
||||
@@ -1541,7 +1596,8 @@ function customerSectionDescription(section: Tab) {
|
||||
sharing: "按资源和权限范围向指定家庭成员分享内容。",
|
||||
privacy: "提交访问、导出或删除申请并跟踪处理进度。",
|
||||
billing: "汇总本家庭及设备的通话时长,并在核价可用时展示费用。",
|
||||
messages: "集中查看陪伴动态、设备、故事和隐私请求的状态提醒。"
|
||||
messages: "集中查看陪伴动态、设备、故事和隐私请求的状态提醒。",
|
||||
models: "配置、测试并激活用于聊天摘要、关键词和故事候选生成的大模型。"
|
||||
} as Record<Tab, string>)[section];
|
||||
}
|
||||
|
||||
@@ -1991,7 +2047,7 @@ function deviceBindingBlockerLabel(value: string) {
|
||||
return ({ target_subject_required: "请选择新的使用人", active_conversation: "设备仍有未结束通话", pending_device_commands: "设备仍有未完成命令", active_firmware_task: "设备仍有进行中的固件任务" } as Record<string, string>)[value] ?? value;
|
||||
}
|
||||
|
||||
function ConversationPanel({ subjects, selectedID, onSelect, conversations, insights, detail, usage, turns, summaries, media, mediaState, mediaError, grants, members, canManageRecordingAccess, actionBusy, audioURLs, loading, detailLoading, loadingMore, error, detailError, selectedConversationID, status, onStatus, engineMode, onEngineMode, from, onFrom, to, onTo, hasMore, onResetFilters, onLoadMore, onOpen, onGrant, onRevoke, onAccess }: {
|
||||
function ConversationPanel({ subjects, selectedID, onSelect, conversations, insights, detail, usage, turns, summaries, media, mediaState, mediaError, grants, members, canManageRecordingAccess, actionBusy, audioURLs, loading, detailLoading, loadingMore, error, detailError, selectedConversationID, status, onStatus, engineMode, onEngineMode, from, onFrom, to, onTo, hasMore, onResetFilters, onLoadMore, onOpen, onGrant, onRevoke, onAccess, activeModel, onRegenerate, stories, onOpenStories }: {
|
||||
subjects: Subject[]; selectedID?: string; onSelect: (id: string) => void; conversations: ConversationSummary[]; insights: Record<string, ConversationInsight>;
|
||||
detail?: ConversationDetail; usage?: ConversationUsage; turns: ConversationTurn[]; summaries: ConversationContentSummary[];
|
||||
media?: ConversationMedia; mediaState: MediaLoadState; mediaError?: string; grants: RecordingAccessGrant[]; members: HouseholdMember[];
|
||||
@@ -2003,9 +2059,17 @@ function ConversationPanel({ subjects, selectedID, onSelect, conversations, insi
|
||||
onGrant: (input: RecordingGrantInput) => void;
|
||||
onRevoke: (grant: RecordingAccessGrant) => Promise<void>;
|
||||
onAccess: (segment: RecordingSegment, action: "PLAY" | "DOWNLOAD", purpose: string) => void;
|
||||
activeModel?: ContentModel; onRegenerate: () => Promise<void>;
|
||||
stories: Story[]; onOpenStories: () => void;
|
||||
}) {
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
return <div className="conversation-layout">
|
||||
const currentSummary = summaries.find((item) => item.type === "conversation_summary");
|
||||
const currentTopic = summaries.find((item) => item.type === "open_topic");
|
||||
const summaryParts = currentSummary?.content.split("\n关键词:") ?? [];
|
||||
const keywords = summaryParts[1]?.split("、").filter(Boolean) ?? [];
|
||||
const storyCandidate = detail ? stories.find((item) => item.conversation_id === detail.id) : undefined;
|
||||
return <div className="conversation-layout memory-timeline-page">
|
||||
<div className="memory-timeline-grid">
|
||||
<div className="conversation-list">
|
||||
<div className="conversation-filter">
|
||||
<div className="conversation-filter__fields">
|
||||
@@ -2018,9 +2082,22 @@ function ConversationPanel({ subjects, selectedID, onSelect, conversations, insi
|
||||
<div className="conversation-filter__summary"><span>已加载 {conversations.length} 条{hasMore ? ",还有更多" : ""}</span>{status || engineMode || from || to ? <button type="button" className="button-small button-secondary" onClick={onResetFilters}>清除筛选</button> : null}</div>
|
||||
</div>
|
||||
{error ? <div className="conversation-list__error" role="alert">{error}</div> : null}
|
||||
{loading && conversations.length === 0 ? <StatePanel title="正在读取聊天记录" detail="按时间倒序加载…" /> : conversations.length ? conversations.map((item) => { const insight = insights[item.id]; return <button type="button" className={selectedConversationID === item.id ? "conversation-row active" : "conversation-row"} key={item.id} disabled={detailLoading && selectedConversationID === item.id} onClick={() => { setDetailOpen(true); void onOpen(item.id); }}><span className={`conversation-row__mood conversation-row__mood--${insight?.mood ?? "steady"}`}><SmileOutlined /></span><div><strong>{formatDate(item.created_at)} · {formatDuration(item.duration_ms)}</strong><span>{insight?.summary || conversationFailureLabel(item.failure_code) || "摘要整理中"}</span>{insight?.topics.length ? <small>{insight.topics.join(" · ")}</small> : null}</div><span className={`tag tag--${item.status.toLowerCase()}`}>{conversationStatusLabel(item.status)}</span></button>; }) : <p className="empty-copy padded">暂无聊天记录。</p>}
|
||||
{loading && conversations.length === 0 ? <StatePanel title="正在读取聊天记录" detail="按时间倒序加载…" /> : conversations.length ? <div className="memory-timeline-list">{conversations.map((item) => { const insight = insights[item.id]; return <button type="button" className={selectedConversationID === item.id ? "conversation-row active" : "conversation-row"} key={item.id} disabled={detailLoading && selectedConversationID === item.id} onClick={() => void onOpen(item.id)}><span className="memory-timeline-dot" /><span className={`conversation-row__mood conversation-row__mood--${insight?.mood ?? "steady"}`}><SmileOutlined /></span><div><strong>{formatDate(item.created_at)} · {formatDuration(item.duration_ms)}</strong><span>{insight?.summary || conversationFailureLabel(item.failure_code) || "摘要整理中"}</span>{insight?.topics.length ? <small>{insight.topics.join(" · ")}</small> : null}</div><span className={`tag tag--${item.status.toLowerCase()}`}>{conversationStatusLabel(item.status)}</span></button>; })}</div> : <p className="empty-copy padded">暂无聊天记录。</p>}
|
||||
{hasMore ? <div className="conversation-list__more"><button type="button" className="button-secondary" disabled={loading || loadingMore} onClick={() => void onLoadMore()}>{loadingMore ? "正在加载…" : "加载更多记录"}</button></div> : null}
|
||||
</div>
|
||||
<section className="memory-summary-panel" aria-label="智能总结">
|
||||
{detailLoading ? <StatePanel title="正在生成回忆卡片" detail="读取聊天、摘要和故事候选…" /> : detail ? <>
|
||||
<header><div><p className="eyebrow">AI MEMORY NOTE</p><h3>这次聊天,长者想起了什么</h3></div><span className="memory-summary-panel__mood"><SmileOutlined /> {insights[detail.id]?.moodLabel ?? "平稳"}</span></header>
|
||||
<div className="memory-summary-panel__meta"><span>{formatDate(detail.created_at)}</span><span>{formatDuration(detail.duration_ms)}</span><span>来源 {currentSummary?.source_turn_ids.length ?? turns.length} 条话轮</span></div>
|
||||
<article className="memory-summary-block"><h4>聊天摘要</h4><p>{summaryParts[0] || insights[detail.id]?.summary || "大模型总结正在生成中。"}</p></article>
|
||||
<article className="memory-summary-block"><h4>关键词</h4><div className="memory-keywords">{keywords.length ? keywords.map((keyword) => <span key={keyword}>{keyword}</span>) : <span>等待提取</span>}</div></article>
|
||||
<article className="memory-summary-block memory-summary-block--story"><h4>故事候选(节选)</h4><strong>{storyCandidate?.title || "故事候选正在整理"}</strong><p>{storyCandidate?.content || "总结完成后,系统会生成一段可由家人审核的故事候选;未经确认不会写入长期记忆。"}</p></article>
|
||||
<article className="memory-summary-block memory-summary-block--warm"><h4>下次可以接着聊</h4><p>{currentTopic?.content || "待总结完成后生成温和的后续话题。"}</p></article>
|
||||
<footer><button type="button" className="button-secondary" onClick={() => setDetailOpen(true)}>查看完整聊天</button>{storyCandidate ? <button type="button" onClick={onOpenStories}>审核并保存为故事</button> : null}<button type="button" onClick={() => void onRegenerate()} disabled={actionBusy || !activeModel}>{actionBusy ? "正在提交…" : "重新生成总结"}</button></footer>
|
||||
</> : <div className="memory-summary-empty"><BookOutlined /><h3>选择一段聊天</h3><p>这里会展示大模型生成的摘要、关键词、故事线索和后续话题。</p></div>}
|
||||
</section>
|
||||
<aside className="memory-model-card"><span className="memory-model-card__icon"><RobotOutlined /></span><p>当前总结模型</p><h3>{activeModel?.display_name || "系统默认模型"}</h3><small>{activeModel ? `${activeModel.provider === "ark" ? "火山方舟" : "OpenAI 兼容"} · ${activeModel.model_id}` : "尚未激活自定义模型,将使用部署环境默认配置"}</small><div><span className={activeModel ? "is-active" : ""} />{activeModel ? "已激活" : "默认回退"}</div>{currentSummary ? <p className="memory-model-card__history"><CheckCircleOutlined /> 最近结果由<br /><strong>{currentSummary.model_ref}</strong><br />生成版本 v{currentSummary.generation_version}</p> : null}</aside>
|
||||
</div>
|
||||
{detailOpen ? <div className="workspace-dialog-backdrop" role="presentation" onMouseDown={(event) => { if (event.currentTarget === event.target) setDetailOpen(false); }}><section className="conversation-detail workspace-dialog workspace-dialog--wide" role="dialog" aria-modal="true" aria-labelledby="conversation-detail-title"><header><div><h3 id="conversation-detail-title">通话详情</h3><p>查看文字内容、整理结果、语音授权与状态历史</p></div><button type="button" aria-label="关闭" onClick={() => setDetailOpen(false)}><CloseOutlined /></button></header><div className="workspace-dialog__body">{detailLoading ? <StatePanel title="正在读取通话详情" detail="正在核验状态、最终话轮、整理结果、用量和录音访问权限…" /> : detailError && selectedConversationID ? <StatePanel title="通话详情读取失败" detail={detailError} action={() => void onOpen(selectedConversationID)} /> : detail ? <>
|
||||
<div className="resource-detail__head"><div><p className="eyebrow">CONVERSATION DETAIL</p><h3>{conversationStatusLabel(detail.status)}</h3><p>{formatDate(detail.created_at)} · {detail.duration_ms ? `${Math.round(detail.duration_ms / 1000)}秒` : "时长待确认"}</p></div></div>
|
||||
{detail.failure_code ? <p className="recording-notice recording-notice--error" role="alert"><strong>{conversationFailureLabel(detail.failure_code) ?? "通话准备失败"}</strong><br />设备收到的失败标识:{detail.failure_code};背景准备状态:{detail.snapshots.context_preparation_status}</p> : null}
|
||||
@@ -2036,6 +2113,24 @@ function ConversationPanel({ subjects, selectedID, onSelect, conversations, insi
|
||||
</div>;
|
||||
}
|
||||
|
||||
function ContentModelPanel({ models, loading, error, canManage, actionBusy, onCreate, onTest, onActivate, onRevoke }: {
|
||||
models: ContentModel[]; loading: boolean; error?: string; canManage: boolean; actionBusy: boolean;
|
||||
onCreate: (input: CreateContentModelRequest) => Promise<boolean>; onTest: (item: ContentModel) => Promise<void>;
|
||||
onActivate: (item: ContentModel) => Promise<void>; onRevoke: (item: ContentModel) => Promise<void>;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draft, setDraft] = useState<CreateContentModelRequest>({ display_name: "豆包聊天总结", provider: "ark", base_url: "https://ark.cn-beijing.volces.com/api/v3", model_id: "", api_key: "", temperature: 0.2, max_tokens: 1280 });
|
||||
const active = models.find((item) => item.status === "active");
|
||||
async function submit(event: FormEvent) { event.preventDefault(); if (await onCreate(draft)) { setOpen(false); setDraft((item) => ({ ...item, display_name: "", model_id: "", api_key: "" })); } }
|
||||
return <div className="model-management-page">
|
||||
<section className="model-active-hero"><div><span className="model-active-hero__icon"><RobotOutlined /></span><div><p>当前总结引擎</p><h3>{active?.display_name || "系统默认模型"}</h3><span>{active ? `${active.model_id} · 下一条总结任务立即生效` : "配置并激活模型后,聊天摘要、关键词和故事候选将由它生成"}</span></div></div>{canManage ? <button type="button" onClick={() => setOpen(true)}><PlusOutlined /> 添加大模型</button> : null}</section>
|
||||
{error ? <div className="workspace__inline-error" role="alert">{error}</div> : null}
|
||||
{loading ? <StatePanel title="正在读取模型配置" detail="密钥不会回显,仅展示末四位提示。" /> : <section className="model-card-grid">{models.length ? models.map((item) => <article className={item.status === "active" ? "model-config-card is-active" : "model-config-card"} key={item.id}><header><span><RobotOutlined /></span><div><h3>{item.display_name}</h3><p>{item.provider === "ark" ? "火山方舟 / 豆包" : "OpenAI 兼容服务"}</p></div><b>{item.status === "active" ? "正在使用" : "备用"}</b></header><dl><div><dt>模型 ID</dt><dd>{item.model_id}</dd></div><div><dt>接口地址</dt><dd>{item.base_url}</dd></div><div><dt>访问密钥</dt><dd>{item.credential_hint}</dd></div><div><dt>生成参数</dt><dd>温度 {item.temperature} · 最多 {item.max_tokens} tokens</dd></div></dl><footer><span className={`model-test-status model-test-status--${item.test_status}`}>{item.test_status === "passed" ? "连通性正常" : item.test_status === "failed" ? `测试失败 · ${item.last_error_code || "请检查配置"}` : "尚未测试"}</span>{canManage ? <div><button type="button" className="button-secondary" disabled={actionBusy} onClick={() => void onTest(item)}>测试连接</button>{item.status !== "active" ? <button type="button" disabled={actionBusy || item.test_status !== "passed"} onClick={() => void onActivate(item)}>设为当前模型</button> : null}{item.status !== "active" ? <button type="button" className="button-danger" disabled={actionBusy} onClick={() => void onRevoke(item)}>移除</button> : null}</div> : null}</footer></article>) : <div className="model-empty"><RobotOutlined /><h3>还没有自定义总结模型</h3><p>添加火山方舟或其他 OpenAI 兼容模型,测试通过后即可激活。</p>{canManage ? <button type="button" onClick={() => setOpen(true)}>添加第一个模型</button> : null}</div>}</section>}
|
||||
<section className="model-security-note"><SafetyCertificateOutlined /><div><strong>密钥如何保存</strong><p>API Key 使用独立主密钥经 AES-256-GCM 加密后存入数据库,页面和接口都不会再次回显明文;每次激活、测试和移除都会写入审计记录。</p></div></section>
|
||||
{open ? <div className="workspace-dialog-backdrop" role="presentation" onMouseDown={(event) => { if (event.currentTarget === event.target) setOpen(false); }}><form className="workspace-dialog model-create-dialog" role="dialog" aria-modal="true" onSubmit={(event) => void submit(event)}><header><div><h3>添加总结大模型</h3><p>该模型用于生成聊天摘要、关键词、故事和下次话题。</p></div><button type="button" aria-label="关闭" onClick={() => setOpen(false)}><CloseOutlined /></button></header><div className="workspace-dialog__body"><label><span>配置名称</span><input autoFocus required maxLength={120} value={draft.display_name} onChange={(event) => setDraft({ ...draft, display_name:event.target.value })} placeholder="例如:豆包聊天总结" /></label><label><span>服务类型</span><select value={draft.provider} onChange={(event) => setDraft({ ...draft, provider:event.target.value as ContentModel["provider"] })}><option value="ark">火山方舟 / 豆包</option><option value="openai_compatible">OpenAI 兼容接口</option></select></label><label><span>接口基础地址</span><input required type="url" value={draft.base_url} onChange={(event) => setDraft({ ...draft, base_url:event.target.value })} /></label><label><span>模型或 Endpoint ID</span><input required value={draft.model_id} onChange={(event) => setDraft({ ...draft, model_id:event.target.value })} placeholder="ep-xxxxxxxx" /></label><label><span>API Key</span><input required type="password" minLength={8} autoComplete="new-password" value={draft.api_key} onChange={(event) => setDraft({ ...draft, api_key:event.target.value })} placeholder="只在本次保存时使用" /></label><div className="model-parameter-row"><label><span>温度</span><input type="number" min="0" max="2" step="0.1" value={draft.temperature} onChange={(event) => setDraft({ ...draft, temperature:Number(event.target.value) })} /></label><label><span>最大输出</span><input type="number" min="256" max="8192" step="128" value={draft.max_tokens} onChange={(event) => setDraft({ ...draft, max_tokens:Number(event.target.value) })} /></label></div></div><footer><button type="button" className="button-secondary" onClick={() => setOpen(false)}>取消</button><button type="submit" disabled={actionBusy || !draft.display_name.trim() || !draft.model_id.trim() || draft.api_key.length < 8}>{actionBusy ? "正在加密保存…" : "保存配置"}</button></footer></form></div> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function RecordingMediaBlock({ retentionMode, media, state, error, grants, members, canManage, actionBusy, audioURLs, onGrant, onRevoke, onAccess }: {
|
||||
retentionMode: string; media?: ConversationMedia; state: MediaLoadState; error?: string; grants: RecordingAccessGrant[]; members: HouseholdMember[];
|
||||
canManage: boolean; actionBusy: boolean; audioURLs: Record<string, string>;
|
||||
|
||||
@@ -39,6 +39,100 @@ main {
|
||||
padding: 5rem 0;
|
||||
}
|
||||
|
||||
/* 回忆时间轴与大模型管理:沿用家庭首页的暖白、陶土红和雾蓝设计语言。 */
|
||||
.customer-page--conversations .workspace__header,
|
||||
.customer-page--models .workspace__header { margin-bottom: 18px; }
|
||||
.memory-timeline-page { display: block !important; min-height: 0 !important; border: 0 !important; overflow: visible !important; background: transparent !important; }
|
||||
.memory-timeline-grid { display: grid; grid-template-columns: minmax(280px, .78fr) minmax(420px, 1.28fr) minmax(220px, .62fr); gap: 16px; align-items: start; }
|
||||
.memory-timeline-grid > * { min-width: 0; border: 1px solid #eadeda; border-radius: 18px; background: #fffdfb; box-shadow: 0 10px 30px rgba(91, 64, 55, .055); }
|
||||
.memory-timeline-grid .conversation-list { overflow: hidden; }
|
||||
.memory-timeline-grid .conversation-filter { border-bottom: 1px solid #eee4e1; background: #faf6f2; }
|
||||
.memory-timeline-grid .conversation-filter__fields { grid-template-columns: 1fr 1fr; }
|
||||
.memory-timeline-grid .conversation-filter__fields label:nth-child(n+3) { display: none; }
|
||||
.memory-timeline-list { position: relative; padding: 8px 0 12px; }
|
||||
.memory-timeline-list::before { position: absolute; top: 15px; bottom: 22px; left: 25px; width: 2px; content: ""; background: #dfcbc4; }
|
||||
.memory-timeline-list .conversation-row { position: relative; grid-template-columns: 16px 36px 1fr; margin: 0 8px; width: calc(100% - 16px); border: 0; border-radius: 13px; background: transparent; }
|
||||
.memory-timeline-list .conversation-row.active { background: #f7ebe6; box-shadow: inset 3px 0 #a73920; }
|
||||
.memory-timeline-list .conversation-row > .tag { grid-column: 3; justify-self: start; }
|
||||
.memory-timeline-dot { z-index: 1; width: 11px; height: 11px; border: 3px solid #fffdfb; border-radius: 50%; background: #b86a55; box-shadow: 0 0 0 1px #d7b8ae; }
|
||||
.memory-summary-panel { padding: 24px; }
|
||||
.memory-summary-panel > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; }
|
||||
.memory-summary-panel h3 { margin: 3px 0 0; color: #493834; font-family: "Noto Serif SC", "Songti SC", serif; font-size: 24px; }
|
||||
.memory-summary-panel__mood { padding: 7px 10px; border-radius: 999px; color: #50725f; background: #edf5ef; white-space: nowrap; }
|
||||
.memory-summary-panel__meta { display: flex; flex-wrap: wrap; gap: 8px 16px; margin: 14px 0 20px; color: #8a7872; font-size: 12px; }
|
||||
.memory-summary-block { margin-top: 14px; padding: 17px 18px; border: 1px solid #ede3df; border-radius: 14px; background: #fff; }
|
||||
.memory-summary-block--warm { border-color: #ecd5cd; background: #fff6f1; }
|
||||
.memory-summary-block h4 { margin: 0 0 9px; color: #6b4f47; font-size: 14px; }
|
||||
.memory-summary-block p { margin: 0; color: #574b47; line-height: 1.85; }
|
||||
.memory-keywords { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.memory-keywords span { padding: 5px 10px; border-radius: 999px; color: #78564d; background: #f4e9e4; font-size: 12px; }
|
||||
.memory-summary-panel > footer { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; }
|
||||
.memory-summary-empty { display: grid; place-items: center; min-height: 370px; color: #9a8179; text-align: center; }
|
||||
.memory-summary-empty .anticon { font-size: 36px; }
|
||||
.memory-summary-empty h3 { margin-bottom: 0; }
|
||||
.memory-model-card { position: sticky; top: 84px; padding: 22px; text-align: center; }
|
||||
.memory-model-card__icon { display: grid; place-items: center; width: 52px; height: 52px; margin: 0 auto 13px; border-radius: 16px; color: #fff; background: #6f8c98; font-size: 24px; }
|
||||
.memory-model-card > p { margin: 5px 0; color: #907d77; font-size: 12px; }
|
||||
.memory-model-card h3 { margin: 6px 0; color: #493834; }
|
||||
.memory-model-card small { display: block; overflow-wrap: anywhere; color: #887873; line-height: 1.6; }
|
||||
.memory-model-card > div { margin: 16px 0; padding: 8px; border-radius: 10px; color: #65736a; background: #f2f5f1; font-size: 12px; }
|
||||
.memory-model-card > div span { display: inline-block; width: 8px; height: 8px; margin-right: 6px; border-radius: 50%; background: #b2aaa6; }
|
||||
.memory-model-card > div span.is-active { background: #65a176; box-shadow: 0 0 0 3px #dceddf; }
|
||||
.memory-model-card__history { padding-top: 14px; border-top: 1px solid #eee4e1; line-height: 1.7; }
|
||||
.memory-model-card__history strong { overflow-wrap: anywhere; color: #6f5a54; font-weight: 600; }
|
||||
|
||||
.model-management-page { display: grid; gap: 18px; }
|
||||
.model-active-hero { display: flex; align-items: center; justify-content: space-between; padding: 24px 26px; border: 1px solid #dfd2ce; border-radius: 18px; background: linear-gradient(120deg, #fffaf6, #edf4f4); }
|
||||
.model-active-hero > div { display: flex; align-items: center; gap: 16px; }
|
||||
.model-active-hero__icon { display: grid; place-items: center; width: 58px; height: 58px; border-radius: 18px; color: #fff; background: #6d8995; font-size: 26px; }
|
||||
.model-active-hero p, .model-active-hero h3 { margin: 0; }
|
||||
.model-active-hero h3 { margin: 3px 0; color: #493934; font-size: 24px; }
|
||||
.model-active-hero span { color: #7f6e68; }
|
||||
.model-card-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||
.model-config-card { padding: 21px; border: 1px solid #eadeda; border-radius: 18px; background: #fffdfb; box-shadow: 0 9px 25px rgba(94, 68, 60, .05); }
|
||||
.model-config-card.is-active { border-color: #9db7aa; box-shadow: inset 0 3px #6f9b7d, 0 9px 25px rgba(94, 68, 60, .05); }
|
||||
.model-config-card header { display: grid; grid-template-columns: 44px 1fr auto; gap: 12px; align-items: center; }
|
||||
.model-config-card header > span { display: grid; place-items: center; width: 44px; height: 44px; border-radius: 13px; color: #6c8792; background: #eaf1f2; font-size: 21px; }
|
||||
.model-config-card h3, .model-config-card p { margin: 0; }
|
||||
.model-config-card header p { color: #927e77; font-size: 12px; }
|
||||
.model-config-card header b { padding: 5px 9px; border-radius: 999px; color: #597462; background: #eaf3ed; font-size: 11px; }
|
||||
.model-config-card dl { margin: 18px 0; padding: 12px 0; border-block: 1px solid #eee5e1; }
|
||||
.model-config-card dl div { display: grid; grid-template-columns: 82px 1fr; gap: 10px; padding: 5px 0; font-size: 12px; }
|
||||
.model-config-card dt { color: #9a8781; }
|
||||
.model-config-card dd { margin: 0; overflow-wrap: anywhere; color: #5c4d48; }
|
||||
.model-config-card footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.model-config-card footer > div { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 7px; }
|
||||
.model-test-status { color: #8a7b76; font-size: 12px; }
|
||||
.model-test-status--passed { color: #4f8260; }
|
||||
.model-test-status--failed { color: #a33f2d; }
|
||||
.model-empty { grid-column: 1 / -1; padding: 54px; border: 1px dashed #d9c8c2; border-radius: 18px; color: #8e7c76; background: #fffaf7; text-align: center; }
|
||||
.model-empty > .anticon { font-size: 38px; }
|
||||
.model-security-note { display: flex; gap: 14px; padding: 18px; border-radius: 14px; color: #5d6963; background: #edf3ef; }
|
||||
.model-security-note > .anticon { margin-top: 3px; color: #5e8a6b; font-size: 20px; }
|
||||
.model-security-note p { margin: 5px 0 0; line-height: 1.65; }
|
||||
.model-parameter-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.model-create-dialog .workspace-dialog__body { display: grid; gap: 14px; }
|
||||
|
||||
/* 非首页业务页统一为轻量、长者优先的内容画布。 */
|
||||
.customer-page:not(.customer-page--home) > .workspace { border-color: #e9deda; border-radius: 18px; background: rgba(255, 253, 251, .96); box-shadow: 0 14px 38px rgba(78, 59, 52, .055); }
|
||||
.customer-page:not(.customer-page--home) .workspace__header { padding: 3px 2px 18px; }
|
||||
.customer-page:not(.customer-page--home) .panel-section,
|
||||
.customer-page:not(.customer-page--home) .customer-message-panel,
|
||||
.customer-page:not(.customer-page--home) .customer-billing-panel { border-radius: 16px; box-shadow: 0 8px 24px rgba(87, 65, 57, .04); }
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.memory-timeline-grid { grid-template-columns: minmax(270px, .8fr) minmax(390px, 1.2fr); }
|
||||
.memory-model-card { position: static; grid-column: 1 / -1; text-align: left; }
|
||||
.model-card-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.memory-timeline-grid { grid-template-columns: 1fr; }
|
||||
.memory-summary-panel { padding: 18px; }
|
||||
.memory-summary-panel > header, .model-active-hero, .model-config-card footer { align-items: stretch; flex-direction: column; }
|
||||
.memory-model-card { grid-column: auto; }
|
||||
.model-parameter-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
@@ -3442,3 +3536,23 @@ textarea:focus,
|
||||
.recent-activity > div > button > .anticon { grid-column: 1; grid-row: 1 / 4; }
|
||||
.recent-activity time,.recent-activity strong,.recent-activity span { grid-column: 2; }
|
||||
}
|
||||
|
||||
/* Keep the selected visual target authoritative over legacy workspace rules. */
|
||||
.customer-page--conversations .memory-timeline-grid { display: grid; grid-template-columns: minmax(280px,.78fr) minmax(420px,1.28fr) minmax(220px,.62fr); gap: 16px; }
|
||||
.customer-page--conversations .memory-timeline-grid .conversation-list { border-right: 1px solid #eadeda; background: #fffdfb; }
|
||||
.customer-page--conversations .memory-timeline-list .conversation-row { grid-template-columns: 16px 34px minmax(0,1fr) !important; }
|
||||
.customer-page--conversations .memory-timeline-list .conversation-row > .tag { grid-column: 3; }
|
||||
.customer-page--conversations .workspace__header,
|
||||
.customer-page--models .workspace__header { justify-content: space-between; }
|
||||
.customer-page--conversations .workspace__header > div:first-child,
|
||||
.customer-page--models .workspace__header > div:first-child { display: block; }
|
||||
.memory-summary-block--story { border-color: #ead7bf; background: #fffaf1; }
|
||||
.memory-summary-block--story strong { display: block; margin-bottom: 7px; color: #644d3e; font-family: "Noto Serif SC", "Songti SC", serif; }
|
||||
@media (max-width: 1180px) {
|
||||
.customer-page--conversations .memory-timeline-grid { grid-template-columns: minmax(270px,.8fr) minmax(390px,1.2fr); }
|
||||
.customer-page--conversations .memory-model-card { grid-column: 1 / -1; }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.customer-page--conversations .memory-timeline-grid { grid-template-columns: 1fr; }
|
||||
.customer-page--conversations .memory-model-card { grid-column: auto; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user