6 Commits
Author SHA1 Message Date
Codex Local a8cdec98d1 feat: finalize operations model workspace 2026-08-31 11:29:56 +08:00
Codex Local 78e0a19acb fix: make reminder actions use real resources 2026-08-30 21:22:21 +08:00
Codex Local dcf0fc0d7f feat: add domestic model provider presets 2026-08-30 19:55:17 +08:00
Codex Local 522785eda8 feat: filter meaningful conversations with local model 2026-08-30 15:20:08 +08:00
Codex Local 89f6641331 feat: redesign family companion customer portal
ci / verify (push) Canceled after 0s
2026-08-30 14:26:28 +08:00
Codex Local 2337c6cfaa fix: align usage heatmap month labels
ci / verify (push) Canceled after 0s
2026-08-30 11:54:52 +08:00
42 changed files with 1900 additions and 210 deletions
+4
View File
@@ -4,6 +4,10 @@
## 未发布 ## 未发布
- 聊天总结改为大模型两级内容价值判断:先判断是否值得保留,再判断是否值得形成故事;纯寒暄不再生成摘要或故事。
- 新增内容价值判断记录,保留模型、提示词版本、置信度和舍弃原因,支持任务幂等与隐私删除。
- 本地开发新增 GPU Ollama/Qwen2.5 7B 部署方式,并通过大模型管理的测试、激活和 worker 生成链路完成端到端验证。
- 修复大模型“测试连接”更新语句和审计结果枚举错误,测试通过的模型现在可以正常激活。
- 维护端默认人设升级为老人陪伴对话规范,覆盖倾听、单问题追问、沉默与重复处理、事实边界、敏感话题和医疗安全。 - 维护端默认人设升级为老人陪伴对话规范,覆盖倾听、单问题追问、沉默与重复处理、事实边界、敏感话题和医疗安全。
- 家庭端长者档案新增日常陪伴、回忆往事、兴趣聊天三个模板,并补充兴趣、优先话题和避免话题的创建入口。 - 家庭端长者档案新增日常陪伴、回忆往事、兴趣聊天三个模板,并补充兴趣、优先话题和避免话题的创建入口。
- Agent固定评测加强单问题追问、先回应再追问、沉默不施压、尊重重复表达和情绪优先回应检查。 - Agent固定评测加强单问题追问、先回应再追问、沉默不施压、尊重重复表达和情绪优先回应检查。
+21
View File
@@ -155,6 +155,26 @@ services:
volumes: volumes:
- ./deploy/keycloak:/opt/keycloak/data/import:ro - ./deploy/keycloak:/opt/keycloak/data/import:ro
ollama:
profiles: [local-model]
image: ollama/ollama:latest
ports:
- "${OLLAMA_PORT:-11434}:11434"
volumes:
- ollama-data:/root/.ollama
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
healthcheck:
test: ["CMD", "ollama", "list"]
interval: 10s
timeout: 5s
retries: 30
otel-collector: otel-collector:
image: otel/opentelemetry-collector-contrib:latest image: otel/opentelemetry-collector-contrib:latest
command: ["--config=/etc/otelcol-contrib/config.yaml"] command: ["--config=/etc/otelcol-contrib/config.yaml"]
@@ -325,3 +345,4 @@ volumes:
postgres-data: postgres-data:
redis-data: redis-data:
minio-data: minio-data:
ollama-data:
+31
View File
@@ -0,0 +1,31 @@
# 本地聊天总结模型
本地开发环境使用 Ollama 提供 OpenAI 兼容接口,默认验证模型为 `qwen2.5:7b`。模型只监听本机 `11434` 端口,应用容器通过 `http://ollama:11434/v1` 访问。
## 启动与下载模型
```powershell
docker compose --profile local-model up -d ollama
docker compose --profile local-model exec ollama ollama pull qwen2.5:7b
```
随后在客户门户的“大模型管理”中创建并激活配置:
- 服务类型:`OpenAI 兼容接口`
- 接口基础地址:`http://ollama:11434/v1`
- 模型 ID:`qwen2.5:7b`
- API Key:本地 Ollama 不校验密钥,可填写长度至少 8 位的本地占位值
- 建议温度:`0.1`
- 建议最大输出:`1200`
必须先执行“测试连接”,通过后才能“设为当前模型”。配置激活后,worker 会在每个总结任务开始时读取当前模型,不需要重启。
## 真实模型回归测试
```powershell
$env:LOCAL_MODEL_BASE_URL='http://localhost:11434/v1'
$env:LOCAL_MODEL_ID='qwen2.5:7b'
go test ./internal/extraction -run TestLocalModelContentValueDecisions -count=1 -v
```
测试覆盖三类输入:纯寒暄应舍弃、有用近况只生成摘要、完整个人回忆生成摘要和故事候选。
+46 -33
View File
@@ -1,46 +1,59 @@
# 回忆时间轴设计 QA # 家庭陪伴门户 v0.74 设计 QA
- source visual truth: `E:\Codex\story\platform\.design-evidence\selected-conversation-timeline.png` - 功能与页面规格:`docs/customer-portal-v074-page-spec.md`
- implementation screenshot: `E:\Codex\story\platform\.design-evidence\implementation-conversation-timeline-final.png` - 设计稿源:`.design-evidence/v074/design/*.png`
- normalized comparison: `selected-conversation-timeline-normalized.png` and `implementation-conversation-timeline-normalized.png` - 实际页面截图:`.design-evidence/v074/implementation/*.png`
- browser viewport: 1265 × 712 CSS px, device scale factor 1 - 同屏对比证据:`.design-evidence/v074/comparison/*.png`
- source pixels: 1487 × 1058; implementation full-page pixels: 1265 × 2150 - 统一视口:1440 × 1024 CSS px,device scale factor 1
- density normalization: source was resized and implementation was top-cropped to 1265 × 900 for an equal-size comparison - 验收状态:本地所有者账号、合成数据一号家庭、2026-08-30、每页默认业务状态
- state: authenticated owner, conversations route, one completed conversation selected, no custom model active
## Full-view comparison evidence ## 页面验收矩阵
The equal-size comparison preserves the source's main information architecture: fixed family navigation, family tab, timeline title, left conversation stream, central AI memory note, and right model status. The implementation intentionally uses live test data and a compact filter header instead of the mock's date navigator and grouped demo dates. This does not remove any core task. | 页面 | 核心功能 | 设计/实现对比 | 结果 |
| --- | --- | --- | --- |
| 首页 | 长者周记、心情线索、关怀提醒、最近动态 | `comparison/home.png` | 通过 |
| 聊天记录 | 时间轴、AI 摘要、关键词、故事候选、下次话题、模型来源 | `comparison/conversations.png` | 通过 |
| 故事与记忆 | 待审故事、审核入口、已确认记忆和搜索 | `comparison/stories.png` | 通过 |
| 长者资料 | 头像、长期资料、交流节奏、兴趣和授权入口 | `comparison/subjects.png` | 通过 |
| 日程与提醒 | 今日陪伴动态、待处理事项、近期状态汇总 | `comparison/messages.png` | 通过 |
| 家庭管理 | 家庭切换、成员、角色、邀请、家庭资料和动态 | `comparison/members.png` | 通过 |
| 陪伴设备 | 绑定、在线状态、固件、诊断和配置 | `comparison/devices.png` | 通过 |
| 内容分享 | 资源级分享、接收人、权限、期限和撤销 | `comparison/sharing.png` | 通过 |
| 数据与授权 | 数据盘点、更正、撤回、私有导出和主体删除 | `comparison/privacy.png` | 通过 |
| 服务用量 | 聊天时长、活跃日、热力图、设备与最近通话 | `comparison/billing.png` | 通过 |
| 大模型管理 | 模型配置、连接测试、激活、移除和密钥说明 | `comparison/models.png` | 通过 |
| 设置与权限 | 身份、家庭角色和 15 项完整权限矩阵 | `comparison/profile.png` | 通过 |
## Required fidelity surfaces ## 必检视觉项
- Fonts and typography: Chinese system sans-serif fallbacks, headline/body weights, line heights, hierarchy, wrapping, and muted metadata are consistent and readable. No clipping or truncation affects core content. - 页面标题与家庭标签紧邻,标题底部到家庭上下文栏的间距为 0;内容区统一 16px 节奏。
- Spacing and layout rhythm: the three-column frame, card padding, border radii, section gaps, selected state, and vertical rhythm follow the source. The narrower implementation viewport appropriately compresses the timeline and model columns. - 侧栏、页头、家庭 tab、卡片边框、圆角、按钮高度和表单控件使用同一套 v0.74 token。
- Colors and tokens: warm neutral page background, white/cream cards, restrained green status color, peach story surface, borders, and shadows map to the selected direction with accessible contrast. - 1440px 视口下 12 个页面均无页面级横向溢出,无裁切的主要按钮或正文。
- Image quality and assets: the selected live test record has no uploaded portrait, so the implementation uses the product's Ant Design icon system rather than inventing a decorative asset. No target logo or product image was replaced by CSS art or custom SVG. - 家庭管理重新出现在一级导航,使用可直接点击的家庭 tab,不再显示“客户组织”。
- Copy and content: all labels stand alone in the product context. The implementation adds clear privacy wording for story candidates and identifies the exact model/result version when available. - 日程与提醒不再使用大段空白消息头;隐私空状态压缩到 150px;故事页长者筛选保持单行。
- Interaction and accessibility: timeline items, full-chat action, story-review action, filters, family tabs, model management entry, and refresh controls are semantic buttons/controls. Disabled buttons use the required wait cursor. Form controls are labelled, and the selected/active/status states are not communicated by color alone. - 禁用按钮统一 `cursor: wait`;长者头像上传可显示本地预览。
Focused region comparison was not required: at 1265 × 900 both the source and implementation expose readable labels, typography, icon alignment, card spacing, selected states, and status treatments without zoom ambiguity. ## 功能检查
## Comparison history - 12 个一级/二级导航均能进入对应页面。
- 新增长者头像上传与预览通过。
- 接受家庭邀请、邀请新成员、绑定设备、新建分享、资料更正、添加大模型、故事详情 7 个非破坏性弹窗打开/关闭通过。
- 聊天记录选中一条记录后,AI 总结区能进入真实加载/结果状态。
- 所有采样到的禁用按钮均显示等待光标。
- 浏览器控制台错误、非预期 HTTP 4xx/5xx、失败请求均为 0。
1. Earlier P2 — the implementation capture started below the family tab and page title because a legacy workspace rule hid the heading and the page retained scroll position. Fixed by adding section-specific workspace header overrides and recapturing a full-page state. Post-fix evidence shows the family tab, `回忆时间轴` title, status and refresh action above the three-column layout. ## 对比迭代
2. Earlier P1 — continuous SSE backlog events triggered all workspace REST effects every two seconds, eventually showing a rate-limit alert and preventing a selected conversation from rendering. Fixed by debouncing authoritative event refreshes to a 15-second minimum and loading recent summaries sequentially. Post-fix browser evidence shows 19 records loaded, a selected completed conversation, generated summary content, and no rate-limit alert.
3. Final comparison — no actionable P0, P1, or P2 mismatch remains. Live-data differences from the mock (no portrait, fewer generated-version comparisons, compact date filtering) are expected product-state differences and do not block the core journey.
## Findings 1. 修复家庭管理入口缺失,统一页面名称与客户视角的信息架构。
2. 移除工作区旧的大空白标题层,合并家庭 tab、实时状态、刷新和页面动作。
3. 故事页长者筛选从第二行收回同一上下文栏,并为故事卡补充“开始审核”。
4. 日程与提醒重构为今日动态、家人待办和近期汇总,不再堆叠无意义消息。
5. 设置页补齐家庭上下文,并集中列出 15 项角色权限。
6. 隐私页压缩空状态;用量页日期范围校准为 2025-09 至 2026-08。
7. 设计稿与实际页以相同 1440 × 1024 视口逐页同屏复核。
No actionable P0/P1/P2 findings. ## 最终结论
P3 follow-up polish: add a dedicated generation-version comparison drawer once multiple production model outputs are available for the same conversation. 没有遗留的 P0、P1 或 P2 视觉/交互问题。设计稿中的演示故事、已配置模型和已有分享,与本地数据库的空状态属于业务数据差异;页面结构、视觉系统和核心操作保持一致。
## Implementation checklist
- [x] Three-column memory timeline implemented.
- [x] Summary, keywords, story candidate and next-topic states implemented.
- [x] Current model and generation provenance shown.
- [x] Core interactions and empty/disabled states verified in the in-app browser.
- [x] Rate-limit regression removed and browser console-visible page errors checked.
final result: passed final result: passed
+31
View File
@@ -0,0 +1,31 @@
# 聊天总结模型厂商接入
聊天总结任务不绑定某个模型。管理员在“大模型管理”中选择厂商、填写对应 API Key 与模型 ID、完成连接测试并激活后,Worker 会在下一条任务执行时读取当前激活配置。
## 已适配厂商
| 页面分类 | `provider` | 默认 Base URL | 需要填写 |
|---|---|---|---|
| 火山方舟 / 豆包 | `ark` | `https://ark.cn-beijing.volces.com/api/v3` | API Key、模型 ID 或 Endpoint ID |
| 阿里云百炼 / 千问 | `dashscope` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | 同地域 API Key、模型 ID |
| DeepSeek | `deepseek` | `https://api.deepseek.com` | API Key、模型 ID |
| 月之暗面 / Kimi | `moonshot` | `https://api.moonshot.cn/v1` | API Key、模型 ID |
| 智谱 AI / GLM | `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | API Key、模型 ID |
| 百度千帆 / 文心 | `qianfan` | `https://qianfan.baidubce.com/v2` | Bearer Token、模型 ID |
| 腾讯混元 | `hunyuan` | `https://api.hunyuan.cloud.tencent.com/v1` | API Key、模型 ID |
| MiniMax | `minimax` | `https://api.minimaxi.com/v1` | API Key、模型 ID |
| 百川智能 | `baichuan` | `https://api.baichuan-ai.com/v1` | API Key、模型 ID |
| 硅基流动 | `siliconflow` | `https://api.siliconflow.cn/v1` | API Key、模型 ID |
| 本地 Ollama | `ollama` | `http://ollama:11434/v1` | 本地模型 ID;密钥使用占位值 |
| 自定义兼容服务 | `openai_compatible` | 管理员填写 | HTTPS Base URL、API Key、模型 ID |
所有云服务使用 Bearer Token 鉴权和 `/chat/completions` 请求。适配器只对官方已明确支持或项目已验证的厂商发送 `response_format`;其他厂商通过严格提示、JSON 提取和响应校验取得结构化结果,避免某些兼容接口因不认识 JSON Mode 参数而拒绝请求。带 `<think>` 思考包装的返回也会在解析前清理。
## 使用流程
1. 选择厂商,页面会预填官方 Base URL 和常用模型名。
2. 按厂商控制台填写 API Key 与准确模型 ID。使用地域或业务空间专属域名时,可修改 Base URL。
3. 保存后执行“测试连接”。测试内容是固定的无长者数据文本。
4. 测试通过后设为当前模型。下一条聊天总结任务立即使用,不需要重启 Worker。
API Key 通过 AES-256-GCM 加密保存,读取接口仅返回末四位提示。连接测试、激活与移除均写入审计日志。
+50
View File
@@ -0,0 +1,50 @@
# 安心陪伴客户门户 v0.74 页面功能与设计基准
日期:2026-08-30
设计方向:沿用已确认的“回忆时间轴”方案,以长者、聊天、心情与家庭协作为核心;暖白纸张底色、雾蓝主操作、陶土色提醒、低阴影和清晰留白。
## 全局信息架构
客户门户只保留家庭用户真正需要的 12 个一级任务。当前家庭用页首标签直接切换;家庭成员、角色和邀请恢复为独立的“家庭管理”页面。客户门户不再出现“客户组织”概念。
全局页面结构固定为:
1. 68px 顶部栏:品牌、全局搜索、提醒、账号。
2. 228px 左侧导航:围绕长者陪伴任务排列。
3. 统一页面首部:标题、用途说明、家庭权限状态。
4. 统一家庭上下文栏:家庭 Tab、添加家庭、实时状态、刷新和页面主操作。
5. 页面内容:不再使用包住整页的大卡片;只在独立对象或需要分组时使用边框容器。
## 逐页功能总结
| 页面 | 用户要解决的问题 | 主要功能 | 首屏重点 | 关键状态 |
|---|---|---|---|---|
| 首页 | 最近有没有陪伴、长者心情如何、哪里需要家人关注 | 长者切换、7 天聊天时长、最近聊天摘要、心情线索、关注提醒、最近动态 | 每位长者最近一次聊天和心情变化 | 无聊天、设备离线、待关注 |
| 聊天记录 | 找到某次聊天并理解聊天内容 | 按长者/日期/状态筛选、AI 摘要、关键词、故事候选、下次话题、录音授权 | 时间轴和当前聊天 AI 总结 | 总结中、总结失败、无授权 |
| 故事与记忆 | 审核模型整理的故事,形成可信长期记忆 | 故事候选审核、修订、确认/拒绝、记忆检索和停用 | 待审核故事与来源聊天 | 空状态、审核中、已确认 |
| 长者资料 | 让豆包知道如何更适合地与这位长者聊天 | 头像、背景、兴趣、优先/回避话题、交流节奏、长期陪伴方向、授权 | 长者卡片和资料完整度 | 头像上传、只读、资料不完整 |
| 日程与提醒 | 知道什么时候聊、哪些事情需要家人处理 | 陪伴计划、提醒时间、设备/故事/隐私待办、事件状态 | 今日安排和待办 | 已完成、即将开始、已错过 |
| 家庭管理 | 管理家庭空间和可以协作的家人 | 家庭 Tab、成员、邀请、角色、所有权、家庭名称 | 家庭成员与角色 | 待接受邀请、只读、危险操作确认 |
| 陪伴设备 | 确认设备能否正常陪伴 | 绑定、在线状态、固件、电量/音量、诊断、解除绑定 | 设备健康和所属长者 | 离线、待同步、诊断中 |
| 内容分享 | 把指定故事/文字/录音安全分享给指定家人 | 资源选择、对象、权限、有效期、撤销 | 当前有效分享 | 无分享、即将过期、已撤销 |
| 数据与授权 | 查看和控制长者数据如何被使用 | 五类分项授权、数据访问/更正/撤回/删除、私有导出 | 授权清单,不以按钮堆叠呈现 | 生效、处理中、已撤回 |
| 服务用量 | 理解聊天使用情况,而不是看无意义设备统计 | 时长指标、活跃日、长者/设备筛选、热力图、最近通话 | 最近一年聊天活动 | 无数据、费用未核价 |
| 大模型管理 | 选择哪个模型用于聊天总结 | 新增配置、连通性测试、激活、停用、参数和生成历史 | 当前激活模型及健康状态 | 未配置、测试失败、已激活 |
| 设置与权限 | 理解当前账号能做什么 | 账号资料、家庭角色、15 项权限矩阵、敏感录音独立授权说明 | 当前身份和权限全表 | 无家庭、权限不足 |
## 统一视觉规格
- 桌面基准:1440 × 1024;内容最大宽度 1360px。
- 页面左右 padding:桌面 28px;平板 20px;手机 14px。
- 页面首部上下 padding:24px / 18px;与家庭上下文栏间距 16px。
- 家庭上下文栏高度不固定,最小 54px;禁止通过空白占位制造层级。
- 页面 section 间距 16px;对象卡片 padding 20–24px;列表行 14–16px。
- 字号:页面标题 28px;区块标题 20px;正文 14px;辅助信息 12px。
- 圆角:页面对象 14px、按钮/输入框 9px、状态胶囊 999px。
- 主色 `#315D9B`;强调 `#C45F3B`;成功 `#5F8F69`;底色 `#FBFAF7`;边线 `#E6E0D8`。
- 禁用按钮必须不可点击且鼠标为等待光标;加载按钮显示明确进行中动词。
- 所有日期基于 Asia/Shanghai,设计数据锚点为 2026-08-30。
## 设计验收规则
每个页面必须在 1440 × 1024 同一视口完成“设计基准截图 / 实际页面截图”对照。P0–P2 的布局、padding、信息层级、文字、色彩、状态与响应式差异必须修复;最终记录写入项目根目录 `design-qa.md`。
+1 -1
View File
@@ -1,7 +1,7 @@
# 设备 API 兼容性约定 # 设备 API 兼容性约定
> 当前设备接口主版本:`/device/v2` > 当前设备接口主版本:`/device/v2`
> 当前接口契约:OpenAPI 0.70.0 > 当前接口契约:OpenAPI 0.73.0
> 适用对象:云端 API、终端固件、模拟终端和设备联调工具 > 适用对象:云端 API、终端固件、模拟终端和设备联调工具
## 1. 兼容目标 ## 1. 兼容目标
+16 -8
View File
@@ -209,15 +209,19 @@ func (s *Service) Test(ctx context.Context, principal identity.Principal, tenant
testStatus, errorCode = "failed", normalizedErrorCode(err) testStatus, errorCode = "failed", normalizedErrorCode(err)
} }
var result Model var result Model
auditResult := "success"
if testStatus != "passed" {
auditResult = "failure"
}
updateErr := database.Transact(ctx, s.registry.pool, func(tx pgx.Tx) error { updateErr := database.Transact(ctx, s.registry.pool, func(tx pgx.Tx) error {
row := tx.QueryRow(ctx, modelUpdateReturning+` WHERE cm.tenant_id=$1::uuid AND cm.public_id=$2::uuid`, current.tenantID, publicID, testStatus, errorCode, current.userID, current.tenantPublicID) row := tx.QueryRow(ctx, modelUpdateReturning, current.tenantID, publicID, testStatus, errorCode, current.userID, current.tenantPublicID)
updated, scanErr := scanModel(row) updated, scanErr := scanModel(row)
if scanErr != nil { if scanErr != nil {
return scanErr return scanErr
} }
result = updated.Model result = updated.Model
resourceID := result.ID resourceID := result.ID
return s.audit.Record(ctx, tx, audit.Event{TenantID: &current.tenantID, ActorType: "user", ActorPublicID: &current.userPublicID, Action: "content_model.test", ResourceType: "content_model", ResourceID: &resourceID, RequestID: requestID, Purpose: "验证总结模型连通性", Result: testStatus, Detail: map[string]any{"error_code": errorCode}}) return s.audit.Record(ctx, tx, audit.Event{TenantID: &current.tenantID, ActorType: "user", ActorPublicID: &current.userPublicID, Action: "content_model.test", ResourceType: "content_model", ResourceID: &resourceID, RequestID: requestID, Purpose: "验证总结模型连通性", Result: auditResult, Detail: map[string]any{"test_status": testStatus, "error_code": errorCode}})
}) })
if updateErr != nil { if updateErr != nil {
return Model{}, updateErr return Model{}, updateErr
@@ -330,11 +334,10 @@ func (s *Service) resolveActor(ctx context.Context, principal identity.Principal
var result actor var result actor
err := s.registry.pool.QueryRow(ctx, ` err := s.registry.pool.QueryRow(ctx, `
SELECT t.id::text,t.public_id::text,ua.id::text,ua.public_id::text, SELECT t.id::text,t.public_id::text,ua.id::text,ua.public_id::text,
COALESCE(bool_or(hm.role IN ('owner','admin') AND hm.status='active'),false) ua.account_kind='operator' AND ua.platform_role IN ('ops_operator','ops_admin')
FROM tenant t JOIN user_account ua ON ua.tenant_id=t.id AND ua.status='active' FROM tenant t JOIN user_account ua ON ua.tenant_id=t.id AND ua.status='active'
LEFT JOIN household_member hm ON hm.user_id=ua.id AND hm.tenant_id=t.id
WHERE t.public_id=$1::uuid AND t.status='active' AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3 WHERE t.public_id=$1::uuid AND t.status='active' AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3
GROUP BY t.id,t.public_id,ua.id,ua.public_id`, tenantPublicID, principal.Issuer, principal.Subject).Scan(&result.tenantID, &result.tenantPublicID, &result.userID, &result.userPublicID, &result.canManage) `, tenantPublicID, principal.Issuer, principal.Subject).Scan(&result.tenantID, &result.tenantPublicID, &result.userID, &result.userPublicID, &result.canManage)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return actor{}, ErrAccessDenied return actor{}, ErrAccessDenied
} }
@@ -358,14 +361,19 @@ func normalizeInput(input *CreateInput) {
func validateInput(input CreateInput) error { func validateInput(input CreateInput) error {
parsed, err := url.Parse(input.BaseURL) parsed, err := url.Parse(input.BaseURL)
validProvider := input.Provider == "ark" || input.Provider == "openai_compatible" validProvider := extraction.SupportsCompatibleProvider(input.Provider)
validURL := err == nil && parsed.Host != "" && (parsed.Scheme == "https" || (parsed.Scheme == "http" && (parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1"))) validURL := err == nil && parsed.Host != "" && (parsed.Scheme == "https" || (parsed.Scheme == "http" && isApprovedLocalModelHost(parsed.Hostname())))
if !validUUID(input.TenantID) || len([]rune(input.DisplayName)) < 1 || len([]rune(input.DisplayName)) > 120 || !validProvider || !validURL || input.ModelID == "" || len(input.APIKey) < 8 || input.Temperature < 0 || input.Temperature > 2 || input.MaxTokens < 256 || input.MaxTokens > 8192 { if !validUUID(input.TenantID) || len([]rune(input.DisplayName)) < 1 || len([]rune(input.DisplayName)) > 120 || !validProvider || !validURL || input.ModelID == "" || len(input.APIKey) < 8 || input.Temperature < 0 || input.Temperature > 2 || input.MaxTokens < 256 || input.MaxTokens > 8192 {
return ErrInvalid return ErrInvalid
} }
return nil return nil
} }
func isApprovedLocalModelHost(host string) bool {
host = strings.ToLower(strings.TrimSpace(host))
return host == "localhost" || host == "127.0.0.1" || host == "::1" || host == "ollama"
}
func credentialHint(key string) string { func credentialHint(key string) string {
runes := []rune(strings.TrimSpace(key)) runes := []rune(strings.TrimSpace(key))
if len(runes) <= 4 { if len(runes) <= 4 {
@@ -398,7 +406,7 @@ func validUUID(value string) bool { _, err := uuid.Parse(value); return err == n
const modelSelect = `SELECT cm.public_id::text,t.public_id::text,cm.display_name,cm.provider,cm.base_url,cm.model_id,cm.credential_hint,cm.temperature::float8,cm.max_tokens,cm.status,cm.test_status,cm.last_tested_at,COALESCE(cm.last_error_code,''),cm.activated_at,cm.version,cm.created_at,cm.updated_at,cm.tenant_id::text,cm.encrypted_api_key,cm.nonce,cm.key_version FROM content_model_config cm JOIN tenant t ON t.id=cm.tenant_id` const modelSelect = `SELECT cm.public_id::text,t.public_id::text,cm.display_name,cm.provider,cm.base_url,cm.model_id,cm.credential_hint,cm.temperature::float8,cm.max_tokens,cm.status,cm.test_status,cm.last_tested_at,COALESCE(cm.last_error_code,''),cm.activated_at,cm.version,cm.created_at,cm.updated_at,cm.tenant_id::text,cm.encrypted_api_key,cm.nonce,cm.key_version FROM content_model_config cm JOIN tenant t ON t.id=cm.tenant_id`
const modelReturning = `cm.public_id::text,$4::text,cm.display_name,cm.provider,cm.base_url,cm.model_id,cm.credential_hint,cm.temperature::float8,cm.max_tokens,cm.status,cm.test_status,cm.last_tested_at,COALESCE(cm.last_error_code,''),cm.activated_at,cm.version,cm.created_at,cm.updated_at,cm.tenant_id::text,cm.encrypted_api_key,cm.nonce,cm.key_version` const modelReturning = `cm.public_id::text,$4::text,cm.display_name,cm.provider,cm.base_url,cm.model_id,cm.credential_hint,cm.temperature::float8,cm.max_tokens,cm.status,cm.test_status,cm.last_tested_at,COALESCE(cm.last_error_code,''),cm.activated_at,cm.version,cm.created_at,cm.updated_at,cm.tenant_id::text,cm.encrypted_api_key,cm.nonce,cm.key_version`
const modelUpdateReturning = `UPDATE content_model_config cm SET test_status=$3,last_tested_at=now(),last_error_code=NULLIF($4,''),updated_at=now(),version=version+1,updated_by=$5::uuid RETURNING cm.public_id::text,$6::text,cm.display_name,cm.provider,cm.base_url,cm.model_id,cm.credential_hint,cm.temperature::float8,cm.max_tokens,cm.status,cm.test_status,cm.last_tested_at,COALESCE(cm.last_error_code,''),cm.activated_at,cm.version,cm.created_at,cm.updated_at,cm.tenant_id::text,cm.encrypted_api_key,cm.nonce,cm.key_version` const modelUpdateReturning = `UPDATE content_model_config cm SET test_status=$3,last_tested_at=now(),last_error_code=NULLIF($4,''),updated_at=now(),version=version+1,updated_by=$5::uuid WHERE cm.tenant_id=$1::uuid AND cm.public_id=$2::uuid RETURNING cm.public_id::text,$6::text,cm.display_name,cm.provider,cm.base_url,cm.model_id,cm.credential_hint,cm.temperature::float8,cm.max_tokens,cm.status,cm.test_status,cm.last_tested_at,COALESCE(cm.last_error_code,''),cm.activated_at,cm.version,cm.created_at,cm.updated_at,cm.tenant_id::text,cm.encrypted_api_key,cm.nonce,cm.key_version`
type rowScanner interface{ Scan(...any) error } type rowScanner interface{ Scan(...any) error }
@@ -0,0 +1,75 @@
package contentmodel
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"testing"
"data-terminal-cloud/internal/audit"
"data-terminal-cloud/internal/extraction"
"data-terminal-cloud/internal/identity"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestServiceTestsAndActivatesCompatibleModel(t *testing.T) {
databaseURL := os.Getenv("TEST_DATABASE_URL")
if databaseURL == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": `{"meaningful":false,"story_worthy":false,"decision_reason":"连通性测试内容不形成故事","summary":"","keywords":[],"open_topic":"","story_title":"","story_content":"","fact_content":"","confidence":0.99}`}}}})
}))
defer server.Close()
ctx := context.Background()
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
registry, err := NewRegistry(pool, "1f1e1d1c1b1a191817161514131211100f0e0d0c0b0a09080706050403020100", extraction.DeterministicGenerator{})
if err != nil {
t.Fatal(err)
}
service, err := NewService(registry, audit.NewRepository())
if err != nil {
t.Fatal(err)
}
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,
}, "content-model-integration-create")
if err != nil {
t.Fatal(err)
}
defer func() {
_, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE resource_id=$1::uuid`, created.ID)
_, _ = pool.Exec(ctx, `DELETE FROM content_model_config WHERE public_id=$1::uuid`, created.ID)
}()
tested, err := service.Test(ctx, principal, created.TenantID, created.ID, "content-model-integration-test")
if err != nil {
t.Fatal(err)
}
if tested.TestStatus != "passed" || tested.Version != created.Version+1 {
t.Fatalf("tested model=%+v", tested)
}
activated, err := service.Activate(ctx, principal, created.TenantID, created.ID, "content-model-integration-activate", tested.Version)
if err != nil {
t.Fatal(err)
}
if activated.Status != "active" || activated.Version != tested.Version+1 {
t.Fatalf("activated model=%+v", activated)
}
}
+52
View File
@@ -0,0 +1,52 @@
package contentmodel
import "testing"
func TestValidateInputAcceptsSupportedDomesticProviders(t *testing.T) {
providers := []string{"ark", "dashscope", "deepseek", "moonshot", "zhipu", "qianfan", "hunyuan", "minimax", "baichuan", "siliconflow"}
for _, provider := range providers {
t.Run(provider, func(t *testing.T) {
input := CreateInput{
TenantID: "10000000-0000-0000-0000-000000000001", DisplayName: provider,
Provider: provider, BaseURL: "https://model.example.com/v1", ModelID: "summary-model",
APIKey: "test-api-key", Temperature: 0.2, MaxTokens: 1200,
}
if err := validateInput(input); err != nil {
t.Fatalf("supported provider was rejected: %v", err)
}
})
}
}
func TestValidateInputRejectsUnknownProvider(t *testing.T) {
input := CreateInput{
TenantID: "10000000-0000-0000-0000-000000000001", DisplayName: "unknown",
Provider: "unknown", BaseURL: "https://model.example.com/v1", ModelID: "summary-model",
APIKey: "test-api-key", Temperature: 0.2, MaxTokens: 1200,
}
if err := validateInput(input); err == nil {
t.Fatal("unknown provider was accepted")
}
}
func TestValidateInputAllowsComposeOllamaEndpoint(t *testing.T) {
input := CreateInput{
TenantID: "10000000-0000-0000-0000-000000000001", DisplayName: "本地 Qwen",
Provider: "openai_compatible", BaseURL: "http://ollama:11434/v1", ModelID: "qwen2.5:7b",
APIKey: "ollama-local-key", Temperature: 0.1, MaxTokens: 1200,
}
if err := validateInput(input); err != nil {
t.Fatalf("compose Ollama endpoint was rejected: %v", err)
}
}
func TestValidateInputRejectsUnapprovedPlainHTTPEndpoint(t *testing.T) {
input := CreateInput{
TenantID: "10000000-0000-0000-0000-000000000001", DisplayName: "不安全端点",
Provider: "openai_compatible", BaseURL: "http://metadata.internal/v1", ModelID: "model",
APIKey: "test-api-key", Temperature: 0.1, MaxTokens: 1200,
}
if err := validateInput(input); err == nil {
t.Fatal("unapproved plaintext endpoint was accepted")
}
}
+161 -24
View File
@@ -23,6 +23,9 @@ type GenerationInput struct {
} }
type GenerationResult struct { type GenerationResult struct {
Meaningful bool `json:"meaningful"`
StoryWorthy bool `json:"story_worthy"`
DecisionReason string `json:"decision_reason"`
Summary string `json:"summary"` Summary string `json:"summary"`
OpenTopic string `json:"open_topic"` OpenTopic string `json:"open_topic"`
StoryTitle string `json:"story_title"` StoryTitle string `json:"story_title"`
@@ -46,17 +49,33 @@ func (DeterministicGenerator) Generate(_ context.Context, input GenerationInput)
if content == "" { if content == "" {
return GenerationResult{}, errors.New("extraction content is required") return GenerationResult{}, errors.New("extraction content is required")
} }
meaningful, storyWorthy, reason := deterministicContentDecision(content)
if !meaningful {
return GenerationResult{ return GenerationResult{
Meaningful: false, StoryWorthy: false, DecisionReason: reason,
Confidence: 0.9, ModelRef: "local-deterministic-extractor", PromptVersion: "content-value-v1",
}, nil
}
storyTitle, storyContent, factContent := "", "", ""
if storyWorthy {
storyTitle = "关于“" + truncateRunes(content, 24) + "”的回忆"
storyContent = content
factContent = content
}
return GenerationResult{
Meaningful: true,
StoryWorthy: storyWorthy,
DecisionReason: reason,
Summary: "长者在本次通话中谈到:" + content, Summary: "长者在本次通话中谈到:" + content,
OpenTopic: "下次可以继续询问长者刚才提到的经历,并让长者自行补充人物、时间和地点。", OpenTopic: "下次可以继续询问长者刚才提到的经历,并让长者自行补充人物、时间和地点。",
StoryTitle: "关于“" + truncateRunes(content, 24) + "”的回忆", StoryTitle: storyTitle,
StoryContent: content, StoryContent: storyContent,
FactContent: content, FactContent: factContent,
Keywords: deterministicKeywords(content), Keywords: deterministicKeywords(content),
Confidence: 0.9, Confidence: 0.9,
ModelRef: "local-deterministic-extractor", ModelRef: "local-deterministic-extractor",
PromptVersion: "story-v1", PromptVersion: "content-value-deterministic-v2",
AllowStory: true, AllowStory: storyWorthy,
}, nil }, nil
} }
@@ -67,6 +86,9 @@ func sensitiveGeneration(decision contentpolicy.Decision) GenerationResult {
} }
reason := safePolicyToken(decision.Reason) reason := safePolicyToken(decision.Reason)
return GenerationResult{ return GenerationResult{
Meaningful: true,
StoryWorthy: false,
DecisionReason: "safety_policy_blocked_story",
Summary: "本次通话包含需要人工谨慎处理的敏感信息,系统未生成普通故事卡片。", Summary: "本次通话包含需要人工谨慎处理的敏感信息,系统未生成普通故事卡片。",
OpenTopic: "如确有必要处理本次敏感内容,应由有权限人员在受控流程中单独核对。", OpenTopic: "如确有必要处理本次敏感内容,应由有权限人员在受控流程中单独核对。",
Confidence: 1, Confidence: 1,
@@ -110,10 +132,10 @@ func NewCompatibleGenerator(provider, baseURL, apiKey, model string, temperature
model = strings.TrimSpace(model) model = strings.TrimSpace(model)
parsed, err := url.Parse(baseURL) parsed, err := url.Parse(baseURL)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return nil, errors.New("Ark base URL must be an absolute HTTP(S) URL") return nil, errors.New("content model base URL must be an absolute HTTP(S) URL")
} }
if provider != "ark" && provider != "openai_compatible" { if !SupportsCompatibleProvider(provider) {
return nil, errors.New("content model provider must be ark or openai_compatible") return nil, errors.New("unsupported content model provider")
} }
if apiKey == "" || model == "" || timeout <= 0 || temperature < 0 || temperature > 2 || maxTokens < 256 || maxTokens > 8192 { if apiKey == "" || model == "" || timeout <= 0 || temperature < 0 || temperature > 2 || maxTokens < 256 || maxTokens > 8192 {
return nil, errors.New("content model credentials and valid generation parameters are required") return nil, errors.New("content model credentials and valid generation parameters are required")
@@ -133,12 +155,17 @@ func (g *ArkGenerator) Generate(ctx context.Context, input GenerationInput) (Gen
requestBody := map[string]any{ requestBody := map[string]any{
"model": g.model, "model": g.model,
"messages": []map[string]string{ "messages": []map[string]string{
{"role": "system", "content": "你是长者口述内容整理助手。只依据输入字幕生成可人工核对的候选,不补充未出现的事实,不把推断写成事实。必须返回JSON对象,字段为summary、keywords、open_topic、story_title、story_content、fact_content、confidence。summary概括本次内容,keywords是1至8个简短关键词字符串组成的数组,open_topic给出一个温和的后续话题,story和fact保留原意,confidence取0到1。"}, {"role": "system", "content": contentValuePrompt},
{"role": "user", "content": content}, {"role": "user", "content": content},
}, },
"temperature": g.temperature, "temperature": g.temperature,
"max_tokens": g.maxTokens, "max_tokens": g.maxTokens,
"response_format": map[string]string{"type": "json_object"}, }
// JSON mode is sent only where the provider documents it or this project
// has exercised it. Other compatible providers still receive an explicit
// JSON-only prompt, followed by strict response extraction and validation.
if providerSupportsJSONMode(g.provider) {
requestBody["response_format"] = map[string]string{"type": "json_object"}
} }
encoded, err := json.Marshal(requestBody) encoded, err := json.Marshal(requestBody)
if err != nil { if err != nil {
@@ -146,22 +173,22 @@ func (g *ArkGenerator) Generate(ctx context.Context, input GenerationInput) (Gen
} }
req, err := http.NewRequestWithContext(ctx, http.MethodPost, g.endpoint, bytes.NewReader(encoded)) req, err := http.NewRequestWithContext(ctx, http.MethodPost, g.endpoint, bytes.NewReader(encoded))
if err != nil { if err != nil {
return GenerationResult{}, fmt.Errorf("build Ark content request: %w", err) return GenerationResult{}, fmt.Errorf("build content model request: %w", err)
} }
req.Header.Set("Authorization", "Bearer "+g.apiKey) req.Header.Set("Authorization", "Bearer "+g.apiKey)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "voice-platform-content-extractor/1.0") req.Header.Set("User-Agent", "voice-platform-content-extractor/1.0")
resp, err := g.client.Do(req) resp, err := g.client.Do(req)
if err != nil { if err != nil {
return GenerationResult{}, outbox.Retryable(fmt.Errorf("call Ark content model: %w", err)) return GenerationResult{}, outbox.Retryable(fmt.Errorf("call content model: %w", err))
} }
defer resp.Body.Close() defer resp.Body.Close()
responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) responseBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if readErr != nil { if readErr != nil {
return GenerationResult{}, outbox.Retryable(fmt.Errorf("read Ark content response: %w", readErr)) return GenerationResult{}, outbox.Retryable(fmt.Errorf("read content model response: %w", readErr))
} }
if resp.StatusCode < 200 || resp.StatusCode >= 300 { if resp.StatusCode < 200 || resp.StatusCode >= 300 {
requestErr := fmt.Errorf("Ark content model returned HTTP %d", resp.StatusCode) requestErr := fmt.Errorf("content model returned HTTP %d", resp.StatusCode)
if resp.StatusCode == http.StatusRequestTimeout || resp.StatusCode == http.StatusTooEarly || resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 { if resp.StatusCode == http.StatusRequestTimeout || resp.StatusCode == http.StatusTooEarly || resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 {
return GenerationResult{}, outbox.Retryable(requestErr) return GenerationResult{}, outbox.Retryable(requestErr)
} }
@@ -175,41 +202,122 @@ func (g *ArkGenerator) Generate(ctx context.Context, input GenerationInput) (Gen
} `json:"choices"` } `json:"choices"`
} }
if err = json.Unmarshal(responseBody, &completion); err != nil || len(completion.Choices) == 0 { if err = json.Unmarshal(responseBody, &completion); err != nil || len(completion.Choices) == 0 {
return GenerationResult{}, outbox.Retryable(errors.New("Ark content response has no usable choice")) return GenerationResult{}, outbox.Retryable(errors.New("content model response has no usable choice"))
} }
jsonContent := strings.TrimSpace(completion.Choices[0].Message.Content) jsonContent := extractJSONObject(completion.Choices[0].Message.Content)
jsonContent = strings.TrimPrefix(jsonContent, "```json") jsonContent = strings.TrimPrefix(jsonContent, "```json")
jsonContent = strings.TrimPrefix(jsonContent, "```") jsonContent = strings.TrimPrefix(jsonContent, "```")
jsonContent = strings.TrimSuffix(jsonContent, "```") jsonContent = strings.TrimSuffix(jsonContent, "```")
var result GenerationResult var result GenerationResult
if err = json.Unmarshal([]byte(strings.TrimSpace(jsonContent)), &result); err != nil { if err = json.Unmarshal([]byte(strings.TrimSpace(jsonContent)), &result); err != nil {
return GenerationResult{}, outbox.Retryable(fmt.Errorf("decode Ark structured content: %w", err)) return GenerationResult{}, outbox.Retryable(fmt.Errorf("decode structured content: %w", err))
} }
result.Summary = strings.TrimSpace(result.Summary) result.Summary = strings.TrimSpace(result.Summary)
result.DecisionReason = strings.TrimSpace(result.DecisionReason)
result.OpenTopic = strings.TrimSpace(result.OpenTopic) result.OpenTopic = strings.TrimSpace(result.OpenTopic)
result.StoryTitle = strings.TrimSpace(result.StoryTitle) result.StoryTitle = strings.TrimSpace(result.StoryTitle)
result.StoryContent = strings.TrimSpace(result.StoryContent) result.StoryContent = strings.TrimSpace(result.StoryContent)
result.FactContent = strings.TrimSpace(result.FactContent) result.FactContent = strings.TrimSpace(result.FactContent)
result.Keywords = normalizedKeywords(result.Keywords) result.Keywords = normalizedKeywords(result.Keywords)
normalizeDecisionContent(&result)
if !validGeneratedContent(result) { if !validGeneratedContent(result) {
return GenerationResult{}, outbox.Retryable(errors.New("Ark structured content failed validation")) return GenerationResult{}, outbox.Retryable(errors.New("structured content failed validation"))
} }
result.ModelRef = g.provider + ":" + g.model result.ModelRef = g.provider + ":" + g.model
result.PromptVersion = "story-" + g.provider + "-json-v2" result.PromptVersion = "content-value-" + g.provider + "-json-v3"
result.AllowStory = true result.AllowStory = result.Meaningful && result.StoryWorthy
return result, nil return result, nil
} }
// SupportsCompatibleProvider is the single backend allowlist used both when a
// model configuration is saved and when a worker resolves that configuration.
func SupportsCompatibleProvider(provider string) bool {
switch provider {
case "ark", "dashscope", "deepseek", "moonshot", "zhipu", "qianfan", "hunyuan", "minimax", "baichuan", "siliconflow", "ollama", "openai_compatible":
return true
default:
return false
}
}
func providerSupportsJSONMode(provider string) bool {
switch provider {
case "ark", "dashscope", "deepseek", "siliconflow", "ollama", "openai_compatible":
return true
default:
return false
}
}
func extractJSONObject(content string) string {
content = strings.TrimSpace(content)
if end := strings.LastIndex(content, "</think>"); end >= 0 {
content = strings.TrimSpace(content[end+len("</think>"):])
}
content = strings.TrimPrefix(content, "```json")
content = strings.TrimPrefix(content, "```")
content = strings.TrimSuffix(content, "```")
content = strings.TrimSpace(content)
start, end := strings.Index(content, "{"), strings.LastIndex(content, "}")
if start >= 0 && end >= start {
return content[start : end+1]
}
return content
}
func normalizeDecisionContent(result *GenerationResult) {
if !result.Meaningful {
result.StoryWorthy = false
result.Summary = ""
result.OpenTopic = ""
result.StoryTitle = ""
result.StoryContent = ""
result.FactContent = ""
result.Keywords = nil
return
}
if !result.StoryWorthy {
result.StoryTitle = ""
result.StoryContent = ""
result.FactContent = ""
}
}
func validGeneratedContent(result GenerationResult) bool { func validGeneratedContent(result GenerationResult) bool {
return result.Summary != "" && len([]rune(result.Summary)) <= 2000 && if result.Confidence < 0 || result.Confidence > 1 || result.DecisionReason == "" || len([]rune(result.DecisionReason)) > 500 {
result.OpenTopic != "" && len([]rune(result.OpenTopic)) <= 1000 && return false
result.StoryTitle != "" && len([]rune(result.StoryTitle)) <= 300 && }
if !result.Meaningful {
return !result.StoryWorthy && result.Summary == "" && result.OpenTopic == "" && result.StoryTitle == "" && result.StoryContent == "" && result.FactContent == "" && len(result.Keywords) == 0
}
if result.Summary == "" || len([]rune(result.Summary)) > 2000 ||
result.OpenTopic == "" || len([]rune(result.OpenTopic)) > 1000 ||
len(result.Keywords) < 1 || len(result.Keywords) > 8 {
return false
}
if !result.StoryWorthy {
return result.StoryTitle == "" && result.StoryContent == "" && result.FactContent == ""
}
return result.StoryTitle != "" && len([]rune(result.StoryTitle)) <= 300 &&
result.StoryContent != "" && len([]rune(result.StoryContent)) <= 20000 && result.StoryContent != "" && len([]rune(result.StoryContent)) <= 20000 &&
result.FactContent != "" && len([]rune(result.FactContent)) <= 5000 && result.FactContent != "" && len([]rune(result.FactContent)) <= 5000 &&
len(result.Keywords) >= 1 && len(result.Keywords) <= 8 &&
result.Confidence >= 0 && result.Confidence <= 1 result.Confidence >= 0 && result.Confidence <= 1
} }
const contentValuePrompt = `你是长者聊天内容筛选与整理助手。只依据输入对话判断和整理,不补充未出现的事实,不把推断写成事实。
先做两级判断:
1. meaningful:对话是否包含值得保留的信息。只有问候、寒暄、嗯啊、测试设备、重复句、零散且无法理解的短句,必须判为false并舍弃,不生成摘要。
2. story_worthy:仅在meaningful为true时判断是否值得形成故事。具体的个人经历或回忆,且人物、时间、地点、事件、行动、感受中至少出现两类,或形成了清楚的事件脉络,才判为true。普通偏好、单个事实或简短近况可以总结,但story_worthy应为false。
必须只返回JSON对象,字段为meaningful、story_worthy、decision_reason、summary、keywords、open_topic、story_title、story_content、fact_content、confidence。
- decision_reason用一句简短中文说明判断依据。
- meaningful=false时,除decision_reason和confidence外,其余文本必须为空字符串,keywords必须为空数组,story_worthy必须为false。
- meaningful=true时,summary概括有意义内容,keywords为1至8个简短关键词,open_topic给出一个温和且不重复追问的后续话题。
- story_worthy=false时,story_title、story_content、fact_content必须为空字符串。
- story_worthy=true时,story_title和story_content忠实整理原话,fact_content只写对话中可核对的事实。
- confidence取0到1。`
func normalizedKeywords(values []string) []string { func normalizedKeywords(values []string) []string {
result := make([]string, 0, min(len(values), 8)) result := make([]string, 0, min(len(values), 8))
seen := make(map[string]struct{}, len(values)) seen := make(map[string]struct{}, len(values))
@@ -255,3 +363,32 @@ func deterministicKeywords(content string) []string {
} }
return []string{string(runes)} return []string{string(runes)}
} }
func deterministicContentDecision(content string) (meaningful, storyWorthy bool, reason string) {
compact := strings.Map(func(character rune) rune {
if unicode.IsSpace(character) || strings.ContainsRune(",。!?;、,.!?;::\"“”‘’()()-", character) {
return -1
}
return character
}, content)
for _, label := range []string{"长者", "豆包", "助手", "用户", "agent", "subject"} {
compact = strings.ReplaceAll(compact, label, "")
}
for _, phrase := range []string{"你好", "您好", "在吗", "嗯", "啊", "哦", "好的", "知道了", "没事", "再见", "拜拜", "听得到吗", "能听见吗", "测试"} {
compact = strings.ReplaceAll(compact, phrase, "")
}
if len([]rune(compact)) < 12 {
return false, false, "内容仅为寒暄、确认或零散短句"
}
storySignals := 0
for _, phrase := range []string{"小时候", "年轻时", "那年", "当时", "后来", "以前", "记得", "有一次", "我们", "我和", "去了", "发生", "因为", "结果", "高兴", "难过", "想起"} {
if strings.Contains(content, phrase) {
storySignals++
}
}
storyWorthy = len([]rune(compact)) >= 48 && storySignals >= 2
if storyWorthy {
return true, true, "包含较完整的个人经历与事件脉络"
}
return true, false, "包含可保留的信息,但尚不足以形成完整故事"
}
@@ -0,0 +1,58 @@
package extraction
import (
"context"
"os"
"testing"
"time"
)
func TestLocalModelContentValueDecisions(t *testing.T) {
baseURL := os.Getenv("LOCAL_MODEL_BASE_URL")
if baseURL == "" {
t.Skip("LOCAL_MODEL_BASE_URL is not set")
}
modelID := os.Getenv("LOCAL_MODEL_ID")
if modelID == "" {
modelID = "qwen2.5:7b"
}
generator, err := NewCompatibleGenerator("openai_compatible", baseURL, "ollama-local-key", modelID, 0.1, 1200, 2*time.Minute)
if err != nil {
t.Fatal(err)
}
tests := []struct {
name, content string
meaningful, storyWorth bool
}{
{
name: "discard small talk",
content: "豆包:您好,今天过得怎么样?\n长者:还行。\n豆包:吃饭了吗?\n长者:嗯,吃了。\n豆包:那您好好休息。\n长者:好,再见。",
},
{
name: "keep useful update without story",
content: "豆包:今天胃口怎么样?\n长者:今天胃口挺好,中午吃了一碗西红柿鸡蛋面,晚上想吃点清淡的。",
meaningful: true,
},
{
name: "create story candidate from coherent memory",
content: "豆包:您还记得第一次当老师的情形吗?\n长者:记得。那是1978年秋天,我二十二岁,被分到河边小学。第一天是姐姐骑自行车送我去的,教室里有三十多个孩子。我紧张得把粉笔掉在地上,一个小姑娘帮我捡起来,全班都笑了,我也跟着笑。后来那个小姑娘一直读到大学,很多年后还回来看过我。",
meaningful: true, storyWorth: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
result, generateErr := generator.Generate(ctx, GenerationInput{Content: test.content})
if generateErr != nil {
t.Fatal(generateErr)
}
if result.Meaningful != test.meaningful || result.StoryWorthy != test.storyWorth || result.AllowStory != test.storyWorth {
t.Fatalf("decision mismatch: meaningful=%v story_worthy=%v allow_story=%v reason=%q summary=%q", result.Meaningful, result.StoryWorthy, result.AllowStory, result.DecisionReason, result.Summary)
}
if !result.Meaningful && (result.Summary != "" || result.StoryContent != "") {
t.Fatalf("discarded conversation produced content: %+v", result)
}
})
}
}
+86 -1
View File
@@ -20,7 +20,7 @@ func TestArkGeneratorProducesValidatedContent(t *testing.T) {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body["model"] != "doubao-test" { if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body["model"] != "doubao-test" {
t.Fatalf("request body=%v err=%v", body, err) t.Fatalf("request body=%v err=%v", body, err)
} }
_ = json.NewEncoder(w).Encode(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": `{"summary":"谈到河边小学","keywords":["河边小学","童年"],"open_topic":"下次可以聊聊同学","story_title":"河边小学的回忆","story_content":"小时候在河边小学读书。","fact_content":"曾在河边小学读书","confidence":0.82}`}}}}) _ = json.NewEncoder(w).Encode(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": `{"meaningful":true,"story_worthy":true,"decision_reason":"包含具体的童年求学回忆","summary":"谈到河边小学","keywords":["河边小学","童年"],"open_topic":"下次可以聊聊同学","story_title":"河边小学的回忆","story_content":"小时候在河边小学读书。","fact_content":"曾在河边小学读书","confidence":0.82}`}}}})
})) }))
defer server.Close() defer server.Close()
generator, err := NewArkGenerator(server.URL+"/api/v3", "test-key", "doubao-test", time.Second) generator, err := NewArkGenerator(server.URL+"/api/v3", "test-key", "doubao-test", time.Second)
@@ -33,6 +33,91 @@ func TestArkGeneratorProducesValidatedContent(t *testing.T) {
} }
} }
func TestCompatibleGeneratorDiscardsSmallTalk(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": `{"meaningful":false,"story_worthy":false,"decision_reason":"只有问候和确认","summary":"","keywords":[],"open_topic":"","story_title":"","story_content":"","fact_content":"","confidence":0.96}`}}}})
}))
defer server.Close()
generator, err := NewCompatibleGenerator("openai_compatible", server.URL+"/v1", "local-key", "qwen-test", 0.1, 512, time.Second)
if err != nil {
t.Fatal(err)
}
result, err := generator.Generate(context.Background(), GenerationInput{Content: "豆包:您好。\n长者:嗯,你好。\n豆包:今天怎么样?\n长者:还行。"})
if err != nil || result.Meaningful || result.StoryWorthy || result.AllowStory || result.DecisionReason == "" {
t.Fatalf("result=%+v err=%v", result, err)
}
}
func TestCompatibleGeneratorKeepsMeaningfulNonStoryWithoutDraft(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": `{"meaningful":true,"story_worthy":false,"decision_reason":"有明确近况但没有事件脉络","summary":"长者今天胃口不错","keywords":["胃口","近况"],"open_topic":"下次可以聊聊最近喜欢的饭菜","story_title":"","story_content":"","fact_content":"","confidence":0.87}`}}}})
}))
defer server.Close()
generator, err := NewCompatibleGenerator("openai_compatible", server.URL+"/v1", "local-key", "qwen-test", 0.1, 512, time.Second)
if err != nil {
t.Fatal(err)
}
result, err := generator.Generate(context.Background(), GenerationInput{Content: "长者:我今天胃口挺好的,中午吃了面条。"})
if err != nil || !result.Meaningful || result.StoryWorthy || result.AllowStory || result.Summary == "" {
t.Fatalf("result=%+v err=%v", result, err)
}
}
func TestCompatibleGeneratorClearsContradictoryStoryFields(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": `{"meaningful":true,"story_worthy":false,"decision_reason":"只是近况","summary":"今天胃口不错","keywords":["胃口"],"open_topic":"最近喜欢吃什么?","story_title":"模型误填标题","story_content":"模型误填故事","fact_content":"模型误填事实","confidence":0.8}`}}}})
}))
defer server.Close()
generator, err := NewCompatibleGenerator("openai_compatible", server.URL+"/v1", "local-key", "qwen-test", 0.1, 512, time.Second)
if err != nil {
t.Fatal(err)
}
result, err := generator.Generate(context.Background(), GenerationInput{Content: "长者:今天胃口不错。"})
if err != nil || result.AllowStory || result.StoryTitle != "" || result.StoryContent != "" || result.FactContent != "" {
t.Fatalf("result=%+v err=%v", result, err)
}
}
func TestCompatibleGeneratorHandlesThinkingWrapper(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": "<think>先判断内容价值</think>\n```json\n{\"meaningful\":false,\"story_worthy\":false,\"decision_reason\":\"只有问候\",\"summary\":\"\",\"keywords\":[],\"open_topic\":\"\",\"story_title\":\"\",\"story_content\":\"\",\"fact_content\":\"\",\"confidence\":0.95}\n```"}}}})
}))
defer server.Close()
generator, err := NewCompatibleGenerator("minimax", server.URL+"/v1", "local-key", "MiniMax-test", 0.1, 512, time.Second)
if err != nil {
t.Fatal(err)
}
result, err := generator.Generate(context.Background(), GenerationInput{Content: "您好。"})
if err != nil || result.Meaningful || result.ModelRef != "minimax:MiniMax-test" {
t.Fatalf("result=%+v err=%v", result, err)
}
}
func TestCompatibleGeneratorOmitsUndocumentedJSONMode(t *testing.T) {
for _, provider := range []string{"moonshot", "zhipu", "qianfan", "hunyuan", "minimax", "baichuan"} {
t.Run(provider, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if _, exists := body["response_format"]; exists {
t.Fatalf("%s request unexpectedly enabled JSON mode", provider)
}
_ = json.NewEncoder(w).Encode(map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": `{"meaningful":false,"story_worthy":false,"decision_reason":"测试内容","summary":"","keywords":[],"open_topic":"","story_title":"","story_content":"","fact_content":"","confidence":0.9}`}}}})
}))
defer server.Close()
generator, err := NewCompatibleGenerator(provider, server.URL, "test-key", "test-model", 0.2, 512, time.Second)
if err != nil {
t.Fatal(err)
}
if _, err := generator.Generate(context.Background(), GenerationInput{Content: "模型连接测试"}); err != nil {
t.Fatal(err)
}
})
}
}
func TestArkGeneratorRetriesTemporaryFailure(t *testing.T) { func TestArkGeneratorRetriesTemporaryFailure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Error(w, "busy", http.StatusTooManyRequests) http.Error(w, "busy", http.StatusTooManyRequests)
+21 -5
View File
@@ -91,9 +91,12 @@ func (p *Processor) Handle(ctx context.Context, event outbox.Event) error {
} }
sourceTurns := subjectTurns(snapshot.turns) sourceTurns := subjectTurns(snapshot.turns)
if len(sourceTurns) == 0 { if len(sourceTurns) == 0 {
return outbox.Retryable(ErrConversationContentNotReady) // Terminal conversations cannot receive more subject turns. Treat the
// extraction request as complete so permanently empty conversations do
// not cycle through retry and dead-letter queues forever.
return nil
} }
content := joinedTurnContent(sourceTurns) content := joinedConversationContent(snapshot.turns)
generated, err := p.generateSafely(ctx, content, snapshot.tenantID) generated, err := p.generateSafely(ctx, content, snapshot.tenantID)
if err != nil { if err != nil {
return err return err
@@ -247,12 +250,16 @@ func subjectTurns(turns []turn) []turn {
return items return items
} }
func joinedTurnContent(turns []turn) string { func joinedConversationContent(turns []turn) string {
parts := make([]string, 0, len(turns)) parts := make([]string, 0, len(turns))
for _, item := range turns { for _, item := range turns {
parts = append(parts, item.text) label := "豆包"
if item.speaker == "subject" {
label = "长者"
} }
return strings.Join(parts, " ") parts = append(parts, label+":"+item.text)
}
return strings.Join(parts, "\n")
} }
func storeGeneration(ctx context.Context, tx pgx.Tx, conversationID string, snapshot extractionSnapshot, generated GenerationResult, generationVersion int64) error { func storeGeneration(ctx context.Context, tx pgx.Tx, conversationID string, snapshot extractionSnapshot, generated GenerationResult, generationVersion int64) error {
@@ -262,6 +269,15 @@ func storeGeneration(ctx context.Context, tx pgx.Tx, conversationID string, snap
sourcePublicIDs = append(sourcePublicIDs, item.publicID) sourcePublicIDs = append(sourcePublicIDs, item.publicID)
} }
sourceJSON, _ := json.Marshal(sourcePublicIDs) sourceJSON, _ := json.Marshal(sourcePublicIDs)
if _, err := tx.Exec(ctx, `
INSERT INTO conversation_content_assessment (tenant_id,subject_id,conversation_id,generation_version,meaningful,story_worthy,decision_reason,model_ref,prompt_version,confidence)
VALUES ($1::uuid,$2::uuid,$3::uuid,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT (conversation_id,generation_version) DO NOTHING`, snapshot.tenantID, snapshot.subjectID, conversationID, generationVersion, generated.Meaningful, generated.StoryWorthy, generated.DecisionReason, generated.ModelRef, generated.PromptVersion, generated.Confidence); err != nil {
return fmt.Errorf("store conversation content assessment: %w", err)
}
if !generated.Meaningful {
return tx.Commit(ctx)
}
if _, err := tx.Exec(ctx, ` if _, err := tx.Exec(ctx, `
INSERT INTO conversation_summary (tenant_id,subject_id,conversation_id,summary_type,content,source_turn_ids,model_ref,prompt_version,generation_version,confidence,status) INSERT INTO conversation_summary (tenant_id,subject_id,conversation_id,summary_type,content,source_turn_ids,model_ref,prompt_version,generation_version,confidence,status)
VALUES ($1::uuid,$2::uuid,$3::uuid,'conversation_summary',$4,$5::jsonb,$6,$7,$8,$9,'READY') VALUES ($1::uuid,$2::uuid,$3::uuid,'conversation_summary',$4,$5::jsonb,$6,$7,$8,$9,'READY')
@@ -39,7 +39,9 @@ func TestProcessorExtractionIdempotencyAndSensitiveContent(t *testing.T) {
consentSnapshotID, consentSnapshotPublicID := uuid.NewString(), uuid.NewString() consentSnapshotID, consentSnapshotPublicID := uuid.NewString(), uuid.NewString()
contextID, contextPublicID := uuid.NewString(), uuid.NewString() contextID, contextPublicID := uuid.NewString(), uuid.NewString()
conversationID, conversationPublicID := uuid.NewString(), uuid.NewString() conversationID, conversationPublicID := uuid.NewString(), uuid.NewString()
emptyConversationID, emptyConversationPublicID := uuid.NewString(), uuid.NewString()
defer func() { defer func() {
_, _ = pool.Exec(ctx, `DELETE FROM conversation_content_assessment WHERE conversation_id=$1::uuid`, conversationID)
_, _ = pool.Exec(ctx, `DELETE FROM fact_candidate WHERE story_id IN (SELECT id FROM story_draft WHERE conversation_id=$1::uuid)`, conversationID) _, _ = pool.Exec(ctx, `DELETE FROM fact_candidate WHERE story_id IN (SELECT id FROM story_draft WHERE conversation_id=$1::uuid)`, conversationID)
_, _ = pool.Exec(ctx, `DELETE FROM story_source WHERE story_revision_id IN (SELECT sr.id FROM story_revision sr JOIN story_draft sd ON sd.id=sr.story_id WHERE sd.conversation_id=$1::uuid)`, conversationID) _, _ = pool.Exec(ctx, `DELETE FROM story_source WHERE story_revision_id IN (SELECT sr.id FROM story_revision sr JOIN story_draft sd ON sd.id=sr.story_id WHERE sd.conversation_id=$1::uuid)`, conversationID)
_, _ = pool.Exec(ctx, `UPDATE story_draft SET current_revision_id=NULL WHERE conversation_id=$1::uuid`, conversationID) _, _ = pool.Exec(ctx, `UPDATE story_draft SET current_revision_id=NULL WHERE conversation_id=$1::uuid`, conversationID)
@@ -48,6 +50,7 @@ func TestProcessorExtractionIdempotencyAndSensitiveContent(t *testing.T) {
_, _ = pool.Exec(ctx, `DELETE FROM conversation_summary WHERE conversation_id=$1::uuid`, conversationID) _, _ = pool.Exec(ctx, `DELETE FROM conversation_summary WHERE conversation_id=$1::uuid`, conversationID)
_, _ = pool.Exec(ctx, `DELETE FROM conversation_turn WHERE conversation_id=$1::uuid`, conversationID) _, _ = pool.Exec(ctx, `DELETE FROM conversation_turn WHERE conversation_id=$1::uuid`, conversationID)
_, _ = pool.Exec(ctx, `DELETE FROM conversation WHERE id=$1::uuid`, conversationID) _, _ = pool.Exec(ctx, `DELETE FROM conversation WHERE id=$1::uuid`, conversationID)
_, _ = pool.Exec(ctx, `DELETE FROM conversation WHERE id=$1::uuid`, emptyConversationID)
_, _ = pool.Exec(ctx, `DELETE FROM context_snapshot WHERE id=$1::uuid`, contextID) _, _ = pool.Exec(ctx, `DELETE FROM context_snapshot WHERE id=$1::uuid`, contextID)
_, _ = pool.Exec(ctx, `DELETE FROM consent_snapshot WHERE id=$1::uuid`, consentSnapshotID) _, _ = pool.Exec(ctx, `DELETE FROM consent_snapshot WHERE id=$1::uuid`, consentSnapshotID)
}() }()
@@ -73,6 +76,18 @@ func TestProcessorExtractionIdempotencyAndSensitiveContent(t *testing.T) {
conversationID, conversationPublicID, consentSnapshotID, contextID, "extract-task-"+conversationID); err != nil { conversationID, conversationPublicID, consentSnapshotID, contextID, "extract-task-"+conversationID); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := pool.Exec(ctx, `
INSERT INTO conversation(id,public_id,tenant_id,household_id,subject_id,device_id,consent_snapshot_id,context_snapshot_id,
provider_task_ref,engine_mode,retention_mode,status,allocation_expires_at,started_at,ended_at,stop_reason)
VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000201',
'10000000-0000-0000-0000-000000000401','10000000-0000-0000-0000-000000000501',
$3::uuid,$4::uuid,$5,'s2s_hybrid','transcript_only','COMPLETED',now()+interval '5 minutes',now()-interval '1 minute',now(),'user_hangup')`,
emptyConversationID, emptyConversationPublicID, consentSnapshotID, contextID, "extract-empty-task-"+emptyConversationID); err != nil {
t.Fatal(err)
}
if err := processor.Handle(ctx, outbox.Event{AggregateID: emptyConversationID, Payload: []byte(`{"generation_version":1}`)}); err != nil {
t.Fatalf("terminal conversation without subject turns should not retry: %v", err)
}
if _, err := pool.Exec(ctx, ` if _, err := pool.Exec(ctx, `
INSERT INTO conversation_turn(tenant_id,subject_id,conversation_id,turn_key,sequence_no,speaker,start_at,end_at,final_text,source_type) INSERT INTO conversation_turn(tenant_id,subject_id,conversation_id,turn_key,sequence_no,speaker,start_at,end_at,final_text,source_type)
VALUES('10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000401',$1::uuid,$2,1,'subject',now()-interval '2 minutes',now()-interval '110 seconds',$3,'provider'), VALUES('10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000401',$1::uuid,$2,1,'subject',now()-interval '2 minutes',now()-interval '110 seconds',$3,'provider'),
@@ -97,7 +112,7 @@ func TestProcessorExtractionIdempotencyAndSensitiveContent(t *testing.T) {
http.Error(w, "database lock", http.StatusServiceUnavailable) http.Error(w, "database lock", http.StatusServiceUnavailable)
return return
} }
_, _ = io.WriteString(w, `{"choices":[{"message":{"content":"{\"summary\":\"长者谈到在河边小学教书\",\"keywords\":[\"河边小学\",\"教书经历\"],\"open_topic\":\"下次可以聊聊当年的学生\",\"story_title\":\"河边小学的回忆\",\"story_content\":\"我年轻时在河边小学教书。\",\"fact_content\":\"年轻时曾在河边小学教书\",\"confidence\":0.88}"}}]}`) _, _ = io.WriteString(w, `{"choices":[{"message":{"content":"{\"meaningful\":true,\"story_worthy\":true,\"decision_reason\":\"包含具体的教书经历\",\"summary\":\"长者谈到在河边小学教书\",\"keywords\":[\"河边小学\",\"教书经历\"],\"open_topic\":\"下次可以聊聊当年的学生\",\"story_title\":\"河边小学的回忆\",\"story_content\":\"我年轻时在河边小学教书。\",\"fact_content\":\"年轻时曾在河边小学教书\",\"confidence\":0.88}"}}]}`)
})) }))
defer server.Close() defer server.Close()
arkGenerator, err := NewArkGenerator(server.URL+"/api/v3", "integration-key", "doubao-integration", 2*time.Second) arkGenerator, err := NewArkGenerator(server.URL+"/api/v3", "integration-key", "doubao-integration", 2*time.Second)
@@ -12,6 +12,7 @@ type safetyTestGenerator struct{ calls int }
func (g *safetyTestGenerator) Generate(context.Context, GenerationInput) (GenerationResult, error) { func (g *safetyTestGenerator) Generate(context.Context, GenerationInput) (GenerationResult, error) {
g.calls++ g.calls++
return GenerationResult{ return GenerationResult{
Meaningful: true, StoryWorthy: true, DecisionReason: "测试故事",
Summary: "模型摘要", OpenTopic: "开放话题", StoryTitle: "标题", Summary: "模型摘要", OpenTopic: "开放话题", StoryTitle: "标题",
StoryContent: "模型输出风险", FactContent: "普通事实", AllowStory: true, StoryContent: "模型输出风险", FactContent: "普通事实", AllowStory: true,
}, nil }, nil
-3
View File
@@ -27,7 +27,6 @@ const (
CapabilityRecordingDownload Capability = "recording.download" CapabilityRecordingDownload Capability = "recording.download"
CapabilityPrivacyRead Capability = "privacy.read" CapabilityPrivacyRead Capability = "privacy.read"
CapabilityPrivacyManage Capability = "privacy.manage" CapabilityPrivacyManage Capability = "privacy.manage"
CapabilityModelManage Capability = "model.manage"
) )
var roleCapabilities = map[Role]map[Capability]struct{}{ var roleCapabilities = map[Role]map[Capability]struct{}{
@@ -36,14 +35,12 @@ var roleCapabilities = map[Role]map[Capability]struct{}{
CapabilityOwnershipTransfer, CapabilitySubjectRead, CapabilitySubjectWrite, CapabilityOwnershipTransfer, CapabilitySubjectRead, CapabilitySubjectWrite,
CapabilityConversationRead, CapabilityStoryReview, CapabilityDeviceManage, CapabilityConversationRead, CapabilityStoryReview, CapabilityDeviceManage,
CapabilityFirmwareManage, CapabilityPrivacyRead, CapabilityPrivacyManage, CapabilityFirmwareManage, CapabilityPrivacyRead, CapabilityPrivacyManage,
CapabilityModelManage,
), ),
RoleAdmin: capabilitySet( RoleAdmin: capabilitySet(
CapabilityHouseholdRead, CapabilityHouseholdManage, CapabilityMemberManage, CapabilityHouseholdRead, CapabilityHouseholdManage, CapabilityMemberManage,
CapabilitySubjectRead, CapabilitySubjectWrite, CapabilityConversationRead, CapabilitySubjectRead, CapabilitySubjectWrite, CapabilityConversationRead,
CapabilityStoryReview, CapabilityDeviceManage, CapabilityStoryReview, CapabilityDeviceManage,
CapabilityFirmwareManage, CapabilityPrivacyRead, CapabilityFirmwareManage, CapabilityPrivacyRead,
CapabilityModelManage,
), ),
RoleEditor: capabilitySet( RoleEditor: capabilitySet(
CapabilityHouseholdRead, CapabilitySubjectRead, CapabilitySubjectWrite, CapabilityHouseholdRead, CapabilitySubjectRead, CapabilitySubjectWrite,
+1 -1
View File
@@ -391,7 +391,7 @@ func (s *Server) sendFinalTranscript(input voiceprovider.StartRequest) {
TaskRef: input.TaskRef, ConversationID: input.ConversationID, TaskRef: input.TaskRef, ConversationID: input.ConversationID,
EventType: "voice_chat.transcript.final", Status: "FINAL", OccurredAt: s.now().UTC(), EventType: "voice_chat.transcript.final", Status: "FINAL", OccurredAt: s.now().UTC(),
Turns: []voiceprovider.FinalTurn{ Turns: []voiceprovider.FinalTurn{
{TurnKey: "turn-1", SequenceNo: 1, Speaker: "subject", StartAt: base, EndAt: base.Add(6 * time.Second), FinalText: "我年轻时常在家乡河边散步,春天的柳树特别好看。", ProviderRef: "mock-turn-1"}, {TurnKey: "turn-1", SequenceNo: 1, Speaker: "subject", StartAt: base, EndAt: base.Add(6 * time.Second), FinalText: "我年轻时常和姐姐在家乡河边散步。有一次春天突然下雨,我们躲在老柳树下,后来一路笑着跑回家,现在想起来还是很高兴。", ProviderRef: "mock-turn-1"},
{TurnKey: "turn-2", SequenceNo: 2, Speaker: "agent", StartAt: base.Add(7 * time.Second), EndAt: base.Add(13 * time.Second), FinalText: "听起来那是一段很温暖的回忆,下次我们可以继续聊聊家乡的春天。", ProviderRef: "mock-turn-2"}, {TurnKey: "turn-2", SequenceNo: 2, Speaker: "agent", StartAt: base.Add(7 * time.Second), EndAt: base.Add(13 * time.Second), FinalText: "听起来那是一段很温暖的回忆,下次我们可以继续聊聊家乡的春天。", ProviderRef: "mock-turn-2"},
}, },
} }
+1
View File
@@ -266,6 +266,7 @@ func (p *DeleteProcessor) redactPostgreSQL(ctx context.Context, request deletion
`UPDATE subject_fact SET content='[已按隐私请求删除]',source_ref=NULL,confirmation_status='superseded',valid_to=COALESCE(valid_to,now()),version=version+1,updated_at=now() WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`, `UPDATE subject_fact SET content='[已按隐私请求删除]',source_ref=NULL,confirmation_status='superseded',valid_to=COALESCE(valid_to,now()),version=version+1,updated_at=now() WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`,
`UPDATE conversation_turn SET final_text='[已按隐私请求删除]',provider_ref=NULL WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`, `UPDATE conversation_turn SET final_text='[已按隐私请求删除]',provider_ref=NULL WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`,
`UPDATE conversation_summary SET content='[已按隐私请求删除]',source_turn_ids='["privacy-redacted"]'::jsonb,status='SUPERSEDED',last_error=NULL WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`, `UPDATE conversation_summary SET content='[已按隐私请求删除]',source_turn_ids='["privacy-redacted"]'::jsonb,status='SUPERSEDED',last_error=NULL WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`,
`UPDATE conversation_content_assessment SET decision_reason='[privacy-redacted]',model_ref='privacy-redacted',prompt_version='privacy-redacted' WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`,
`UPDATE story_revision sr SET content='[已按隐私请求删除]',edit_reason=NULL,model_ref=NULL,prompt_version=NULL FROM story_draft sd WHERE sr.story_id=sd.id AND sd.tenant_id=$1::uuid AND sd.subject_id=$2::uuid`, `UPDATE story_revision sr SET content='[已按隐私请求删除]',edit_reason=NULL,model_ref=NULL,prompt_version=NULL FROM story_draft sd WHERE sr.story_id=sd.id AND sd.tenant_id=$1::uuid AND sd.subject_id=$2::uuid`,
`UPDATE story_draft SET title='[已按隐私请求删除]',status='SUPERSEDED',review_reason=NULL,version=version+1,updated_at=now() WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`, `UPDATE story_draft SET title='[已按隐私请求删除]',status='SUPERSEDED',review_reason=NULL,version=version+1,updated_at=now() WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`,
`UPDATE fact_candidate SET content='[已按隐私请求删除]',time_range='{}'::jsonb,source_turn_ids='["privacy-redacted"]'::jsonb,status='SUPERSEDED',review_reason=NULL,version=version+1,updated_at=now() WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`, `UPDATE fact_candidate SET content='[已按隐私请求删除]',time_range='{}'::jsonb,source_turn_ids='["privacy-redacted"]'::jsonb,status='SUPERSEDED',review_reason=NULL,version=version+1,updated_at=now() WHERE tenant_id=$1::uuid AND subject_id=$2::uuid`,
+4 -2
View File
@@ -15,6 +15,7 @@ var ErrCursorUnavailable = errors.New("web event cursor is unavailable")
type Event struct { type Event struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Type string `json:"type"` Type string `json:"type"`
HouseholdID string `json:"household_id"`
ResourceType string `json:"resource_type"` ResourceType string `json:"resource_type"`
ResourceID string `json:"resource_id"` ResourceID string `json:"resource_id"`
SubjectID string `json:"subject_id,omitempty"` SubjectID string `json:"subject_id,omitempty"`
@@ -65,9 +66,10 @@ func (s *Service) ListAfter(ctx context.Context, principal identity.Principal, c
limit = 100 limit = 100
} }
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT we.id,we.event_type,we.resource_type,we.resource_public_id::text, SELECT we.id,we.event_type,h.public_id::text,we.resource_type,we.resource_public_id::text,
COALESCE(sp.public_id::text,''),we.status,we.occurred_at COALESCE(sp.public_id::text,''),we.status,we.occurred_at
FROM web_event we FROM web_event we
JOIN household h ON h.id=we.household_id AND h.tenant_id=we.tenant_id
JOIN household_member hm ON hm.tenant_id=we.tenant_id AND hm.household_id=we.household_id AND hm.status='active' JOIN household_member hm ON hm.tenant_id=we.tenant_id AND hm.household_id=we.household_id AND hm.status='active'
JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=we.tenant_id AND ua.status='active' JOIN user_account ua ON ua.id=hm.user_id AND ua.tenant_id=we.tenant_id AND ua.status='active'
LEFT JOIN subject_profile sp ON sp.id=we.subject_id AND sp.tenant_id=we.tenant_id LEFT JOIN subject_profile sp ON sp.id=we.subject_id AND sp.tenant_id=we.tenant_id
@@ -80,7 +82,7 @@ func (s *Service) ListAfter(ctx context.Context, principal identity.Principal, c
items := make([]Event, 0) items := make([]Event, 0)
for rows.Next() { for rows.Next() {
var item Event var item Event
if err := rows.Scan(&item.ID, &item.Type, &item.ResourceType, &item.ResourceID, &item.SubjectID, &item.Status, &item.OccurredAt); err != nil { if err := rows.Scan(&item.ID, &item.Type, &item.HouseholdID, &item.ResourceType, &item.ResourceID, &item.SubjectID, &item.Status, &item.OccurredAt); err != nil {
return nil, err return nil, err
} }
items = append(items, item) items = append(items, item)
@@ -67,7 +67,7 @@ func TestServiceCursorIsolationCleanupAndPresenceExpiry(t *testing.T) {
t.Fatalf("cross-user cursor error=%v", err) t.Fatalf("cross-user cursor error=%v", err)
} }
items, err := service.ListAfter(ctx, owner, ids[0], 1) items, err := service.ListAfter(ctx, owner, ids[0], 1)
if err != nil || len(items) != 1 || items[0].ID != ids[1] || items[0].SubjectID != "10000000-0000-0000-0000-000000000411" { if err != nil || len(items) != 1 || items[0].ID != ids[1] || items[0].HouseholdID != "10000000-0000-0000-0000-000000000211" || items[0].SubjectID != "10000000-0000-0000-0000-000000000411" {
t.Fatalf("events=%+v err=%v", items, err) t.Fatalf("events=%+v err=%v", items, err)
} }
allItems, err := service.ListAfter(ctx, owner, 0, 0) allItems, err := service.ListAfter(ctx, owner, 0, 0)
@@ -0,0 +1,5 @@
BEGIN;
DROP TABLE IF EXISTS conversation_content_assessment;
COMMIT;
@@ -0,0 +1,28 @@
BEGIN;
CREATE TABLE conversation_content_assessment (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
public_id uuid NOT NULL UNIQUE DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL REFERENCES tenant(id),
subject_id uuid NOT NULL,
conversation_id uuid NOT NULL,
generation_version bigint NOT NULL CHECK (generation_version > 0),
meaningful boolean NOT NULL,
story_worthy boolean NOT NULL,
decision_reason varchar(500) NOT NULL CHECK (length(decision_reason) > 0),
model_ref varchar(160) NOT NULL,
prompt_version varchar(80) NOT NULL,
confidence numeric(5,4) NOT NULL CHECK (confidence BETWEEN 0 AND 1),
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT conversation_content_assessment_decision_check CHECK (meaningful OR NOT story_worthy),
CONSTRAINT conversation_content_assessment_conversation_tenant_fk FOREIGN KEY (conversation_id, tenant_id)
REFERENCES conversation (id, tenant_id),
CONSTRAINT conversation_content_assessment_subject_tenant_fk FOREIGN KEY (subject_id, tenant_id)
REFERENCES subject_profile (id, tenant_id),
CONSTRAINT conversation_content_assessment_generation_key UNIQUE (conversation_id, generation_version)
);
CREATE INDEX conversation_content_assessment_conversation_idx
ON conversation_content_assessment (conversation_id, generation_version DESC);
COMMIT;
@@ -0,0 +1,14 @@
BEGIN;
UPDATE content_model_config
SET provider = 'openai_compatible'
WHERE provider NOT IN ('ark', 'openai_compatible');
ALTER TABLE content_model_config
DROP CONSTRAINT content_model_config_provider_check;
ALTER TABLE content_model_config
ADD CONSTRAINT content_model_config_provider_check
CHECK (provider IN ('ark', 'openai_compatible'));
COMMIT;
@@ -0,0 +1,30 @@
BEGIN;
ALTER TABLE content_model_config
DROP CONSTRAINT content_model_config_provider_check;
ALTER TABLE content_model_config
ADD CONSTRAINT content_model_config_provider_check CHECK (provider IN (
'ark',
'dashscope',
'deepseek',
'moonshot',
'zhipu',
'qianfan',
'hunyuan',
'minimax',
'baichuan',
'siliconflow',
'ollama',
'openai_compatible'
));
-- Classify local Ollama records created before provider-specific options were
-- available. This changes only the label/transport capability profile; the
-- encrypted credential, activation state and model identity stay untouched.
UPDATE content_model_config
SET provider = 'ollama'
WHERE provider = 'openai_compatible'
AND base_url ~* '^http://(ollama|localhost|127\.0\.0\.1)(:[0-9]+)?(/|$)';
COMMIT;
+8 -3
View File
@@ -4597,10 +4597,11 @@ components:
WebEvent: WebEvent:
type: object type: object
additionalProperties: false additionalProperties: false
required: [id, type, resource_type, resource_id, status, occurred_at] required: [id, type, household_id, resource_type, resource_id, status, occurred_at]
properties: properties:
id: { type: integer, format: int64 } id: { type: integer, format: int64 }
type: { type: string, enum: [device.presence.changed, conversation.started, conversation.completed, conversation.failed, story.created, story.revised, media.upload.completed, export.ready, privacy_request.updated, device.command.updated] } type: { type: string, enum: [device.presence.changed, conversation.started, conversation.completed, conversation.failed, story.created, story.revised, media.upload.completed, export.ready, privacy_request.updated, device.command.updated] }
household_id: { type: string, format: uuid }
resource_type: { type: string } resource_type: { type: string }
resource_id: { type: string, format: uuid } resource_id: { type: string, format: uuid }
subject_id: { type: string, format: uuid } subject_id: { type: string, format: uuid }
@@ -5668,7 +5669,7 @@ components:
id: { type: string, format: uuid } id: { type: string, format: uuid }
tenant_id: { type: string, format: uuid } tenant_id: { type: string, format: uuid }
display_name: { type: string, minLength: 1, maxLength: 120 } display_name: { type: string, minLength: 1, maxLength: 120 }
provider: { type: string, enum: [ark, openai_compatible] } provider: { $ref: '#/components/schemas/ContentModelProvider' }
base_url: { type: string, format: uri } base_url: { type: string, format: uri }
model_id: { type: string, minLength: 1, maxLength: 200 } model_id: { type: string, minLength: 1, maxLength: 200 }
credential_hint: { type: string, description: 仅包含掩码和密钥末四位。 } credential_hint: { type: string, description: 仅包含掩码和密钥末四位。 }
@@ -5688,12 +5689,16 @@ components:
required: [display_name, provider, base_url, model_id, api_key, temperature, max_tokens] required: [display_name, provider, base_url, model_id, api_key, temperature, max_tokens]
properties: properties:
display_name: { type: string, minLength: 1, maxLength: 120 } display_name: { type: string, minLength: 1, maxLength: 120 }
provider: { type: string, enum: [ark, openai_compatible] } provider: { $ref: '#/components/schemas/ContentModelProvider' }
base_url: { type: string, format: uri, maxLength: 500 } base_url: { type: string, format: uri, maxLength: 500 }
model_id: { type: string, minLength: 1, maxLength: 200 } model_id: { type: string, minLength: 1, maxLength: 200 }
api_key: { type: string, minLength: 8, maxLength: 1000, writeOnly: true } api_key: { type: string, minLength: 8, maxLength: 1000, writeOnly: true }
temperature: { type: number, minimum: 0, maximum: 2 } temperature: { type: number, minimum: 0, maximum: 2 }
max_tokens: { type: integer, minimum: 256, maximum: 8192 } max_tokens: { type: integer, minimum: 256, maximum: 8192 }
ContentModelProvider:
type: string
description: 模型厂商分类;所有类型均通过 OpenAI Chat Completions 兼容协议调用。
enum: [ark, dashscope, deepseek, moonshot, zhipu, qianfan, hunyuan, minimax, baichuan, siliconflow, ollama, openai_compatible]
StoryStatus: StoryStatus:
type: string type: string
enum: [CANDIDATE, IN_REVIEW, CONFIRMED, REJECTED, SUPERSEDED] enum: [CANDIDATE, IN_REVIEW, CONFIRMED, REJECTED, SUPERSEDED]
+1
View File
@@ -98,6 +98,7 @@ DELETE FROM story_source WHERE story_revision_id IN (SELECT id FROM story_revisi
UPDATE story_draft SET current_revision_id=NULL WHERE id IN (SELECT id FROM smoke_story_cleanup); UPDATE story_draft SET current_revision_id=NULL WHERE id IN (SELECT id FROM smoke_story_cleanup);
DELETE FROM story_revision WHERE story_id IN (SELECT id FROM smoke_story_cleanup); DELETE FROM story_revision WHERE story_id IN (SELECT id FROM smoke_story_cleanup);
DELETE FROM story_draft WHERE id IN (SELECT id FROM smoke_story_cleanup); DELETE FROM story_draft WHERE id IN (SELECT id FROM smoke_story_cleanup);
DELETE FROM conversation_content_assessment WHERE conversation_id IN (SELECT id FROM smoke_cleanup);
DELETE FROM conversation_summary WHERE conversation_id IN (SELECT id FROM smoke_cleanup); DELETE FROM conversation_summary WHERE conversation_id IN (SELECT id FROM smoke_cleanup);
DELETE FROM conversation_turn WHERE conversation_id IN (SELECT id FROM smoke_cleanup); DELETE FROM conversation_turn WHERE conversation_id IN (SELECT id FROM smoke_cleanup);
DELETE FROM provider_event WHERE conversation_id IN (SELECT id FROM smoke_cleanup); DELETE FROM provider_event WHERE conversation_id IN (SELECT id FROM smoke_cleanup);
+192
View File
@@ -0,0 +1,192 @@
import { createRequire } from "node:module";
import { mkdir } from "node:fs/promises";
import { resolve } from "node:path";
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
const { chromium } = require("playwright");
const webURL = process.env.VISUAL_WEB_URL ?? "http://127.0.0.1:5173";
const username = process.env.VISUAL_USERNAME ?? "owner";
const password = process.env.VISUAL_PASSWORD ?? "LocalOnly123!";
const outputDirectory = resolve(process.cwd(), ".design-evidence/v074/implementation");
const designOutputDirectory = resolve(process.cwd(), ".design-evidence/v074/design");
const avatarFixture = resolve(process.cwd(), ".design-evidence/avatar-upload-fixture.png");
const sections = [
["home", "陪伴周记", "首页"],
["conversations", "回忆时间轴", "聊天记录"],
["stories", "故事与记忆", "故事与记忆"],
["subjects", "长者资料", "长者资料"],
["messages", "提醒与待办", "提醒与待办"],
["members", "家庭管理", "家庭管理"],
["devices", "陪伴设备", "陪伴设备"],
["sharing", "内容分享", "内容分享"],
["privacy", "数据与授权", "数据与授权"],
["billing", "服务用量", "服务用量"],
["profile", "设置与权限", "设置与权限"],
];
await mkdir(outputDirectory, { recursive: true });
await mkdir(designOutputDirectory, { recursive: true });
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" });
const designPage = await browser.newPage({ viewport: { width: 1440, height: 1024 }, deviceScaleFactor: 1 });
for (const [section] of sections) {
await designPage.goto(`${webURL}/design-v074/index.html?section=${section}`, { waitUntil: "networkidle" });
await designPage.screenshot({ path: resolve(designOutputDirectory, `${section}.png`) });
}
await designPage.close();
const page = await browser.newPage({ viewport: { width: 1440, height: 1024 }, deviceScaleFactor: 1 });
page.setDefaultTimeout(6000);
const consoleErrors = [];
const failedRequests = [];
const errorResponses = [];
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
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 results = [];
try {
await page.goto(webURL, { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /^登\s*录$/ }).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();
const webOriginPattern = new RegExp(`^${new URL(webURL).origin.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:/|$)`);
await page.waitForURL(webOriginPattern, { timeout: 15000 });
await page.waitForTimeout(1200);
await page.screenshot({ path: resolve(outputDirectory, "after-login.png") });
await page.locator(".customer-account-button").waitFor({ timeout: 15000 });
for (const [index, [section, heading, menuLabel]] of sections.entries()) {
if (index > 0) await page.getByRole("menuitem", { name: new RegExp(`${menuLabel}$`) }).click();
await page.getByRole("heading", { name: heading, exact: true, level: 1 }).waitFor({ timeout: 15000 });
if (section === "conversations") {
const firstConversation = page.locator("button.conversation-row").first();
if (await firstConversation.isVisible()) {
await firstConversation.click();
await page.waitForTimeout(700);
}
}
await page.waitForTimeout(250);
const layout = await page.evaluate(() => {
const header = document.querySelector(".customer-page__header")?.getBoundingClientRect();
const context = document.querySelector(".workspace__context-bar")?.getBoundingClientRect();
const content = document.querySelector(".workspace__context-bar")?.nextElementSibling?.getBoundingClientRect();
const overflow = [...document.querySelectorAll("body *")]
.filter((element) => element instanceof HTMLElement && element.scrollWidth > element.clientWidth + 2 && getComputedStyle(element).overflowX === "visible")
.filter((element) => !element.classList.contains("ant-badge") && !element.classList.contains("family-member-card__avatar") && !element.closest(".usage-activity-card"))
.slice(0, 20)
.map((element) => `${element.tagName}.${element.className}: ${element.clientWidth}/${element.scrollWidth}`);
return {
headerBottom: header?.bottom,
contextTop: context?.top,
contextBottom: context?.bottom,
contentTop: content?.top,
overflow,
pageHorizontalOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
customerOrganizationText: document.body.innerText.includes("客户组织"),
};
});
await page.screenshot({ path: resolve(outputDirectory, `${section}.png`) });
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();
const avatarInput = page.locator(".subject-avatar-upload input[type=file]");
await avatarInput.setInputFiles(avatarFixture);
await page.getByAltText("待上传的长者头像预览").waitFor();
const avatarPreviewVisible = await page.getByAltText("待上传的长者头像预览").isVisible();
await page.screenshot({ path: resolve(outputDirectory, "subjects-avatar-upload.png") });
const disabledCursorResult = await page.evaluate(() => {
const controls = [...document.querySelectorAll("button:disabled")];
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();
const modalChecks = [];
async function checkModal(menuLabel, triggerSelector, triggerName, dialogHeading, closeName = "关闭") {
try {
console.log(`Checking modal: ${menuLabel} / ${triggerName}`);
await page.getByRole("menuitem", { name: new RegExp(`${menuLabel}$`) }).click();
await page.locator(triggerSelector).first().click();
const dialog = page.getByRole("dialog").last();
await dialog.getByRole("heading", { name: dialogHeading, exact: true }).waitFor();
await dialog.getByRole("button", { name: closeName, exact: true }).click();
await dialog.waitFor({ state: "hidden" });
modalChecks.push({ menuLabel, triggerName, passed: true });
} catch (error) {
console.error(`Modal check failed: ${menuLabel} / ${triggerName}: ${String(error)}`);
modalChecks.push({ menuLabel, triggerName, passed: false, error: String(error) });
}
}
await checkModal("家庭管理", ".family-accept-button", "接受邀请", "接受家庭邀请");
await checkModal("家庭管理", ".family-member-card--invite", "邀请新成员", "邀请家庭成员");
await checkModal("陪伴设备", ".workspace--devices .workspace__actions button:has-text('绑定设备')", "绑定设备", "绑定新设备");
await checkModal("内容分享", ".subject-list-toolbar > button", "新建分享", "新建资源分享");
await checkModal("数据与授权", ".privacy-actions button:has-text('资料更正')", "资料更正", "提交资料更正");
await page.getByRole("menuitem", { name: /故事与记忆$/ }).click();
await page.locator("button.story-candidate-card").first().click();
const storyDialog = page.getByRole("dialog").last();
await storyDialog.getByRole("heading", { name: "故事详情", exact: true }).waitFor();
await storyDialog.getByRole("button", { name: "关闭", exact: true }).click();
modalChecks.push({ menuLabel: "故事与记忆", triggerName: "故事卡片", passed: true });
const familyNavigationVisible = await page.getByRole("menuitem", { name: /家庭管理/ }).isVisible();
const pageFailures = results.filter(({ layout }) => layout.pageHorizontalOverflow > 2 || layout.overflow.length || layout.customerOrganizationText || (layout.contextTop !== undefined && layout.headerBottom !== undefined && layout.contextTop - layout.headerBottom > 4));
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, 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 (!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 {
await browser.close();
}
+110
View File
@@ -0,0 +1,110 @@
import { createRequire } from "node:module";
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
const { chromium } = require("playwright");
const webURL = process.env.VISUAL_WEB_URL ?? "http://localhost:3100";
const username = process.env.VISUAL_USERNAME ?? "operator";
const password = process.env.VISUAL_PASSWORD ?? "OpsLocalOnly123!";
const browser = await chromium.launch({
headless: true,
executablePath: process.env.CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe",
});
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1 });
const consoleErrors = [];
const failedRequests = [];
const errorResponses = [];
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
page.on("pageerror", (error) => consoleErrors.push(`pageerror: ${error.message}`));
page.on("response", (response) => { if (response.status() >= 400) errorResponses.push(`${response.status()} ${response.url()}`); });
page.on("requestfailed", (request) => {
const reason = request.failure()?.errorText ?? "failed";
const pathname = new URL(request.url()).pathname;
if (request.method() === "GET" && pathname === "/api/v1/events" && reason.includes("ERR_ABORTED")) return;
if (pathname.startsWith("/api/v1/") && reason.includes("ERR_ABORTED")) return;
failedRequests.push(`${request.method()} ${request.url()} ${reason}`);
});
const areas = [
["总览", "维护总览", null],
["设备管理", "设备管理", "devices"],
["运行配置", "运行配置", "config"],
["大模型管理", "大模型管理", "models"],
["内容质量", "内容质量", "quality"],
["用量、费用与额度", "用量、费用与额度", "billing"],
["告警处置", "告警处置", "alerts"],
["联调与诊断", "联调与诊断", "diagnostics"],
];
try {
await page.goto(`${webURL}/maintenance`, { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "登录维护中心", exact: true }).click();
await page.waitForURL(/localhost:58180|127\.0\.0\.1:58180/);
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await page.locator("#kc-login").click();
await page.waitForURL(new RegExp(`^${new URL(webURL).origin.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/maintenance(?:[?#]|$)`), { timeout: 15000 });
await page.getByRole("heading", { name: "维护总览", exact: true, level: 1 }).waitFor({ timeout: 15000 });
const navigation = page.getByRole("navigation", { name: "维护中心主导航" });
const areaResults = [];
for (const [label, heading, area] of areas) {
await navigation.getByRole("button", { name: label, exact: true }).click();
await page.getByRole("heading", { name: heading, exact: true, level: 1 }).waitFor();
areaResults.push({ label, area: new URL(page.url()).searchParams.get("area"), expectedArea: area });
}
const search = page.getByPlaceholder("搜索设备、通话、作业", { exact: true });
await search.fill("告警");
await search.press("Enter");
await page.getByRole("heading", { name: "告警处置", exact: true, level: 1 }).waitFor();
const alertSearchWorks = new URL(page.url()).searchParams.get("area") === "alerts";
await search.fill("10000000-0000-0000-0000-000000000511");
await search.press("Enter");
await page.getByRole("heading", { name: "联调与诊断", exact: true, level: 1 }).waitFor();
const identifierSearchWorks = new URL(page.url()).searchParams.get("area") === "diagnostics";
await navigation.getByRole("button", { name: "大模型管理", exact: true }).click();
await page.getByRole("heading", { name: "大模型管理", exact: true, level: 1 }).waitFor();
await page.getByRole("button", { name: /添加大模型/ }).click();
const modelDialog = page.getByRole("dialog", { name: "添加总结大模型", exact: true });
await modelDialog.waitFor();
const providerButtons = modelDialog.locator(".model-provider-group button");
const modelPaletteWorks = await providerButtons.evaluateAll((controls) => controls.length > 0 && controls.every((item) => {
const channels = getComputedStyle(item).backgroundColor.match(/\d+/g)?.slice(0, 3).map(Number) ?? [];
return channels.length === 3 && channels.reduce((sum, value) => sum + value, 0) / 3 > 210;
}));
await modelDialog.getByRole("button", { name: "关闭", exact: true }).click();
await navigation.getByRole("button", { name: "设备管理", exact: true }).click();
await page.getByRole("button", { name: /新增设备/ }).click();
const createDialog = page.getByRole("dialog", { name: "新增设备", exact: true });
await createDialog.waitFor();
await createDialog.getByRole("button", { name: "关闭", exact: true }).click();
await page.getByRole("button", { name: /编辑/ }).first().click();
const editDialog = page.getByRole("dialog", { name: "编辑设备", exact: true });
await editDialog.waitFor();
await editDialog.getByRole("button", { name: "关闭", exact: true }).click();
await navigation.getByRole("button", { name: "联调与诊断", exact: true }).click();
await page.getByPlaceholder("输入完整编号", { exact: true }).fill("10000000-0000-0000-0000-000000000511");
await page.getByRole("button", { name: "查询", exact: true }).click();
await page.getByRole("button", { name: "导出脱敏诊断包", exact: true }).waitFor();
await page.getByRole("button", { name: "导出脱敏诊断包", exact: true }).click();
const exportForm = page.getByRole("form").last();
const exportSubmit = exportForm.getByRole("button", { name: "生成并下载", exact: true });
const exportInitiallyDisabled = await exportSubmit.isDisabled();
await exportForm.getByRole("textbox", { name: "导出原因", exact: true }).fill("页面功能测试,不下载");
const exportReady = await exportSubmit.isEnabled();
await exportForm.getByRole("button", { name: "取消", exact: true }).click();
const areaFailures = areaResults.filter((item) => item.area !== item.expectedArea);
const relevantResponseErrors = [...new Set(errorResponses)].filter((item) => !item.includes("/api/v1/events") && !(item.startsWith("404 ") && item.endsWith("/api/v1/ops/diagnostics/platform-health")));
const relevantConsoleErrors = consoleErrors.filter((item) => !item.startsWith("Failed to load resource:"));
const report = { areaResults, areaFailures, alertSearchWorks, identifierSearchWorks, modelPaletteWorks, exportInitiallyDisabled, exportReady, consoleErrors: relevantConsoleErrors, errorResponses: relevantResponseErrors, failedRequests };
console.log(JSON.stringify(report, null, 2));
if (areaFailures.length || !alertSearchWorks || !identifierSearchWorks || !modelPaletteWorks || !exportInitiallyDisabled || !exportReady || relevantConsoleErrors.length || relevantResponseErrors.length || failedRequests.length) {
throw new Error("Maintenance navigation/function smoke failed");
}
} finally {
await browser.close();
}
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -34,13 +34,14 @@ export type ConversationDetail = components["schemas"]["ConversationDetail"];
export type ConversationUsage = components["schemas"]["ConversationUsage"]; export type ConversationUsage = components["schemas"]["ConversationUsage"];
export type ConversationTurn = components["schemas"]["ConversationTurn"]; export type ConversationTurn = components["schemas"]["ConversationTurn"];
export type ConversationContentSummary = components["schemas"]["ConversationContentSummary"]; export type ConversationContentSummary = components["schemas"]["ConversationContentSummary"];
export type ContentModelProvider = "ark" | "dashscope" | "deepseek" | "moonshot" | "zhipu" | "qianfan" | "hunyuan" | "minimax" | "baichuan" | "siliconflow" | "ollama" | "openai_compatible";
export type ContentModel = { export type ContentModel = {
id: string; tenant_id: string; display_name: string; provider: "ark" | "openai_compatible"; id: string; tenant_id: string; display_name: string; provider: ContentModelProvider;
base_url: string; model_id: string; credential_hint: string; temperature: number; max_tokens: number; base_url: string; model_id: string; credential_hint: string; temperature: number; max_tokens: number;
status: "inactive" | "active" | "revoked"; test_status: "untested" | "passed" | "failed"; 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; 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 CreateContentModelRequest = { display_name: string; provider: ContentModelProvider; base_url: string; model_id: string; api_key: string; temperature: number; max_tokens: number };
export type ConversationMedia = components["schemas"]["ConversationMedia"]; export type ConversationMedia = components["schemas"]["ConversationMedia"];
export type MediaSegment = components["schemas"]["MediaSegment"]; export type MediaSegment = components["schemas"]["MediaSegment"];
export type RecordingAccessGrant = components["schemas"]["RecordingAccessGrant"]; export type RecordingAccessGrant = components["schemas"]["RecordingAccessGrant"];
+9 -4
View File
@@ -4202,6 +4202,8 @@ export interface components {
id: number; id: number;
/** @enum {string} */ /** @enum {string} */
type: "device.presence.changed" | "conversation.started" | "conversation.completed" | "conversation.failed" | "story.created" | "story.revised" | "media.upload.completed" | "export.ready" | "privacy_request.updated" | "device.command.updated"; type: "device.presence.changed" | "conversation.started" | "conversation.completed" | "conversation.failed" | "story.created" | "story.revised" | "media.upload.completed" | "export.ready" | "privacy_request.updated" | "device.command.updated";
/** Format: uuid */
household_id: string;
resource_type: string; resource_type: string;
/** Format: uuid */ /** Format: uuid */
resource_id: string; resource_id: string;
@@ -5347,8 +5349,7 @@ export interface components {
/** Format: uuid */ /** Format: uuid */
tenant_id: string; tenant_id: string;
display_name: string; display_name: string;
/** @enum {string} */ provider: components["schemas"]["ContentModelProvider"];
provider: "ark" | "openai_compatible";
/** Format: uri */ /** Format: uri */
base_url: string; base_url: string;
model_id: string; model_id: string;
@@ -5374,8 +5375,7 @@ export interface components {
}; };
CreateContentModelRequest: { CreateContentModelRequest: {
display_name: string; display_name: string;
/** @enum {string} */ provider: components["schemas"]["ContentModelProvider"];
provider: "ark" | "openai_compatible";
/** Format: uri */ /** Format: uri */
base_url: string; base_url: string;
model_id: string; model_id: string;
@@ -5383,6 +5383,11 @@ export interface components {
temperature: number; temperature: number;
max_tokens: number; max_tokens: number;
}; };
/**
* @description 模型厂商分类;所有类型均通过 OpenAI Chat Completions 兼容协议调用。
* @enum {string}
*/
ContentModelProvider: "ark" | "dashscope" | "deepseek" | "moonshot" | "zhipu" | "qianfan" | "hunyuan" | "minimax" | "baichuan" | "siliconflow" | "ollama" | "openai_compatible";
/** @enum {string} */ /** @enum {string} */
StoryStatus: "CANDIDATE" | "IN_REVIEW" | "CONFIRMED" | "REJECTED" | "SUPERSEDED"; StoryStatus: "CANDIDATE" | "IN_REVIEW" | "CONFIRMED" | "REJECTED" | "SUPERSEDED";
StorySource: { StorySource: {
+301
View File
@@ -452,3 +452,304 @@ input[type="checkbox"], input[type="radio"], input[type="range"] { accent-color:
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; } *, *::before, *::after { transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
} }
/* Customer portal v0.74 — authoritative page shell and cross-page spacing. */
.customer-layout {
--v074-paper: #fbfaf7;
--v074-surface: #fffefd;
--v074-ink: #262525;
--v074-muted: #77726c;
--v074-line: #e6e0d8;
--v074-blue: #315d9b;
--v074-blue-soft: #edf3fb;
--v074-coral: #c45f3b;
--v074-coral-soft: #fff2ec;
--v074-green: #5f8f69;
--v074-green-soft: #edf6ef;
min-height: 100vh;
color: var(--v074-ink);
background: var(--v074-paper) !important;
}
.customer-header {
position: fixed !important;
z-index: 120 !important;
top: 0;
right: 0;
left: 0;
display: flex !important;
align-items: center !important;
height: 68px !important;
padding: 0 24px !important;
border-bottom: 1px solid var(--v074-line) !important;
background: rgba(255,254,252,.98) !important;
}
.customer-header__brand { flex: none; }
.customer-header__brand > strong { color: var(--v074-ink) !important; font-size: 21px !important; }
.customer-header__actions { display: flex !important; margin-left: auto; }
.customer-brand-mark { width: 34px !important; height: 34px !important; border: 1px solid var(--v074-ink) !important; color: var(--v074-ink) !important; background: #fff !important; }
.customer-search { width: min(360px,34vw) !important; }
.customer-search .ant-input-affix-wrapper,
.customer-search .ant-input-group-addon .ant-btn { height: 40px !important; border-color: var(--v074-line) !important; border-radius: 9px !important; background: #fff !important; }
.customer-layout__body { min-height: 100vh !important; padding-top: 68px !important; background: var(--v074-paper) !important; }
.customer-sider {
position: fixed !important;
z-index: 100 !important;
top: 68px !important;
bottom: 0 !important;
left: 0 !important;
display: flex !important;
height: calc(100dvh - 68px) !important;
border-right: 1px solid var(--v074-line) !important;
background: var(--v074-surface) !important;
}
.customer-sider .ant-layout-sider-children { display: flex; min-height: 0; flex: 1; flex-direction: column; overflow: hidden auto; }
.customer-sider__title { flex: none; height: 50px !important; padding: 0 20px !important; color: var(--v074-muted) !important; }
.customer-sider .ant-menu { flex: 1; padding: 4px 10px 12px !important; overflow: visible !important; background: transparent !important; }
.customer-sider .ant-menu-item,
.customer-sider .ant-menu-submenu-title { min-height: 44px !important; height: 44px !important; margin: 2px 0 !important; padding-inline: 13px !important; border-radius: 9px !important; color: #4a4947 !important; font-size: 14px !important; line-height: 44px !important; }
.customer-sider .ant-menu-item-selected,
.customer-sider .ant-menu-submenu-selected > .ant-menu-submenu-title { color: var(--v074-blue) !important; background: var(--v074-blue-soft) !important; font-weight: 650; }
.customer-sider .ant-menu-sub.ant-menu-inline { background: transparent !important; }
.customer-sider .ant-menu-sub .ant-menu-item { padding-left: 44px !important; }
.customer-sider__help { flex: none; margin: 10px 14px 16px !important; padding: 12px !important; border: 1px solid var(--v074-line); border-radius: 10px !important; background: #faf7f2 !important; }
.customer-content { min-width: 0 !important; margin-left: 224px !important; background: var(--v074-paper) !important; transition: margin-left .18s ease; }
.customer-sider.ant-layout-sider-collapsed + .customer-content { margin-left: 72px !important; }
.customer-page.customer-page {
width: min(1360px,100%) !important;
max-width: 1360px !important;
margin: 0 auto !important;
padding: 0 28px 56px !important;
}
.customer-page > .ant-breadcrumb { display: none !important; }
.customer-page > .customer-page__header {
display: flex !important;
align-items: flex-start !important;
justify-content: space-between !important;
gap: 16px !important;
min-height: 0 !important;
margin: 0 !important;
padding: 24px 0 18px !important;
border-bottom: 1px solid var(--v074-line);
}
.customer-page__header h1 { margin: 0 0 7px !important; color: var(--v074-ink) !important; font-size: 28px !important; line-height: 1.2 !important; letter-spacing: -.03em; }
.customer-page__header p { margin: 0 !important; color: var(--v074-muted) !important; font-size: 14px !important; }
.customer-page__header .ant-tag { margin-top: 2px !important; border: 0 !important; border-radius: 999px !important; color: #467550 !important; background: var(--v074-green-soft) !important; }
.customer-page.customer-page > .workspace { padding: 0 !important; border: 0 !important; border-radius: 0 !important; background: transparent !important; box-shadow: none !important; }
.workspace__context-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 58px;
margin: 0 0 16px;
padding: 8px 10px;
border-bottom: 1px solid var(--v074-line);
flex-wrap: wrap;
}
.workspace__context-bar .household-tabs { flex: 1 1 280px; min-width: 0; }
.household-tabs { display: flex !important; align-items: center; gap: 8px !important; margin: 0 !important; padding: 0 !important; border: 0 !important; }
.household-tabs button {
flex: 0 0 auto;
min-width: 0 !important;
min-height: 38px !important;
height: 38px !important;
padding: 0 14px !important;
border: 1px solid var(--v074-line) !important;
border-radius: 9px !important;
color: var(--v074-ink) !important;
background: #fff !important;
box-shadow: none !important;
white-space: nowrap;
}
.household-tabs button.active { border-color: #e5d2bd !important; background: #f6ecdf !important; font-weight: 650; }
.household-tabs .household-tabs__add { flex: none; width: 38px !important; padding: 0 !important; }
.workspace__actions { display: flex !important; align-items: center !important; justify-content: flex-end !important; gap: 8px !important; flex: 1 1 auto; min-width: 0; margin: 0 !important; flex-wrap: wrap; }
.workspace__actions > button,
.workspace__actions > .button-secondary,
.workspace__actions select { min-height: 38px !important; height: 38px !important; border-radius: 9px !important; }
.workspace__actions > button:not(.button-secondary) { border-color: var(--v074-blue) !important; color: #fff !important; background: var(--v074-blue) !important; }
.workspace__actions .event-stream-status { white-space: nowrap; }
.customer-page--stories .workspace__actions { width: auto !important; flex: 0 1 auto !important; flex-wrap: nowrap !important; }
.customer-page--stories .workspace-story-subject {
display: grid !important;
grid-template-columns: auto 180px;
align-items: center;
gap: 8px;
width: auto !important;
margin: 0 !important;
}
.customer-page--stories .workspace-story-subject select { width: 180px !important; min-width: 180px !important; }
.workspace__header { margin: 0 0 16px !important; padding: 0 0 18px !important; }
.workspace__header h2 { font-size: 25px !important; }
.workspace__action-message,.workspace__inline-error,.workspace__readonly-note { margin: 0 0 14px !important; padding: 12px 14px !important; border-radius: 10px !important; }
.customer-layout button:disabled,
.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,
.customer-page .device-list-panel,
.customer-page .family-members-panel,
.customer-page .family-home-card,
.customer-page .family-activity-card,
.customer-page .subject-profile-card,
.customer-page .story-candidate-card,
.customer-page .confirmed-memory-card,
.customer-page .privacy-card,
.customer-page .privacy-exports,
.customer-page .model-config-card,
.customer-page .usage-overview__metrics,
.customer-page .usage-activity-card,
.customer-page .usage-overview__details > section {
border-color: var(--v074-line) !important;
border-radius: 14px !important;
background: var(--v074-surface) !important;
box-shadow: none !important;
}
.customer-page .section-title { min-height: 48px; padding: 14px 18px !important; border-bottom-color: var(--v074-line) !important; }
.customer-page .data-row,.customer-page .subject-data-row { min-height: 60px; padding: 13px 18px !important; border-bottom-color: var(--v074-line) !important; }
.customer-page input,.customer-page select,.customer-page textarea { border-color: var(--v074-line) !important; border-radius: 9px !important; background: #fff !important; }
.customer-page button { min-height: 38px; border-radius: 9px !important; box-shadow: none !important; }
.customer-page button:not(.ant-btn):focus-visible { outline: 2px solid var(--v074-blue); outline-offset: 2px; }
/* Family management */
.family-overview { display: grid; gap: 16px; }
.family-overview__head { align-items: center !important; margin: 0 !important; padding: 18px 20px !important; border: 1px solid var(--v074-line) !important; border-radius: 14px !important; background: var(--v074-surface) !important; }
.family-overview__identity > small { color: var(--v074-muted) !important; }
.family-overview__title-row { gap: 8px !important; }
.family-overview__title-row h2 { font-size: 22px !important; }
.family-overview__identity p { margin-bottom: 0 !important; }
.family-overview__layout { grid-template-columns: minmax(0,1.45fr) minmax(280px,.65fr) !important; gap: 16px !important; }
.family-members-panel { padding: 0 18px 18px !important; }
.family-member-grid { grid-template-columns: repeat(2,minmax(0,1fr)) !important; gap: 12px !important; }
.family-member-card { min-height: 112px !important; padding: 18px !important; border-color: var(--v074-line) !important; border-radius: 12px !important; background: #fff !important; }
.family-member-card--invite { border-style: dashed !important; }
.family-home-card__image { min-height: 116px !important; background: #8e8986 !important; }
.family-activity-card article { padding: 13px 16px !important; }
/* Elder profiles */
.subject-management { display: grid; gap: 16px; }
.subject-card-layout { display: block !important; }
.subject-card-grid { grid-template-columns: repeat(3,minmax(250px,1fr)) !important; gap: 14px !important; }
.subject-profile-card { min-height: 225px !important; padding: 20px !important; }
.subject-profile-card.active { border: 2px solid #36485f !important; }
.subject-profile-card__avatar { width: 58px !important; height: 58px !important; }
.subject-profile-card__facts { margin-top: 16px !important; border-radius: 11px !important; }
.subject-profile-card__footer { margin-top: 15px !important; padding-top: 14px !important; }
.subject-profile-card--add { border-style: dashed !important; }
.subject-avatar-upload { cursor: pointer !important; }
.subject-avatar-upload input,.subject-avatar-editor input { pointer-events: auto !important; cursor: pointer !important; }
.subject-create-modal { width: min(720px,calc(100vw - 32px)) !important; max-height: calc(100dvh - 32px) !important; }
.subject-create-modal__body { gap: 14px !important; padding: 20px 24px !important; }
/* Stories, sharing, privacy, and models */
.story-toolbar,.subject-list-toolbar,.privacy-toolbar { margin: 0 0 16px !important; border: 1px solid var(--v074-line) !important; border-radius: 14px !important; background: var(--v074-surface) !important; }
.story-candidate-grid { grid-template-columns: repeat(3,minmax(240px,1fr)) !important; gap: 14px !important; }
.story-candidate-card { min-height: 190px !important; padding: 18px !important; }
.story-candidate-card__footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: auto; }
.story-candidate-card__footer b { color: var(--v074-blue); font-size: 12px; font-weight: 650; }
.privacy-layout > .state-panel { min-height: 150px !important; padding: 28px !important; }
.privacy-layout { gap: 16px !important; }
.subject-list-toolbar { padding: 16px 18px !important; }
.privacy-toolbar { display: grid !important; grid-template-columns: minmax(160px,.55fr) minmax(250px,.8fr) minmax(420px,1.65fr) !important; align-items: end !important; gap: 16px !important; padding: 18px !important; }
.privacy-actions { display: flex !important; align-items: center !important; justify-content: flex-end !important; gap: 8px !important; flex-wrap: wrap !important; }
.privacy-actions button { min-height: 36px !important; }
.privacy-grid { grid-template-columns: repeat(2,minmax(0,1fr)) !important; gap: 14px !important; }
.model-management-page { gap: 16px !important; }
.model-active-hero { padding: 20px 22px !important; border-color: var(--v074-line) !important; border-radius: 14px !important; background: #f5f8f6 !important; }
.model-card-grid { gap: 14px !important; }
.model-config-card { padding: 20px !important; }
/* Device inventory */
.device-list-panel { padding: 0 18px 18px !important; }
.device-list-panel > .section-title { margin-inline: -18px !important; }
.device-card { overflow: hidden; border-color: var(--v074-line) !important; border-radius: 13px !important; background: #fff !important; box-shadow: none !important; }
.device-card__summary { min-height: 112px !important; padding: 18px 20px !important; }
.device-card__actions { padding: 14px 18px !important; border-left-color: var(--v074-line) !important; }
/* Reminder center replaces the undifferentiated message stack. */
.customer-message-panel.reminder-center { display: grid; gap: 16px; padding: 0 !important; border: 0 !important; background: transparent !important; }
.reminder-center__grid { display: grid; grid-template-columns: minmax(0,1.45fr) minmax(300px,.7fr); gap: 16px; }
.reminder-card { overflow: hidden; border: 1px solid var(--v074-line); border-radius: 14px; background: var(--v074-surface); }
.reminder-card > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 19px 20px 14px; border-bottom: 1px solid var(--v074-line); }
.reminder-card h3 { margin: 0; font-size: 20px; }
.reminder-card header p { margin: 6px 0 0; color: var(--v074-muted); font-size: 12px; }
.reminder-row { display: grid; grid-template-columns: 78px 14px minmax(0,1fr) auto; align-items: center; gap: 10px; width: 100%; min-height: 66px; padding: 12px 20px; border: 0; border-bottom: 1px solid var(--v074-line); border-radius: 0; color: var(--v074-ink); background: transparent; text-align: left; box-shadow: none; }
.reminder-row:last-child { border-bottom: 0; }
.reminder-row:hover,.reminder-row:focus-visible { background: #f8fafb; }
.reminder-row time,.reminder-row small { color: var(--v074-muted); font-size: 12px; }
.reminder-row__dot { width: 10px; height: 10px; border-radius: 50%; background: var(--v074-blue); }
.reminder-row p,.reminder-todo p { margin: 4px 0 0; color: var(--v074-muted); font-size: 12px; }
.reminder-count { display: grid; place-items: center; min-width: 28px; height: 28px; border-radius: 999px; color: var(--v074-coral); background: var(--v074-coral-soft); font-size: 12px; }
.reminder-todos { display: grid; }
.reminder-todo { display: grid; grid-template-columns: 10px minmax(0,1fr) auto; align-items: center; gap: 12px; width: 100%; min-height: 72px; padding: 13px 18px; border: 0; border-bottom: 1px solid var(--v074-line); border-radius: 0; color: var(--v074-ink); background: transparent; text-align: left; box-shadow: none; }
.reminder-todo:last-child { border-bottom: 0; }
.reminder-todo:hover,.reminder-todo:focus-visible { background: #faf8f5; }
.reminder-todo > span { width: 8px; height: 8px; border-radius: 50%; background: var(--v074-blue); }
.reminder-todo--warning > span { background: var(--v074-coral); }
.reminder-todo small { color: var(--v074-blue); white-space: nowrap; }
.reminder-empty { display: grid; place-items: center; gap: 8px; min-height: 190px; padding: 28px; color: var(--v074-muted); text-align: center; }
.reminder-empty .anticon { color: #9aa9b6; font-size: 30px; }
.reminder-empty strong { color: var(--v074-ink); }
.reminder-history__summary { display: grid; grid-template-columns: repeat(4,1fr); }
.reminder-history__summary > div,.reminder-history__summary > button { padding: 18px 20px; border: 0; border-right: 1px solid var(--v074-line); border-radius: 0; color: var(--v074-ink); background: transparent; text-align: left; box-shadow: none; }
.reminder-history__summary > button:hover,.reminder-history__summary > button:focus-visible { background: #f8fafb; }
.reminder-history__summary > :last-child { border-right: 0; }
.reminder-history__summary strong { display: block; font-size: 24px; }
.reminder-history__summary span { color: var(--v074-muted); font-size: 12px; }
/* Usage: keep activity close to the page header and remove the oversized wrapper. */
.usage-overview { display: grid !important; gap: 16px !important; margin: 0 !important; }
.usage-overview__toolbar { padding: 0 !important; }
.usage-overview__toolbar > div:first-child { display: none !important; }
.usage-overview__filters { margin-left: auto !important; }
.usage-overview__metrics { margin: 0 !important; }
.usage-activity-card { margin: 0 !important; padding: 18px 20px !important; }
.usage-activity-card > header { padding: 0 0 12px !important; }
/* Profile and permissions */
.customer-profile { gap: 16px !important; padding: 0 !important; }
.customer-profile__context { margin-bottom: 0 !important; }
.customer-profile__context .household-tabs { flex: 1 1 auto; }
.customer-profile > .ant-row { margin-inline: 0 !important; }
.customer-profile .ant-card { border-color: var(--v074-line) !important; border-radius: 14px !important; background: var(--v074-surface) !important; box-shadow: none !important; }
.permission-matrix-card { margin: 0 !important; }
.permission-matrix th,.permission-matrix td { padding: 11px 14px !important; border-bottom-color: var(--v074-line) !important; }
.permission-matrix thead th { background: #faf7f2 !important; }
@media (max-width: 1180px) {
.subject-card-grid { grid-template-columns: repeat(2,minmax(240px,1fr)) !important; }
.story-candidate-grid { grid-template-columns: repeat(2,minmax(240px,1fr)) !important; }
.privacy-toolbar { grid-template-columns: minmax(160px,.55fr) minmax(260px,1fr) !important; }
.privacy-actions { grid-column: 1 / -1; justify-content: flex-start !important; }
.family-overview__layout,.reminder-center__grid { grid-template-columns: 1fr !important; }
}
@media (max-width: 900px) {
.customer-header { padding-inline: 14px !important; }
.customer-sider { top: 68px !important; width: 224px !important; min-width: 224px !important; max-width: 224px !important; height: calc(100dvh - 68px) !important; transform: translateX(0); }
.customer-sider.ant-layout-sider-collapsed { transform: translateX(-100%); }
.customer-content,.customer-sider.ant-layout-sider-collapsed + .customer-content { margin-left: 0 !important; }
.customer-page.customer-page { padding-inline: 20px !important; }
.workspace__context-bar { align-items: stretch; flex-direction: column; }
.workspace__actions { justify-content: flex-start !important; flex-wrap: wrap; }
.family-member-grid,.privacy-grid,.model-card-grid { grid-template-columns: 1fr !important; }
}
@media (max-width: 700px) {
.customer-header { height: 58px !important; }
.customer-layout__body { padding-top: 58px !important; }
.customer-sider { top: 58px !important; height: calc(100dvh - 58px) !important; }
.customer-page.customer-page { padding: 0 14px 44px !important; }
.customer-page > .customer-page__header { padding: 18px 0 14px !important; }
.customer-page__header h1 { font-size: 24px !important; }
.customer-page__header .ant-tag { display: none; }
.workspace__context-bar { margin-bottom: 12px; padding-inline: 0; }
.workspace__actions .event-stream-status { display: none; }
.subject-card-grid,.story-candidate-grid,.privacy-toolbar { grid-template-columns: 1fr !important; }
.privacy-actions { grid-column: auto; }
.reminder-row { grid-template-columns: 62px 12px minmax(0,1fr); }
.reminder-row small { display: none; }
.reminder-history__summary { grid-template-columns: repeat(2,1fr); }
.reminder-history__summary > :nth-child(2) { border-right: 0; }
.reminder-history__summary > :nth-child(-n+2) { border-bottom: 1px solid var(--v074-line); }
}
+13 -6
View File
@@ -126,7 +126,7 @@ function UsageOverviewView({ audience, overview, loading, error, periodDays, onP
<section className="usage-activity-card"> <section className="usage-activity-card">
<header><div><strong>{metricMode === "duration" ? "聊天活动" : "费用活动"}</strong><span>{overview?.scope.device_name || overview?.scope.household_name || overview?.scope.tenant_name || "当前范围"}</span></div><div className="usage-density-tabs">{(["daily", "weekly", "cumulative"] as DensityMode[]).map((mode) => <button type="button" className={densityMode === mode ? "active" : ""} key={mode} onClick={() => setDensityMode(mode)}>{({ daily: "每日", weekly: "每周", cumulative: "累计" } as Record<DensityMode, string>)[mode]}</button>)}</div></header> <header><div><strong>{metricMode === "duration" ? "聊天活动" : "费用活动"}</strong><span>{overview?.scope.device_name || overview?.scope.household_name || overview?.scope.tenant_name || "当前范围"}</span></div><div className="usage-density-tabs">{(["daily", "weekly", "cumulative"] as DensityMode[]).map((mode) => <button type="button" className={densityMode === mode ? "active" : ""} key={mode} onClick={() => setDensityMode(mode)}>{({ daily: "每日", weekly: "每周", cumulative: "累计" } as Record<DensityMode, string>)[mode]}</button>)}</div></header>
<div className="usage-heatmap-scroll"> <div className="usage-heatmap-scroll">
<div className="usage-months">{heatmap.months.map((item) => <span key={item.key}>{item.label}</span>)}</div> <div className="usage-months" style={{ gridTemplateColumns: `repeat(${heatmap.weeks}, 11px)` }}>{heatmap.months.map((item) => <span key={item.key} style={{ gridColumnStart: item.week }}>{item.label}</span>)}</div>
<div className="usage-heatmap-wrap"><div className="usage-weekdays"><span>一</span><span>三</span><span>五</span></div><div className="usage-heatmap">{heatmap.cells.map((cell) => cell.date ? <button type="button" key={cell.date} className={`level-${cell.level} ${selectedDate === cell.date ? "selected" : ""}`} title={`${cell.date} · ${metricMode === "duration" ? formatDuration(cell.rawDuration ?? 0) : formatMoney(cell.rawCost ?? 0, overview?.currency)} · ${cell.count ?? 0}次`} aria-label={`${cell.date},${cell.count ?? 0}次通话`} onClick={() => setSelectedDate(cell.date)} /> : <span key={cell.key} />)}</div></div> <div className="usage-heatmap-wrap"><div className="usage-weekdays"><span>一</span><span>三</span><span>五</span></div><div className="usage-heatmap">{heatmap.cells.map((cell) => cell.date ? <button type="button" key={cell.date} className={`level-${cell.level} ${selectedDate === cell.date ? "selected" : ""}`} title={`${cell.date} · ${metricMode === "duration" ? formatDuration(cell.rawDuration ?? 0) : formatMoney(cell.rawCost ?? 0, overview?.currency)} · ${cell.count ?? 0}次`} aria-label={`${cell.date},${cell.count ?? 0}次通话`} onClick={() => setSelectedDate(cell.date)} /> : <span key={cell.key} />)}</div></div>
</div> </div>
<footer><span>{selectedDay ? `${selectedDay.date}:${selectedDay.conversation_count} 次 · ${formatDuration(selectedDay.duration_ms)}${selectedDay.cost_micros ? ` · ${formatMoney(selectedDay.cost_micros, overview?.currency)}` : ""}` : "点击色块查看当天数据"}</span><div className="usage-legend"><span>少</span>{[0, 1, 2, 3, 4].map((level) => <i className={`level-${level}`} key={level} />)}<span>多</span></div></footer> <footer><span>{selectedDay ? `${selectedDay.date}:${selectedDay.conversation_count} 次 · ${formatDuration(selectedDay.duration_ms)}${selectedDay.cost_micros ? ` · ${formatMoney(selectedDay.cost_micros, overview?.currency)}` : ""}` : "点击色块查看当天数据"}</span><div className="usage-legend"><span>少</span>{[0, 1, 2, 3, 4].map((level) => <i className={`level-${level}`} key={level} />)}<span>多</span></div></footer>
@@ -163,7 +163,7 @@ function useUsageOverviewRequest(accessToken: string, periodDays: number, load:
} }
function buildHeatmap(overview: UsageOverview | undefined, metric: MetricMode, density: DensityMode) { function buildHeatmap(overview: UsageOverview | undefined, metric: MetricMode, density: DensityMode) {
if (!overview) return { cells: [] as HeatCell[], months: [] as Array<{ key: string; label: string }> }; if (!overview) return { cells: [] as HeatCell[], months: [] as MonthMarker[], weeks: 0 };
const daily = new Map(overview.daily.map((item) => [item.date, item])); const daily = new Map(overview.daily.map((item) => [item.date, item]));
const start = dateOnly(new Date(overview.from)); const start = dateOnly(new Date(overview.from));
const end = dateOnly(new Date(overview.to)); const end = dateOnly(new Date(overview.to));
@@ -193,15 +193,22 @@ function buildHeatmap(overview: UsageOverview | undefined, metric: MetricMode, d
const padding = (start.getDay() + 6) % 7; const padding = (start.getDay() + 6) % 7;
const cells: HeatCell[] = Array.from({ length: padding }, (_, index) => ({ key: `pad-${index}` })); const cells: HeatCell[] = Array.from({ length: padding }, (_, index) => ({ key: `pad-${index}` }));
values.forEach((item) => cells.push({ ...item, key: item.date, level: level(item.value) })); values.forEach((item) => cells.push({ ...item, key: item.date, level: level(item.value) }));
const months: Array<{ key: string; label: string }> = []; const months: MonthMarker[] = [];
values.forEach((item) => { values.forEach((item, index) => {
const key = item.date.slice(0, 7); const key = item.date.slice(0, 7);
if (!months.some((month) => month.key === key)) months.push({ key, label: `${Number(key.slice(5))}月` }); if (!months.some((month) => month.key === key)) {
months.push({ key, label: `${Number(key.slice(5))}月`, week: Math.floor((padding + index) / 7) + 1 });
}
}); });
return { cells, months }; // A range can start on the last few days of a month. In that case the first
// two labels would occupy adjacent week columns and overlap, so retain the
// first complete month label instead.
if (months.length > 1 && months[1].week - months[0].week < 3) months.shift();
return { cells, months, weeks: Math.ceil((padding + values.length) / 7) };
} }
type HeatCell = { key: string; date?: string; rawDuration?: number; rawCost?: number; count?: number; value?: number; level?: number }; type HeatCell = { key: string; date?: string; rawDuration?: number; rawCost?: number; count?: number; value?: number; level?: number };
type MonthMarker = { key: string; label: string; week: number };
function dateOnly(value: Date) { return new Date(value.getFullYear(), value.getMonth(), value.getDate()); } function dateOnly(value: Date) { return new Date(value.getFullYear(), value.getMonth(), value.getDate()); }
function localDateKey(value: Date) { return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}-${String(value.getDate()).padStart(2, "0")}`; } function localDateKey(value: Date) { return `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, "0")}-${String(value.getDate()).padStart(2, "0")}`; }
+37
View File
@@ -0,0 +1,37 @@
import type { ContentModelProvider } from "./api/client";
export type ContentModelProviderPreset = {
id: ContentModelProvider;
name: string;
shortName: string;
category: "domestic" | "local" | "custom";
baseURL: string;
modelID: string;
modelPlaceholder: string;
keyLabel: string;
description: string;
docsURL?: string;
};
export const contentModelProviderPresets: ContentModelProviderPreset[] = [
{ id: "ark", name: "火山方舟 / 豆包", shortName: "豆包", category: "domestic", baseURL: "https://ark.cn-beijing.volces.com/api/v3", modelID: "", modelPlaceholder: "模型 ID 或推理 Endpoint ID", keyLabel: "方舟 API Key", description: "豆包及方舟模型,支持模型 ID 或专属推理接入点。", docsURL: "https://www.volcengine.com/docs/82379/1795150" },
{ id: "dashscope", name: "阿里云百炼 / 千问", shortName: "千问", category: "domestic", baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", modelID: "qwen-plus", modelPlaceholder: "例如 qwen-plus", keyLabel: "百炼 API Key", description: "使用北京地域 OpenAI 兼容入口,Key 与地域需一致。", docsURL: "https://help.aliyun.com/zh/model-studio/compatibility-of-openai-with-dashscope" },
{ id: "deepseek", name: "DeepSeek", shortName: "DeepSeek", category: "domestic", baseURL: "https://api.deepseek.com", modelID: "deepseek-v4-flash", modelPlaceholder: "例如 deepseek-v4-flash", keyLabel: "DeepSeek API Key", description: "DeepSeek 官方 OpenAI 兼容接口。", docsURL: "https://api-docs.deepseek.com/zh-cn/" },
{ id: "moonshot", name: "月之暗面 / Kimi", shortName: "Kimi", category: "domestic", baseURL: "https://api.moonshot.cn/v1", modelID: "kimi-k2.5", modelPlaceholder: "例如 kimi-k2.5", keyLabel: "Moonshot API Key", description: "Kimi 官方开放平台兼容接口。", docsURL: "https://platform.moonshot.cn/docs" },
{ id: "zhipu", name: "智谱 AI / GLM", shortName: "智谱 GLM", category: "domestic", baseURL: "https://open.bigmodel.cn/api/paas/v4", modelID: "glm-5", modelPlaceholder: "例如 glm-5", keyLabel: "智谱 API Key", description: "智谱开放平台 OpenAI Chat Completions 接口。", docsURL: "https://docs.bigmodel.cn/cn/guide/develop/openai/introduction" },
{ id: "qianfan", name: "百度千帆 / 文心", shortName: "百度千帆", category: "domestic", baseURL: "https://qianfan.baidubce.com/v2", modelID: "", modelPlaceholder: "填写千帆控制台中的 model 参数", keyLabel: "千帆 Bearer Token", description: "千帆 V2 OpenAI 兼容入口,使用 Bearer Token。", docsURL: "https://cloud.baidu.com/doc/qianfan/s/rmh4stn9m" },
{ id: "hunyuan", name: "腾讯混元", shortName: "腾讯混元", category: "domestic", baseURL: "https://api.hunyuan.cloud.tencent.com/v1", modelID: "hunyuan-turbos-latest", modelPlaceholder: "例如 hunyuan-turbos-latest", keyLabel: "混元 API Key", description: "腾讯混元 OpenAI 兼容入口。", docsURL: "https://cloud.tencent.com/document/product/1729/111007" },
{ id: "minimax", name: "MiniMax", shortName: "MiniMax", category: "domestic", baseURL: "https://api.minimaxi.com/v1", modelID: "MiniMax-M2.7", modelPlaceholder: "例如 MiniMax-M2.7", keyLabel: "MiniMax API Key", description: "MiniMax 文本对话兼容接口,已适配思考内容包装。", docsURL: "https://platform.minimaxi.com/docs/api-reference/text-chat-openai" },
{ id: "baichuan", name: "百川智能", shortName: "百川", category: "domestic", baseURL: "https://api.baichuan-ai.com/v1", modelID: "Baichuan2-Turbo", modelPlaceholder: "例如 Baichuan2-Turbo", keyLabel: "百川 API Key", description: "百川官方 Chat Completions 接口。", docsURL: "https://platform.baichuan-ai.com/docs/api" },
{ id: "siliconflow", name: "硅基流动", shortName: "硅基流动", category: "domestic", baseURL: "https://api.siliconflow.cn/v1", modelID: "", modelPlaceholder: "填写硅基流动模型 ID", keyLabel: "SiliconFlow API Key", description: "一个 Key 接入多种国内开源模型。", docsURL: "https://docs.siliconflow.cn/cn/api-reference/chat-completions/chat-completions" },
{ id: "ollama", name: "本地 Ollama", shortName: "本地 Ollama", category: "local", baseURL: "http://ollama:11434/v1", modelID: "qwen2.5:7b", modelPlaceholder: "例如 qwen2.5:7b", keyLabel: "本地占位密钥", description: "当前 Docker 网络内的本地模型,不向外部发送聊天内容。" },
{ id: "openai_compatible", name: "自定义兼容服务", shortName: "自定义服务", category: "custom", baseURL: "https://", modelID: "", modelPlaceholder: "填写服务提供方给出的模型 ID", keyLabel: "API Key", description: "用于其他支持 OpenAI Chat Completions 的 HTTPS 服务。" },
];
export function contentModelProviderPreset(provider: ContentModelProvider) {
return contentModelProviderPresets.find((item) => item.id === provider) ?? contentModelProviderPresets[contentModelProviderPresets.length - 1];
}
export function contentModelProviderLabel(provider: ContentModelProvider) {
return contentModelProviderPreset(provider).name;
}
+14 -3
View File
@@ -1,4 +1,4 @@
import { StrictMode } from "react"; import { StrictMode, useEffect, useState } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { ConfigProvider } from "antd"; import { ConfigProvider } from "antd";
import zhCN from "antd/locale/zh_CN"; import zhCN from "antd/locale/zh_CN";
@@ -7,7 +7,18 @@ import { MaintenancePortal } from "./pages/MaintenancePortal";
import "./styles.css"; import "./styles.css";
import "./cal-theme.css"; import "./cal-theme.css";
const Portal = window.location.pathname.startsWith("/maintenance") ? MaintenancePortal : CustomerPortal; function PortalRouter() {
const [pathname, setPathname] = useState(() => window.location.pathname);
useEffect(() => {
const syncPathname = () => setPathname(window.location.pathname);
window.addEventListener("popstate", syncPathname);
return () => window.removeEventListener("popstate", syncPathname);
}, []);
const Portal = pathname.startsWith("/maintenance") ? MaintenancePortal : CustomerPortal;
return <Portal />;
}
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
@@ -67,7 +78,7 @@ createRoot(document.getElementById("root")!).render(
} }
}} }}
> >
<Portal /> <PortalRouter />
</ConfigProvider> </ConfigProvider>
</StrictMode> </StrictMode>
); );
+18 -16
View File
@@ -42,7 +42,6 @@ import {
SafetyCertificateOutlined, SafetyCertificateOutlined,
SearchOutlined, SearchOutlined,
SettingOutlined, SettingOutlined,
RobotOutlined,
TeamOutlined, TeamOutlined,
UserOutlined UserOutlined
} from "@ant-design/icons"; } from "@ant-design/icons";
@@ -53,16 +52,15 @@ type PortalSection = CustomerSection | "profile";
const sectionMeta: Record<PortalSection, { title: string; description: string; breadcrumb: string }> = { const sectionMeta: Record<PortalSection, { title: string; description: string; breadcrumb: string }> = {
home: { title: "陪伴周记", description: "从每位长者出发,查看最近聊天、心情线索和需要家人关心的变化。", breadcrumb: "首页" }, home: { title: "陪伴周记", description: "从每位长者出发,查看最近聊天、心情线索和需要家人关心的变化。", breadcrumb: "首页" },
members: { title: "家人共享", description: "管理家庭成员、邀请、角色和家庭空间。", breadcrumb: "家人共享" }, members: { title: "家庭管理", description: "管理家庭空间、成员邀请、角色和所有权。", breadcrumb: "家庭管理" },
subjects: { title: "长者资料", description: "维护长期陪伴资料、交流节奏、话题偏好和分项授权。", breadcrumb: "长者资料" }, subjects: { title: "长者资料", description: "维护长期陪伴资料、交流节奏、话题偏好和分项授权。", breadcrumb: "长者资料" },
devices: { title: "设备管理", description: "绑定陪伴设备,查看在线状态、固件版本和音量配置。", breadcrumb: "设备管理" }, devices: { title: "陪伴设备", description: "绑定设备并查看在线状态、固件、音量和诊断结果。", breadcrumb: "陪伴设备" },
conversations: { title: "聊天记录", description: "按长者和日期回看聊天摘要、话题、心情线索与完整内容。", breadcrumb: "聊天记录" }, conversations: { title: "回忆时间轴", description: "以时间串起每一次聊天,查看 AI 摘要、关键词、故事候选与下次话题。", breadcrumb: "聊天记录" },
stories: { title: "故事与记忆", description: "核对智能整理的故事,沉淀经过家庭确认的长期记忆。", breadcrumb: "故事与记忆" }, stories: { title: "故事与记忆", description: "核对智能整理的故事,沉淀经过家庭确认的长期记忆。", breadcrumb: "故事与记忆" },
sharing: { title: "资源分享", description: "面向指定家人分享故事、文字或录音,并随时撤销权限。", breadcrumb: "授权与隐私 / 资源分享" }, sharing: { title: "内容分享", description: "向指定家人分享故事、文字或录音,并随时撤销权限。", breadcrumb: "隐私与授权 / 内容分享" },
privacy: { title: "隐私请求", description: "申请访问、导出或删除个人数据,并跟踪处理状态。", breadcrumb: "授权与隐私 / 隐私请求" }, privacy: { title: "数据与授权", description: "按项目查看授权,并申请访问、更正、撤回、删除或私有导出。", breadcrumb: "隐私与授权 / 数据与授权" },
billing: { title: "用量与费用", description: "查看本家庭及设备的通话时长;存在已核价记录时可切换费用。", breadcrumb: "用量与费用" }, billing: { title: "服务用量", description: "围绕长者聊天查看时长、活跃日和最近通话。", breadcrumb: "服务用量" },
messages: { title: "日程与提醒", description: "查看陪伴动态、设备状态和需要处理的家庭提醒。", breadcrumb: "日程与提醒" }, messages: { title: "提醒与待办", description: "查看服务端记录的陪伴动态,并处理故事、设备和隐私事项。", breadcrumb: "提醒与待办" },
models: { title: "大模型管理", description: "配置、测试并激活用于聊天总结、关键词和故事候选生成的大模型。", breadcrumb: "设置与权限 / 大模型管理" },
profile: { title: "设置与权限", description: "查看登录身份、家庭角色和全部权限范围。", breadcrumb: "设置与权限" } profile: { title: "设置与权限", description: "查看登录身份、家庭角色和全部权限范围。", breadcrumb: "设置与权限" }
}; };
@@ -85,8 +83,8 @@ export function CustomerPortal() {
{ key: "conversations", icon: <CommentOutlined />, label: "聊天记录" }, { key: "conversations", icon: <CommentOutlined />, label: "聊天记录" },
{ key: "stories", icon: <BookOutlined />, label: "故事与记忆" }, { key: "stories", icon: <BookOutlined />, label: "故事与记忆" },
{ key: "subjects", icon: <HeartOutlined />, label: "长者资料" }, { key: "subjects", icon: <HeartOutlined />, label: "长者资料" },
{ key: "messages", icon: <MessageOutlined />, label: "日程与提醒" }, { key: "messages", icon: <MessageOutlined />, label: "提醒与待办" },
{ key: "members", icon: <TeamOutlined />, label: "家人共享" }, { key: "members", icon: <TeamOutlined />, label: "家庭管理" },
{ type: "divider" }, { type: "divider" },
{ key: "devices", icon: <LaptopOutlined />, label: "陪伴设备" }, { key: "devices", icon: <LaptopOutlined />, label: "陪伴设备" },
{ {
@@ -99,7 +97,6 @@ export function CustomerPortal() {
] ]
}, },
{ key: "billing", icon: <CreditCardOutlined />, label: "服务用量" }, { key: "billing", icon: <CreditCardOutlined />, label: "服务用量" },
{ key: "models", icon: <RobotOutlined />, label: "大模型管理" },
{ key: "profile", icon: <SettingOutlined />, label: "设置与权限" } { key: "profile", icon: <SettingOutlined />, label: "设置与权限" }
], []); ], []);
@@ -123,7 +120,6 @@ export function CustomerPortal() {
[["分享", "share"], "sharing"], [["分享", "share"], "sharing"],
[["费用", "额度", "账单", "cost"], "billing"], [["费用", "额度", "账单", "cost"], "billing"],
[["消息", "通知", "提醒"], "messages"], [["消息", "通知", "提醒"], "messages"],
[["大模型", "模型", "豆包", "总结"], "models"],
[["长者", "老人", "档案", "subject"], "subjects"], [["长者", "老人", "档案", "subject"], "subjects"],
[["家庭", "成员", "邀请", "member"], "members"] [["家庭", "成员", "邀请", "member"], "members"]
]; ];
@@ -145,7 +141,7 @@ export function CustomerPortal() {
</div> </div>
<div className="customer-header__actions"> <div className="customer-header__actions">
<Input.Search value={searchValue} onChange={(event) => setSearchValue(event.target.value)} onSearch={search} prefix={<SearchOutlined />} placeholder="搜索长者、设备、通话" allowClear className="customer-search" /> <Input.Search value={searchValue} onChange={(event) => setSearchValue(event.target.value)} onSearch={search} prefix={<SearchOutlined />} placeholder="搜索长者、设备、通话" allowClear className="customer-search" />
<Tooltip title="消息中心"><Badge dot><Button type="text" icon={<BellOutlined />} aria-label="打开消息中心" onClick={() => navigate("messages")} /></Badge></Tooltip> <Tooltip title="提醒与待办"><Badge dot><Button type="text" icon={<BellOutlined />} aria-label="打开提醒与待办" onClick={() => navigate("messages")} /></Badge></Tooltip>
{auth.currentUser || designPreview ? <Dropdown menu={{ items: accountMenu }} placement="bottomRight"> {auth.currentUser || designPreview ? <Dropdown menu={{ items: accountMenu }} placement="bottomRight">
<Button type="text" className="customer-account-button"> <Button type="text" className="customer-account-button">
<Avatar size={36} src={designPreview ? "/assets/family-chen.png" : undefined} icon={<UserOutlined />} /> <Avatar size={36} src={designPreview ? "/assets/family-chen.png" : undefined} icon={<UserOutlined />} />
@@ -234,7 +230,14 @@ function CustomerLogin({ onLogin }: { onLogin: () => void }) {
} }
function CustomerProfile({ currentUser, onLogout }: { currentUser: NonNullable<ReturnType<typeof useAuth>["currentUser"]>; onLogout: () => void }) { function CustomerProfile({ currentUser, onLogout }: { currentUser: NonNullable<ReturnType<typeof useAuth>["currentUser"]>; onLogout: () => void }) {
const memberships = currentUser.accounts.flatMap((account) => account.household_memberships);
return <div className="customer-profile"> return <div className="customer-profile">
<div className="workspace__context-bar customer-profile__context">
<nav className="household-tabs" role="tablist" aria-label="当前家庭">
{memberships.length ? memberships.map((membership, index) => <button type="button" role="tab" aria-selected={index === 0} className={index === 0 ? "active" : ""} key={`${membership.household_id}-${index}`}>{membership.household_name}</button>) : <button type="button" role="tab" aria-selected="true" className="active">尚未加入家庭</button>}
</nav>
<span className="event-stream-status event-stream-status--connected">权限实时校验</span>
</div>
<Card> <Card>
<div className="customer-profile__identity"><Avatar size={64} icon={<UserOutlined />} /><div><h2>{currentUser.identity.display_name || currentUser.identity.preferred_username}</h2><p>{currentUser.identity.preferred_username}</p></div><Button onClick={onLogout}>退出登录</Button></div> <div className="customer-profile__identity"><Avatar size={64} icon={<UserOutlined />} /><div><h2>{currentUser.identity.display_name || currentUser.identity.preferred_username}</h2><p>{currentUser.identity.preferred_username}</p></div><Button onClick={onLogout}>退出登录</Button></div>
</Card> </Card>
@@ -264,11 +267,10 @@ const permissionRows = [
["privacy.manage", "处理隐私请求", true, false, false, false], ["privacy.manage", "处理隐私请求", true, false, false, false],
["recording.play", "播放聊天录音", false, false, false, false], ["recording.play", "播放聊天录音", false, false, false, false],
["recording.download", "下载聊天录音", false, false, false, false], ["recording.download", "下载聊天录音", false, false, false, false],
["model.manage", "管理总结大模型", true, true, false, false],
] as const; ] as const;
function PermissionMatrix() { function PermissionMatrix() {
return <Card className="permission-matrix-card" title="全部权限说明" extra={<Tag>15 项</Tag>}> return <Card className="permission-matrix-card" title="全部权限说明" extra={<Tag>{permissionRows.length} 项</Tag>}>
<p className="permission-matrix-note">家庭角色决定一般操作权限;播放和下载录音属于敏感权限,必须由所有者或管理员按长者、用途和有效期单独授权。</p> <p className="permission-matrix-note">家庭角色决定一般操作权限;播放和下载录音属于敏感权限,必须由所有者或管理员按长者、用途和有效期单独授权。</p>
<div className="permission-matrix-wrap"><table className="permission-matrix"><thead><tr><th>权限</th><th>所有者</th><th>管理员</th><th>编辑者</th><th>查看者</th></tr></thead><tbody>{permissionRows.map(([code, label, owner, admin, editor, viewer]) => <tr key={code}><th><strong>{label}</strong><small>{code}</small></th>{[owner, admin, editor, viewer].map((enabled, index) => <td key={index} className={enabled ? "is-enabled" : ""}>{enabled ? <CheckOutlined aria-label="包含" /> : <MinusOutlined aria-label="不包含" />}</td>)}</tr>)}</tbody></table></div> <div className="permission-matrix-wrap"><table className="permission-matrix"><thead><tr><th>权限</th><th>所有者</th><th>管理员</th><th>编辑者</th><th>查看者</th></tr></thead><tbody>{permissionRows.map(([code, label, owner, admin, editor, viewer]) => <tr key={code}><th><strong>{label}</strong><small>{code}</small></th>{[owner, admin, editor, viewer].map((enabled, index) => <td key={index} className={enabled ? "is-enabled" : ""}>{enabled ? <CheckOutlined aria-label="包含" /> : <MinusOutlined aria-label="不包含" />}</td>)}</tr>)}</tbody></table></div>
</Card>; </Card>;
+24 -2
View File
@@ -29,6 +29,7 @@ import {
MenuFoldOutlined, MenuFoldOutlined,
MenuUnfoldOutlined, MenuUnfoldOutlined,
ReloadOutlined, ReloadOutlined,
RobotOutlined,
SearchOutlined, SearchOutlined,
SettingOutlined, SettingOutlined,
ToolOutlined, ToolOutlined,
@@ -42,8 +43,9 @@ import { OpsBillingWorkspace } from "./OpsBillingWorkspace";
import { OpsConfigWorkspace } from "./OpsConfigWorkspace"; import { OpsConfigWorkspace } from "./OpsConfigWorkspace";
import { OpsDiagnosticsWorkspace } from "./OpsDiagnosticsWorkspace"; import { OpsDiagnosticsWorkspace } from "./OpsDiagnosticsWorkspace";
import { OpsDeviceWorkspace } from "./OpsDeviceWorkspace"; import { OpsDeviceWorkspace } from "./OpsDeviceWorkspace";
import { OpsContentModelWorkspace } from "./OpsContentModelWorkspace";
type MaintenanceArea = "overview" | "devices" | "config" | "quality" | "billing" | "alerts" | "diagnostics"; type MaintenanceArea = "overview" | "devices" | "config" | "models" | "quality" | "billing" | "alerts" | "diagnostics";
type HealthState = { type HealthState = {
loading: boolean; loading: boolean;
@@ -59,6 +61,7 @@ const areaMeta: Record<MaintenanceArea, { title: string; description: string; ic
overview: { title: "维护总览", description: "集中查看服务健康、构建版本和当前维护权限。", icon: <DashboardOutlined /> }, overview: { title: "维护总览", description: "集中查看服务健康、构建版本和当前维护权限。", icon: <DashboardOutlined /> },
devices: { title: "设备管理", description: "登记设备、维护家庭与长者归属、吊销设备并安全轮换密钥。", icon: <DatabaseOutlined /> }, devices: { title: "设备管理", description: "登记设备、维护家庭与长者归属、吊销设备并安全轮换密钥。", icon: <DatabaseOutlined /> },
config: { title: "运行配置", description: "管理配置版本、资源引用、离线评测、审批和灰度发布。", icon: <SettingOutlined /> }, config: { title: "运行配置", description: "管理配置版本、资源引用、离线评测、审批和灰度发布。", icon: <SettingOutlined /> },
models: { title: "大模型管理", description: "按客户组织配置、测试并激活聊天总结、关键词和故事候选生成模型。", icon: <RobotOutlined /> },
quality: { title: "内容质量", description: "按模型和提示词版本检查故事、事实与记忆的人工审核反馈。", icon: <BarChartOutlined /> }, quality: { title: "内容质量", description: "按模型和提示词版本检查故事、事实与记忆的人工审核反馈。", icon: <BarChartOutlined /> },
billing: { title: "用量、费用与额度", description: "查看整体通话时长并按家庭、设备筛选,同时维护核价与硬额度策略。", icon: <DollarOutlined /> }, billing: { title: "用量、费用与额度", description: "查看整体通话时长并按家庭、设备筛选,同时维护核价与硬额度策略。", icon: <DollarOutlined /> },
alerts: { title: "告警处置", description: "维护告警路由、通知测试、静默窗口和处置时间线。", icon: <AlertOutlined /> }, alerts: { title: "告警处置", description: "维护告警路由、通知测试、静默窗口和处置时间线。", icon: <AlertOutlined /> },
@@ -69,6 +72,7 @@ export function MaintenancePortal() {
const auth = useAuth(); const auth = useAuth();
const [collapsed, setCollapsed] = useState(() => window.innerWidth <= 720); const [collapsed, setCollapsed] = useState(() => window.innerWidth <= 720);
const [area, setArea] = useState<MaintenanceArea>(() => areaFromURL()); const [area, setArea] = useState<MaintenanceArea>(() => areaFromURL());
const [searchValue, setSearchValue] = useState("");
const [health, setHealth] = useState<HealthState>({ loading: true }); const [health, setHealth] = useState<HealthState>({ loading: true });
const refreshHealth = useCallback(async (signal?: AbortSignal) => { const refreshHealth = useCallback(async (signal?: AbortSignal) => {
@@ -120,11 +124,28 @@ export function MaintenancePortal() {
window.history.pushState({ area: next }, "", `${url.pathname}${url.search}${url.hash}`); window.history.pushState({ area: next }, "", `${url.pathname}${url.search}${url.hash}`);
} }
function search() {
const value = searchValue.trim().toLowerCase();
if (!value) return;
const matches: Array<[string[], MaintenanceArea]> = [
[["总览", "健康", "版本", "health"], "overview"],
[["设备管理", "资产", "密钥", "device management"], "devices"],
[["配置", "灰度", "模型路由", "config"], "config"],
[["大模型", "模型", "豆包", "ollama", "model"], "models"],
[["质量", "审核", "反馈", "quality"], "quality"],
[["费用", "额度", "账单", "用量", "billing", "cost"], "billing"],
[["告警", "静默", "路由", "alert"], "alerts"]
];
const target = matches.find(([keywords]) => keywords.some((keyword) => value.includes(keyword)))?.[1] ?? "diagnostics";
navigate(target);
}
const menuItems: MenuProps["items"] = [ const menuItems: MenuProps["items"] = [
{ type: "group", label: "平台维护", children: [ { type: "group", label: "平台维护", children: [
{ key: "overview", icon: <DashboardOutlined />, label: "维护总览" }, { key: "overview", icon: <DashboardOutlined />, label: "维护总览" },
{ key: "devices", icon: <DatabaseOutlined />, label: "设备管理" }, { key: "devices", icon: <DatabaseOutlined />, label: "设备管理" },
{ key: "config", icon: <SettingOutlined />, label: "运行配置" }, { key: "config", icon: <SettingOutlined />, label: "运行配置" },
{ key: "models", icon: <RobotOutlined />, label: "大模型管理" },
{ key: "quality", icon: <BarChartOutlined />, label: "内容质量" }, { key: "quality", icon: <BarChartOutlined />, label: "内容质量" },
{ key: "billing", icon: <DollarOutlined />, label: "用量与费用" }, { key: "billing", icon: <DollarOutlined />, label: "用量与费用" },
{ key: "alerts", icon: <AlertOutlined />, label: "告警处置" }, { key: "alerts", icon: <AlertOutlined />, label: "告警处置" },
@@ -147,7 +168,7 @@ export function MaintenancePortal() {
{(Object.entries(areaMeta) as Array<[MaintenanceArea, (typeof areaMeta)[MaintenanceArea]]>).map(([key, item]) => <button type="button" className={area === key ? "active" : ""} key={key} onClick={() => navigate(key)}>{item.title.replace("维护", "")}</button>)} {(Object.entries(areaMeta) as Array<[MaintenanceArea, (typeof areaMeta)[MaintenanceArea]]>).map(([key, item]) => <button type="button" className={area === key ? "active" : ""} key={key} onClick={() => navigate(key)}>{item.title.replace("维护", "")}</button>)}
</nav> </nav>
<div className="maintenance-header__actions"> <div className="maintenance-header__actions">
<Input prefix={<SearchOutlined />} placeholder="搜索设备、通话、作业" allowClear className="maintenance-search" /> <Input.Search value={searchValue} onChange={(event) => setSearchValue(event.target.value)} onSearch={search} prefix={<SearchOutlined />} placeholder="搜索设备、通话、作业" allowClear className="maintenance-search" />
<Tooltip title="平台就绪状态"><Badge status={health.ready === "ready" ? "success" : health.loading ? "processing" : "warning"} /></Tooltip> <Tooltip title="平台就绪状态"><Badge status={health.ready === "ready" ? "success" : health.loading ? "processing" : "warning"} /></Tooltip>
<Button type="text" icon={<BellOutlined />} aria-label="查看告警" onClick={() => navigate("alerts")} /> <Button type="text" icon={<BellOutlined />} aria-label="查看告警" onClick={() => navigate("alerts")} />
{auth.currentUser ? <Dropdown menu={{ items: accountMenu }} placement="bottomRight"><Button type="text" className="maintenance-account"><Avatar size={30} icon={<UserOutlined />} /><span>{auth.currentUser.identity.display_name || auth.currentUser.identity.preferred_username}</span></Button></Dropdown> : <Button type="primary" onClick={() => void auth.login()}>维护人员登录</Button>} {auth.currentUser ? <Dropdown menu={{ items: accountMenu }} placement="bottomRight"><Button type="text" className="maintenance-account"><Avatar size={30} icon={<UserOutlined />} /><span>{auth.currentUser.identity.display_name || auth.currentUser.identity.preferred_username}</span></Button></Dropdown> : <Button type="primary" onClick={() => void auth.login()}>维护人员登录</Button>}
@@ -176,6 +197,7 @@ export function MaintenancePortal() {
{area === "overview" ? <MaintenanceOverview health={health} accounts={operationsAccounts} /> : null} {area === "overview" ? <MaintenanceOverview health={health} accounts={operationsAccounts} /> : null}
{area === "devices" ? <OpsDeviceWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null} {area === "devices" ? <OpsDeviceWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
{area === "config" ? <OpsConfigWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null} {area === "config" ? <OpsConfigWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
{area === "models" ? <OpsContentModelWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
{area === "quality" ? <ContentQualityWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null} {area === "quality" ? <ContentQualityWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
{area === "billing" ? <OpsBillingWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null} {area === "billing" ? <OpsBillingWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
{area === "alerts" ? <OpsAlertWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null} {area === "alerts" ? <OpsAlertWorkspace accessToken={auth.oidcUser.access_token} accounts={operationsAccounts} /> : null}
+105
View File
@@ -0,0 +1,105 @@
import { useCallback, useEffect, useState } from "react";
import { Alert, Button, Card, Select, Space } from "antd";
import { ReloadOutlined } from "@ant-design/icons";
import { platformApi, type ContentModel, type CreateContentModelRequest, type CurrentUser } from "../api/client";
import { ContentModelPanel } from "./SaaSWorkspace";
export function OpsContentModelWorkspace({ accessToken, accounts }: {
accessToken: string;
accounts: CurrentUser["accounts"];
}) {
const [tenantID, setTenantID] = useState(accounts[0]?.tenant_id ?? "");
const [models, setModels] = useState<ContentModel[]>([]);
const [loading, setLoading] = useState(false);
const [actionBusy, setActionBusy] = useState(false);
const [error, setError] = useState<string>();
const [message, setMessage] = useState<string>();
const account = accounts.find((item) => item.tenant_id === tenantID);
const canManage = Boolean(account && ["ops_operator", "ops_admin"].includes(account.platform_role));
useEffect(() => {
if (!accounts.some((item) => item.tenant_id === tenantID)) setTenantID(accounts[0]?.tenant_id ?? "");
}, [accounts, tenantID]);
const loadModels = useCallback(async (signal?: AbortSignal) => {
if (!tenantID) { setModels([]); return; }
setLoading(true);
setError(undefined);
try {
setModels((await platformApi.contentModels(tenantID, accessToken, signal)).data.items);
} catch (cause) {
if (!(cause instanceof DOMException && cause.name === "AbortError")) setError(errorText(cause));
} finally {
if (!signal?.aborted) setLoading(false);
}
}, [accessToken, tenantID]);
useEffect(() => {
const controller = new AbortController();
void loadModels(controller.signal);
return () => controller.abort();
}, [loadModels]);
async function runAction(successMessage: string, action: () => Promise<void>) {
setActionBusy(true);
setError(undefined);
setMessage(undefined);
try {
await action();
await loadModels();
setMessage(successMessage);
return true;
} catch (cause) {
setError(errorText(cause));
return false;
} finally {
setActionBusy(false);
}
}
async function createModel(input: CreateContentModelRequest) {
if (!tenantID || !canManage) return false;
return runAction("大模型配置已安全保存,请完成连通性测试后再激活。", async () => {
await platformApi.createContentModel(tenantID, input, accessToken);
});
}
async function testModel(item: ContentModel) {
if (!tenantID || !canManage) return;
await runAction("连通性测试已完成。", async () => {
await platformApi.testContentModel(tenantID, item.id, accessToken);
});
}
async function activateModel(item: ContentModel) {
if (!tenantID || !canManage) return;
await runAction(`“${item.display_name}”已激活,下一次总结任务会立即使用该模型。`, async () => {
await platformApi.activateContentModel(tenantID, item, accessToken);
});
}
async function revokeModel(item: ContentModel) {
if (!tenantID || !canManage) return;
await runAction("未激活模型已移除,密钥材料已清除。", async () => {
await platformApi.revokeContentModel(tenantID, item, accessToken);
});
}
return <div className="ops-content-model-workspace">
<Card className="quality-toolbar">
<div><strong>模型配置范围</strong><span>模型配置按客户组织隔离;只有维护操作员和维护管理员可以变更。</span></div>
<Space wrap>
<Select value={tenantID || undefined} style={{ minWidth: 240 }} placeholder="选择客户组织" onChange={setTenantID} options={accounts.map((item) => ({ value: item.tenant_id, label: item.tenant_name }))} />
<Button icon={<ReloadOutlined />} loading={loading} onClick={() => void loadModels()}>刷新模型配置</Button>
</Space>
</Card>
{!canManage ? <Alert type="info" showIcon message="当前维护角色为只读" description="维护查看员和维护审批员可以查看配置,但不能新增、测试、激活或移除模型。" /> : null}
{message ? <Alert type="success" showIcon closable message={message} onClose={() => setMessage(undefined)} /> : null}
<ContentModelPanel models={models} loading={loading} error={error} canManage={canManage} actionBusy={actionBusy}
onCreate={createModel} onTest={testModel} onActivate={activateModel} onRevoke={revokeModel} />
</div>;
}
function errorText(cause: unknown) {
return cause instanceof Error ? cause.message : "大模型配置操作失败";
}
+1 -1
View File
@@ -53,7 +53,7 @@ export function OpsDiagnosticsWorkspace({ accessToken, accounts }: { accessToken
async function search(event?:FormEvent){event?.preventDefault();if(!value.trim())return;setDiagnosticControlDraft(undefined);setDiagnosticBundleDraft(undefined);setLoading(true);setError(undefined);setMessage(undefined);try{const result=await platformApi.searchOpsDiagnostics(tenantID,field,value.trim(),accessToken);setItems(result.data.items)}catch(cause){setError(errorText(cause))}finally{setLoading(false)}} async function search(event?:FormEvent){event?.preventDefault();if(!value.trim())return;setDiagnosticControlDraft(undefined);setDiagnosticBundleDraft(undefined);setLoading(true);setError(undefined);setMessage(undefined);try{const result=await platformApi.searchOpsDiagnostics(tenantID,field,value.trim(),accessToken);setItems(result.data.items)}catch(cause){setError(errorText(cause))}finally{setLoading(false)}}
async function loadExternalDependencies(signal?:AbortSignal){if(!tenantID)return;setBusy(true);setError(undefined);try{const result=await platformApi.opsExternalDependencies(tenantID,accessToken,signal);setExternalDependencies(result.data.items)}catch(cause){if(!(cause instanceof DOMException&&cause.name==="AbortError"))setError(errorText(cause))}finally{if(!signal?.aborted)setBusy(false)}} async function loadExternalDependencies(signal?:AbortSignal){if(!tenantID)return;setBusy(true);setError(undefined);try{const result=await platformApi.opsExternalDependencies(tenantID,accessToken,signal);setExternalDependencies(result.data.items)}catch(cause){if(!(cause instanceof DOMException&&cause.name==="AbortError"))setError(errorText(cause))}finally{if(!signal?.aborted)setBusy(false)}}
async function loadPlatformHealth(signal?:AbortSignal){if(!tenantID)return;setBusy(true);setError(undefined);try{const result=await platformApi.opsPlatformHealth(tenantID,accessToken,signal);setPlatformHealth(result.data)}catch(cause){if(!(cause instanceof DOMException&&cause.name==="AbortError"))setError(errorText(cause))}finally{if(!signal?.aborted)setBusy(false)}} async function loadPlatformHealth(signal?:AbortSignal){if(!tenantID)return;setBusy(true);setError(undefined);try{const result=await platformApi.opsPlatformHealth(tenantID,accessToken,signal);setPlatformHealth(result.data)}catch(cause){if(cause instanceof APIRequestError&&cause.status===404){setPlatformHealth(undefined);return}if(!(cause instanceof DOMException&&cause.name==="AbortError"))setError(errorText(cause))}finally{if(!signal?.aborted)setBusy(false)}}
function openDiagnosticControl(kind:DiagnosticControlKind,item:OpsDiagnosticRecord){setDiagnosticControlDraft({kind,item,reason:"",acknowledged:false});setError(undefined);setMessage(undefined)} function openDiagnosticControl(kind:DiagnosticControlKind,item:OpsDiagnosticRecord){setDiagnosticControlDraft({kind,item,reason:"",acknowledged:false});setError(undefined);setMessage(undefined)}
async function executeDiagnosticControl(event:FormEvent){ async function executeDiagnosticControl(event:FormEvent){
event.preventDefault();const draft=diagnosticControlDraft;if(!draft||!draft.reason.trim()||(diagnosticControlNeedsAcknowledgement(draft.kind)&&!draft.acknowledged))return; event.preventDefault();const draft=diagnosticControlDraft;if(!draft||!draft.reason.trim()||(diagnosticControlNeedsAcknowledgement(draft.kind)&&!draft.acknowledged))return;
+166 -59
View File
@@ -41,6 +41,7 @@ import {
type ConversationTurn, type ConversationTurn,
type ConversationMedia, type ConversationMedia,
type ContentModel, type ContentModel,
type ContentModelProvider,
type CreateContentModelRequest, type CreateContentModelRequest,
type CreateSubjectFactRequest, type CreateSubjectFactRequest,
type CreateSubjectRequest, type CreateSubjectRequest,
@@ -73,13 +74,15 @@ import {
type UpdateSubjectRequest, type UpdateSubjectRequest,
type Story type Story
} from "../api/client"; } from "../api/client";
import { contentModelProviderLabel, contentModelProviderPreset, contentModelProviderPresets } from "../contentModelProviders";
import { FamilyUsageOverview } from "../components/UsageOverview"; import { FamilyUsageOverview } from "../components/UsageOverview";
export type CustomerSection = "home" | "members" | "subjects" | "devices" | "conversations" | "stories" | "sharing" | "privacy" | "billing" | "messages" | "models"; export type CustomerSection = "home" | "members" | "subjects" | "devices" | "conversations" | "stories" | "sharing" | "privacy" | "billing" | "messages";
type Tab = CustomerSection | "firmware"; type Tab = CustomerSection | "firmware";
type LoadState = "idle" | "loading" | "ready" | "error"; type LoadState = "idle" | "loading" | "ready" | "error";
type MediaLoadState = "idle" | "loading" | "ready" | "restricted" | "error"; type MediaLoadState = "idle" | "loading" | "ready" | "restricted" | "error";
type EventStreamState = "connecting" | "connected" | "reconnecting"; type EventStreamState = "connecting" | "connected" | "reconnecting";
type WebEventNotice = { id: string; type: string; householdID: string; resourceType: string; resourceID: string; subjectID?: string; status: string; occurredAt: string };
type ConversationPace = "slow" | "normal" | "lively"; type ConversationPace = "slow" | "normal" | "lively";
type DialoguePreset = { type DialoguePreset = {
key: "daily" | "memory" | "interest"; key: "daily" | "memory" | "interest";
@@ -196,8 +199,6 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
const [turns, setTurns] = useState<ConversationTurn[]>([]); const [turns, setTurns] = useState<ConversationTurn[]>([]);
const [contentSummaries, setContentSummaries] = useState<ConversationContentSummary[]>([]); const [contentSummaries, setContentSummaries] = useState<ConversationContentSummary[]>([]);
const [contentModels, setContentModels] = useState<ContentModel[]>([]); const [contentModels, setContentModels] = useState<ContentModel[]>([]);
const [modelLoading, setModelLoading] = useState(false);
const [modelError, setModelError] = useState<string>();
const [conversationInsights, setConversationInsights] = useState<Record<string, ConversationInsight>>({}); const [conversationInsights, setConversationInsights] = useState<Record<string, ConversationInsight>>({});
const [conversationMedia, setConversationMedia] = useState<ConversationMedia>(); const [conversationMedia, setConversationMedia] = useState<ConversationMedia>();
const [mediaState, setMediaState] = useState<MediaLoadState>("idle"); const [mediaState, setMediaState] = useState<MediaLoadState>("idle");
@@ -217,6 +218,7 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
const [firmwareReleaseImpact, setFirmwareReleaseImpact] = useState<FirmwareReleaseImpact>(); const [firmwareReleaseImpact, setFirmwareReleaseImpact] = useState<FirmwareReleaseImpact>();
const [firmwareImpactLoading, setFirmwareImpactLoading] = useState(false); const [firmwareImpactLoading, setFirmwareImpactLoading] = useState(false);
const [selectedStory, setSelectedStory] = useState<Story>(); const [selectedStory, setSelectedStory] = useState<Story>();
const [storyReviewRequestID, setStoryReviewRequestID] = useState<string>();
const [detailLoading, setDetailLoading] = useState(false); const [detailLoading, setDetailLoading] = useState(false);
const [refreshRevision, setRefreshRevision] = useState(0); const [refreshRevision, setRefreshRevision] = useState(0);
const [actionBusy, setActionBusy] = useState(false); const [actionBusy, setActionBusy] = useState(false);
@@ -267,7 +269,7 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
const [rollbackReleaseID, setRollbackReleaseID] = useState(""); const [rollbackReleaseID, setRollbackReleaseID] = useState("");
const [eventStreamState, setEventStreamState] = useState<EventStreamState>("connecting"); const [eventStreamState, setEventStreamState] = useState<EventStreamState>("connecting");
const [lastEventAt, setLastEventAt] = useState<string>(); const [lastEventAt, setLastEventAt] = useState<string>();
const [eventNotices, setEventNotices] = useState<Array<{ id: string; type: string; resourceType: string; status: string; occurredAt: string }>>([]); const [eventNotices, setEventNotices] = useState<WebEventNotice[]>([]);
const eventRefreshTimer = useRef<number | undefined>(undefined); const eventRefreshTimer = useRef<number | undefined>(undefined);
const lastEventRefreshAt = useRef(0); const lastEventRefreshAt = useRef(0);
const conversationDetailController = useRef<AbortController | undefined>(undefined); const conversationDetailController = useRef<AbortController | undefined>(undefined);
@@ -276,6 +278,7 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
useEffect(() => setTab(section), [section]); useEffect(() => setTab(section), [section]);
function selectSection(next: CustomerSection) { function selectSection(next: CustomerSection) {
if (next !== "stories") setStoryReviewRequestID(undefined);
setTab(next); setTab(next);
onSectionChange?.(next); onSectionChange?.(next);
} }
@@ -378,11 +381,14 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
if (data) { if (data) {
setLastEventAt(new Date().toISOString()); setLastEventAt(new Date().toISOString());
try { try {
const item = JSON.parse(data) as { id?: number; type?: string; resource_type?: string; status?: string; occurred_at?: string }; const item = JSON.parse(data) as { id?: number; type?: string; household_id?: string; resource_type?: string; resource_id?: string; subject_id?: string; status?: string; occurred_at?: string };
setEventNotices((current) => [{ setEventNotices((current) => [{
id: String(item.id ?? id), id: String(item.id ?? id),
type: item.type ?? "resource.updated", type: item.type ?? "resource.updated",
householdID: item.household_id ?? "",
resourceType: item.resource_type ?? "resource", resourceType: item.resource_type ?? "resource",
resourceID: item.resource_id ?? "",
subjectID: item.subject_id,
status: item.status ?? "UPDATED", status: item.status ?? "UPDATED",
occurredAt: item.occurred_at ?? new Date().toISOString() occurredAt: item.occurred_at ?? new Date().toISOString()
}, ...current.filter((notice) => notice.id !== String(item.id ?? id))].slice(0, 50)); }, ...current.filter((notice) => notice.id !== String(item.id ?? id))].slice(0, 50));
@@ -574,7 +580,6 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
const canManageFirmware = enableFirmware && (selectedHousehold?.capabilities.includes("firmware.manage") ?? false); const canManageFirmware = enableFirmware && (selectedHousehold?.capabilities.includes("firmware.manage") ?? false);
const canManageRecordingAccess = selectedHousehold?.role === "owner" || selectedHousehold?.role === "admin"; const canManageRecordingAccess = selectedHousehold?.role === "owner" || selectedHousehold?.role === "admin";
const canManageSharing = selectedHousehold?.role === "owner" || selectedHousehold?.role === "admin"; const canManageSharing = selectedHousehold?.role === "owner" || selectedHousehold?.role === "admin";
const canManageModels = selectedHousehold?.role === "owner" || selectedHousehold?.role === "admin";
const policyVersion = accounts.find((account) => account.tenant_id === selectedHousehold?.tenant_id)?.policy_version ?? ""; const policyVersion = accounts.find((account) => account.tenant_id === selectedHousehold?.tenant_id)?.policy_version ?? "";
const consentNotice = `我已阅读隐私政策${policyVersion || "当前"}版本,并同意对后续新通话启用“${consentLabel(newConsentType)}”。${consentImpactLabel(newConsentType)}我知道该授权可以单独撤回。`; const consentNotice = `我已阅读隐私政策${policyVersion || "当前"}版本,并同意对后续新通话启用“${consentLabel(newConsentType)}”。${consentImpactLabel(newConsentType)}我知道该授权可以单独撤回。`;
const selectedConsentAlreadyEffective = consents.some((item) => item.type === newConsentType && item.effective); const selectedConsentAlreadyEffective = consents.some((item) => item.type === newConsentType && item.effective);
@@ -583,30 +588,12 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
const loadContentModels = useCallback(async (signal?: AbortSignal) => { const loadContentModels = useCallback(async (signal?: AbortSignal) => {
if (!selectedHousehold?.tenant_id) { setContentModels([]); return; } if (!selectedHousehold?.tenant_id) { setContentModels([]); return; }
setModelLoading(true); setModelError(undefined);
try { setContentModels((await platformApi.contentModels(selectedHousehold.tenant_id, accessToken, signal)).data.items); } try { setContentModels((await platformApi.contentModels(selectedHousehold.tenant_id, accessToken, signal)).data.items); }
catch (cause) { if (!(cause instanceof DOMException && cause.name === "AbortError")) setModelError(errorMessage(cause)); } catch (cause) { if (!(cause instanceof DOMException && cause.name === "AbortError")) setContentModels([]); }
finally { if (!signal?.aborted) setModelLoading(false); }
}, [accessToken, selectedHousehold?.tenant_id]); }, [accessToken, selectedHousehold?.tenant_id]);
useEffect(() => { const controller = new AbortController(); void loadContentModels(controller.signal); return () => controller.abort(); }, [loadContentModels, refreshRevision]); useEffect(() => { const controller = new AbortController(); void loadContentModels(controller.signal); return () => controller.abort(); }, [loadContentModels, refreshRevision]);
async function createContentModel(input: CreateContentModelRequest) {
if (!selectedHousehold?.tenant_id) return false;
return runAction("大模型配置已安全保存,请完成连通性测试后激活。", async () => { await platformApi.createContentModel(selectedHousehold.tenant_id, input, accessToken); await loadContentModels(); });
}
async function testContentModel(item: ContentModel) {
if (!selectedHousehold?.tenant_id) return;
await runAction("连通性测试已完成。", async () => { await platformApi.testContentModel(selectedHousehold.tenant_id, item.id, accessToken); await loadContentModels(); });
}
async function activateContentModel(item: ContentModel) {
if (!selectedHousehold?.tenant_id) return;
await runAction(`“${item.display_name}”已激活,下一次总结任务会立即使用该模型。`, async () => { await platformApi.activateContentModel(selectedHousehold.tenant_id, item, accessToken); await loadContentModels(); });
}
async function revokeContentModel(item: ContentModel) {
if (!selectedHousehold?.tenant_id) return;
await runAction("未激活模型已移除,密钥材料已清除。", async () => { await platformApi.revokeContentModel(selectedHousehold.tenant_id, item, accessToken); await loadContentModels(); });
}
async function regenerateSummary() { async function regenerateSummary() {
if (!selectedConversationID) return; if (!selectedConversationID) return;
await runAction("已使用当前激活模型排队重新总结;结果生成后会自动刷新。", async () => { await runAction("已使用当前激活模型排队重新总结;结果生成后会自动刷新。", async () => {
@@ -1003,6 +990,41 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
} }
} }
async function openStoryReminder(story: Story) {
setSelectedSubjectID(story.subject_id);
setStoryReviewRequestID(story.id);
selectSection("stories");
await openStory(story.id);
}
async function openEventNotice(notice: WebEventNotice) {
if (notice.subjectID) setSelectedSubjectID(notice.subjectID);
switch (notice.resourceType) {
case "story":
if (notice.resourceID) setStoryReviewRequestID(notice.resourceID);
selectSection("stories");
if (notice.resourceID) await openStory(notice.resourceID);
return;
case "conversation":
selectSection("conversations");
if (notice.resourceID) await openConversation(notice.resourceID);
return;
case "upload_batch":
selectSection("conversations");
return;
case "privacy_request":
case "export_task":
selectSection("privacy");
return;
case "device":
case "device_command":
selectSection("devices");
return;
default:
selectSection("home");
}
}
async function reviseStory(story: Story, content: string, reason: string, errorType?: ContentErrorType) { async function reviseStory(story: Story, content: string, reason: string, errorType?: ContentErrorType) {
await runAction("故事已保存为新的人工修订版本。", async () => { await runAction("故事已保存为新的人工修订版本。", async () => {
const response = await platformApi.reviseStory(story, content, reason, accessToken, errorType); const response = await platformApi.reviseStory(story, content, reason, accessToken, errorType);
@@ -1308,16 +1330,11 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
return ( return (
<section className={`workspace workspace--${tab}`} aria-label="家庭业务工作台"> <section className={`workspace workspace--${tab}`} aria-label="家庭业务工作台">
<div className="workspace__context-bar">
{state === "ready" && households.length ? <nav className="household-tabs" role="tablist" aria-label="切换家庭"> {state === "ready" && households.length ? <nav className="household-tabs" role="tablist" aria-label="切换家庭">
{households.map((household) => <button type="button" role="tab" aria-selected={household.id === selectedHouseholdID} className={household.id === selectedHouseholdID ? "active" : ""} key={household.id} onClick={() => setSelectedHouseholdID(household.id)}>{household.name}</button>)} {households.map((household) => <button type="button" role="tab" aria-selected={household.id === selectedHouseholdID} className={household.id === selectedHouseholdID ? "active" : ""} key={household.id} onClick={() => setSelectedHouseholdID(household.id)}>{household.name}</button>)}
<button type="button" className="household-tabs__add" aria-label="新增家庭" onClick={() => { setNewHouseholdTenantID(selectedHousehold?.tenant_id ?? accounts[0]?.tenant_id ?? ""); setHouseholdCreateOpen(true); }}><PlusOutlined /></button> <button type="button" className="household-tabs__add" aria-label="新增家庭" onClick={() => { setNewHouseholdTenantID(selectedHousehold?.tenant_id ?? accounts[0]?.tenant_id ?? ""); setHouseholdCreateOpen(true); }}><PlusOutlined /></button>
</nav> : null} </nav> : <span />}
<div className="workspace__header">
<div>
<p className="eyebrow">FAMILY COMPANION</p>
<h2>{customerSectionTitle(tab)}</h2>
<p>{customerSectionDescription(tab)}</p>
</div>
<div className="workspace__actions"> <div className="workspace__actions">
{tab === "stories" ? <label className="workspace-story-subject"> {tab === "stories" ? <label className="workspace-story-subject">
<span>长者</span> <span>长者</span>
@@ -1332,6 +1349,13 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
{tab === "devices" && canManageDevice ? <button type="button" onClick={() => setDeviceClaimOpen(true)}><PlusOutlined /> 绑定设备</button> : null} {tab === "devices" && canManageDevice ? <button type="button" onClick={() => setDeviceClaimOpen(true)}><PlusOutlined /> 绑定设备</button> : null}
</div> </div>
</div> </div>
{showNavigation ? <div className="workspace__header">
<div>
<p className="eyebrow">FAMILY COMPANION</p>
<h2>{customerSectionTitle(tab)}</h2>
<p>{customerSectionDescription(tab)}</p>
</div>
</div> : null}
{invitationAcceptOpen ? <div className="workspace-dialog-backdrop" role="presentation" onMouseDown={(event) => { if (event.currentTarget === event.target) setInvitationAcceptOpen(false); }}><form className="workspace-dialog" role="dialog" aria-modal="true" aria-labelledby="accept-invitation-title" onSubmit={(event) => { void acceptInvitation(event); setInvitationAcceptOpen(false); }}><header><div><h3 id="accept-invitation-title">接受家庭邀请</h3><p>先预览家庭、角色和有效期,确认后才会加入。</p></div><button type="button" aria-label="关闭" onClick={() => setInvitationAcceptOpen(false)}><CloseOutlined /></button></header><div className="workspace-dialog__body"><label><span>一次性邀请凭据</span><input autoFocus value={acceptInvitationToken} minLength={32} maxLength={128} autoComplete="off" onChange={(event) => setAcceptInvitationToken(event.target.value)} placeholder="粘贴一次性邀请凭据" /><small>凭据长度为 32 至 128 个字符。</small></label></div><footer><button type="button" className="button-secondary" onClick={() => setInvitationAcceptOpen(false)}>取消</button><button type="submit" disabled={actionBusy || acceptInvitationToken.trim().length < 32 || acceptInvitationToken.trim().length > 128}>{actionBusy ? "正在核验…" : "预览并接受"}</button></footer></form></div> : null} {invitationAcceptOpen ? <div className="workspace-dialog-backdrop" role="presentation" onMouseDown={(event) => { if (event.currentTarget === event.target) setInvitationAcceptOpen(false); }}><form className="workspace-dialog" role="dialog" aria-modal="true" aria-labelledby="accept-invitation-title" onSubmit={(event) => { void acceptInvitation(event); setInvitationAcceptOpen(false); }}><header><div><h3 id="accept-invitation-title">接受家庭邀请</h3><p>先预览家庭、角色和有效期,确认后才会加入。</p></div><button type="button" aria-label="关闭" onClick={() => setInvitationAcceptOpen(false)}><CloseOutlined /></button></header><div className="workspace-dialog__body"><label><span>一次性邀请凭据</span><input autoFocus value={acceptInvitationToken} minLength={32} maxLength={128} autoComplete="off" onChange={(event) => setAcceptInvitationToken(event.target.value)} placeholder="粘贴一次性邀请凭据" /><small>凭据长度为 32 至 128 个字符。</small></label></div><footer><button type="button" className="button-secondary" onClick={() => setInvitationAcceptOpen(false)}>取消</button><button type="submit" disabled={actionBusy || acceptInvitationToken.trim().length < 32 || acceptInvitationToken.trim().length > 128}>{actionBusy ? "正在核验…" : "预览并接受"}</button></footer></form></div> : null}
@@ -1464,9 +1488,8 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
stories={stories} onOpenStories={() => selectSection("stories")} stories={stories} onOpenStories={() => selectSection("stories")}
onRevoke={revokeRecordingAccess} onAccess={accessRecording} /> onRevoke={revokeRecordingAccess} onAccess={accessRecording} />
) : null} ) : null}
{tab === "models" ? <ContentModelPanel models={contentModels} loading={modelLoading} error={modelError} canManage={canManageModels} actionBusy={actionBusy} onCreate={createContentModel} onTest={testContentModel} onActivate={activateContentModel} onRevoke={revokeContentModel} /> : null}
{tab === "stories" ? <StoryPanel selectedID={selectedSubjectID} {tab === "stories" ? <StoryPanel selectedID={selectedSubjectID}
stories={stories} memories={memories} selected={selectedStory} loading={subjectLoading} actionBusy={actionBusy} canReview={canReviewStory} stories={stories} memories={memories} selected={selectedStory} openRequestID={storyReviewRequestID} loading={subjectLoading} actionBusy={actionBusy} canReview={canReviewStory}
onOpen={openStory} onOpenConversation={openStoryConversation} onRevise={reviseStory} onConfirm={confirmStory} onReject={rejectStory} onUpdateFact={updateFactCandidate} onUpdateMemory={updateMemory} onDeleteMemory={deleteMemory} /> : null} onOpen={openStory} onOpenConversation={openStoryConversation} onRevise={reviseStory} onConfirm={confirmStory} onReject={rejectStory} onUpdateFact={updateFactCandidate} onUpdateMemory={updateMemory} onDeleteMemory={deleteMemory} /> : null}
{tab === "sharing" && canManageSharing ? <SharingPanel subjects={subjects} selectedID={selectedSubjectID} onSelect={setSelectedSubjectID} {tab === "sharing" && canManageSharing ? <SharingPanel subjects={subjects} selectedID={selectedSubjectID} onSelect={setSelectedSubjectID}
members={members} stories={stories} conversations={conversations} media={conversationMedia} grants={sharingGrants} members={members} stories={stories} conversations={conversations} media={conversationMedia} grants={sharingGrants}
@@ -1478,7 +1501,7 @@ export function SaaSWorkspace({ accessToken, accounts, section = "home", onSecti
{tab === "privacy" && !canReadPrivacy ? <StatePanel title="当前角色不能查看隐私请求" detail="家庭查看者没有隐私请求读取权限,请联系家庭所有者或管理员。" /> : null} {tab === "privacy" && !canReadPrivacy ? <StatePanel title="当前角色不能查看隐私请求" detail="家庭查看者没有隐私请求读取权限,请联系家庭所有者或管理员。" /> : null}
{tab === "sharing" && !canManageSharing ? <StatePanel title="当前角色不能管理资源分享" detail="仅家庭所有者或管理员可以创建、撤销故事、文字和录音分享。" /> : null} {tab === "sharing" && !canManageSharing ? <StatePanel title="当前角色不能管理资源分享" detail="仅家庭所有者或管理员可以创建、撤销故事、文字和录音分享。" /> : null}
{tab === "billing" ? <FamilyUsageOverview accessToken={accessToken} household={selectedHousehold} devices={devices.filter((item) => item.household_id === selectedHousehold.id)} /> : null} {tab === "billing" ? <FamilyUsageOverview accessToken={accessToken} household={selectedHousehold} devices={devices.filter((item) => item.household_id === selectedHousehold.id)} /> : null}
{tab === "messages" ? <CustomerMessagePanel notices={eventNotices} devices={devices.filter((item) => item.household_id === selectedHousehold.id)} presence={presence} stories={stories} privacyRequests={privacyRequests} eventState={eventStreamState} /> : null} {tab === "messages" ? <CustomerMessagePanel notices={eventNotices.filter((item) => item.householdID === selectedHousehold.id)} devices={devices.filter((item) => item.household_id === selectedHousehold.id)} presence={presence} stories={stories} privacyRequests={privacyRequests} eventState={eventStreamState} onNavigate={selectSection} onReviewStory={openStoryReminder} onOpenNotice={openEventNotice} /> : null}
</> </>
) : null} ) : null}
</section> </section>
@@ -1557,31 +1580,41 @@ function CustomerHomePanel({ subject, subjects, devices, presence, conversations
</div>; </div>;
} }
function CustomerMessagePanel({ notices, devices, presence, stories, privacyRequests, eventState }: { function CustomerMessagePanel({ notices, devices, presence, stories, privacyRequests, eventState, onNavigate, onReviewStory, onOpenNotice }: {
notices: Array<{ id: string; type: string; resourceType: string; status: string; occurredAt: string }>; notices: WebEventNotice[];
devices: ManagedDevice[]; devices: ManagedDevice[];
presence: Record<string, DevicePresence>; presence: Record<string, DevicePresence>;
stories: Story[]; stories: Story[];
privacyRequests: PrivacyRequest[]; privacyRequests: PrivacyRequest[];
eventState: EventStreamState; eventState: EventStreamState;
onNavigate: (section: CustomerSection) => void;
onReviewStory: (story: Story) => Promise<void>;
onOpenNotice: (notice: WebEventNotice) => Promise<void>;
}) { }) {
const offline = devices.filter((item) => presence[item.id] && !presence[item.id].online); const offline = devices.filter((item) => presence[item.id] && !presence[item.id].online);
const pendingStories = stories.filter((item) => item.status === "CANDIDATE" || item.status === "IN_REVIEW"); const pendingStories = stories.filter((item) => item.status === "CANDIDATE" || item.status === "IN_REVIEW");
const activePrivacy = privacyRequests.filter((item) => !["COMPLETED", "REJECTED", "FAILED"].includes(item.status)); const activePrivacy = privacyRequests.filter((item) => !["COMPLETED", "REJECTED", "FAILED"].includes(item.status));
const derived = [ const derived = [
...offline.map((item) => ({ id: `offline-${item.id}`, title: "设备当前离线", detail: `${item.subject_name} · ${item.model}`, tone: "warning" })), ...pendingStories.map((item) => ({ id: `story-${item.id}`, title: "故事等待家庭确认", detail: item.title, tone: "info", action: "去审核", run: () => onReviewStory(item) })),
...pendingStories.map((item) => ({ id: `story-${item.id}`, title: "故事等待家庭确认", detail: item.title, tone: "info" })), ...offline.map((item) => ({ id: `offline-${item.id}`, title: "设备当前离线", detail: `${item.subject_name} · ${item.model}`, tone: "warning", action: "查看设备", run: () => onNavigate("devices") })),
...activePrivacy.map((item) => ({ id: `privacy-${item.id}`, title: "隐私请求处理中", detail: `${item.request_type} · ${item.status}`, tone: "info" })) ...activePrivacy.map((item) => ({ id: `privacy-${item.id}`, title: "数据请求处理中", detail: `${privacyRequestTypeLabel(item.request_type)} · ${privacyStatusLabel(item.status)}`, tone: "info", action: "查看进度", run: () => onNavigate("privacy") }))
]; ];
return <section className="customer-message-panel"> const now = new Date();
<div className="panel-title"><div><p className="eyebrow">MESSAGE CENTER</p><h3>消息中心</h3><p>这里只展示状态提醒,不在通知中携带转写、录音、故事正文或隐私原因。</p></div><span className={`event-stream-status event-stream-status--${eventState}`}>{eventState === "connected" ? "实时连接" : eventState === "reconnecting" ? "重新连接" : "正在连接"}</span></div> const today = new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", weekday: "short" }).format(now);
{derived.length ? <div className="message-list">{derived.map((item) => <article key={item.id} className={`message-item message-item--${item.tone}`}><span aria-hidden="true" /><div><strong>{item.title}</strong><p>{item.detail}</p></div></article>)}</div> : <p className="workspace__readonly-note">当前没有需要处理的家庭提醒。</p>} const todayNotices = notices.filter((notice) => sameLocalDate(new Date(notice.occurredAt), now));
<div className="message-timeline"><h4>本次登录收到的实时事件</h4>{notices.length ? notices.map((notice) => <article key={notice.id}><time>{formatDate(notice.occurredAt)}</time><div><strong>{eventTypeLabel(notice.type)}</strong><p>{resourceTypeLabel(notice.resourceType)} · {notice.status}</p></div></article>) : <p>暂未收到新的状态事件。服务端事件到达后会自动出现在这里。</p>}</div> return <section className="customer-message-panel reminder-center">
<div className="reminder-center__grid">
<section className="reminder-card reminder-card--today"><header><div><h3>今天的系统动态</h3><p>{today} · 仅显示服务端实际产生的状态事件</p></div><span className={`event-stream-status event-stream-status--${eventState}`}>{eventState === "connected" ? "实时连接" : eventState === "reconnecting" ? "重新连接" : "正在连接"}</span></header>
{todayNotices.length ? todayNotices.slice(0, 8).map((notice) => <button type="button" className="reminder-row" key={notice.id} onClick={() => void onOpenNotice(notice)}><time>{formatDate(notice.occurredAt)}</time><span className="reminder-row__dot" aria-hidden="true" /><div><strong>{eventTypeLabel(notice.type)}</strong><p>{resourceTypeLabel(notice.resourceType)} · {eventStatusLabel(notice.status)}</p></div><small>查看详情 ›</small></button>) : <div className="reminder-empty"><ClockCircleOutlined /><strong>今天还没有新的系统动态</strong><span>完成通话、产生故事、设备变化或隐私请求更新后才会出现。</span></div>}
</section>
<aside className="reminder-card"><header><div><h3>需要家人处理</h3><p>全部来自当前故事、设备和隐私请求状态</p></div><span className="reminder-count">{derived.length}</span></header>{derived.length ? <div className="reminder-todos">{derived.map((item) => <button type="button" key={item.id} className={`reminder-todo reminder-todo--${item.tone}`} onClick={() => void item.run()}><span aria-hidden="true" /><div><strong>{item.title}</strong><p>{item.detail}</p></div><small>{item.action} ›</small></button>)}</div> : <div className="reminder-empty"><CheckCircleOutlined /><strong>当前没有待处理提醒</strong><span>故事、设备和隐私接口均未返回待处理事项。</span></div>}</aside>
</div>
<section className="reminder-card reminder-history"><header><div><h3>当前业务状态</h3><p>数量直接来自业务接口,不使用演示日程或前端占位数据</p></div></header><div className="reminder-history__summary"><button type="button" onClick={() => onNavigate("stories")}><strong>{pendingStories.length}</strong><span>待确认故事</span></button><button type="button" onClick={() => onNavigate("devices")}><strong>{offline.length}</strong><span>离线设备</span></button><button type="button" onClick={() => onNavigate("privacy")}><strong>{activePrivacy.length}</strong><span>处理中数据请求</span></button><div><strong>{todayNotices.length}</strong><span>今天实时事件</span></div></div></section>
</section>; </section>;
} }
function customerSectionTitle(section: Tab) { function customerSectionTitle(section: Tab) {
return ({ home: "陪伴周记", members: "家人共享", subjects: "长者资料", devices: "陪伴设备", firmware: "固件发布", conversations: "回忆时间轴", stories: "故事与记忆", sharing: "内容分享", privacy: "隐私与授权", billing: "服务用量", messages: "日程与提醒", models: "大模型管理" } as Record<Tab, string>)[section]; return ({ home: "陪伴周记", members: "家庭管理", subjects: "长者资料", devices: "陪伴设备", firmware: "固件发布", conversations: "回忆时间轴", stories: "故事与记忆", sharing: "内容分享", privacy: "数据与授权", billing: "服务用量", messages: "提醒与待办" } as Record<Tab, string>)[section];
} }
function customerSectionDescription(section: Tab) { function customerSectionDescription(section: Tab) {
@@ -1596,8 +1629,7 @@ function customerSectionDescription(section: Tab) {
sharing: "按资源和权限范围向指定家庭成员分享内容。", sharing: "按资源和权限范围向指定家庭成员分享内容。",
privacy: "提交访问、导出或删除申请并跟踪处理进度。", privacy: "提交访问、导出或删除申请并跟踪处理进度。",
billing: "汇总本家庭及设备的通话时长,并在核价可用时展示费用。", billing: "汇总本家庭及设备的通话时长,并在核价可用时展示费用。",
messages: "集中查看陪伴动态、设备、故事和隐私请求的状态提醒。", messages: "集中查看服务端记录的陪伴动态,并处理设备、故事和隐私请求待办。"
models: "配置、测试并激活用于聊天摘要、关键词和故事候选生成的大模型。"
} as Record<Tab, string>)[section]; } as Record<Tab, string>)[section];
} }
@@ -1609,6 +1641,18 @@ function resourceTypeLabel(type: string) {
return ({ device: "设备", conversation: "通话", story: "故事", upload_batch: "媒体", export_task: "导出任务", privacy_request: "隐私请求", device_command: "设备指令" } as Record<string, string>)[type] ?? "业务资源"; return ({ device: "设备", conversation: "通话", story: "故事", upload_batch: "媒体", export_task: "导出任务", privacy_request: "隐私请求", device_command: "设备指令" } as Record<string, string>)[type] ?? "业务资源";
} }
function eventStatusLabel(status: string) {
return ({
ACTIVE: "进行中", COMPLETED: "已完成", FAILED: "失败", CANDIDATE: "待审核", IN_REVIEW: "审核中", CONFIRMED: "已确认", REJECTED: "已拒绝",
ONLINE: "在线", OFFLINE: "离线", READY: "已就绪", PENDING: "待处理", PROCESSING: "处理中", SUCCEEDED: "已成功", CANCELLED: "已取消",
CREATED: "已创建", UPDATED: "已更新", UPLOADED: "已上传"
} as Record<string, string>)[status.toUpperCase()] ?? status;
}
function sameLocalDate(left: Date, right: Date) {
return !Number.isNaN(left.getTime()) && left.getFullYear() === right.getFullYear() && left.getMonth() === right.getMonth() && left.getDate() === right.getDate();
}
function MemberPanel({ household, members, invitations, canManage, canTransfer, actionBusy, inviteeHint, onInviteeHint, inviteRole, onInviteRole, issuedToken, onDismissToken, onCreateInvitation, onUpdateRole, onRemove, onRevoke, onTransfer, onAddHousehold, onAcceptInvitation, onRenameHousehold }: { function MemberPanel({ household, members, invitations, canManage, canTransfer, actionBusy, inviteeHint, onInviteeHint, inviteRole, onInviteRole, issuedToken, onDismissToken, onCreateInvitation, onUpdateRole, onRemove, onRevoke, onTransfer, onAddHousehold, onAcceptInvitation, onRenameHousehold }: {
household: Household; households: Household[]; members: HouseholdMember[]; invitations: MemberInvitation[]; canManage: boolean; canTransfer: boolean; actionBusy: boolean; household: Household; households: Household[]; members: HouseholdMember[]; invitations: MemberInvitation[]; canManage: boolean; canTransfer: boolean; actionBusy: boolean;
inviteeHint: string; onInviteeHint: (value: string) => void; inviteRole: "admin" | "editor" | "viewer"; onInviteRole: (value: "admin" | "editor" | "viewer") => void; inviteeHint: string; onInviteeHint: (value: string) => void; inviteRole: "admin" | "editor" | "viewer"; onInviteRole: (value: "admin" | "editor" | "viewer") => void;
@@ -2063,11 +2107,13 @@ function ConversationPanel({ subjects, selectedID, onSelect, conversations, insi
stories: Story[]; onOpenStories: () => void; stories: Story[]; onOpenStories: () => void;
}) { }) {
const [detailOpen, setDetailOpen] = useState(false); const [detailOpen, setDetailOpen] = useState(false);
const [timelineDayState, setTimelineDayState] = useState<Record<string, boolean>>({});
const currentSummary = summaries.find((item) => item.type === "conversation_summary"); const currentSummary = summaries.find((item) => item.type === "conversation_summary");
const currentTopic = summaries.find((item) => item.type === "open_topic"); const currentTopic = summaries.find((item) => item.type === "open_topic");
const summaryParts = currentSummary?.content.split("\n关键词:") ?? []; const summaryParts = currentSummary?.content.split("\n关键词:") ?? [];
const keywords = summaryParts[1]?.split("、").filter(Boolean) ?? []; const keywords = summaryParts[1]?.split("、").filter(Boolean) ?? [];
const storyCandidate = detail ? stories.find((item) => item.conversation_id === detail.id) : undefined; const storyCandidate = detail ? stories.find((item) => item.conversation_id === detail.id) : undefined;
const timelineGroups = groupConversationsByDay(conversations);
return <div className="conversation-layout memory-timeline-page"> return <div className="conversation-layout memory-timeline-page">
<div className="memory-timeline-grid"> <div className="memory-timeline-grid">
<div className="conversation-list"> <div className="conversation-list">
@@ -2079,11 +2125,10 @@ function ConversationPanel({ subjects, selectedID, onSelect, conversations, insi
<label><span>开始日期</span><input disabled={loadingMore} type="date" value={from} max={to || undefined} onChange={(event) => onFrom(event.target.value)} /></label> <label><span>开始日期</span><input disabled={loadingMore} type="date" value={from} max={to || undefined} onChange={(event) => onFrom(event.target.value)} /></label>
<label><span>结束日期</span><input disabled={loadingMore} type="date" value={to} min={from || undefined} onChange={(event) => onTo(event.target.value)} /></label> <label><span>结束日期</span><input disabled={loadingMore} type="date" value={to} min={from || undefined} onChange={(event) => onTo(event.target.value)} /></label>
</div> </div>
<div className="conversation-filter__summary"><span>已加载 {conversations.length} 条{hasMore ? ",还有更多" : ""}</span>{status || engineMode || from || to ? <button type="button" className="button-small button-secondary" onClick={onResetFilters}>清除筛选</button> : null}</div> <div className="conversation-filter__summary"><span>已加载 {conversations.length} 条{hasMore ? ",还有更多" : ""} · 最近 2 个记录日默认展开</span>{status || engineMode || from || to ? <button type="button" className="button-small button-secondary" onClick={onResetFilters}>清除筛选</button> : null}</div>
</div> </div>
{error ? <div className="conversation-list__error" role="alert">{error}</div> : null} {error ? <div className="conversation-list__error" role="alert">{error}</div> : null}
{loading && conversations.length === 0 ? <StatePanel title="正在读取聊天记录" detail="按时间倒序加载…" /> : conversations.length ? <div className="memory-timeline-list">{conversations.map((item) => { const insight = insights[item.id]; return <button type="button" className={selectedConversationID === item.id ? "conversation-row active" : "conversation-row"} key={item.id} disabled={detailLoading && selectedConversationID === item.id} onClick={() => void onOpen(item.id)}><span className="memory-timeline-dot" /><span className={`conversation-row__mood conversation-row__mood--${insight?.mood ?? "steady"}`}><SmileOutlined /></span><div><strong>{formatDate(item.created_at)} · {formatDuration(item.duration_ms)}</strong><span>{insight?.summary || conversationFailureLabel(item.failure_code) || "摘要整理中"}</span>{insight?.topics.length ? <small>{insight.topics.join(" · ")}</small> : null}</div><span className={`tag tag--${item.status.toLowerCase()}`}>{conversationStatusLabel(item.status)}</span></button>; })}</div> : <p className="empty-copy padded">暂无聊天记录。</p>} {loading && conversations.length === 0 ? <StatePanel title="正在读取聊天记录" detail="按时间倒序加载…" /> : conversations.length ? <div className="memory-timeline-scroll"><div className="memory-timeline-list">{timelineGroups.map((group, index) => { const expanded = timelineDayState[group.key] ?? index < 2; return <section className="timeline-day-group" key={group.key}><button type="button" className="timeline-day-group__header" aria-expanded={expanded} aria-controls={`timeline-day-${group.key}`} onClick={() => setTimelineDayState((current) => ({ ...current, [group.key]: !expanded }))}><span><strong>{group.label}</strong><small>{group.weekday}</small></span><span className="timeline-day-group__count">{group.items.length} 次聊天</span><span className={expanded ? "timeline-day-group__chevron is-expanded" : "timeline-day-group__chevron"} aria-hidden="true">⌄</span></button>{expanded ? <div className="timeline-day-group__items" id={`timeline-day-${group.key}`}>{group.items.map((item) => { const insight = insights[item.id]; return <button type="button" className={selectedConversationID === item.id ? "conversation-row active" : "conversation-row"} key={item.id} disabled={detailLoading && selectedConversationID === item.id} onClick={() => void onOpen(item.id)}><span className="memory-timeline-dot" /><span className={`conversation-row__mood conversation-row__mood--${insight?.mood ?? "steady"}`}><SmileOutlined /></span><div><strong>{formatConversationTime(item.created_at)} · {formatDuration(item.duration_ms)}</strong><span>{insight?.summary || conversationFailureLabel(item.failure_code) || "摘要整理中"}</span>{insight?.topics.length ? <small>{insight.topics.join(" · ")}</small> : null}</div><span className={`tag tag--${item.status.toLowerCase()}`}>{conversationStatusLabel(item.status)}</span></button>; })}</div> : null}</section>; })}</div>{hasMore ? <div className="conversation-list__more"><button type="button" className="button-secondary" disabled={loading || loadingMore} onClick={() => void onLoadMore()}>{loadingMore ? "正在加载…" : "加载更多记录"}</button></div> : null}</div> : <p className="empty-copy padded">暂无聊天记录。</p>}
{hasMore ? <div className="conversation-list__more"><button type="button" className="button-secondary" disabled={loading || loadingMore} onClick={() => void onLoadMore()}>{loadingMore ? "正在加载…" : "加载更多记录"}</button></div> : null}
</div> </div>
<section className="memory-summary-panel" aria-label="智能总结"> <section className="memory-summary-panel" aria-label="智能总结">
{detailLoading ? <StatePanel title="正在生成回忆卡片" detail="读取聊天、摘要和故事候选…" /> : detail ? <> {detailLoading ? <StatePanel title="正在生成回忆卡片" detail="读取聊天、摘要和故事候选…" /> : detail ? <>
@@ -2096,7 +2141,7 @@ function ConversationPanel({ subjects, selectedID, onSelect, conversations, insi
<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> <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>} </> : <div className="memory-summary-empty"><BookOutlined /><h3>选择一段聊天</h3><p>这里会展示大模型生成的摘要、关键词、故事线索和后续话题。</p></div>}
</section> </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> <aside className="memory-model-card"><span className="memory-model-card__icon"><RobotOutlined /></span><p>当前总结模型</p><h3>{activeModel?.display_name || "系统默认模型"}</h3><small>{activeModel ? `${contentModelProviderLabel(activeModel.provider)} · ${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> </div>
{detailOpen ? <div className="workspace-dialog-backdrop" role="presentation" onMouseDown={(event) => { if (event.currentTarget === event.target) setDetailOpen(false); }}><section className="conversation-detail workspace-dialog workspace-dialog--wide" role="dialog" aria-modal="true" aria-labelledby="conversation-detail-title"><header><div><h3 id="conversation-detail-title">通话详情</h3><p>查看文字内容、整理结果、语音授权与状态历史</p></div><button type="button" aria-label="关闭" onClick={() => setDetailOpen(false)}><CloseOutlined /></button></header><div className="workspace-dialog__body">{detailLoading ? <StatePanel title="正在读取通话详情" detail="正在核验状态、最终话轮、整理结果、用量和录音访问权限…" /> : detailError && selectedConversationID ? <StatePanel title="通话详情读取失败" detail={detailError} action={() => void onOpen(selectedConversationID)} /> : detail ? <> {detailOpen ? <div className="workspace-dialog-backdrop" role="presentation" onMouseDown={(event) => { if (event.currentTarget === event.target) setDetailOpen(false); }}><section className="conversation-detail workspace-dialog workspace-dialog--wide" role="dialog" aria-modal="true" aria-labelledby="conversation-detail-title"><header><div><h3 id="conversation-detail-title">通话详情</h3><p>查看文字内容、整理结果、语音授权与状态历史</p></div><button type="button" aria-label="关闭" onClick={() => setDetailOpen(false)}><CloseOutlined /></button></header><div className="workspace-dialog__body">{detailLoading ? <StatePanel title="正在读取通话详情" detail="正在核验状态、最终话轮、整理结果、用量和录音访问权限…" /> : detailError && selectedConversationID ? <StatePanel title="通话详情读取失败" detail={detailError} action={() => void onOpen(selectedConversationID)} /> : detail ? <>
<div className="resource-detail__head"><div><p className="eyebrow">CONVERSATION DETAIL</p><h3>{conversationStatusLabel(detail.status)}</h3><p>{formatDate(detail.created_at)} · {detail.duration_ms ? `${Math.round(detail.duration_ms / 1000)}秒` : "时长待确认"}</p></div></div> <div className="resource-detail__head"><div><p className="eyebrow">CONVERSATION DETAIL</p><h3>{conversationStatusLabel(detail.status)}</h3><p>{formatDate(detail.created_at)} · {detail.duration_ms ? `${Math.round(detail.duration_ms / 1000)}秒` : "时长待确认"}</p></div></div>
@@ -2113,7 +2158,7 @@ function ConversationPanel({ subjects, selectedID, onSelect, conversations, insi
</div>; </div>;
} }
function ContentModelPanel({ models, loading, error, canManage, actionBusy, onCreate, onTest, onActivate, onRevoke }: { export function ContentModelPanel({ models, loading, error, canManage, actionBusy, onCreate, onTest, onActivate, onRevoke }: {
models: ContentModel[]; loading: boolean; error?: string; canManage: boolean; actionBusy: boolean; models: ContentModel[]; loading: boolean; error?: string; canManage: boolean; actionBusy: boolean;
onCreate: (input: CreateContentModelRequest) => Promise<boolean>; onTest: (item: ContentModel) => Promise<void>; onCreate: (input: CreateContentModelRequest) => Promise<boolean>; onTest: (item: ContentModel) => Promise<void>;
onActivate: (item: ContentModel) => Promise<void>; onRevoke: (item: ContentModel) => Promise<void>; onActivate: (item: ContentModel) => Promise<void>; onRevoke: (item: ContentModel) => Promise<void>;
@@ -2121,13 +2166,44 @@ function ContentModelPanel({ models, loading, error, canManage, actionBusy, onCr
const [open, setOpen] = useState(false); 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 [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"); 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: "" })); } } const selectedProvider = contentModelProviderPreset(draft.provider);
const providerGroups = [
{ category: "domestic" as const, title: "国内主流云模型", detail: "已预填各厂商官方兼容接口" },
{ category: "local" as const, title: "本地模型", detail: "聊天内容不离开本机" },
{ category: "custom" as const, title: "其他服务", detail: "接入任意 OpenAI 兼容 HTTPS 接口" },
];
function selectProvider(provider: ContentModelProvider) {
const preset = contentModelProviderPreset(provider);
setDraft((item) => ({
...item,
provider,
display_name: `${preset.shortName}聊天总结`,
base_url: preset.baseURL,
model_id: preset.modelID,
api_key: provider === "ollama" ? "ollama-local-key" : "",
}));
}
async function submit(event: FormEvent) {
event.preventDefault();
if (await onCreate(draft)) {
setOpen(false);
setDraft((item) => ({ ...item, api_key: "" }));
}
}
return <div className="model-management-page"> 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> <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} {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>} {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>{contentModelProviderLabel(item.provider)}</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>选择一个厂商,只需填写密钥并确认模型 ID,测试通过后即可激活。</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> <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} {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" aria-labelledby="model-create-title" onSubmit={(event) => void submit(event)}><header><div><h3 id="model-create-title">添加总结大模型</h3><p>先选厂商,再填写该账号对应的密钥和模型信息。</p></div><button type="button" aria-label="关闭" onClick={() => setOpen(false)}><CloseOutlined /></button></header><div className="workspace-dialog__body">
<section className="model-provider-catalog" aria-label="选择模型厂商">{providerGroups.map((group) => <div className="model-provider-group" key={group.category}><div><strong>{group.title}</strong><small>{group.detail}</small></div><div>{contentModelProviderPresets.filter((item) => item.category === group.category).map((item) => <button type="button" aria-pressed={draft.provider === item.id} className={draft.provider === item.id ? "is-selected" : ""} disabled={actionBusy} key={item.id} onClick={() => selectProvider(item.id)}><span>{item.shortName}</span><small>{item.description}</small></button>)}</div></div>)}</section>
<section className="model-provider-selection"><div><strong>{selectedProvider.name}</strong><span>{selectedProvider.description} · OpenAI Chat Completions 兼容协议</span></div>{selectedProvider.docsURL ? <a href={selectedProvider.docsURL} target="_blank" rel="noreferrer">查看官方接入文档</a> : null}</section>
<label><span>配置名称</span><input autoFocus required maxLength={120} value={draft.display_name} onChange={(event) => setDraft({ ...draft, display_name:event.target.value })} placeholder={`例如:${selectedProvider.shortName}聊天总结`} /></label>
<label><span>接口基础地址</span><input required type="url" value={draft.base_url} onChange={(event) => setDraft({ ...draft, base_url:event.target.value })} /><small>已预填官方地址;只有使用专属地域、业务空间或代理网关时才需要修改。</small></label>
<label><span>模型 ID</span><input required value={draft.model_id} onChange={(event) => setDraft({ ...draft, model_id:event.target.value })} placeholder={selectedProvider.modelPlaceholder} /><small>必须与该厂商控制台中显示的模型名称或 Endpoint ID 完全一致。</small></label>
<label><span>{selectedProvider.keyLabel}</span><input required type="password" minLength={8} autoComplete="new-password" value={draft.api_key} onChange={(event) => setDraft({ ...draft, api_key:event.target.value })} placeholder={draft.provider === "ollama" ? "本地服务使用占位值即可" : "只在本次保存时使用,保存后不再回显"} /></label>
<details className="model-advanced-settings"><summary>高级生成参数</summary><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></details>
</div><footer><button type="button" className="button-secondary" onClick={() => setOpen(false)}>取消</button><button type="submit" disabled={actionBusy || !draft.display_name.trim() || !draft.base_url.trim() || !draft.model_id.trim() || draft.api_key.length < 8}>{actionBusy ? "正在加密保存…" : "保存并去测试"}</button></footer></form></div> : null}
</div>; </div>;
} }
@@ -2188,9 +2264,9 @@ function RecordingMediaBlock({ retentionMode, media, state, error, grants, membe
</section>; </section>;
} }
function StoryPanel({ selectedID, stories, memories, selected, loading, actionBusy, canReview, onOpen, onOpenConversation, onRevise, onConfirm, onReject, onUpdateFact, onUpdateMemory, onDeleteMemory }: { function StoryPanel({ selectedID, stories, memories, selected, openRequestID, loading, actionBusy, canReview, onOpen, onOpenConversation, onRevise, onConfirm, onReject, onUpdateFact, onUpdateMemory, onDeleteMemory }: {
selectedID?: string; stories: Story[]; memories: CuratedMemory[]; selectedID?: string; stories: Story[]; memories: CuratedMemory[];
selected?: Story; loading: boolean; actionBusy: boolean; canReview: boolean; onOpen: (id: string) => Promise<void>; selected?: Story; openRequestID?: string; loading: boolean; actionBusy: boolean; canReview: boolean; onOpen: (id: string) => Promise<void>;
onOpenConversation: (story: Story) => Promise<void>; onOpenConversation: (story: Story) => Promise<void>;
onRevise: (story: Story, content: string, reason: string, errorType?: ContentErrorType) => Promise<void>; onRevise: (story: Story, content: string, reason: string, errorType?: ContentErrorType) => Promise<void>;
onConfirm: (story: Story, reason: string) => Promise<void>; onReject: (story: Story, reason: string, errorType?: ContentErrorType) => Promise<void>; onConfirm: (story: Story, reason: string) => Promise<void>; onReject: (story: Story, reason: string, errorType?: ContentErrorType) => Promise<void>;
@@ -2205,6 +2281,12 @@ function StoryPanel({ selectedID, stories, memories, selected, loading, actionBu
const [selectedMemoryID, setSelectedMemoryID] = useState<string>(); const [selectedMemoryID, setSelectedMemoryID] = useState<string>();
const [detailOpen, setDetailOpen] = useState(false); const [detailOpen, setDetailOpen] = useState(false);
useEffect(() => { setContent(selected?.content ?? ""); setReason(""); setErrorType(""); }, [selected]); useEffect(() => { setContent(selected?.content ?? ""); setReason(""); setErrorType(""); }, [selected]);
useEffect(() => {
if (openRequestID && selected?.id === openRequestID) {
setSelectedMemoryID(undefined);
setDetailOpen(true);
}
}, [openRequestID, selected?.id]);
const reviewable = canReview && (selected?.status === "CANDIDATE" || selected?.status === "IN_REVIEW"); const reviewable = canReview && (selected?.status === "CANDIDATE" || selected?.status === "IN_REVIEW");
const visibleMemories = memories.filter((memory) => memory.content.toLocaleLowerCase().includes(memoryQuery.trim().toLocaleLowerCase())); const visibleMemories = memories.filter((memory) => memory.content.toLocaleLowerCase().includes(memoryQuery.trim().toLocaleLowerCase()));
const selectedMemory = memories.find((memory) => memory.id === selectedMemoryID); const selectedMemory = memories.find((memory) => memory.id === selectedMemoryID);
@@ -2212,7 +2294,7 @@ function StoryPanel({ selectedID, stories, memories, selected, loading, actionBu
useEffect(() => { setSelectedMemoryID(undefined); setDetailOpen(false); setMemoryQuery(""); }, [selectedID]); useEffect(() => { setSelectedMemoryID(undefined); setDetailOpen(false); setMemoryQuery(""); }, [selectedID]);
return <div className="story-layout story-board"> return <div className="story-layout story-board">
<section className="story-candidates"><div className="story-board__section-title"><h3>故事候选 <span>{stories.length}</span></h3></div><div className="story-candidate-grid"> <section className="story-candidates"><div className="story-board__section-title"><h3>故事候选 <span>{stories.length}</span></h3></div><div className="story-candidate-grid">
{stories.map((story) => <button type="button" className={!selectedMemory && selected?.id === story.id ? "story-candidate-card active" : "story-candidate-card"} key={story.id} onClick={() => { setSelectedMemoryID(undefined); setDetailOpen(true); void onOpen(story.id); }}><span className="story-candidate-card__status"><HourglassOutlined /> {storyStatusLabel(story.status)}</span><strong>{story.title}</strong><small><TagOutlined /> v{story.revision_no}</small></button>)} {stories.map((story) => <button type="button" className={!selectedMemory && selected?.id === story.id ? "story-candidate-card active" : "story-candidate-card"} key={story.id} onClick={() => { setSelectedMemoryID(undefined); setDetailOpen(true); void onOpen(story.id); }}><span className="story-candidate-card__status"><HourglassOutlined /> {storyStatusLabel(story.status)}</span><strong>{story.title}</strong><span className="story-candidate-card__footer"><small><TagOutlined /> v{story.revision_no}</small><b>开始审核</b></span></button>)}
{!stories.length ? <div className="story-board-empty story-board-empty--compact"><span><BookOutlined /></span><h3>暂无故事候选</h3><p>通话整理出的候选会显示在这里,等待家庭成员审核。</p></div> : null} {!stories.length ? <div className="story-board-empty story-board-empty--compact"><span><BookOutlined /></span><h3>暂无故事候选</h3><p>通话整理出的候选会显示在这里,等待家庭成员审核。</p></div> : null}
</div></section> </div></section>
<section className="confirmed-memories"><div className="story-board__section-title story-board__section-title--bordered"><h3>已确认记忆 <small>{visibleMemories.length}/{memories.length}</small></h3></div><label className="memory-search-field"><SearchOutlined /><input value={memoryQuery} maxLength={200} onChange={(event) => setMemoryQuery(event.target.value)} placeholder="搜索当前长者的记忆" /></label> <section className="confirmed-memories"><div className="story-board__section-title story-board__section-title--bordered"><h3>已确认记忆 <small>{visibleMemories.length}/{memories.length}</small></h3></div><label className="memory-search-field"><SearchOutlined /><input value={memoryQuery} maxLength={200} onChange={(event) => setMemoryQuery(event.target.value)} placeholder="搜索当前长者的记忆" /></label>
@@ -2407,7 +2489,7 @@ function PrivacyPanel({ subjects, selectedID, onSelect, requests, exports, conse
{deleteConfirmation && deleteConfirmation.trim() !== selectedSubject.preferred_name ? <p className="privacy-delete-confirmation__error" role="alert">称呼必须与“{selectedSubject.preferred_name}”完全一致。</p> : null} {deleteConfirmation && deleteConfirmation.trim() !== selectedSubject.preferred_name ? <p className="privacy-delete-confirmation__error" role="alert">称呼必须与“{selectedSubject.preferred_name}”完全一致。</p> : null}
<div className="privacy-delete-confirmation__actions"><button type="button" className="button-secondary" disabled={actionBusy} onClick={() => { setDeleteOpen(false); setDeleteReason(""); setDeleteConfirmation(""); }}>取消</button><button type="submit" className="button-danger" disabled={actionBusy || !deleteReason.trim() || deleteConfirmation.trim() !== selectedSubject.preferred_name}>{actionBusy ? "正在执行删除对账…" : "确认永久删除"}</button></div> <div className="privacy-delete-confirmation__actions"><button type="button" className="button-secondary" disabled={actionBusy} onClick={() => { setDeleteOpen(false); setDeleteReason(""); setDeleteConfirmation(""); }}>取消</button><button type="submit" className="button-danger" disabled={actionBusy || !deleteReason.trim() || deleteConfirmation.trim() !== selectedSubject.preferred_name}>{actionBusy ? "正在执行删除对账…" : "确认永久删除"}</button></div>
</form></div> : null} </form></div> : null}
{correctionOpen && selectedSubject ? <div className="workspace-dialog-backdrop" role="presentation"><form className="workspace-dialog" aria-label="资料更正申请" onSubmit={(event) => void submitCorrection(event)}><header><div><h3>提交资料更正</h3><p>更正会生成新的档案版本,不改写历史通话快照。</p></div><button type="button" aria-label="关闭" onClick={() => setCorrectionOpen(false)}><CloseOutlined /></button></header><div className="workspace-dialog__body"><label><span>称呼</span><input autoFocus required maxLength={120} value={correctionName} onChange={(event) => setCorrectionName(event.target.value)} /></label><label><span>语言</span><input required minLength={2} maxLength={20} value={correctionLanguage} onChange={(event) => setCorrectionLanguage(event.target.value)} /></label><label><span>时区</span><input required maxLength={100} value={correctionTimezone} onChange={(event) => setCorrectionTimezone(event.target.value)} placeholder="Asia/Shanghai" /></label><p className="workspace-dialog__notice">隐私请求和审计只记录更正字段名称,不复制档案正文。</p></div><footer><button type="button" className="button-secondary" onClick={() => setCorrectionOpen(false)}>取消</button><button type="submit" disabled={actionBusy || !correctionName.trim() || !correctionLanguage.trim() || !correctionTimezone.trim()}>{actionBusy ? "正在更正…" : "确认更正"}</button></footer></form></div> : null} {correctionOpen && selectedSubject ? <div className="workspace-dialog-backdrop" role="presentation"><form className="workspace-dialog" role="dialog" aria-modal="true" aria-labelledby="privacy-correction-title" onSubmit={(event) => void submitCorrection(event)}><header><div><h3 id="privacy-correction-title">提交资料更正</h3><p>更正会生成新的档案版本,不改写历史通话快照。</p></div><button type="button" aria-label="关闭" onClick={() => setCorrectionOpen(false)}><CloseOutlined /></button></header><div className="workspace-dialog__body"><label><span>称呼</span><input autoFocus required maxLength={120} value={correctionName} onChange={(event) => setCorrectionName(event.target.value)} /></label><label><span>语言</span><input required minLength={2} maxLength={20} value={correctionLanguage} onChange={(event) => setCorrectionLanguage(event.target.value)} /></label><label><span>时区</span><input required maxLength={100} value={correctionTimezone} onChange={(event) => setCorrectionTimezone(event.target.value)} placeholder="Asia/Shanghai" /></label><p className="workspace-dialog__notice">隐私请求和审计只记录更正字段名称,不复制档案正文。</p></div><footer><button type="button" className="button-secondary" onClick={() => setCorrectionOpen(false)}>取消</button><button type="submit" disabled={actionBusy || !correctionName.trim() || !correctionLanguage.trim() || !correctionTimezone.trim()}>{actionBusy ? "正在更正…" : "确认更正"}</button></footer></form></div> : null}
{withdrawalOpen ? <div className="workspace-dialog-backdrop" role="presentation"><form className="workspace-dialog" aria-label="批量撤回授权" onSubmit={(event) => void submitWithdrawal(event)}><header><div><h3>批量撤回授权</h3><p>撤回立即生效,依赖这些授权的活动通话会安全停止。</p></div><button type="button" aria-label="关闭" onClick={() => setWithdrawalOpen(false)}><CloseOutlined /></button></header><div className="workspace-dialog__body"><fieldset className="privacy-consent-selection"><legend>选择要撤回的当前有效授权</legend>{effectiveConsentTypes.map((type) => <label key={type}><input type="checkbox" checked={withdrawalTypes.includes(type)} onChange={(event) => setWithdrawalTypes(event.target.checked ? [...withdrawalTypes, type] : withdrawalTypes.filter((item) => item !== type))} /><span>{consentTypeLabel(type)}</span></label>)}</fieldset><p className="workspace-dialog__notice">个性化记忆授权撤回后,已有记忆会停用并清除可召回向量。</p></div><footer><button type="button" className="button-secondary" onClick={() => setWithdrawalOpen(false)}>取消</button><button type="submit" className="button-danger" disabled={actionBusy || withdrawalTypes.length === 0}>{actionBusy ? "正在撤回…" : `确认撤回 ${withdrawalTypes.length} 项`}</button></footer></form></div> : null} {withdrawalOpen ? <div className="workspace-dialog-backdrop" role="presentation"><form className="workspace-dialog" aria-label="批量撤回授权" onSubmit={(event) => void submitWithdrawal(event)}><header><div><h3>批量撤回授权</h3><p>撤回立即生效,依赖这些授权的活动通话会安全停止。</p></div><button type="button" aria-label="关闭" onClick={() => setWithdrawalOpen(false)}><CloseOutlined /></button></header><div className="workspace-dialog__body"><fieldset className="privacy-consent-selection"><legend>选择要撤回的当前有效授权</legend>{effectiveConsentTypes.map((type) => <label key={type}><input type="checkbox" checked={withdrawalTypes.includes(type)} onChange={(event) => setWithdrawalTypes(event.target.checked ? [...withdrawalTypes, type] : withdrawalTypes.filter((item) => item !== type))} /><span>{consentTypeLabel(type)}</span></label>)}</fieldset><p className="workspace-dialog__notice">个性化记忆授权撤回后,已有记忆会停用并清除可召回向量。</p></div><footer><button type="button" className="button-secondary" onClick={() => setWithdrawalOpen(false)}>取消</button><button type="submit" className="button-danger" disabled={actionBusy || withdrawalTypes.length === 0}>{actionBusy ? "正在撤回…" : `确认撤回 ${withdrawalTypes.length} 项`}</button></footer></form></div> : null}
{loading && !requests.length ? <StatePanel title="正在读取隐私请求" detail="同步后台子任务和盘点结果…" /> : requests.length ? <div className="privacy-grid">{requests.map((request) => { {loading && !requests.length ? <StatePanel title="正在读取隐私请求" detail="同步后台子任务和盘点结果…" /> : requests.length ? <div className="privacy-grid">{requests.map((request) => {
const counts = (request.result_summary.inventory_counts ?? {}) as Record<string, number>; const counts = (request.result_summary.inventory_counts ?? {}) as Record<string, number>;
@@ -2616,6 +2698,31 @@ function subjectProfileChangeSummary(subject: Subject, input: UpdateSubjectReque
if (JSON.stringify(nextAvoidedTopics) !== JSON.stringify(currentAvoidedTopics)) changes.push(`避免主动提及“${contextItemsLabel(currentAvoidedTopics)}”→“${contextItemsLabel(nextAvoidedTopics)}”`); if (JSON.stringify(nextAvoidedTopics) !== JSON.stringify(currentAvoidedTopics)) changes.push(`避免主动提及“${contextItemsLabel(currentAvoidedTopics)}”→“${contextItemsLabel(nextAvoidedTopics)}”`);
return changes.length ? changes : ["无有效变化"]; return changes.length ? changes : ["无有效变化"];
} }
function groupConversationsByDay(conversations: ConversationSummary[]) {
const groups = new Map<string, { key: string; label: string; weekday: string; items: ConversationSummary[] }>();
const today = localDateKey(new Date());
const yesterdayDate = new Date();
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
const yesterday = localDateKey(yesterdayDate);
for (const conversation of conversations) {
const date = new Date(conversation.created_at);
const key = localDateKey(date);
let group = groups.get(key);
if (!group) {
group = {
key,
label: key === today ? "今天" : key === yesterday ? "昨天" : new Intl.DateTimeFormat("zh-CN", { month: "long", day: "numeric" }).format(date),
weekday: new Intl.DateTimeFormat("zh-CN", { weekday: "short" }).format(date),
items: []
};
groups.set(key, group);
}
group.items.push(conversation);
}
return [...groups.values()];
}
function formatConversationTime(value: string) { return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false }).format(new Date(value)); }
function formatDate(value: string) { return new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }).format(new Date(value)); } function formatDate(value: string) { return new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }).format(new Date(value)); }
function statusLabel(value: string) { return ({ active: "启用", suspended: "暂停", deleting: "删除处理中" } as Record<string, string>)[value] ?? value; } function statusLabel(value: string) { return ({ active: "启用", suspended: "暂停", deleting: "删除处理中" } as Record<string, string>)[value] ?? value; }
function factTypeLabel(value: string) { return ({ family_note: "家庭信息", preference: "个人偏好", life_event: "人生事件", health_context: "健康背景" } as Record<string, string>)[value] ?? value; } function factTypeLabel(value: string) { return ({ family_note: "家庭信息", preference: "个人偏好", life_event: "人生事件", health_context: "健康背景" } as Record<string, string>)[value] ?? value; }
+73 -7
View File
@@ -30,7 +30,7 @@ button {
} }
button:hover { transform: translateY(-1px); } button:hover { transform: translateY(-1px); }
button:disabled { cursor: wait; opacity: 0.65; } button:disabled { cursor: not-allowed; opacity: 0.65; }
.button-secondary { color: #244356; background: #e5ece9; } .button-secondary { color: #244356; background: #e5ece9; }
main { main {
@@ -45,12 +45,22 @@ main {
.memory-timeline-page { display: block !important; min-height: 0 !important; border: 0 !important; overflow: visible !important; background: transparent !important; } .memory-timeline-page { display: block !important; min-height: 0 !important; border: 0 !important; overflow: visible !important; background: transparent !important; }
.memory-timeline-grid { display: grid; grid-template-columns: minmax(280px, .78fr) minmax(420px, 1.28fr) minmax(220px, .62fr); gap: 16px; align-items: start; } .memory-timeline-grid { display: grid; grid-template-columns: minmax(280px, .78fr) minmax(420px, 1.28fr) minmax(220px, .62fr); gap: 16px; align-items: start; }
.memory-timeline-grid > * { min-width: 0; border: 1px solid #eadeda; border-radius: 18px; background: #fffdfb; box-shadow: 0 10px 30px rgba(91, 64, 55, .055); } .memory-timeline-grid > * { min-width: 0; border: 1px solid #eadeda; border-radius: 18px; background: #fffdfb; box-shadow: 0 10px 30px rgba(91, 64, 55, .055); }
.memory-timeline-grid .conversation-list { overflow: hidden; } .memory-timeline-grid .conversation-list { display: flex; max-height: min(720px, calc(100dvh - 210px)); overflow: hidden; flex-direction: column; }
.memory-timeline-grid .conversation-filter { border-bottom: 1px solid #eee4e1; background: #faf6f2; } .memory-timeline-grid .conversation-filter { border-bottom: 1px solid #eee4e1; background: #faf6f2; }
.memory-timeline-grid .conversation-filter__fields { grid-template-columns: 1fr 1fr; } .memory-timeline-grid .conversation-filter__fields { grid-template-columns: 1fr 1fr; }
.memory-timeline-grid .conversation-filter__fields label:nth-child(n+3) { display: none; } .memory-timeline-grid .conversation-filter__fields label:nth-child(n+3) { display: none; }
.memory-timeline-list { position: relative; padding: 8px 0 12px; } .memory-timeline-scroll { min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; }
.memory-timeline-list::before { position: absolute; top: 15px; bottom: 22px; left: 25px; width: 2px; content: ""; background: #dfcbc4; } .memory-timeline-list { padding: 8px 0 12px; }
.timeline-day-group + .timeline-day-group { border-top: 1px solid #eee5e1; }
.timeline-day-group__header { display: grid; grid-template-columns: minmax(0, 1fr) auto 18px; align-items: center; gap: 8px; width: 100%; padding: 11px 14px; border: 0; border-radius: 0; color: #66514a; background: #fffaf7; box-shadow: none; text-align: left; }
.timeline-day-group__header:hover { color: #793b2c; background: #f8eeea; transform: none; box-shadow: none; }
.timeline-day-group__header > span:first-child { display: flex; align-items: baseline; gap: 7px; min-width: 0; }
.timeline-day-group__header strong { color: #553f39; font-size: 13px; }
.timeline-day-group__header small, .timeline-day-group__count { color: #9a8179; font-size: 11px; font-weight: 500; }
.timeline-day-group__chevron { color: #a27164; font-size: 18px; line-height: 1; transform: rotate(-90deg); transition: transform .18s ease; }
.timeline-day-group__chevron.is-expanded { transform: rotate(0deg); }
.timeline-day-group__items { position: relative; padding: 2px 0 8px; }
.timeline-day-group__items::before { position: absolute; top: 9px; bottom: 17px; left: 25px; width: 2px; content: ""; background: #dfcbc4; }
.memory-timeline-list .conversation-row { position: relative; grid-template-columns: 16px 36px 1fr; margin: 0 8px; width: calc(100% - 16px); border: 0; border-radius: 13px; background: transparent; } .memory-timeline-list .conversation-row { position: relative; grid-template-columns: 16px 36px 1fr; margin: 0 8px; width: calc(100% - 16px); border: 0; border-radius: 13px; background: transparent; }
.memory-timeline-list .conversation-row.active { background: #f7ebe6; box-shadow: inset 3px 0 #a73920; } .memory-timeline-list .conversation-row.active { background: #f7ebe6; box-shadow: inset 3px 0 #a73920; }
.memory-timeline-list .conversation-row > .tag { grid-column: 3; justify-self: start; } .memory-timeline-list .conversation-row > .tag { grid-column: 3; justify-self: start; }
@@ -111,7 +121,29 @@ main {
.model-security-note > .anticon { margin-top: 3px; color: #5e8a6b; font-size: 20px; } .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-security-note p { margin: 5px 0 0; line-height: 1.65; }
.model-parameter-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } .model-parameter-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.model-create-dialog .workspace-dialog__body { display: grid; gap: 14px; } .model-create-dialog { width: min(900px, calc(100vw - 32px)); max-height: calc(100dvh - 32px); }
.model-create-dialog .workspace-dialog__body { display: grid; gap: 16px; overflow-y: auto; }
.model-create-dialog label > small { display: block; margin-top: 5px; color: #917f78; font-size: 11px; line-height: 1.5; }
.model-provider-catalog { display: grid; gap: 13px; padding: 15px; border: 1px solid #eadfda; border-radius: 14px; background: #fbf8f5; }
.model-provider-group { display: grid; grid-template-columns: 142px minmax(0, 1fr); gap: 12px; align-items: start; }
.model-provider-group > div:first-child { padding-top: 8px; }
.model-provider-group > div:first-child strong, .model-provider-group > div:first-child small { display: block; }
.model-provider-group > div:first-child strong { color: #4f413c; font-size: 13px; }
.model-provider-group > div:first-child small { margin-top: 3px; color: #998680; font-size: 10px; line-height: 1.45; }
.model-provider-group > div:last-child { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
.model-provider-group button { min-height: 64px; padding: 9px 11px; border: 1px solid #e6d9d4; border-radius: 11px; color: #594a45; background: #fff; text-align: left; box-shadow: none; }
.model-provider-group button:hover { border-color: #9baeb7; background: #f7fafb; }
.model-provider-group button.is-selected { border-color: #617e8b; color: #344e5a; background: #edf4f6; box-shadow: inset 0 0 0 1px #617e8b; }
.model-provider-group button span, .model-provider-group button small { display: block; }
.model-provider-group button span { font-size: 13px; font-weight: 700; }
.model-provider-group button small { margin-top: 3px; overflow: hidden; color: #93817a; font-size: 10px; font-weight: 400; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; }
.model-provider-selection { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 12px 14px; border-radius: 12px; color: #53636a; background: #edf3f4; }
.model-provider-selection strong, .model-provider-selection span { display: block; }
.model-provider-selection span { margin-top: 2px; color: #78888e; font-size: 11px; }
.model-provider-selection a { color: #506f7c; font-size: 12px; white-space: nowrap; }
.model-advanced-settings { padding: 12px 14px; border: 1px solid #e9dfdb; border-radius: 12px; background: #fffdfb; }
.model-advanced-settings summary { color: #695752; cursor: pointer; font-size: 13px; font-weight: 650; }
.model-advanced-settings[open] summary { margin-bottom: 13px; }
/* 非首页业务页统一为轻量、长者优先的内容画布。 */ /* 非首页业务页统一为轻量、长者优先的内容画布。 */
.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 { border-color: #e9deda; border-radius: 18px; background: rgba(255, 253, 251, .96); box-shadow: 0 14px 38px rgba(78, 59, 52, .055); }
@@ -131,6 +163,9 @@ main {
.memory-summary-panel > header, .model-active-hero, .model-config-card footer { align-items: stretch; flex-direction: column; } .memory-summary-panel > header, .model-active-hero, .model-config-card footer { align-items: stretch; flex-direction: column; }
.memory-model-card { grid-column: auto; } .memory-model-card { grid-column: auto; }
.model-parameter-row { grid-template-columns: 1fr; } .model-parameter-row { grid-template-columns: 1fr; }
.model-provider-group { grid-template-columns: 1fr; }
.model-provider-group > div:last-child { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.model-provider-selection { align-items: flex-start; flex-direction: column; }
} }
.hero { .hero {
@@ -3235,7 +3270,8 @@ textarea:focus,
.usage-density-tabs button { min-height: 30px !important; padding: 0 7px !important; border-radius: 7px !important; color: #96969c !important; background: transparent !important; font-size: 12px; font-weight: 550; } .usage-density-tabs button { min-height: 30px !important; padding: 0 7px !important; border-radius: 7px !important; color: #96969c !important; background: transparent !important; font-size: 12px; font-weight: 550; }
.usage-density-tabs button.active { color: #25252a !important; background: #f1f1f3 !important; } .usage-density-tabs button.active { color: #25252a !important; background: #f1f1f3 !important; }
.usage-heatmap-scroll { overflow-x: auto; padding: 2px 2px 10px; scrollbar-width: thin; } .usage-heatmap-scroll { overflow-x: auto; padding: 2px 2px 10px; scrollbar-width: thin; }
.usage-months { display: flex; justify-content: space-between; min-width: 790px; margin: 0 0 9px 30px; color: #919197; font-size: 10px; } .usage-months { display: grid; grid-auto-rows: 14px; gap: 4px; width: max-content; margin: 0 0 9px 30px; color: #919197; font-size: 10px; }
.usage-months span { overflow: visible; white-space: nowrap; }
.usage-heatmap-wrap { display: flex; align-items: flex-start; gap: 10px; min-width: 820px; } .usage-heatmap-wrap { display: flex; align-items: flex-start; gap: 10px; min-width: 820px; }
.usage-weekdays { display: grid; grid-template-rows: repeat(3,18px); gap: 8px; width: 20px; padding-top: 7px; color: #aaaab0; font-size: 9px; } .usage-weekdays { display: grid; grid-template-rows: repeat(3,18px); gap: 8px; width: 20px; padding-top: 7px; color: #aaaab0; font-size: 9px; }
.usage-heatmap { display: grid; grid-auto-flow: column; grid-auto-columns: 11px; grid-template-rows: repeat(7,11px); gap: 4px; width: max-content; min-height: 101px; } .usage-heatmap { display: grid; grid-auto-flow: column; grid-auto-columns: 11px; grid-template-rows: repeat(7,11px); gap: 4px; width: max-content; min-height: 101px; }
@@ -3480,7 +3516,7 @@ textarea:focus,
.customer-layout button:not(.ant-btn) { border-radius: 8px !important; } .customer-layout button:not(.ant-btn) { border-radius: 8px !important; }
.customer-layout button:disabled, .customer-layout button:disabled,
.customer-layout [role="button"][aria-disabled="true"] { cursor: wait !important; opacity: .5; transform: none !important; } .customer-layout [role="button"][aria-disabled="true"] { cursor: not-allowed !important; opacity: .5; transform: none !important; }
.subject-avatar-upload { position: relative; display: grid; grid-row: span 2; place-items: center; align-content: center; gap: 7px; min-height: 132px; overflow: hidden; border: 1px dashed #c9c0b5; border-radius: 12px; color: var(--journal-blue); background: #fbf8f3; cursor: pointer; } .subject-avatar-upload { position: relative; display: grid; grid-row: span 2; place-items: center; align-content: center; gap: 7px; min-height: 132px; overflow: hidden; border: 1px dashed #c9c0b5; border-radius: 12px; color: var(--journal-blue); background: #fbf8f3; cursor: pointer; }
.subject-avatar-upload input,.subject-avatar-editor input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; } .subject-avatar-upload input,.subject-avatar-editor input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.subject-avatar-upload > .anticon { font-size: 27px; } .subject-avatar-upload > .anticon { font-size: 27px; }
@@ -3556,3 +3592,33 @@ textarea:focus,
.customer-page--conversations .memory-timeline-grid { grid-template-columns: 1fr; } .customer-page--conversations .memory-timeline-grid { grid-template-columns: 1fr; }
.customer-page--conversations .memory-model-card { grid-column: auto; } .customer-page--conversations .memory-model-card { grid-column: auto; }
} }
/* Keep family-facing interactive cards in the warm, calm customer palette. */
.customer-page .timeline-day-group__header:not(.ant-btn) { border: 0 !important; border-radius: 0 !important; color: #66514a !important; background: #fffaf7 !important; box-shadow: none !important; }
.customer-page .timeline-day-group__header:not(.ant-btn):hover { color: #793b2c !important; background: #f8eeea !important; }
:is(.customer-page, .maintenance-workspace) .model-provider-group button:not(.ant-btn) { border: 1px solid #e6d9d4 !important; color: #594a45 !important; background: #fff !important; box-shadow: none !important; }
:is(.customer-page, .maintenance-workspace) .model-provider-group button:not(.ant-btn):hover { border-color: #cfa79c !important; color: #6e3f34 !important; background: #fff8f5 !important; }
:is(.customer-page, .maintenance-workspace) .model-provider-group button.is-selected:not(.ant-btn) { border-color: #78919a !important; color: #344e5a !important; background: #edf4f6 !important; box-shadow: inset 0 0 0 1px #78919a !important; }
.ops-content-model-workspace { display: grid; gap: 16px; }
.customer-page .reminder-todos { display: grid; overflow: hidden; margin: 0 18px 18px; border: 1px solid #eee2de; border-radius: 12px; background: #fff; }
.customer-page .reminder-todo:not(.ant-btn) { display: grid; grid-template-columns: 8px minmax(0, 1fr) auto; align-items: center; gap: 10px; min-height: 70px; padding: 13px 16px !important; border: 0 !important; border-bottom: 1px solid #f0e7e4 !important; border-radius: 0 !important; color: #554641 !important; background: #fff !important; box-shadow: none !important; text-align: left; }
.customer-page .reminder-todo:last-child { border-bottom: 0 !important; }
.customer-page .reminder-todo:hover:not(.ant-btn) { color: #554641 !important; background: #fff8f5 !important; }
.customer-page .reminder-todo > span { width: 7px; height: 7px; border-radius: 50%; background: #6f91a1; }
.customer-page .reminder-todo--warm > span { background: #d27755; }
.customer-page .reminder-todo strong, .customer-page .reminder-todo p { margin: 0; }
.customer-page .reminder-todo strong { color: #4e3e39; font-size: 13px; }
.customer-page .reminder-todo p { margin-top: 4px; color: #8b7770; font-size: 11px; font-weight: 400; line-height: 1.45; }
.customer-page .reminder-todo small { color: #8e4939; font-size: 11px; white-space: nowrap; }
.customer-page .reminder-history__summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); overflow: hidden; margin: 0 18px 18px; border: 1px solid #eee2de; border-radius: 12px; background: #fff; }
.customer-page .reminder-history__summary > button:not(.ant-btn), .customer-page .reminder-history__summary > div { display: grid; gap: 5px; min-height: 84px; padding: 17px 18px !important; border: 0 !important; border-right: 1px solid #f0e7e4 !important; border-radius: 0 !important; color: #554641 !important; background: #fff !important; box-shadow: none !important; text-align: left; }
.customer-page .reminder-history__summary > *:last-child { border-right: 0 !important; }
.customer-page .reminder-history__summary > button:hover:not(.ant-btn) { color: #554641 !important; background: #fff8f5 !important; }
.customer-page .reminder-history__summary strong { color: #65473e; font-size: 23px; }
.customer-page .reminder-history__summary span { color: #8b7770; font-size: 11px; font-weight: 500; }
@media (max-width: 760px) {
.memory-timeline-grid .conversation-list { max-height: min(640px, 68dvh); }
.customer-page .reminder-history__summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.customer-page .reminder-history__summary > *:nth-child(2) { border-right: 0 !important; }
.customer-page .reminder-history__summary > *:nth-child(-n+2) { border-bottom: 1px solid #f0e7e4 !important; }
}