diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fc84c03 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,22 @@ +* text=auto + +*.js text eol=lf +*.jsx text eol=lf +*.ts text eol=lf +*.tsx text eol=lf +*.json text eol=lf +*.md text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.css text eol=lf +*.html text eol=lf +*.svg text eol=lf + +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.webp binary +*.ico binary +*.pdf binary +*.docx binary diff --git a/.gitignore b/.gitignore index 486f128..c729d9c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,54 @@ -node_modules -dist -dist-ssr +# Dependencies and build output +node_modules/ +**/node_modules/ +dist/ +**/dist/ +dist-ssr/ +coverage/ +.vite/ + +# Local configuration and secrets .env -server/.env +.env.* +!.env.example *.local -.env.local -.env.*.local +*.pem +*.key +id_rsa* +id_ed25519* + +# Runtime data +.smoke-data/ +**/.smoke-data/ +data/ +uploads/ +storage/ +*.db +*.sqlite +*.sqlite3 +*.dump +*.sql + +# Test and diagnostic output +playwright-report/ +test-results/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.smoke-*.log + +# Archives and local backups +*.zip +*.7z +*.tar +*.tar.gz +backups/ + +# Editors and OS metadata .DS_Store -.idea +Thumbs.db +.idea/ .vscode/* !.vscode/extensions.json -npm-debug.log* +.claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..25fc7a7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,45 @@ +# 版本迭代记录 + +本项目遵循语义化版本。早期版本由经过脱敏的本地源码快照恢复,因此使用带日期的 `snapshot-*` 标签;自 `v1.0.0` 起按正式发布流程维护。 + +## [1.0.0] - 2026-07-30 + +### 新增 + +- Telegram、普通 WhatsApp 和企业邮箱统一消息中心。 +- 多账号绑定、默认账号切换、渠道状态和账号级操作。 +- WebSocket 实时消息、全站通知、已读上报和历史游标分页。 +- 图片、文档、语音、贴纸、相册等媒体链路。 +- 上下文感知的多语种双向翻译、逐句意图识别及独立开关。 +- 每会话上下文记忆、自动压缩、最近消息优先和历史低优先级补齐。 +- 情绪、风险、客户画像、雷达图、知识库、智能回复与翻译备注。 +- WhatsApp 消息状态、撤回/编辑事件、输入状态、群组、联系人、代理、设备、失败重试、死信重放及备份接口。 +- 团队、RBAC、API Key、审计、商机、任务、分析和支付基础能力。 +- 全站蓝色品牌设计系统和 Telegram 风格消息界面。 + +### 修复 + +- 修复会话点击后错误置顶、删除群组后残留、消息列表空值崩溃。 +- 修复语言切换、意图开关 JSON 写入、智能回复采纳与关闭。 +- 修复头像、用户名、国家信息和渠道文案混用。 +- 修复历史消息、实时会话列表、WhatsApp 媒体下载和状态同步问题。 + +## [snapshot-2026.07.30-pre-ui] - 2026-07-30 + +- 多账号与 WhatsApp 接入后的界面改造前快照。 +- 已具备主要业务模块,保留后续上下文翻译与界面统一前的状态。 + +## [snapshot-2026.07.28-pre-multiaccount] - 2026-07-28 + +- Telegram 消息闭环和主要 SaaS 页面快照。 +- 多账号、WhatsApp 网关与后续企业级能力尚未合入。 + +## [snapshot-2026.07.27-platform] - 2026-07-27 + +- React 前端、Node.js API、PostgreSQL 与 Telegram Gateway 集成版本。 +- 建立客户、消息、AI、团队与设置等平台基础。 + +## [snapshot-2026.07.25-initial] - 2026-07-25 + +- 初始 Web 产品原型与页面结构。 +- 作为历史参考保留,不建议部署。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1b9f794 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,38 @@ +# 协作与提交规范 + +## 分支 + +- `main`:始终保持可构建、可部署。 +- `feature/`:新功能。 +- `fix/`:缺陷修复。 +- `docs/`:仅文档。 + +禁止直接覆盖历史标签。合并前应同步 `main` 并完成与变更风险相匹配的测试。 + +## 提交信息 + +使用 Conventional Commits: + +```text +feat(inbox): add message intent toggle +fix(whatsapp): reconcile delivery status +docs(deploy): document reverse proxy setup +``` + +常用类型:`feat`、`fix`、`refactor`、`test`、`docs`、`chore`、`perf`、`security`。 + +## 变更检查 + +1. 不提交密钥、账号登录态、数据库、客户媒体和本地日志。 +2. 更新相关接口类型、错误文案和文档。 +3. 前端至少运行 `npm run typecheck` 和 `npm run build`。 +4. 后端改动运行 `server` 测试;WhatsApp 改动运行网关测试。 +5. 用户路径变更补充或更新 Playwright E2E。 +6. 数据结构变更必须可重复执行,并说明备份与回滚步骤。 + +## 发布 + +1. 更新 `CHANGELOG.md` 和版本号。 +2. 完成全量测试和 Docker 健康检查。 +3. 创建带说明的版本标签:`git tag -a vX.Y.Z -m "..."`。 +4. 推送分支和标签,随后按部署文档发布。 diff --git a/README.md b/README.md index 0879fb5..5d70626 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,79 @@ # 灵犀跨境 -灵犀跨境是面向跨境业务团队的 Telegram 客户沟通与 AI 经营平台。当前版本只开放 Telegram,所有开放功能均访问真实 API、PostgreSQL、DeepSeek 和 Telegram Gateway,不包含前端 Mock 数据。 +灵犀跨境是一套面向跨境业务团队的多渠道客户经营 SaaS。系统将 Telegram、普通 WhatsApp 账号和企业邮箱接入统一消息中心,并提供上下文翻译、逐句意图识别、情绪与风险分析、客户画像、商机、任务、知识库和经营分析能力。 -## 服务组成 +当前仓库包含 Web 前端、业务 API 和 WhatsApp Gateway;Telegram 通过独立部署的 Telegram Gateway 接入。开放功能均连接真实数据库和真实网关,不使用前端 Mock 作为生产数据源。 -- React + TypeScript 前端,由 Nginx 提供静态资源和 API 反向代理。 -- Node.js API,负责独立租户、用户认证、CRM、会话、AI 和网关适配。 -- PostgreSQL,持久化租户隔离的业务数据。 -- 已有 Telegram Gateway,负责真实 Telegram 账号与 TDLib 操作。 -- DeepSeek Anthropic-compatible API,负责翻译、情感、意图、风险、摘要和回复建议。 +## 核心能力 -## Docker 启动 +- 多租户注册、登录、RBAC、API Key、密码修改和会话撤销。 +- 多 Telegram、WhatsApp、企业邮箱账号绑定、切换和状态管理。 +- 实时会话、历史分页、已读、引用、媒体、消息状态和 WebSocket 推送。 +- 自动识别目标语言的双向翻译,逐会话上下文压缩与历史后台补齐。 +- 情感、意图、风险、摘要、智能回复和知识库辅助。 +- 客户、标签、画像、商机、任务、团队、通知、审计和经营分析。 +- WhatsApp 群组、联系人、代理、设备、重试、死信重放与备份接口。 -1. 复制 `.env.example` 为 `.env`。 -2. 生成数据库、JWT、Webhook 随机密钥。 -3. 设置轮换后的 `DEEPSEEK_API_KEY`。 -4. 设置 Telegram Gateway 的租户令牌、租户 ID 和账号 ID。 -5. 启动: +## 快速启动 + +要求:Docker Engine 24+、Docker Compose v2,以及一个可访问的 Telegram Gateway。 ```powershell +Copy-Item .env.example .env +# 编辑 .env,替换所有 replace-with-* 配置 docker compose --env-file .env up -d --build +docker compose ps ``` -默认访问地址:`http://127.0.0.1:8088`。 - -健康检查: +默认入口为 `http://127.0.0.1:8088`。存活与就绪检查: ```powershell Invoke-RestMethod http://127.0.0.1:8088/health -Invoke-RestMethod http://127.0.0.1:8088/api/dashboard/overview +docker compose exec api node -e "fetch('http://127.0.0.1:3100/health/ready').then(async r=>{console.log(await r.text());process.exit(r.ok?0:1)})" ``` -业务 API 需要通过注册或登录取得 Bearer Token。浏览器永远不会收到 DeepSeek 或 Telegram Gateway 服务凭据。 +完整部署步骤、生产加固和回滚方法见 [部署文档](docs/DEPLOYMENT.md)。 -## 开发检查 +## 本地开发与测试 ```powershell -npm install +npm ci npm run typecheck npm run build -Set-Location server -npm install -node --check src/index.js +Push-Location server +npm ci +npm test +Pop-Location + +Push-Location whatsapp-gateway +npm ci +npm test +Pop-Location + +npm run test:e2e ``` -## 安全说明 +E2E 默认访问 `http://127.0.0.1:8088`,可通过 `E2E_BASE_URL` 修改。 -- `.env` 已加入 `.gitignore`,严禁提交真实密钥。 -- AI 回复只提供人工确认建议,不会自动向 Telegram 发送。 -- 所有业务资源都通过 JWT 中的租户 ID 隔离。 -- Webhook 必须配置独立随机密钥;生产部署应进一步使用 HTTPS、反向代理和 IP 白名单。 +## 文档 + +- [系统架构](docs/ARCHITECTURE.md) +- [技术选型](docs/TECH-STACK.md) +- [配置说明](docs/CONFIGURATION.md) +- [部署与回滚](docs/DEPLOYMENT.md) +- [接口目录](docs/API.md) +- [测试策略](docs/TESTING.md) +- [运维手册](docs/OPERATIONS.md) +- [备份恢复](docs/BACKUP-RESTORE.md) +- [版本历史](CHANGELOG.md) +- [协作规范](CONTRIBUTING.md) +- [安全策略](SECURITY.md) + +## 版本 + +`main` 保存当前版本。历史源码以 `snapshot-YYYY.MM.DD-*` 标签归档,当前稳定版本使用语义化标签。任何历史标签都可通过 `git checkout ` 独立检出。 + +## 安全 + +禁止提交 `.env`、数据库导出、私钥、Telegram/WhatsApp 登录态或客户媒体。浏览器不接触 DeepSeek 与网关服务凭据。发现安全问题请按 [SECURITY.md](SECURITY.md) 中的私下通报流程处理。 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..02ca22f --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,28 @@ +# 安全策略 + +## 私下报告 + +安全漏洞、凭据泄漏或租户越权问题不要提交公开 Issue。请通过公司内部安全联系人或仓库维护者私下报告,并附影响范围、复现条件和建议缓解方式。 + +## 敏感数据 + +以下内容不得进入 Git: + +- `.env` 与任何真实 API Key、JWT、Webhook、SMTP、数据库密码。 +- SSH 私钥、证书私钥和个人访问令牌。 +- Telegram TDLib、WhatsApp Baileys 登录态及关联设备凭据。 +- PostgreSQL 导出、客户消息、联系人、媒体与生产日志。 + +若密钥曾出现在聊天、日志、压缩包或提交中,应立即轮换;仅从提交中删除并不能消除泄漏风险。 + +## 上线基线 + +- 所有公网入口启用 HTTPS、HSTS 和可信反向代理。 +- 每个环境使用不同的强随机密钥;Webhook 密钥互不复用。 +- PostgreSQL 与网关不直接暴露公网。 +- 管理操作执行 RBAC、租户归属校验、审计和必要的二次确认。 +- 定期验证备份可恢复,并为登录、Webhook、消息发送和 AI 请求设置限流与告警。 + +## 支持范围 + +安全修复优先发布到当前 `main` 和最新稳定版本。历史 `snapshot-*` 仅用于审计与回溯,不提供安全维护。 diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..b43225a --- /dev/null +++ b/docs/API.md @@ -0,0 +1,81 @@ +# API 目录 + +API 统一位于 `/api`,内部渠道回调位于 `/internal/webhooks`。除注册、登录、健康检查和签名 Webhook 外,业务接口均要求 `Authorization: Bearer `。 + +成功响应采用统一数据包装;错误响应包含可展示消息和 HTTP 状态。调用方必须处理 `401`、`403`、`404`、`409`、`429` 和 `5xx`。 + +## 认证与安全 + +- `POST /api/auth/register`、`POST /api/auth/login` +- `GET /api/auth/me` +- `POST /api/auth/ws-ticket`:生成短期 WebSocket 票据 +- `POST /api/auth/change-password` +- `POST /api/auth/logout-all` +- `GET|POST|DELETE /api/api-keys` + +## 消息与会话 + +- `GET /api/conversations` +- `GET /api/conversations/:id` +- `GET /api/conversations/:id/messages/page`:游标分页 +- `POST /api/conversations/:id/messages`:发送文本/回复 +- `POST /api/conversations/:id/media` +- `POST /api/conversations/:id/album` +- `POST /api/conversations/:id/read` +- `POST /api/conversations/:id/presence` +- `POST /api/conversations/:id/messages/action` +- `PATCH /api/conversations/:id/messages/:messageId` +- `GET /api/conversations/:id/messages/:messageId/attachment` +- `POST /api/conversations/:id/actions`:归档、静音、清空、删除等会话动作 + +## 渠道 + +- `GET /api/channels` +- Telegram:`/api/channels/telegram/start` 以及 `phone`、`code`、`password`、`state` +- WhatsApp:`/api/channels/whatsapp/start`、状态、联系人、代理、设备、资料、指标和备份 +- 邮箱:`POST /api/channels/email/connect` +- 通用:设为默认、状态、日志、删除和渠道测试 +- 可靠投递:Webhook 投递记录/重放、出站任务/重试 + +## WhatsApp 群组 + +- `GET /api/conversations/:id/whatsapp/group` +- `POST /api/conversations/:id/whatsapp/group/action` + +动作由服务端白名单控制,包括成员、管理员、名称、描述和邀请链接管理。 + +## AI + +- `POST /api/ai/translate` +- `POST /api/ai/analyze` +- `POST /api/ai/replies` +- `POST /api/ai/summarize` +- `POST /api/ai/text-action` +- `POST /api/ai/adoptions` +- `GET|PATCH /api/ai/translations` +- `GET /api/conversations/:id/ai-status` +- `POST /api/conversations/:id/messages/:messageId/analyze` + +AI 接口是业务增强能力。模型超时或限流不应阻断原始消息收发。 + +## 客户经营与管理 + +- 客户:列表、详情、导入、导出、查重、合并和基础 CRUD。 +- 商机、任务等资源:统一 CRUD 路由。 +- 团队:成员、邀请、角色、操作日志和登录记录。 +- 设置、通知、知识库、计费、仪表盘、分析与全局搜索。 + +## Webhook + +- `POST /internal/webhooks/telegram/:tenantId/:channelId` +- `POST /internal/webhooks/whatsapp/:tenantId/:channelId` + +生产调用必须携带对应签名。处理器同时校验 URL 中租户/渠道与数据库归属,事件应携带稳定 ID 以实现幂等。 + +## 健康检查 + +- `GET /health/live`:进程存活。 +- `GET /health/ready`:依赖就绪。 +- Nginx 暴露 `/health` 供容器健康检查。 + +接口的请求/响应类型以 `src/services/`、`src/types/` 和 `server/src/index.js` 为当前实现准则。正式对外开放 API 前,应从路由生成 OpenAPI 3 文档并进行契约测试。 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..a304818 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,79 @@ +# 系统架构 + +## 1. 架构目标 + +灵犀跨境以“租户隔离、渠道可扩展、消息实时、AI 异步化”为核心。浏览器只访问统一 Web/API 入口;Telegram、WhatsApp、邮箱和模型凭据只保存在服务端环境与加密存储中。 + +```text +Browser + │ HTTPS / WebSocket + ▼ +Nginx (web:80) + ├── React SPA + └── /api, /internal, /health ──► Node.js API (:3100) + ├── PostgreSQL 16 + ├── DeepSeek Anthropic API + ├── Telegram Gateway / TDLib + ├── WhatsApp Gateway / Baileys + ├── IMAP + SMTP + └── Stripe +``` + +## 2. 仓库结构 + +| 路径 | 职责 | +| --- | --- | +| `src/` | React 页面、组件、状态、API 客户端、国际化与类型 | +| `server/src/index.js` | 认证、租户业务、消息、AI、渠道、Webhook、WebSocket 与数据初始化 | +| `whatsapp-gateway/` | 普通 WhatsApp 多账号连接、消息/媒体/群组/联系人操作 | +| `tests/e2e/` | Playwright 桌面与移动端验收 | +| `docker/` | Nginx 与邮件协议测试辅助镜像 | +| `docs/` | 架构、部署、接口、测试与运维文档 | + +## 3. 核心数据流 + +### 入站消息 + +1. Telegram/WhatsApp 网关向带租户和渠道标识的内部 Webhook 投递事件;邮箱轮询器从 IMAP 拉取邮件。 +2. API 校验签名、渠道归属与事件幂等标识。 +3. 消息、会话、联系人和状态写入 PostgreSQL。 +4. API 向当前租户的 WebSocket 客户端广播增量事件。 +5. 文本消息进入 AI 高优先级队列;较早历史进入低优先级补齐队列。 +6. 翻译、意图、情感、风险和会话记忆完成后再次广播更新。 + +### 出站消息 + +1. 前端提交渠道、会话、正文、媒体、回复目标和客户端幂等标识。 +2. API 验证 RBAC、租户归属、渠道状态与限流。 +3. 先落库发送任务,再调用对应网关或 SMTP。 +4. 网关状态事件持续对账已发送、已送达、已读和失败状态。 +5. 可恢复错误进入持久化重试;超过阈值进入死信等待人工重放。 + +### 上下文 AI + +- 最近消息优先处理,以保证实时聊天速度。 +- 每个会话维护独立上下文;超过长度阈值后压缩为结构化记忆。 +- 历史消息按游标后台补齐,避免首次打开阻塞。 +- 翻译目标可自动识别,也可由坐席指定;智能回复保持人工确认发送。 + +## 4. 隔离与权限 + +- JWT 含用户、租户和角色信息;业务查询必须带 `tenant_id`。 +- 渠道、会话、消息、客户、任务与管理资源均在服务端校验租户归属。 +- WebSocket 使用短期票据,不把长期 JWT 放入 URL。 +- API Key 仅在创建时返回明文,持久化摘要并支持撤销。 +- 邮箱密码使用 `EMAIL_CREDENTIAL_KEY` 加密;网关登录态位于独立持久卷。 + +## 5. 可靠性边界 + +- PostgreSQL 是平台业务数据的事实来源。 +- Telegram/WhatsApp 平台状态由异步事件最终一致对账。 +- WebSocket 只用于加速展示;断线后客户端必须通过 REST 游标补齐。 +- Webhook 接收快速确认,耗时处理异步化;失败投递可查询和重放。 +- AI 失败不能阻断原始消息收发,任务可独立重试。 + +## 6. 当前约束 + +- Telegram Gateway 为独立仓库和独立部署,本仓库只保存适配与配置。 +- 普通 WhatsApp 接入依赖非官方 Web 多设备协议,生产使用前必须评估账号风控和平台条款。 +- API 目前集中在单入口文件,业务增长后应按领域拆分路由、服务、仓储和迁移文件。 diff --git a/docs/BACKUP-RESTORE.md b/docs/BACKUP-RESTORE.md new file mode 100644 index 0000000..cb56454 --- /dev/null +++ b/docs/BACKUP-RESTORE.md @@ -0,0 +1,60 @@ +# 备份与恢复 + +## 备份范围 + +1. PostgreSQL:租户、用户、客户、消息、任务、审计和持久队列。 +2. `lingxi_whatsapp_data`:WhatsApp 多设备登录态与网关缓存。 +3. Telegram Gateway:由其独立部署执行 TDLib 数据和配置备份。 +4. 环境密钥:存入公司 Secret 管理系统,不与数据备份打包。 +5. 对象存储或上传目录:若生产启用,按独立生命周期备份。 + +## PostgreSQL + +```bash +docker compose exec -T postgres pg_dump \ + -U "${LX_DB_USER:-lingxi}" \ + -d "${LX_DB_NAME:-lingxi_crossborder}" \ + -Fc > "lingxi-postgres-$(date +%Y%m%d-%H%M%S).dump" +``` + +恢复必须在隔离环境先演练: + +```bash +createdb lingxi_restore_test +pg_restore --clean --if-exists --no-owner \ + --dbname lingxi_restore_test lingxi-postgres-YYYYMMDD-HHMMSS.dump +``` + +## WhatsApp 持久卷 + +备份前短暂停止网关写入,避免文件不一致: + +```bash +docker compose stop whatsapp-gateway +docker run --rm \ + -v lingxi-crossborder_lingxi_whatsapp_data:/source:ro \ + -v "$PWD/backups:/backup" \ + alpine tar -czf /backup/whatsapp-data.tar.gz -C /source . +docker compose start whatsapp-gateway +``` + +恢复前保留现有卷副本,在测试环境验证账号能重连;登录态过期时应走重新授权,不能把旧凭据强行用于其他账号。 + +## 策略 + +- PostgreSQL:每日全量,生产规模扩大后增加连续归档或托管 PITR。 +- 网关登录态:每日加密备份,账号变更后额外备份。 +- 至少保留 7 个日备、4 个周备、6 个月备,按法规和客户合同调整。 +- 备份使用独立凭据、服务端加密和异地存储。 + +## 恢复演练 + +每月至少一次: + +1. 在隔离网络创建全新环境。 +2. 恢复数据库、网关数据和必要配置。 +3. 验证租户数、关键表行数和最近消息时间。 +4. 验证登录、会话读取、渠道重连、收发和媒体下载。 +5. 记录 RPO、RTO、失败点和改进项。 + +仅“备份成功”不等于可恢复;没有完成验收的备份不得视为合格。 diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md new file mode 100644 index 0000000..8c20e70 --- /dev/null +++ b/docs/CONFIGURATION.md @@ -0,0 +1,63 @@ +# 配置说明 + +以 `.env.example` 为唯一模板。开发、测试、生产分别保存自己的 `.env`,不得提交 Git。 + +## 基础配置 + +| 变量 | 必填 | 说明 | +| --- | --- | --- | +| `LX_DB_USER` | 否 | PostgreSQL 用户,默认 `lingxi` | +| `LX_DB_PASSWORD` | 是 | 数据库强密码 | +| `LX_DB_NAME` | 否 | 数据库名,默认 `lingxi_crossborder` | +| `LX_JWT_SECRET` | 是 | 至少 32 字节随机值 | +| `LX_WEB_PORT` | 否 | Web 暴露端口,默认 `8088` | +| `APP_PUBLIC_URL` | 是 | 用户访问的 HTTPS 根地址 | +| `PUBLIC_WEBHOOK_BASE_URL` | 是 | 外部网关可访问的 Webhook 根地址 | + +## AI + +| 变量 | 必填 | 说明 | +| --- | --- | --- | +| `DEEPSEEK_API_KEY` | 是 | 服务端模型密钥 | +| `DEEPSEEK_MODEL` | 是 | 当前默认 `deepseek-v4-flash` | + +API 基础地址在 Compose 中设置为 `https://api.deepseek.com/anthropic`。切换模型前应验证 Anthropic 兼容接口、JSON 输出稳定性、延迟和成本。 + +## Telegram + +| 变量 | 说明 | +| --- | --- | +| `TELEGRAM_GATEWAY_URL` | 独立 Telegram Gateway API 地址 | +| `TELEGRAM_GATEWAY_TOKEN` | 网关服务令牌 | +| `TELEGRAM_GATEWAY_TENANT_ID` | 网关租户 | +| `TELEGRAM_GATEWAY_SERVICE_EMAIL` / `PASSWORD` | 服务账号 | +| `TELEGRAM_GATEWAY_ACCOUNT_ID` | 可选默认账号 | +| `TELEGRAM_WEBHOOK_SECRET` | Telegram Webhook 独立签名密钥 | + +## WhatsApp + +| 变量 | 说明 | +| --- | --- | +| `WHATSAPP_GATEWAY_TOKEN` | API 与网关间认证,至少 32 位 | +| `WHATSAPP_WEBHOOK_SECRET` | Webhook 签名密钥,不能与 Token 相同 | +| `WHATSAPP_LOG_LEVEL` | `info`、`warn` 或 `error` | + +WhatsApp 登录态保存在 `lingxi_whatsapp_data` 卷,不得复制到源码目录或提交 Git。 + +## 邮箱与支付 + +- 团队邀请:`SMTP_HOST`、`SMTP_PORT`、`SMTP_SECURE`、`SMTP_USER`、`SMTP_PASSWORD`、`SMTP_FROM`。 +- 企业邮箱渠道使用用户提供的 IMAP/SMTP 信息;`EMAIL_CREDENTIAL_KEY` 用于加密凭据。 +- Stripe:`STRIPE_SECRET_KEY`、`STRIPE_WEBHOOK_SECRET` 和各套餐 `STRIPE_PRICE_*`。 + +## 生成随机密钥 + +PowerShell 示例: + +```powershell +$bytes = New-Object byte[] 48 +[Security.Cryptography.RandomNumberGenerator]::Fill($bytes) +[Convert]::ToBase64String($bytes) +``` + +每个变量单独生成,不复用。生产密钥应放入部署平台的 Secret 管理,不写入 Compose、镜像或 CI 日志。 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..3cc11e5 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,73 @@ +# 部署与回滚 + +## 1. 前置条件 + +- Linux x86_64 主机,建议 4 核、8 GB 内存、50 GB 可扩展磁盘。 +- Docker Engine 24+、Compose v2。 +- 域名、HTTPS 证书和可访问的 Telegram Gateway。 +- 出站可访问 Telegram、WhatsApp、DeepSeek、邮箱服务和 Stripe。 + +## 2. 首次部署 + +```bash +git clone ssh://git@git.ohkeji.cn:23/wmq/lingxi-web.git +cd lingxi-web +cp .env.example .env +# 编辑 .env 并替换全部占位值 +docker compose --env-file .env build +docker compose --env-file .env up -d +docker compose ps +``` + +检查: + +```bash +curl -fsS http://127.0.0.1:8088/health +docker compose exec api node -e "fetch('http://127.0.0.1:3100/health/ready').then(async r=>{console.log(await r.text());process.exit(r.ok?0:1)})" +docker compose logs --tail=100 api whatsapp-gateway web +``` + +## 3. 生产入口 + +在 Compose 外层部署 Caddy、Nginx 或云负载均衡: + +- 只对公网开放 80/443。 +- 将 HTTPS 请求转发到 `127.0.0.1:8088`。 +- 保留 `Host`、`X-Forwarded-For`、`X-Forwarded-Proto`。 +- WebSocket 路径启用 Upgrade/Connection 头并提高空闲超时。 +- PostgreSQL、API、WhatsApp Gateway 不映射公网端口。 + +将 `APP_PUBLIC_URL` 和 `PUBLIC_WEBHOOK_BASE_URL` 改为正式 HTTPS 地址,随后重建 API。 + +## 4. 更新 + +```bash +git fetch --tags +git checkout main +git pull --ff-only +docker compose --env-file .env build +docker compose --env-file .env up -d +docker compose ps +``` + +更新前先执行数据库和 WhatsApp 登录态备份。更新后验证登录、渠道状态、收发、WebSocket、AI、媒体和健康检查。 + +## 5. 回滚 + +1. 记录当前版本:`git describe --tags --always`。 +2. 恢复与目标版本兼容的数据库备份。 +3. 检出稳定标签,例如 `git checkout v1.0.0`。 +4. 重新构建并启动:`docker compose up -d --build`。 +5. 执行健康和关键业务检查。 + +`snapshot-*` 是历史源码归档,不保证数据库向后兼容,不应直接作为生产回滚点。 + +## 6. 零停机演进 + +当前 Compose 是单实例,会有短暂重启窗口。需要零停机时应: + +- 将 PostgreSQL 迁移到托管或高可用实例。 +- 将 Web/API 部署为至少两个实例并使用外部负载均衡。 +- WebSocket 使用共享事件总线或粘性会话。 +- 将持久任务改用共享队列并确保幂等。 +- 数据库变更采用“先兼容、再切换、后清理”的多阶段迁移。 diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md new file mode 100644 index 0000000..da2a8d9 --- /dev/null +++ b/docs/OPERATIONS.md @@ -0,0 +1,58 @@ +# 运维手册 + +## 日常检查 + +```powershell +docker compose ps +docker compose logs --since=30m api +docker compose logs --since=30m whatsapp-gateway +Invoke-RestMethod http://127.0.0.1:8088/health +docker compose exec api node -e "fetch('http://127.0.0.1:3100/health/ready').then(async r=>{console.log(await r.text());process.exit(r.ok?0:1)})" +``` + +重点关注:API 错误率、Webhook 延迟与死信数、出站队列深度、WebSocket 在线数、AI 延迟/限流、数据库连接与磁盘、渠道断线数。 + +## 常见故障 + +### 页面“加载失败” + +1. 检查 `web` 与 `api` 健康状态。 +2. 检查浏览器请求的 HTTP 状态和 API 日志。 +3. `401` 重新登录;`403` 检查角色;`5xx` 检查数据库和外部依赖。 +4. 不要通过清库或删除持久卷解决普通错误。 + +### 渠道显示已连接但无消息 + +1. 验证渠道最近心跳、Webhook 状态和投递日志。 +2. 检查 Webhook 地址是否能从网关容器访问。 +3. 检查签名密钥是否一致、渠道 ID 是否属于当前租户。 +4. 触发安全的状态对账;不要反复解绑账号。 + +### WebSocket 不实时 + +1. 确认反向代理允许 Upgrade。 +2. 检查短期票据是否过期以及客户端是否自动重连。 +3. 断线期间通过 REST 游标补齐,不依赖丢失的内存事件。 + +### AI 变慢或失败 + +1. 查看模型响应状态、超时和限流。 +2. 确认高优先级实时队列未被历史补齐占满。 +3. 暂停历史补齐而不是暂停消息接收。 +4. 原始消息应照常展示,待模型恢复后重试增强任务。 + +## 变更与事件处理 + +- 配置、密钥、数据库和网关升级都先备份并记录操作人。 +- 重大故障先止损:暂停高风险发送、保留日志、不要破坏现场。 +- 恢复后补做根因、影响租户、时间线、数据一致性和预防措施记录。 + +## 告警建议 + +- 健康检查连续 3 次失败。 +- 5 分钟 API 5xx 比例超过 2%。 +- Webhook 最老未处理任务超过 60 秒或死信新增。 +- WhatsApp/Telegram 渠道离线超过 2 分钟。 +- PostgreSQL 磁盘超过 75%、连接超过 80%。 +- AI P95 超过目标或 `429` 持续出现。 +- 备份任务失败或超过 24 小时未成功。 diff --git a/docs/TECH-STACK.md b/docs/TECH-STACK.md new file mode 100644 index 0000000..36b8e21 --- /dev/null +++ b/docs/TECH-STACK.md @@ -0,0 +1,56 @@ +# 技术选型 + +## Web + +| 技术 | 版本范围 | 选择原因 | +| --- | --- | --- | +| React | 18.3 | 组件生态成熟,适合高交互消息工作台 | +| TypeScript | 5.7 | 为接口、消息事件和页面状态提供静态约束 | +| Vite | 5.4 | 本地启动和生产构建快速 | +| React Router | 7.x | 页面路由和错误边界 | +| TanStack Query | 5.x | 服务端状态缓存、刷新和失败重试 | +| Zustand | 4.x | 轻量跨组件客户端状态 | +| Tailwind CSS | 3.4 | 设计令牌与响应式界面 | +| Recharts | 2.15 | 仪表盘趋势和客户画像雷达图 | +| Zod + React Hook Form | 3.x / 7.x | 表单校验与可维护的输入状态 | +| Lucide React | 0.462 | 一致的线性图标系统 | + +## API 与数据 + +| 技术 | 作用 | +| --- | --- | +| Node.js 20 | 前后端统一运行时,适合 I/O 密集的消息网关 | +| Express 5 | REST、Webhook 与中间件 | +| PostgreSQL 16 | 多租户业务数据、队列和审计的持久化 | +| `pg` | 直接、透明的 SQL 访问 | +| `ws` | 实时消息与状态广播 | +| JWT + bcryptjs | 身份令牌与密码哈希 | +| Helmet + CORS | 基础 HTTP 安全头和来源约束 | +| IMAPFlow + Nodemailer | 企业邮箱收取与发送 | +| Mailparser | MIME 邮件解析 | +| Mammoth + pdf-parse | DOCX/PDF 知识文件抽取 | + +## 渠道与 AI + +| 组件 | 作用 | +| --- | --- | +| Telegram Gateway + TDLib | 普通 Telegram 账号登录、同步和操作 | +| Baileys 7 | WhatsApp 多设备连接、消息和群组能力 | +| DeepSeek Anthropic-compatible API | 翻译、意图、情感、风险、摘要和回复建议 | +| Stripe | 套餐结算与 Webhook 对账 | + +## 交付与质量 + +| 技术 | 作用 | +| --- | --- | +| Docker Compose | 本地、测试和单机生产编排 | +| Nginx | 静态站点、SPA fallback、API/WebSocket 反向代理 | +| Node Test Runner | API 与网关单元/协议测试 | +| Playwright | Chromium 桌面与移动端视觉/交互 E2E | + +## 取舍 + +- 当前采用模块化单体 API,部署简单、事务边界清晰;代码量继续增长时应优先拆文件而不是立即拆微服务。 +- PostgreSQL 同时承载业务和持久任务,减少早期基础设施;高吞吐阶段可引入 Redis 与专用队列。 +- WhatsApp 普通账号接入覆盖面高,但稳定性和合规性弱于官方 Business Platform,应为企业客户预留官方适配器。 +- AI 采用异步增强而非消息主链路依赖,模型故障时仍可正常沟通。 diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..6b4257e --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,45 @@ +# 测试策略 + +## 测试层次 + +| 层次 | 命令 | 覆盖 | +| --- | --- | --- | +| 类型检查 | `npm run typecheck` | 前端 TypeScript | +| 生产构建 | `npm run build` | 类型、打包、资源引用 | +| API 单元/业务 | `npm test --prefix server` | 业务函数、权限与数据逻辑 | +| 邮件协议 | `npm run test:email-protocol --prefix server` | IMAP/SMTP 协议 | +| 邮件 API E2E | `npm run test:email-api --prefix server` | 真实测试邮件服务链路 | +| WhatsApp 网关 | `npm test --prefix whatsapp-gateway` | 路由、状态、消息与可靠性 | +| 浏览器 E2E | `npm run test:e2e` | 桌面/移动端主要用户路径 | + +## 提交前最低要求 + +- 文档或纯样式:类型检查、构建、相关页面 E2E。 +- 前端业务:类型检查、构建、完整 Playwright。 +- API:语法检查、API 测试、相关 E2E。 +- 网关:网关测试、容器健康、真实测试账号冒烟。 +- 认证、租户、支付、备份:完整回归并人工复核安全边界。 + +## 真实链路验收 + +Mock 测试不能证明渠道真实可用。发布前使用无重要数据的测试账号逐项验证: + +1. Telegram 手机号、验证码、二步密码登录。 +2. WhatsApp QR/配对、断开、重连和多账号切换。 +3. 邮箱 IMAP 首次同步、SMTP 发信和附件。 +4. 双向文本、引用、图片、文档、语音、贴纸、相册。 +5. 已发送、送达、已读、编辑、撤回、输入状态。 +6. 历史分页、断网恢复、WebSocket 重连和增量补齐。 +7. 多语言翻译、意图开关、会话记忆压缩和 AI 失败降级。 +8. Webhook 失败重试、死信、人工重放和幂等。 +9. RBAC 越权请求、租户交叉资源 ID 和登录限流。 + +## 测试数据 + +- 只使用专用测试租户和测试账号。 +- 不把手机号、验证码、会话凭据、邮件密码和生产消息写入测试代码或报告。 +- Playwright 失败产物可能包含页面数据;归档前脱敏,且不得提交 Git。 + +## 发布记录 + +每次版本标签应记录执行日期、提交 SHA、环境、测试命令、通过/跳过/失败数量和已知限制。跳过项必须说明原因,不能等同于通过。 diff --git a/index.html b/index.html index f5b1639..dda4067 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,8 @@ + + 灵犀跨境 · 跨境AI经营服务平台 diff --git a/playwright.config.ts b/playwright.config.ts index 7709cb1..50604b0 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -5,6 +5,9 @@ export default defineConfig({ timeout: 30_000, expect: { timeout: 10_000 }, fullyParallel: false, + // E2E projects share one real tenant and gateway; serialize mutations to avoid + // desktop/mobile runs racing on conversation ordering and channel state. + workers: 1, retries: 0, reporter: [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]], use: { diff --git a/public/brand-logo-concept.png b/public/brand-logo-concept.png new file mode 100644 index 0000000..b69de22 Binary files /dev/null and b/public/brand-logo-concept.png differ diff --git a/public/brand-mark.png b/public/brand-mark.png new file mode 100644 index 0000000..96c32de Binary files /dev/null and b/public/brand-mark.png differ diff --git a/public/brand-mark.svg b/public/brand-mark.svg new file mode 100644 index 0000000..4456437 --- /dev/null +++ b/public/brand-mark.svg @@ -0,0 +1,4 @@ + + 灵犀跨境 + + diff --git a/public/brand-symbol.svg b/public/brand-symbol.svg new file mode 100644 index 0000000..7549d64 --- /dev/null +++ b/public/brand-symbol.svg @@ -0,0 +1,4 @@ + + 灵犀跨境品牌符号 + + diff --git a/server/package-lock.json b/server/package-lock.json index 3bb11bf..a32a3df 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -14,6 +14,7 @@ "helmet": "^8.1.0", "imapflow": "^1.6.1", "jsonwebtoken": "^9.0.2", + "libphonenumber-js": "^1.11.20", "mailparser": "^3.9.14", "mammoth": "^1.9.0", "nodemailer": "^7.0.5", @@ -891,6 +892,12 @@ "libqp": "2.1.1" } }, + "node_modules/libphonenumber-js": { + "version": "1.11.20", + "resolved": "https://registry.npmmirror.com/libphonenumber-js/-/libphonenumber-js-1.11.20.tgz", + "integrity": "sha512-/ipwAMvtSZRdiQBHqW1qxqeYiBMzncOQLVA+62MWYr7N4m7Q2jqpJ0WgT7zlOEOpyLRSqrMXidbJpC0J77AaKA==", + "license": "MIT" + }, "node_modules/libqp": { "version": "2.1.1", "resolved": "https://registry.npmmirror.com/libqp/-/libqp-2.1.1.tgz", diff --git a/server/package.json b/server/package.json index 5007c95..00e04c9 100644 --- a/server/package.json +++ b/server/package.json @@ -16,6 +16,7 @@ "helmet": "^8.1.0", "imapflow": "^1.6.1", "jsonwebtoken": "^9.0.2", + "libphonenumber-js": "^1.11.20", "mailparser": "^3.9.14", "mammoth": "^1.9.0", "nodemailer": "^7.0.5", diff --git a/server/src/ai.js b/server/src/ai.js index 8a48ef2..2a11d44 100644 --- a/server/src/ai.js +++ b/server/src/ai.js @@ -7,7 +7,11 @@ function parseJson(text) { return JSON.parse(cleaned) } -export async function deepseek(system, user, json = false) { +export async function deepseek(system, user, jsonOrOptions = false) { + const options = typeof jsonOrOptions === 'boolean' + ? { json: jsonOrOptions } + : { ...(jsonOrOptions || {}) } + const json = Boolean(options.json) const apiKey = process.env.DEEPSEEK_API_KEY if (!apiKey) throw Object.assign(new Error('DEEPSEEK_API_KEY 尚未配置'), { status: 503 }) const endpoint = `${(process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com/anthropic').replace(/\/$/, '')}/v1/messages` @@ -17,7 +21,10 @@ export async function deepseek(system, user, json = false) { for (let attempt = 0; attempt < attempts; attempt += 1) { const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), Number(process.env.AI_TIMEOUT_MS || 45000)) + const timeout = setTimeout( + () => controller.abort(), + Number(options.timeoutMs || process.env.AI_TIMEOUT_MS || 30000), + ) try { const response = await fetch(endpoint, { method: 'POST', @@ -29,8 +36,11 @@ export async function deepseek(system, user, json = false) { }, body: JSON.stringify({ model: process.env.DEEPSEEK_MODEL || 'deepseek-v4-flash', - max_tokens: Number(process.env.AI_MAX_TOKENS || 4096), - temperature: json ? 0 : 0.3, + max_tokens: Number(options.maxTokens || process.env.AI_MAX_TOKENS || 4096), + temperature: options.temperature ?? (json ? 0 : 0.3), + thinking: options.thinking === true + ? { type: 'enabled' } + : { type: 'disabled' }, system, messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }], }), @@ -47,6 +57,11 @@ export async function deepseek(system, user, json = false) { lastError = error prompt = `${user}\n\n上一次响应不是合法 JSON。请重新生成完整结果:只允许输出一个可被 JSON.parse 直接解析的 JSON 值;不得使用 Markdown;字符串中的双引号、换行和反斜杠必须正确转义。` } + } catch (error) { + if (error?.name === 'AbortError') { + throw Object.assign(new Error('AI 响应超时,请稍后重试'), { status: 504 }) + } + throw error } finally { clearTimeout(timeout) } @@ -54,8 +69,8 @@ export async function deepseek(system, user, json = false) { throw Object.assign(new Error('AI 返回格式异常,自动重试后仍无法解析'), { status: 502, cause: lastError }) } -export function conversationContext(messages) { - return messages.slice(-80).map((m) => { +export function conversationContext(messages, limit = 30) { + return messages.slice(-limit).map((m) => { const role = m.sender === 'agent' ? '坐席' : m.sender === 'system' ? '系统' : '客户' const content = m.originalText || m.attachmentName || `[${m.type || '消息'}]` const translation = m.translatedText && m.translatedText !== content ? `(译文:${m.translatedText})` : '' diff --git a/server/src/country.js b/server/src/country.js new file mode 100644 index 0000000..f6a1d17 --- /dev/null +++ b/server/src/country.js @@ -0,0 +1,85 @@ +import { parsePhoneNumberFromString } from 'libphonenumber-js' + +const VALID_SOURCE = new Set(['manual', 'account-profile', 'phone-prefix', 'unknown']) + +export function normalizeCountryCode(value) { + const code = String(value || '').trim().toUpperCase() + return /^[A-Z]{2}$/.test(code) ? code : '' +} + +export function inferCountryFromPhone(value) { + const raw = String(value || '').trim() + if (!raw) return '' + const normalized = raw.startsWith('+') ? raw : `+${raw.replace(/[^\d]/g, '')}` + try { + return normalizeCountryCode(parsePhoneNumberFromString(normalized)?.country) + } catch { + return '' + } +} + +export function resolveCustomerCountry(input = {}, existing = {}, options = {}) { + const manualCountry = normalizeCountryCode(input.country) + if (options.manual === true) { + return { + country: manualCountry, + countrySource: manualCountry ? 'manual' : 'unknown', + countryConfidence: manualCountry ? 1 : 0, + countryInferredAt: new Date().toISOString(), + } + } + + const existingSource = VALID_SOURCE.has(existing.countrySource) + ? existing.countrySource + : 'unknown' + if (existingSource === 'manual' && normalizeCountryCode(existing.country)) { + return { + country: normalizeCountryCode(existing.country), + countrySource: 'manual', + countryConfidence: 1, + countryInferredAt: existing.countryInferredAt || existing.updatedAt, + } + } + + const accountCountry = normalizeCountryCode( + input.accountCountry || input.countryCode || input.profileCountry, + ) + if (accountCountry) { + return { + country: accountCountry, + countrySource: 'account-profile', + countryConfidence: 0.98, + countryInferredAt: new Date().toISOString(), + } + } + + const phoneCountry = inferCountryFromPhone(input.phone || existing.phone) + if (phoneCountry) { + return { + country: phoneCountry, + countrySource: 'phone-prefix', + countryConfidence: 0.92, + countryInferredAt: new Date().toISOString(), + } + } + + const preserved = normalizeCountryCode(input.country || existing.country) + const preservedSource = VALID_SOURCE.has(input.countrySource) + ? input.countrySource + : existingSource + if (preserved && preservedSource !== 'unknown') { + return { + country: preserved, + countrySource: preservedSource, + countryConfidence: Number(input.countryConfidence ?? existing.countryConfidence ?? 0.7), + countryInferredAt: input.countryInferredAt || existing.countryInferredAt, + } + } + + return { + country: '', + countrySource: 'unknown', + countryConfidence: 0, + countryInferredAt: null, + } +} diff --git a/server/src/db.js b/server/src/db.js index 9a8b60f..c105f9f 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -90,8 +90,11 @@ export async function deleteResource(tenantId, kind, id) { } export async function audit(req, action, target, detail = {}) { + // pg 会把顶层 JavaScript 数组编码成 PostgreSQL 数组文本(如 {ai}), + // jsonb 无法解析该格式。显式 JSON 序列化可同时兼容对象、数组和标量。 + const serializedDetail = JSON.stringify(detail ?? {}) await pool.query( 'INSERT INTO audit_logs(id,tenant_id,user_id,action,target,detail,ip) VALUES($1,$2,$3,$4,$5,$6,$7)', - [uid(), req.auth?.tenantId ?? null, req.auth?.userId ?? null, action, target, detail, req.ip], + [uid(), req.auth?.tenantId ?? null, req.auth?.userId ?? null, action, target, serializedDetail, req.ip], ) } diff --git a/server/src/index.js b/server/src/index.js index b9677ef..dec2793 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -16,6 +16,8 @@ import { gatewayRequest, postGateway, putGateway, uploadGateway } from './gatewa import { postWhatsApp, putWhatsApp, uploadWhatsApp, whatsappRequest } from './whatsapp.js' import { conversationContext, deepseek } from './ai.js' import { countBy, dateKey, percent, telegramAttachment } from './business.js' +import { resolveCustomerCountry } from './country.js' +import { knowledgeMetadata, rankKnowledge } from './knowledge.js' import { decryptEmailPassword, encryptEmailPassword, @@ -51,10 +53,34 @@ function broadcast(tenantId, event) { } } -const automaticTranslationQueue = [] +const automaticTranslationQueues = { high: [], low: [] } const automaticTranslationKeys = new Set() const automaticTranslationCache = new Map() let activeAutomaticTranslations = 0 +let activeLowPriorityTranslations = 0 +let lowPriorityResumeAt = 0 + +function sortMessagesChronologically(messages) { + return [...messages].sort((a, b) => { + const time = Date.parse(a.createdAt || '') - Date.parse(b.createdAt || '') + return time || String(a.id || '').localeCompare(String(b.id || '')) + }) +} + +function contextMemoryText(conversation, customer) { + const memory = conversation?.aiContextMemory + if (memory?.summary) { + return [ + `会话长期摘要:${memory.summary}`, + memory.facts?.length ? `已确认事实:${memory.facts.join(';')}` : '', + memory.commitments?.length ? `双方承诺:${memory.commitments.join(';')}` : '', + memory.products?.length ? `产品与型号:${memory.products.join(';')}` : '', + memory.unresolved?.length ? `未决事项:${memory.unresolved.join(';')}` : '', + memory.preferences?.length ? `客户偏好:${memory.preferences.join(';')}` : '', + ].filter(Boolean).join('\n').slice(0, 3200) + } + return String(conversation?.summary || customer?.aiProfile?.summary || '').trim().slice(0, 1600) +} function isNonLinguisticText(text) { return !/[\p{L}]/u.test(String(text || '')) @@ -72,48 +98,154 @@ function isSuspiciousTranslation(source, candidate) { return translated.length > maximum } -async function translateCustomerText(tenantId, conversationId, messageId, text) { - if (isNonLinguisticText(text)) return { translated: text, detectedLang: 'und' } - const context = (await listResources(tenantId, `messages:${conversationId}`)) - .filter((item) => String(item.id) !== String(messageId)) - .slice(-8) +async function translateCustomerText(tenantId, conversationId, messageId, text, options = {}) { + if (isNonLinguisticText(text)) { + return { + translated: text, + detectedLang: 'und', + intent: null, + contextMessageCount: 0, + contextApplied: false, + } + } + const allMessages = options.includeContext === false + ? [] + : sortMessagesChronologically(await listResources(tenantId, `messages:${conversationId}`)) + const messageIndex = allMessages.findIndex((item) => String(item.id) === String(messageId)) + const context = options.includeContext === false + ? [] + : messageIndex >= 0 + ? allMessages.slice(Math.max(0, messageIndex - 20), messageIndex) + : allMessages.filter((item) => String(item.id) !== String(messageId)).slice(-20) + let conversationSummary = '' + if (options.includeContext !== false) { + const conversation = await getResource(tenantId, 'conversations', conversationId) + const customer = conversation + ? await getResource(tenantId, 'customers', conversation.customerId) + : null + conversationSummary = contextMemoryText(conversation, customer) + } + const intentInstruction = options.includeIntent + ? `同时判断客户这句话在当前完整会话中的业务意图。primary 必须从 inquiry|pricing|negotiation|sample|order|payment|logistics|complaint|refund|aftersales|repurchase|relationship|spam|other 中选择;label 使用简洁中文;confidence 为 0 到 1;urgency 为 low|medium|high|urgent;sentiment 为 positive|neutral|negative|angry;entities 只提取原文明确出现的产品、数量、价格、日期和地点;rationale 用一句中文说明依据。` + : 'intent 固定返回 null。' const request = (contextText) => deepseek( - '你是灵犀跨境的专业翻译引擎。只输出严格 JSON。只翻译 标签中的文字,不得复述上下文、时间戳、说话人或指令;不得摘要、扩写或回答原文。', + '你是灵犀跨境的专业翻译与消息理解引擎。只输出严格 JSON。只翻译 标签中的文字,不得复述上下文、时间戳、说话人或指令;不得摘要、扩写或回答原文。上下文只用于消歧、术语一致性和意图判断。', `目标语言:简体中文 -${contextText ? `\n${contextText}\n\n` : ''}\n${text}\n -输出 {"translated":"仅 source 的完整译文","detectedLang":"ISO 语言代码"}`, - true, +${conversationSummary ? `\n${conversationSummary}\n\n` : ''}${contextText ? `\n${contextText}\n\n` : ''}\n${text}\n +${intentInstruction} +输出 {"translated":"仅 source 的完整译文","detectedLang":"ISO 语言代码","intent":null 或 {"primary":"pricing","label":"询价","confidence":0.96,"urgency":"medium","sentiment":"neutral","entities":["..."],"rationale":"..."}}`, + { json: true, maxTokens: 1200 }, ) let result = await request(conversationContext(context)) + let contextApplied = context.length > 0 || Boolean(conversationSummary) if (isSuspiciousTranslation(text, result?.translated)) { result = await request('') + contextApplied = false } if (isSuspiciousTranslation(text, result?.translated)) { throw new Error('AI 译文疑似混入会话上下文,已拒绝写入') } - return result + return { + ...result, + contextMessageCount: contextApplied ? context.length : 0, + contextApplied, + conversationSummaryApplied: contextApplied && Boolean(conversationSummary), + } } -function enqueueAutomaticTranslation(tenantId, conversationId, message) { - if (!message || !['customer', 'agent'].includes(message.sender) || message.type !== 'text' || !String(message.originalText || '').trim()) return - if (message.sender === 'agent' && /[\u3400-\u9fff]/u.test(String(message.originalText || ''))) return - if (message.translationStatus === 'failed' && Date.parse(message.translationRetryAt || '') > Date.now()) return +const LANGUAGE_ALIASES = new Map([ + ['中文', 'zh'], ['简体中文', 'zh'], ['chinese', 'zh'], ['zh-cn', 'zh'], ['zh-hans', 'zh'], + ['英语', 'en'], ['英文', 'en'], ['english', 'en'], ['en-us', 'en'], ['en-gb', 'en'], + ['西班牙语', 'es'], ['spanish', 'es'], ['español', 'es'], + ['葡萄牙语', 'pt'], ['portuguese', 'pt'], ['português', 'pt'], + ['法语', 'fr'], ['french', 'fr'], ['français', 'fr'], + ['德语', 'de'], ['german', 'de'], ['deutsch', 'de'], + ['阿拉伯语', 'ar'], ['arabic', 'ar'], ['العربية', 'ar'], + ['俄语', 'ru'], ['russian', 'ru'], ['русский', 'ru'], + ['日语', 'ja'], ['japanese', 'ja'], ['日本語', 'ja'], + ['韩语', 'ko'], ['korean', 'ko'], ['한국어', 'ko'], + ['意大利语', 'it'], ['italian', 'it'], ['italiano', 'it'], + ['土耳其语', 'tr'], ['turkish', 'tr'], ['türkçe', 'tr'], + ['越南语', 'vi'], ['vietnamese', 'vi'], ['tiếng việt', 'vi'], + ['泰语', 'th'], ['thai', 'th'], ['ไทย', 'th'], + ['印尼语', 'id'], ['印度尼西亚语', 'id'], ['indonesian', 'id'], + ['马来语', 'ms'], ['malay', 'ms'], + ['印地语', 'hi'], ['hindi', 'hi'], ['हिन्दी', 'hi'], + ['孟加拉语', 'bn'], ['bengali', 'bn'], + ['乌尔都语', 'ur'], ['urdu', 'ur'], + ['波斯语', 'fa'], ['persian', 'fa'], ['farsi', 'fa'], + ['荷兰语', 'nl'], ['dutch', 'nl'], ['nederlands', 'nl'], + ['波兰语', 'pl'], ['polish', 'pl'], ['polski', 'pl'], + ['乌克兰语', 'uk'], ['ukrainian', 'uk'], + ['希伯来语', 'he'], ['hebrew', 'he'], + ['斯瓦希里语', 'sw'], ['swahili', 'sw'], +]) + +function normalizeLanguageCode(value) { + const source = String(value || '').trim() + if (!source) return '' + const lowered = source.toLowerCase() + if (['auto', 'und', 'unknown'].includes(lowered)) return '' + if (LANGUAGE_ALIASES.has(lowered)) return LANGUAGE_ALIASES.get(lowered) + const code = lowered.split(/[-_]/)[0] + return /^[a-z]{2,3}$/.test(code) ? code : '' +} + +async function resolveConversationLanguage(tenantId, conversation, requestedTarget = 'auto') { + const explicit = normalizeLanguageCode(requestedTarget) + if (explicit) return { code: explicit, source: 'manual' } + const messages = (await listResources(tenantId, `messages:${conversation.id}`)) + .sort((a, b) => Date.parse(a.createdAt || '') - Date.parse(b.createdAt || '')) + const recentCustomerLanguage = [...messages] + .reverse() + .filter((message) => message.sender === 'customer') + .map((message) => normalizeLanguageCode(message.originalLang)) + .find(Boolean) + if (recentCustomerLanguage) return { code: recentCustomerLanguage, source: 'recent-message', messages } + const customer = await getResource(tenantId, 'customers', conversation.customerId) + const profileLanguage = normalizeLanguageCode( + customer?.language || customer?.aiProfile?.preferredLanguage, + ) + if (profileLanguage) return { code: profileLanguage, source: 'customer-profile', messages } + return { code: 'auto', source: 'model', messages } +} + +function enqueueAutomaticTranslation(tenantId, conversationId, message, options = {}) { + if (!message || !['customer', 'agent'].includes(message.sender) || message.type !== 'text' || !String(message.originalText || '').trim()) return false + if (message.sender === 'agent' && /[\u3400-\u9fff]/u.test(String(message.originalText || ''))) return false + if (message.translationStatus === 'failed' && Date.parse(message.translationRetryAt || '') > Date.now()) return false const key = `${tenantId}:${conversationId}:${message.id}` const targetField = message.sender === 'agent' ? 'translationCn' : 'translatedText' - if (message[targetField] || automaticTranslationKeys.has(key)) return + const mayNeedIntent = message.sender === 'customer' && !message.intentAnalysis + if ((!mayNeedIntent && message[targetField]) || automaticTranslationKeys.has(key)) return false + const priority = options.priority === 'low' ? 'low' : 'high' automaticTranslationKeys.add(key) - automaticTranslationQueue.push({ key, tenantId, conversationId, message, targetField }) + automaticTranslationQueues[priority].push({ + key, tenantId, conversationId, message, targetField, priority, + }) + if (priority === 'high') enqueueContextCompaction(tenantId, conversationId) pumpAutomaticTranslations() + return true } function pumpAutomaticTranslations() { - while (activeAutomaticTranslations < 3 && automaticTranslationQueue.length) { - const job = automaticTranslationQueue.shift() + while (activeAutomaticTranslations < 3) { + let job = automaticTranslationQueues.high.shift() + if ( + !job + && activeLowPriorityTranslations === 0 + && automaticTranslationQueues.low.length + && Date.now() >= lowPriorityResumeAt + ) { + job = automaticTranslationQueues.low.shift() + } + if (!job) break activeAutomaticTranslations++ + if (job.priority === 'low') activeLowPriorityTranslations++ void processAutomaticTranslation(job) .catch(async (error) => { console.error(JSON.stringify({ - message: 'Telegram 自动翻译失败', + message: '消息自动翻译或意图识别失败', tenantId: job.tenantId, conversationId: job.conversationId, telegramMessageId: job.message.id, @@ -136,6 +268,11 @@ function pumpAutomaticTranslations() { .finally(() => { automaticTranslationKeys.delete(job.key) activeAutomaticTranslations-- + if (job.priority === 'low') { + activeLowPriorityTranslations-- + lowPriorityResumeAt = Date.now() + 800 + setTimeout(pumpAutomaticTranslations, 800).unref?.() + } pumpAutomaticTranslations() }) } @@ -143,13 +280,24 @@ function pumpAutomaticTranslations() { async function processAutomaticTranslation({ tenantId, conversationId, message, targetField }) { const current = await getResource(tenantId, `messages:${conversationId}`, String(message.id)) - if (!current || current[targetField]) return + if (!current) return + const [conversation, settings] = await Promise.all([ + getResource(tenantId, 'conversations', conversationId), + getResource(tenantId, 'singleton', 'settings'), + ]) + const intentEnabled = current.sender === 'customer' + && conversation?.messageIntentEnabled !== false + && (settings?.ai?.messageIntentAnalysis ?? true) !== false + if (current[targetField] && (!intentEnabled || current.intentAnalysis)) return const text = String(current.originalText || '').trim() if (!text) return - const sharedKey = `${message.id}:${crypto.createHash('sha256').update(text).digest('hex')}` + const sharedKey = `${message.id}:${intentEnabled ? 'intent' : 'translation'}:${crypto.createHash('sha256').update(text).digest('hex')}` let translationPromise = automaticTranslationCache.get(sharedKey) if (!translationPromise) { - translationPromise = translateCustomerText(tenantId, conversationId, message.id, text).catch((error) => { + translationPromise = translateCustomerText(tenantId, conversationId, message.id, text, { + includeIntent: intentEnabled, + includeContext: (settings?.ai?.contextualTranslation ?? true) !== false, + }).catch((error) => { automaticTranslationCache.delete(sharedKey) throw error }) @@ -162,20 +310,310 @@ async function processAutomaticTranslation({ tenantId, conversationId, message, } const latest = await getResource(tenantId, `messages:${conversationId}`, String(message.id)) if (!latest) return + const intent = intentEnabled && result.intent && typeof result.intent === 'object' + ? { + primary: String(result.intent.primary || 'other'), + label: String(result.intent.label || '其他'), + confidence: Math.max(0, Math.min(1, Number(result.intent.confidence || 0))), + urgency: ['low', 'medium', 'high', 'urgent'].includes(result.intent.urgency) + ? result.intent.urgency + : 'low', + sentiment: ['positive', 'neutral', 'negative', 'angry'].includes(result.intent.sentiment) + ? result.intent.sentiment + : 'neutral', + entities: Array.isArray(result.intent.entities) + ? result.intent.entities.map(String).filter(Boolean).slice(0, 8) + : [], + rationale: String(result.intent.rationale || '').slice(0, 240), + analyzedAt: new Date().toISOString(), + model: process.env.DEEPSEEK_MODEL || 'deepseek-v4-flash', + } + : latest.intentAnalysis const translated = await putResource(tenantId, `messages:${conversationId}`, { ...latest, - [targetField]: String(result.translated), + [targetField]: latest[targetField] || String(result.translated), originalLang: String(result.detectedLang || latest.originalLang || 'auto'), translationStatus: 'completed', translatedAt: new Date().toISOString(), + translationMethod: result.contextApplied ? 'ai-contextual' : 'ai-direct', + translationContextMessages: Number(result.contextMessageCount || 0), + translationConversationSummaryApplied: Boolean(result.conversationSummaryApplied), + translationModel: process.env.DEEPSEEK_MODEL || 'deepseek-v4-flash', + ...(intentEnabled ? { + intentAnalysis: intent, + intentStatus: intent ? 'completed' : 'failed', + } : {}), }) - const conversation = await getResource(tenantId, 'conversations', conversationId) if (String(conversation?.lastMessage?.id || '') === String(translated.id)) { await patchResource(tenantId, 'conversations', conversationId, { lastMessage: translated }) } broadcast(tenantId, { type: 'message.updated', conversationId, message: translated }) } +function messageNeedsAutomaticAi(message, conversation, settings) { + if ( + !message + || !['customer', 'agent'].includes(message.sender) + || message.type !== 'text' + || !String(message.originalText || '').trim() + || isNonLinguisticText(message.originalText) + ) return false + if (message.sender === 'agent') { + return !/[\u3400-\u9fff]/u.test(String(message.originalText || '')) + && !String(message.translationCn || '').trim() + } + const intentEnabled = conversation?.messageIntentEnabled !== false + && (settings?.ai?.messageIntentAnalysis ?? true) !== false + return !String(message.translatedText || '').trim() + || (intentEnabled && !message.intentAnalysis) +} + +const activeBackfillJobs = new Set() +const backfillTimers = new Map() + +async function runConversationBackfill(tenantId, conversationId) { + const key = `${tenantId}:${conversationId}` + if (activeBackfillJobs.has(key)) return + activeBackfillJobs.add(key) + try { + const [conversation, settings, messages] = await Promise.all([ + getResource(tenantId, 'conversations', conversationId), + getResource(tenantId, 'singleton', 'settings'), + listResources(tenantId, `messages:${conversationId}`), + ]) + if (!conversation) { + await deleteResource(tenantId, 'ai-backfill-jobs', conversationId) + return + } + const eligible = sortMessagesChronologically(messages) + .filter((message) => ( + ['customer', 'agent'].includes(message.sender) + && message.type === 'text' + && !isNonLinguisticText(message.originalText) + )) + const pending = eligible.filter((message) => messageNeedsAutomaticAi(message, conversation, settings)) + const current = await getResource(tenantId, 'ai-backfill-jobs', conversationId) + const completedCount = eligible.length - pending.length + const status = pending.length ? 'running' : 'completed' + const job = await putResource(tenantId, 'ai-backfill-jobs', { + ...(current || {}), + id: conversationId, + conversationId, + status, + totalCount: eligible.length, + completedCount, + pendingCount: pending.length, + progress: eligible.length ? Number((completedCount / eligible.length).toFixed(4)) : 1, + startedAt: current?.startedAt || new Date().toISOString(), + completedAt: pending.length ? null : new Date().toISOString(), + lastError: '', + }) + broadcast(tenantId, { + type: 'conversation.ai-backfill.updated', + conversationId, + backfill: job, + }) + if (!pending.length) return + for (const message of pending.slice(0, 4)) { + enqueueAutomaticTranslation(tenantId, conversationId, message, { priority: 'low' }) + } + const timer = setTimeout(() => { + backfillTimers.delete(key) + void runConversationBackfill(tenantId, conversationId) + }, 15_000) + timer.unref?.() + backfillTimers.set(key, timer) + } catch (error) { + await putResource(tenantId, 'ai-backfill-jobs', { + id: conversationId, + conversationId, + status: 'failed', + lastError: String(error.message || error), + retryAt: new Date(Date.now() + 30_000).toISOString(), + }) + const timer = setTimeout(() => { + backfillTimers.delete(key) + void runConversationBackfill(tenantId, conversationId) + }, 30_000) + timer.unref?.() + backfillTimers.set(key, timer) + } finally { + activeBackfillJobs.delete(key) + } +} + +async function scheduleConversationBackfill(tenantId, conversationId) { + const existing = await getResource(tenantId, 'ai-backfill-jobs', conversationId) + if (!existing || existing.status === 'completed' || existing.status === 'failed') { + await putResource(tenantId, 'ai-backfill-jobs', { + ...(existing || {}), + id: conversationId, + conversationId, + status: 'queued', + queuedAt: new Date().toISOString(), + completedAt: null, + }) + } + void runConversationBackfill(tenantId, conversationId) +} + +const discoveredBackfillTenants = new Set() +function discoverTenantBackfills(tenantId, conversations) { + if (discoveredBackfillTenants.has(tenantId)) return + discoveredBackfillTenants.add(tenantId) + void (async () => { + for (const conversation of conversations) { + await scheduleConversationBackfill(tenantId, conversation.id) + enqueueContextCompaction(tenantId, conversation.id) + await new Promise((resolve) => setTimeout(resolve, 40)) + } + })().catch((error) => { + discoveredBackfillTenants.delete(tenantId) + console.error(JSON.stringify({ + message: '会话历史 AI 回填发现失败', + tenantId, + error: error.message, + })) + }) +} + +const contextCompactionQueue = [] +const contextCompactionKeys = new Set() +let activeContextCompactions = 0 + +function enqueueContextCompaction(tenantId, conversationId) { + const key = `${tenantId}:${conversationId}` + if (contextCompactionKeys.has(key)) return + contextCompactionKeys.add(key) + contextCompactionQueue.push({ key, tenantId, conversationId }) + pumpContextCompactions() +} + +function pumpContextCompactions() { + if (activeContextCompactions > 0 || !contextCompactionQueue.length) return + const job = contextCompactionQueue.shift() + activeContextCompactions++ + void compactConversationContext(job.tenantId, job.conversationId) + .catch(async (error) => { + await patchResource(job.tenantId, 'conversations', job.conversationId, { + aiContextStatus: 'failed', + aiContextError: String(error.message || error), + aiContextRetryAt: new Date(Date.now() + 60_000).toISOString(), + }) + }) + .finally(() => { + contextCompactionKeys.delete(job.key) + activeContextCompactions-- + setTimeout(pumpContextCompactions, 1200).unref?.() + }) +} + +async function compactConversationContext(tenantId, conversationId) { + const [conversation, messages] = await Promise.all([ + getResource(tenantId, 'conversations', conversationId), + listResources(tenantId, `messages:${conversationId}`), + ]) + if (!conversation) return + const ordered = sortMessagesChronologically(messages) + .filter((message) => ['customer', 'agent'].includes(message.sender)) + const totalCharacters = ordered.reduce( + (sum, message) => sum + String(message.originalText || '').length, + 0, + ) + if (ordered.length <= 80 && totalCharacters <= 16_000) { + if (conversation.aiContextStatus !== 'ready') { + await patchResource(tenantId, 'conversations', conversationId, { + aiContextStatus: 'waiting', + aiContextSourceMessageCount: 0, + aiContextPendingMessages: Math.max(0, ordered.length - 30), + }) + } + return + } + + const compactableEnd = Math.max(0, ordered.length - 30) + const compactable = ordered.slice(0, compactableEnd) + const priorMemory = conversation.aiContextMemory || null + const compactedIds = new Set( + Array.isArray(conversation.aiContextCompactedMessageIds) + ? conversation.aiContextCompactedMessageIds.map(String) + : [], + ) + // 兼容已生成长期记忆但尚未写入逐条检查点的旧数据。 + if (!compactedIds.size && priorMemory?.updatedThroughMessageId) { + const previousIndex = ordered.findIndex( + (message) => String(message.id) === String(priorMemory.updatedThroughMessageId), + ) + for (const message of ordered.slice(0, Math.min(compactableEnd, previousIndex + 1))) { + compactedIds.add(String(message.id)) + } + } + const segment = compactable + .filter((message) => !compactedIds.has(String(message.id))) + .slice(0, 80) + if (!segment.length) { + await patchResource(tenantId, 'conversations', conversationId, { + aiContextStatus: 'ready', + aiContextPendingMessages: 0, + aiContextSourceMessageCount: compactedIds.size, + aiContextCompactedMessageIds: [...compactedIds], + }) + return + } + + await patchResource(tenantId, 'conversations', conversationId, { + aiContextStatus: 'compressing', + aiContextError: '', + }) + const result = await deepseek( + '你是企业会话长期记忆压缩器。只输出严格 JSON。将旧摘要与新增消息合并成可持续增量更新的业务记忆。必须保留已确认事实、数量、金额、日期、型号、双方承诺、异议、未决问题和客户偏好;删除寒暄、重复表述和无业务价值内容;不得猜测。', + `${JSON.stringify(priorMemory || {})} +${conversationContext(segment, 80)} +输出 { + "summary":"不超过500字的会话长期摘要", + "facts":["已确认事实,最多12条"], + "commitments":["双方承诺,最多10条"], + "products":["产品、型号和数量,最多10条"], + "unresolved":["未决问题,最多10条"], + "preferences":["客户偏好,最多8条"] +}`, + { json: true, maxTokens: 1800 }, + ) + for (const message of segment) compactedIds.add(String(message.id)) + const memory = { + summary: String(result.summary || '').slice(0, 2000), + facts: Array.isArray(result.facts) ? result.facts.map(String).slice(0, 12) : [], + commitments: Array.isArray(result.commitments) ? result.commitments.map(String).slice(0, 10) : [], + products: Array.isArray(result.products) ? result.products.map(String).slice(0, 10) : [], + unresolved: Array.isArray(result.unresolved) ? result.unresolved.map(String).slice(0, 10) : [], + preferences: Array.isArray(result.preferences) ? result.preferences.map(String).slice(0, 8) : [], + updatedThroughMessageId: String(segment.at(-1).id), + sourceMessageCount: compactedIds.size, + updatedAt: new Date().toISOString(), + model: process.env.DEEPSEEK_MODEL || 'deepseek-v4-flash', + version: 1, + } + const remaining = compactable.filter( + (message) => !compactedIds.has(String(message.id)), + ).length + const updated = await patchResource(tenantId, 'conversations', conversationId, { + aiContextMemory: memory, + aiContextCompactedMessageIds: [...compactedIds], + aiContextStatus: remaining ? 'queued' : 'ready', + aiContextPendingMessages: remaining, + aiContextSourceMessageCount: memory.sourceMessageCount, + aiContextCompressedAt: memory.updatedAt, + }) + broadcast(tenantId, { + type: 'conversation.context.updated', + conversationId, + conversation: updated, + }) + if (remaining) { + setTimeout(() => enqueueContextCompaction(tenantId, conversationId), 2000).unref?.() + } +} + const automaticProfileTimers = new Map() const automaticProfileCache = new Map() @@ -203,8 +641,23 @@ async function analyzeAndPersistConversation(tenantId, conversationId) { if (!conversation) throw new Error('待分析会话不存在') const customer = await getResource(tenantId, 'customers', conversation.customerId) if (!customer) throw new Error('待分析客户不存在') - const messages = await listResources(tenantId, `messages:${conversationId}`) - const context = conversationContext(messages) + const messages = sortMessagesChronologically( + await listResources(tenantId, `messages:${conversationId}`), + ) + const messageIntentSignals = messages + .filter((message) => message.sender === 'customer' && message.intentAnalysis) + .slice(-50) + .map((message) => ({ + at: message.createdAt, + text: String(message.originalText || '').slice(0, 240), + intent: message.intentAnalysis.primary, + label: message.intentAnalysis.label, + confidence: message.intentAnalysis.confidence, + urgency: message.intentAnalysis.urgency, + sentiment: message.intentAnalysis.sentiment, + })) + const context = `${contextMemoryText(conversation, customer) ? `\n${contextMemoryText(conversation, customer)}\n\n` : ''}${conversationContext(sortMessagesChronologically(messages), 50)} +${JSON.stringify(messageIntentSignals)}` if (!context.trim()) return null const sharedKey = crypto.createHash('sha256').update(context).digest('hex') let analysisPromise = automaticProfileCache.get(sharedKey) @@ -218,7 +671,6 @@ async function analyzeAndPersistConversation(tenantId, conversationId) { "sentiment":"positive|neutral-positive|neutral|negative|angry", "riskLevel":"none|low|medium|high|urgent", "riskNote":"", - "country":"两位国家代码或空字符串", "products":[], "tags":[], "summary":"中文画像摘要", @@ -254,6 +706,8 @@ async function analyzeAndPersistConversation(tenantId, conversationId) { "dealProbability":0到100, "churnProbability":0到100, "confidenceScore":0到100, + "intentBreakdown":[{"intent":"pricing","label":"询价","count":2,"share":0.5}], + "lastMessageIntent":{"intent":"pricing","label":"询价","urgency":"medium"}, "evidence":["最多5条会话证据,简短转述"] } }`, @@ -271,7 +725,6 @@ async function analyzeAndPersistConversation(tenantId, conversationId) { ...customer, company: String(result.aiProfile?.companyName || '').trim() || customer.company, language: String(result.aiProfile?.preferredLanguage || '').trim() || customer.language, - country: /^[A-Z]{2}$/i.test(String(result.country || '')) ? String(result.country).toUpperCase() : customer.country, products: Array.from(new Set([...(customer.products || []), ...(result.products || [])])), tags: Array.from(new Set([...(customer.tags || []), ...(result.tags || [])])), intentScore, @@ -284,6 +737,7 @@ async function analyzeAndPersistConversation(tenantId, conversationId) { ...(result.aiProfile || {}), summary: String(result.summary || ''), nextActions: Array.isArray(result.nextActions) ? result.nextActions : [], + profileVersion: 2, }, aiAnalyzedAt: new Date().toISOString(), }) @@ -631,31 +1085,84 @@ const kinds = { knowledge: 'knowledge', } function withDefaults(kind, body) { - if (kind === 'customers') return { - name: '', company: '', country: 'US', channel: 'telegram', email: '', phone: '', + if (kind === 'customers') { + const value = { + name: '', company: '', country: '', channel: 'telegram', email: '', phone: '', telegram: '', products: [], tags: [], source: 'telegram', stage: 'new', intentScore: 0, intentLevel: 'low', sentiment: 'neutral', riskLevel: 'none', valueLevel: 'D', isKey: false, ownerId: null, aiProfile: { summary: '', keyPoints: [], nextActions: [] }, ...body, + } + return { + ...value, + ...resolveCustomerCountry(value, {}, { + manual: value.countrySource === 'manual', + }), + } } return body } for (const [path, kind] of Object.entries(kinds)) { app.get(`/api/${path}`, async (req, res) => { let list = await listResources(req.auth.tenantId, kind) + if (kind === 'customers') { + list = await Promise.all(list.map(async (customer) => { + const country = resolveCustomerCountry(customer, customer) + if ( + customer.country === country.country + && customer.countrySource === country.countrySource + ) return customer + return putResource(req.auth.tenantId, 'customers', { ...customer, ...country }) + })) + } const keyword = String(req.query.keyword || '').toLowerCase() if (keyword) list = list.filter((x) => JSON.stringify(x).toLowerCase().includes(keyword)) + if (kind === 'knowledge' && req.query.category) { + list = list.filter((item) => item.category === req.query.category) + } ok(res, { list, total: list.length }) }) app.post(`/api/${path}`, async (req, res) => { - const value = await putResource(req.auth.tenantId, kind, { ...withDefaults(kind, req.body), createdAt: new Date().toISOString() }) + const payload = kind === 'customers' && req.body.country + ? { ...req.body, countrySource: 'manual' } + : req.body + const prepared = withDefaults(kind, payload) + const enriched = kind === 'knowledge' + ? { + enabled: true, + version: 1, + status: 'ready', + citedCount: 0, + languages: ['auto'], + quality: 'normal', + source: 'manual', + ...prepared, + ...knowledgeMetadata(prepared.content), + } + : prepared + const value = await putResource(req.auth.tenantId, kind, { ...enriched, createdAt: new Date().toISOString() }) await audit(req, `${kind}.create`, value.id); ok(res, value) }) app.patch(`/api/${path}/:id`, async (req, res) => { - const value = await patchResource(req.auth.tenantId, kind, req.params.id, req.body) + const current = await getResource(req.auth.tenantId, kind, req.params.id) + if (!current) throw fail(404, '记录不存在') + let patch = req.body + if (kind === 'customers') { + const merged = { ...current, ...req.body } + patch = { + ...req.body, + ...resolveCustomerCountry(merged, current, { + manual: Object.prototype.hasOwnProperty.call(req.body, 'country'), + }), + } + } + if (kind === 'knowledge' && Object.prototype.hasOwnProperty.call(req.body, 'content')) { + patch = { ...patch, ...knowledgeMetadata(req.body.content) } + } + const value = await patchResource(req.auth.tenantId, kind, req.params.id, patch) if (!value) throw fail(404, '记录不存在') - await audit(req, `${kind}.update`, req.params.id, req.body); ok(res, value) + await audit(req, `${kind}.update`, req.params.id, patch); ok(res, value) }) app.delete(`/api/${path}/:id`, async (req, res) => { if (!(await deleteResource(req.auth.tenantId, kind, req.params.id))) throw fail(404, '记录不存在') @@ -771,6 +1278,7 @@ app.get('/api/conversations', async (req, res) => { return String(b.id).localeCompare(String(a.id)) }) const list = conversations.map((x) => ({ ...x, customer: customerMap.get(x.customerId) })).filter((x) => x.customer) + discoverTenantBackfills(req.auth.tenantId, list) ok(res, { list, total: list.length }) }) @@ -1241,8 +1749,11 @@ app.post('/api/conversations/:id/actions', async (req, res) => { app.get('/api/conversations/:id', async (req, res) => { const conversation = await getResource(req.auth.tenantId, 'conversations', req.params.id) if (!conversation) throw fail(404, '会话不存在') - const customer = await getResource(req.auth.tenantId, 'customers', conversation.customerId) - ok(res, { conversation, customer }) + const [customer, aiBackfill] = await Promise.all([ + getResource(req.auth.tenantId, 'customers', conversation.customerId), + getResource(req.auth.tenantId, 'ai-backfill-jobs', req.params.id), + ]) + ok(res, { conversation, customer, aiBackfill }) }) app.get('/api/conversations/:id/messages', async (req, res) => { const list = await listResources(req.auth.tenantId, `messages:${req.params.id}`) @@ -1251,13 +1762,38 @@ app.get('/api/conversations/:id/messages', async (req, res) => { if (time) return time return Number(a.id || 0) - Number(b.id || 0) }) - // 首次打开会话时为最近的历史消息补齐译文。按真实消息时间取最近 - // 100 条,避免资源更新时间导致旧消息长期占据回填窗口。 - for (const message of list.slice(-100)) { + // 首次打开会话时为最近的历史消息补齐译文和意图。历史回填限制 + // 为 30 条控制模型成本,后续实时消息会逐条处理。 + for (const message of list.slice(-30)) { enqueueAutomaticTranslation(req.auth.tenantId, req.params.id, message) } + void scheduleConversationBackfill(req.auth.tenantId, req.params.id) + enqueueContextCompaction(req.auth.tenantId, req.params.id) ok(res, list) }) +app.get('/api/conversations/:id/ai-status', async (req, res) => { + const [conversation, backfill] = await Promise.all([ + getResource(req.auth.tenantId, 'conversations', req.params.id), + getResource(req.auth.tenantId, 'ai-backfill-jobs', req.params.id), + ]) + if (!conversation) throw fail(404, '会话不存在') + ok(res, { + backfill: backfill || { + status: 'not-started', + totalCount: 0, + completedCount: 0, + pendingCount: 0, + progress: 0, + }, + context: { + status: conversation.aiContextStatus || 'waiting', + sourceMessageCount: Number(conversation.aiContextSourceMessageCount || 0), + pendingMessages: Number(conversation.aiContextPendingMessages || 0), + compressedAt: conversation.aiContextCompressedAt || null, + memory: conversation.aiContextMemory || null, + }, + }) +}) app.get('/api/conversations/:id/messages/page', async (req, res) => { const conversation = await getResource(req.auth.tenantId, 'conversations', req.params.id) if (!conversation) throw fail(404, '会话不存在') @@ -1316,6 +1852,36 @@ app.patch('/api/conversations/:id/messages/:messageId', async (req, res) => { }) ok(res, value) }) +app.post('/api/conversations/:id/messages/:messageId/analyze', async (req, res) => { + const message = await getResource( + req.auth.tenantId, + `messages:${req.params.id}`, + req.params.messageId, + ) + if (!message) throw fail(404, '消息不存在') + if (message.sender !== 'customer' || message.type !== 'text' || isNonLinguisticText(message.originalText)) { + throw fail(422, '当前消息没有可识别的客户文本') + } + const conversation = await getResource(req.auth.tenantId, 'conversations', req.params.id) + if (!conversation) throw fail(404, '会话不存在') + if (conversation.messageIntentEnabled === false) { + throw fail(409, '当前会话已关闭逐句意图识别') + } + if (req.body.force === true && message.intentAnalysis) { + await putResource(req.auth.tenantId, `messages:${req.params.id}`, { + ...message, + intentAnalysis: null, + intentStatus: 'pending', + }) + } + await processAutomaticTranslation({ + tenantId: req.auth.tenantId, + conversationId: req.params.id, + message, + targetField: 'translatedText', + }) + ok(res, await getResource(req.auth.tenantId, `messages:${req.params.id}`, req.params.messageId)) +}) app.get('/api/conversations/:id/messages/:messageId/attachment', async (req, res) => { const conversation = await getResource(req.auth.tenantId, 'conversations', req.params.id) if (!conversation) throw fail(404, '会话不存在') @@ -1468,6 +2034,15 @@ app.patch('/api/conversations/:id', async (req, res) => { delete patch.markRead const value = await patchResource(req.auth.tenantId, 'conversations', req.params.id, patch) if (!value) throw fail(404, '会话不存在') + if (patch.messageIntentEnabled === true) { + const messages = await listResources(req.auth.tenantId, `messages:${req.params.id}`) + for (const message of messages.slice(-30)) { + if (message.sender === 'customer' && !message.intentAnalysis) { + enqueueAutomaticTranslation(req.auth.tenantId, req.params.id, message) + } + } + void scheduleConversationBackfill(req.auth.tenantId, req.params.id) + } ok(res, value) }) app.post('/api/conversations/:id/messages', async (req, res) => { @@ -1501,18 +2076,58 @@ app.post('/api/conversations/:id/messages', async (req, res) => { if (!originalText) throw fail(400, '消息内容不能为空') let telegramText = originalText let originalLang = 'auto' + let targetLang = normalizeLanguageCode(req.body.targetLang) || 'auto' let translationCn = String(req.body.translationCn || '').trim().slice(0, 8000) - if (req.body.autoTranslate !== false && /[\u3400-\u9fff]/u.test(originalText)) { - const context = await listResources(req.auth.tenantId, `messages:${conversation.id}`) - const translation = await deepseek( - '你是灵犀跨境的中文到英文商务全文翻译引擎。只输出严格 JSON,不要 Markdown。必须完整翻译,不得摘要、删减、扩写或遗漏;保留数字、金额、型号、链接、换行与专有名词;英文应自然、专业且可直接发给海外客户。', - `会话上下文:\n${conversationContext(context)}\n需要发送的中文原文:\n${originalText}\n输出 {"translated":"完整英文译文","detectedLang":"zh-CN"}`, - true, + let translationMethod = 'source' + let translationContextMessages = 0 + let translationModel = '' + let translationConversationSummaryApplied = false + if (req.body.autoTranslate !== false) { + const resolvedLanguage = await resolveConversationLanguage( + req.auth.tenantId, + conversation, + req.body.targetLang, ) - if (!translation?.translated) throw fail(502, 'AI 未返回英文全文译文,消息未发送') + const context = sortMessagesChronologically(resolvedLanguage.messages + || await listResources(req.auth.tenantId, `messages:${conversation.id}`) + ) + const settings = await getResource(req.auth.tenantId, 'singleton', 'settings') + const contextualMessages = (settings?.ai?.contextualTranslation ?? true) !== false + ? context.slice(-20) + : [] + const customer = contextualMessages.length + ? await getResource(req.auth.tenantId, 'customers', conversation.customerId) + : null + const conversationSummary = contextualMessages.length + ? contextMemoryText(conversation, customer) + : '' + const targetInstruction = resolvedLanguage.code === 'auto' + ? '自动判断最近一条客户消息使用的主要语言;若无客户语言线索,使用英语' + : `ISO 代码 ${resolvedLanguage.code} 对应的语言` + const translation = await deepseek( + '你是跨境商务消息翻译器。只翻译 ,输出严格 JSON。保持原意、数字、金额、型号、链接、换行和专有名词;不得解释、回答、摘要或增加承诺。译文必须自然且可直接发送。若源语言已是目标语言,原样返回。', + `目标语言:${targetInstruction} + +${conversationContext(contextualMessages, 20)} + +${conversationSummary ? `\n${conversationSummary}\n` : ''} + +${originalText} + +输出 {"translated":"完整译文","detectedLang":"源语言 ISO 代码","targetLang":"实际目标语言 ISO 代码"}`, + { json: true, maxTokens: Math.min(2200, Math.max(600, originalText.length * 3)) }, + ) + if (!translation?.translated) throw fail(502, 'AI 未返回完整译文,消息未发送') telegramText = String(translation.translated).trim() - originalLang = String(translation.detectedLang || 'zh-CN') - translationCn = originalText + originalLang = normalizeLanguageCode(translation.detectedLang) || 'auto' + targetLang = normalizeLanguageCode(translation.targetLang) + || normalizeLanguageCode(resolvedLanguage.code) + || 'en' + if (/[\u3400-\u9fff]/u.test(originalText)) translationCn = originalText + translationContextMessages = contextualMessages.length + translationConversationSummaryApplied = Boolean(conversationSummary) + translationMethod = contextualMessages.length ? 'ai-contextual' : 'ai-direct' + translationModel = process.env.DEEPSEEK_MODEL || 'deepseek-v4-flash' } if (conversation.channel === 'email') { if (!channel?.encryptedPassword) throw fail(409, '会话未关联可用 Email 渠道') @@ -1539,9 +2154,14 @@ app.post('/api/conversations/:id/messages', async (req, res) => { type: 'text', originalText, translatedText: telegramText !== originalText ? telegramText : undefined, + translationMethod, + translationContextMessages, + translationConversationSummaryApplied, + translationModel, translationCn: translationCn || undefined, translationStatus: translationCn ? 'completed' : 'pending', originalLang, + targetLang, status: 'sent', replyToId: req.body.replyToId ? String(req.body.replyToId) : undefined, replyPreview: req.body.replyPreview ? String(req.body.replyPreview).slice(0, 240) : undefined, @@ -1588,9 +2208,13 @@ app.post('/api/conversations/:id/messages', async (req, res) => { conversationId: conversation.id, originalText, translatedText: telegramText !== originalText ? telegramText : undefined, + translationMethod, + translationContextMessages, + translationConversationSummaryApplied, + translationModel, translationCn: translationCn || undefined, originalLang, - targetLang: 'en', + targetLang, createdAt: new Date().toISOString(), }) const message = await putResource(req.auth.tenantId, `messages:${conversation.id}`, { @@ -1600,9 +2224,14 @@ app.post('/api/conversations/:id/messages', async (req, res) => { type: 'text', originalText, translatedText: telegramText !== originalText ? telegramText : undefined, + translationMethod, + translationContextMessages, + translationConversationSummaryApplied, + translationModel, translationCn: translationCn || undefined, translationStatus: translationCn ? 'completed' : 'pending', originalLang, + targetLang, status: result?.deliveryStatus || (result?.queued ? 'sending' : 'sent'), replyToId: req.body.replyToId ? String(req.body.replyToId) : undefined, replyPreview: req.body.replyPreview ? String(req.body.replyPreview).slice(0, 240) : undefined, @@ -1648,18 +2277,27 @@ app.post('/api/conversations/:id/messages', async (req, res) => { conversationId: conversation.id, originalText, translatedText: telegramText !== originalText ? telegramText : undefined, + translationMethod, + translationContextMessages, + translationConversationSummaryApplied, + translationModel, translationCn: translationCn || undefined, originalLang, - targetLang: 'en', + targetLang, createdAt: new Date().toISOString(), }) const message = await putResource(req.auth.tenantId, `messages:${conversation.id}`, { id: resultMessageId, conversationId: conversation.id, sender: 'agent', type: req.body.type || 'text', originalText, translatedText: telegramText !== originalText ? telegramText : undefined, + translationMethod, + translationContextMessages, + translationConversationSummaryApplied, + translationModel, translationCn: translationCn || undefined, translationStatus: translationCn ? 'completed' : 'pending', originalLang, + targetLang, status: result?.deliveryState === 'sent' ? 'sent' : 'sending', replyToId: req.body.replyToId ? String(req.body.replyToId) : undefined, replyPreview: req.body.replyPreview ? String(req.body.replyPreview).slice(0, 240) : undefined, @@ -2564,6 +3202,7 @@ async function upsertWhatsAppMessage(tenantId, channel, rawMessage, options = {} translationCn: outgoingMeta?.translationCn || existingMessage?.translationCn, translationStatus: outgoingMeta?.translationCn || existingMessage?.translationCn ? 'completed' : 'pending', originalLang: outgoingMeta?.originalLang || existingMessage?.originalLang || 'auto', + targetLang: outgoingMeta?.targetLang || existingMessage?.targetLang, status: rawMessage.fromMe ? rawMessage.deliveryStatus || 'sent' : 'delivered', createdAt: rawMessage.createdAt || new Date(Number(rawMessage.timestamp || Date.now() / 1000) * 1000).toISOString(), editedAt: rawMessage.edited ? new Date().toISOString() : existingMessage?.editedAt, @@ -3057,14 +3696,32 @@ app.delete('/api/channels/:id', async (req, res) => { async function messagesFor(req) { if (!req.body.conversationId) return [] - return listResources(req.auth.tenantId, `messages:${req.body.conversationId}`) + return sortMessagesChronologically( + await listResources(req.auth.tenantId, `messages:${req.body.conversationId}`), + ) } + +async function retrieveKnowledge(tenantId, query, limit = 5) { + const items = await listResources(tenantId, 'knowledge') + return rankKnowledge(items, query, limit) +} + app.post('/api/ai/translate', async (req, res) => { const messages = await messagesFor(req) + const settings = await getResource(req.auth.tenantId, 'singleton', 'settings') + const contextEnabled = (settings?.ai?.contextualTranslation ?? true) !== false + const contextText = contextEnabled ? conversationContext(messages, 30) : '' + const translationConversation = contextEnabled && req.body.conversationId + ? await getResource(req.auth.tenantId, 'conversations', req.body.conversationId) + : null + const translationCustomer = translationConversation + ? await getResource(req.auth.tenantId, 'customers', translationConversation.customerId) + : null + const conversationSummary = contextMemoryText(translationConversation, translationCustomer) const result = await deepseek( - '你是灵犀跨境的专业跨境贸易翻译。只输出严格 JSON,不要 Markdown。', - `目标语言:${req.body.targetLang}\n场景:${req.body.scenario || '跨境商务'}\n上下文:\n${conversationContext(messages)}\n待翻译:\n${req.body.text}\n输出 {"translated":"...","detectedLang":"ISO语言代码","terms":[{"source":"...","target":"..."}]}`, - true, + '你是灵犀跨境的专业跨境贸易翻译。只输出严格 JSON,不要 Markdown。会话上下文仅用于代词消歧、术语一致性、语气和业务含义判断;只翻译 内的内容,不得把上下文拼入译文。', + `目标语言:${req.body.targetLang}\n场景:${req.body.scenario || '跨境商务'}\n${conversationSummary ? `\n${conversationSummary}\n\n` : ''}\n${contextText}\n\n\n${req.body.text}\n\n输出 {"translated":"仅 source 的完整译文","detectedLang":"ISO语言代码","terms":[{"source":"...","target":"..."}]}`, + { json: true, maxTokens: Math.min(2200, Math.max(600, String(req.body.text || '').length * 3)) }, ) const historyItem = await putResource(req.auth.tenantId, 'translation-history', { sourceLang: req.body.sourceLang || result.detectedLang || 'auto', @@ -3078,7 +3735,17 @@ app.post('/api/ai/translate', async (req, res) => { createdBy: req.auth.userId, createdAt: new Date().toISOString(), }) - ok(res, { ...result, historyItem }) + ok(res, { + ...result, + historyItem, + meta: { + method: contextText ? 'ai-contextual' : 'ai-direct', + contextApplied: Boolean(contextText || conversationSummary), + contextMessageCount: Math.min(messages.length, 30), + conversationSummaryApplied: Boolean(conversationSummary), + model: process.env.DEEPSEEK_MODEL || 'deepseek-v4-flash', + }, + }) }) app.get('/api/ai/translations', async (req, res) => { const list = (await listResources(req.auth.tenantId, 'translation-history')) @@ -3103,19 +3770,72 @@ app.post('/api/ai/analyze', async (req, res) => { }) app.post('/api/ai/replies', async (req, res) => { const messages = await messagesFor(req) + const conversation = req.body.conversationId + ? await getResource(req.auth.tenantId, 'conversations', req.body.conversationId) + : null + if (!conversation) throw fail(404, '会话不存在') + const replyCustomer = await getResource(req.auth.tenantId, 'customers', conversation.customerId) + const conversationMemory = contextMemoryText(conversation, replyCustomer) + const resolvedLanguage = await resolveConversationLanguage( + req.auth.tenantId, + conversation, + req.body.targetLang, + ) + const targetInstruction = resolvedLanguage.code === 'auto' + ? '根据最近客户消息自动判断;没有语言线索时使用英语' + : `使用 ISO 代码 ${resolvedLanguage.code} 对应的语言` + const recentCustomerText = [...messages] + .reverse() + .filter((message) => message.sender === 'customer') + .slice(0, 4) + .map((message) => message.originalText) + .filter(Boolean) + .reverse() + .join('\n') + const knowledgeHits = await retrieveKnowledge( + req.auth.tenantId, + `${conversationMemory}\n${recentCustomerText}`, + 5, + ) + const knowledgeContext = knowledgeHits.length + ? knowledgeHits.map((hit) => ({ + id: hit.item.id, + title: hit.item.title, + score: Number(hit.score.toFixed(3)), + excerpt: hit.snippet, + })) + : [] const result = await deepseek( - '你是跨境销售助理。只输出严格 JSON 数组,生成三条可人工确认的英文回复建议。content 必须是可直接发送给海外客户的完整英文;translationCn 必须是 content 准确、自然且完整的简体中文翻译;reason、expectedEffect 和 cultureTip 使用中文。translationCn 仅供中国客服理解,不得添加英文中不存在的信息。', - `${conversationContext(messages)}\n输出数组元素结构 {"id":"唯一值","style":"stable|active|brief","content":"英文回复","translationCn":"英文回复的完整简体中文翻译","targetLang":"en","reason":"中文理由","knowledgeRefs":[],"expectedEffect":"中文说明","cultureTip":"中文提示"}`, - true, + '你是跨境销售回复助手。基于会话事实和给定企业知识生成三条可人工确认的回复,不得编造价格、库存、交期、政策或承诺。知识不足时明确需要人工确认。只输出严格 JSON 数组。content 使用客户语言且可直接发送;translationCn 是 content 完整准确的简体中文翻译;knowledgeRefs 只能填写已提供知识的 id;其余说明使用简体中文。', + `客户目标语言:${targetInstruction} + +${conversationContext(messages, 30)} + +${conversationMemory ? `\n${conversationMemory}\n` : ''} + +${JSON.stringify(knowledgeContext)} + +输出 3 个元素: +{"id":"唯一值","style":"stable|active|brief","content":"客户语言回复","translationCn":"完整中文翻译","targetLang":"实际 ISO 语言代码","reason":"选择理由","knowledgeRefs":[],"expectedEffect":"预期效果","cultureTip":"必要时填写"}`, + { json: true, maxTokens: 2600 }, ) if (!Array.isArray(result)) throw fail(502, 'AI 回复建议格式异常') - let suggestions = result.slice(0, 3) + const allowedKnowledge = new Map(knowledgeHits.map((hit) => [String(hit.item.id), hit.item])) + let suggestions = result.slice(0, 3).map((item) => ({ + ...item, + id: String(item.id || uid()), + knowledgeRefs: Array.isArray(item.knowledgeRefs) + ? [...new Set(item.knowledgeRefs.map(String))] + .filter((id) => allowedKnowledge.has(id)) + .slice(0, 3) + : [], + })) const missingTranslations = suggestions.filter((item) => !String(item?.translationCn || '').trim()) if (missingTranslations.length) { const translated = await deepseek( '你是专业商务翻译。只输出严格 JSON 数组。将每个 content 完整翻译成自然、准确的简体中文,不得遗漏或添加信息。', `待翻译内容:${JSON.stringify(missingTranslations.map(({ id, content }) => ({ id, content })))}\n输出结构:[{"id":"原始 id","translationCn":"完整简体中文翻译"}]`, - true, + { json: true, maxTokens: 1400 }, ) const translationMap = new Map( (Array.isArray(translated) ? translated : []) @@ -3129,9 +3849,26 @@ app.post('/api/ai/replies', async (req, res) => { if (suggestions.length !== 3 || suggestions.some((item) => !item?.content || !item?.translationCn)) { throw fail(502, 'AI 回复建议缺少完整的中英文内容') } + suggestions = await Promise.all(suggestions.map(async (suggestion) => { + const knowledgeRefs = suggestion.knowledgeRefs || [] + await putResource(req.auth.tenantId, 'ai-reply-suggestions', { + ...suggestion, + conversationId: conversation.id, + knowledgeRefs, + createdAt: new Date().toISOString(), + }) + return { + ...suggestion, + knowledgeRefs: knowledgeRefs.map((id) => allowedKnowledge.get(id)?.title || id), + knowledgeRefIds: knowledgeRefs, + } + })) ok(res, suggestions) }) app.post('/api/ai/adoptions', async (req, res) => { + const suggestion = req.body.suggestionId + ? await getResource(req.auth.tenantId, 'ai-reply-suggestions', String(req.body.suggestionId)) + : null const value = await putResource(req.auth.tenantId, 'ai-adoptions', { id: uid(), conversationId: String(req.body.conversationId || ''), @@ -3140,6 +3877,15 @@ app.post('/api/ai/adoptions', async (req, res) => { adoptedBy: req.auth.userId, createdAt: new Date().toISOString(), }) + for (const knowledgeId of suggestion?.knowledgeRefs || []) { + const item = await getResource(req.auth.tenantId, 'knowledge', String(knowledgeId)) + if (!item) continue + await putResource(req.auth.tenantId, 'knowledge', { + ...item, + citedCount: Number(item.citedCount || 0) + 1, + lastCitedAt: new Date().toISOString(), + }) + } ok(res, value) }) app.post('/api/ai/summarize', async (req, res) => { @@ -3147,7 +3893,14 @@ app.post('/api/ai/summarize', async (req, res) => { ok(res, { summary: await deepseek('用中文简洁总结跨境商务会话。', conversationContext(messages)) }) }) app.post('/api/ai/text-action', async (req, res) => { - ok(res, { result: await deepseek('你是跨境商务写作助手,只返回处理后的文本。', `操作:${req.body.action}\n语气:${req.body.tone || ''}\n目标语言:${req.body.targetLang || ''}\n文本:${req.body.text}`) }) + const target = normalizeLanguageCode(req.body.targetLang) || req.body.targetLang || '' + ok(res, { + result: await deepseek( + '你是跨境商务写作助手。只返回处理后的正文,不要标题、解释或引号。保留事实、数字、专有名词和原有承诺边界。', + `操作:${req.body.action}\n语气:${req.body.tone || ''}\n目标语言:${target}\n文本:${req.body.text}`, + { maxTokens: 1600 }, + ), + }) }) const emptyKpi = () => ({ value: 0, delta: 0 }) async function businessDataset(tenantId) { @@ -3336,7 +4089,7 @@ app.delete('/api/team/:id', (_req, _res, next) => next(fail(501, '成员删除 const defaultSettings = { company: { name: '灵犀跨境', industry: '', size: '', contact: '', email: '', intro: '', products: [], regions: [] }, - ai: { replyLanguage: 'zh-CN', replyTone: 'professional', autoGenerate: true, requireConfirm: true, sentimentAnalysis: true, riskAlert: true, cultureCheck: true, allowHistoryRef: true }, + ai: { replyLanguage: 'zh-CN', replyTone: 'professional', autoGenerate: true, requireConfirm: true, sentimentAnalysis: true, messageIntentAnalysis: true, contextualTranslation: true, riskAlert: true, cultureCheck: true, allowHistoryRef: true }, notification: { newInquiry: true, highIntent: true, riskAlert: true, taskDue: true, taskOverdue: true, channelOffline: true, planExpire: true, byEmail: false, bySms: false }, translate: { industry: '', formality: 'professional', targetCountry: '', localization: true, keepBrand: true, keepModel: true, autoUnit: true, cultureReminder: true, religionReminder: true }, language: 'zh-CN', timezone: 'Asia/Hong_Kong', sessionRetentionMonths: 12, brandColor: '#2563eb', onboarding: {}, @@ -3403,11 +4156,19 @@ app.post('/api/knowledge/upload', async (req, res) => { else if (extension === 'docx') content = (await mammoth.extractRawText({ buffer })).value else if (extension === 'pdf') content = (await pdfParse(buffer)).text else throw fail(415, `暂不支持 ${extension || '未知'} 文件`) + content = content + .replace(/\u0000/g, '') + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{4,}/g, '\n\n') + .trim() + if (content.length < 20) throw fail(422, `文件 ${file.name || ''} 未解析出足够的文本内容`) items.push(await putResource(req.auth.tenantId, 'knowledge', { title: String(file.name).replace(/\.[^.]+$/, ''), category: 'product', - content: content.trim(), tags: ['文件导入'], enabled: true, version: 1, + content, tags: ['文件导入'], enabled: true, version: 1, updatedBy: req.auth.userId, source: 'file', fileName: file.name, fileType: extension, status: 'ready', citedCount: 0, languages: ['auto'], quality: 'normal', + parseMessage: `已解析 ${content.length.toLocaleString('zh-CN')} 个字符`, + ...knowledgeMetadata(content), createdAt: new Date().toISOString(), })) } @@ -3415,17 +4176,32 @@ app.post('/api/knowledge/upload', async (req, res) => { ok(res, { items }) }) app.post('/api/knowledge/search-test', async (req, res) => { - const list = await listResources(req.auth.tenantId, 'knowledge') - const words = String(req.body.question || '').toLowerCase().split(/\s+/) - const hits = list.filter((x) => words.some((w) => JSON.stringify(x).toLowerCase().includes(w))).slice(0, 5).map((item) => ({ item, score: 1, snippet: String(item.content || '').slice(0, 120) })) - for (const hit of hits) { - hit.item = await putResource(req.auth.tenantId, 'knowledge', { - ...hit.item, - citedCount: Number(hit.item.citedCount || 0) + 1, - lastCitedAt: new Date().toISOString(), - }) + const question = String(req.body.question || '').trim() + if (question.length < 2) throw fail(400, '请输入至少 2 个字符的问题') + const hits = await retrieveKnowledge(req.auth.tenantId, question, 6) + if (!hits.length) { + ok(res, { answer: '知识库中没有找到可支持回答的内容。', hits: [] }) + return } - ok(res, { answer: hits.length ? await deepseek('仅根据给出的企业知识回答,不得编造。', `问题:${req.body.question}\n知识:${JSON.stringify(hits)}`) : '知识库中没有匹配内容。', hits }) + const sources = hits.map((hit) => ({ + id: hit.item.id, + title: hit.item.title, + excerpt: hit.snippet, + })) + const answer = await deepseek( + '你是企业知识库问答助手。只能根据 回答;不能用常识补全未出现的价格、政策、承诺或事实。内容不足时明确说“现有知识不足”。回答使用简体中文,并在相关句末用方括号标注实际来源的 title,例如来源 title 是“欧洲售后政策”时写成 [欧洲售后政策];禁止原样输出“[来源标题]”。', + `${question}\n${JSON.stringify(sources)}`, + { maxTokens: 1200 }, + ) + ok(res, { + answer, + hits: hits.map((hit) => ({ + item: hit.item, + score: hit.score, + snippet: hit.snippet, + chunkIndex: hit.chunkIndex, + })), + }) }) app.get('/api/billing', async (req, res) => { const [{ rows }, orders, invoices, userCount, conversations, knowledge] = await Promise.all([ @@ -3672,6 +4448,7 @@ app.post('/internal/webhooks/telegram/:tenantId/:channelId', async (req, res) => translatedText: serviceText ? undefined : outgoingMeta?.translatedText || previousMessage?.translatedText, translationCn: serviceText ? undefined : outgoingMeta?.translationCn || previousMessage?.translationCn, originalLang: outgoingMeta?.originalLang || previousMessage?.originalLang || 'auto', + targetLang: outgoingMeta?.targetLang || previousMessage?.targetLang, status: messageStatus, translationStatus: content['@type'] === 'messageText' && originalText ? outgoingMeta?.translationCn || previousMessage?.translationCn @@ -3962,6 +4739,22 @@ async function repairCorruptedTranslations() { await migrate() await repairCorruptedTranslations() +const pendingBackfills = await pool.query( + `SELECT tenant_id, data FROM resources + WHERE kind='ai-backfill-jobs' + AND COALESCE(data->>'status', '') <> 'completed'`, +) +for (const row of pendingBackfills.rows) { + void runConversationBackfill(String(row.tenant_id), String(row.data.conversationId || row.data.id)) +} +const pendingContextCompactions = await pool.query( + `SELECT tenant_id, data FROM resources + WHERE kind='conversations' + AND COALESCE(data->>'aiContextStatus', '') IN ('queued', 'compressing', 'failed')`, +) +for (const row of pendingContextCompactions.rows) { + enqueueContextCompaction(String(row.tenant_id), String(row.data.id)) +} const existingTelegramChannels = await pool.query( `SELECT tenant_id, data FROM resources WHERE kind='channels' diff --git a/server/src/knowledge.js b/server/src/knowledge.js new file mode 100644 index 0000000..b0ec702 --- /dev/null +++ b/server/src/knowledge.js @@ -0,0 +1,107 @@ +function normalize(value) { + return String(value || '') + .normalize('NFKC') + .toLowerCase() + .replace(/\s+/g, ' ') + .trim() +} + +export function knowledgeTokens(value) { + const text = normalize(value) + const tokens = new Set(text.match(/[a-z0-9][a-z0-9._+-]{1,}/g) || []) + const chineseRuns = text.match(/[\u3400-\u9fff]+/g) || [] + for (const run of chineseRuns) { + if (run.length === 1) tokens.add(run) + for (let size = 2; size <= 3; size += 1) { + for (let index = 0; index <= run.length - size; index += 1) { + tokens.add(run.slice(index, index + size)) + } + } + } + return [...tokens].slice(0, 300) +} + +export function chunkKnowledge(content, maximum = 900, overlap = 120) { + const text = String(content || '').replace(/\r\n/g, '\n').trim() + if (!text) return [] + const paragraphs = text.split(/\n{2,}/).map((item) => item.trim()).filter(Boolean) + const chunks = [] + let current = '' + const flush = () => { + if (!current.trim()) return + chunks.push(current.trim()) + current = current.slice(-overlap) + } + for (const paragraph of paragraphs.length ? paragraphs : [text]) { + if (paragraph.length > maximum) { + if (current) flush() + for (let start = 0; start < paragraph.length; start += maximum - overlap) { + chunks.push(paragraph.slice(start, start + maximum).trim()) + } + current = '' + continue + } + if (current && current.length + paragraph.length + 2 > maximum) flush() + current = current ? `${current}\n\n${paragraph}` : paragraph + } + if (current.trim()) chunks.push(current.trim()) + return [...new Set(chunks)].filter(Boolean) +} + +function scoreChunk(item, chunk, queryTokens, normalizedQuery) { + const chunkText = normalize(chunk) + const titleText = normalize(`${item.title || ''} ${(item.tags || []).join(' ')}`) + let matched = 0 + let weighted = 0 + for (const token of queryTokens) { + if (chunkText.includes(token)) { + matched += 1 + weighted += token.length >= 3 ? 1.35 : 1 + } + if (titleText.includes(token)) weighted += 1.8 + } + if (normalizedQuery.length >= 3 && chunkText.includes(normalizedQuery)) weighted += 4 + const coverage = queryTokens.length ? matched / queryTokens.length : 0 + return weighted + coverage * 3 +} + +export function rankKnowledge(items, query, limit = 5) { + const normalizedQuery = normalize(query) + const queryTokens = knowledgeTokens(normalizedQuery) + if (!normalizedQuery || !queryTokens.length) return [] + const candidates = [] + for (const item of items) { + if (item.enabled === false || item.status === 'failed') continue + const chunks = chunkKnowledge(item.content) + chunks.forEach((chunk, index) => { + const rawScore = scoreChunk(item, chunk, queryTokens, normalizedQuery) + if (rawScore <= 0) return + candidates.push({ + item, + chunkIndex: index, + snippet: chunk.slice(0, 560), + rawScore, + }) + }) + } + candidates.sort((a, b) => b.rawScore - a.rawScore) + const bestByItem = new Map() + for (const candidate of candidates) { + if (!bestByItem.has(candidate.item.id)) bestByItem.set(candidate.item.id, candidate) + } + const selected = [...bestByItem.values()].slice(0, Math.max(1, limit)) + const maximum = selected[0]?.rawScore || 1 + return selected.map((candidate) => ({ + ...candidate, + score: Math.max(0.01, Math.min(1, candidate.rawScore / maximum)), + })) +} + +export function knowledgeMetadata(content) { + const chunks = chunkKnowledge(content) + return { + characterCount: String(content || '').length, + chunkCount: chunks.length, + indexedAt: new Date().toISOString(), + } +} diff --git a/src/components/auth/AuthShell.tsx b/src/components/auth/AuthShell.tsx new file mode 100644 index 0000000..72b7e85 --- /dev/null +++ b/src/components/auth/AuthShell.tsx @@ -0,0 +1,126 @@ +import type { ReactNode } from 'react' +import { ArrowUpRight, Check, Languages } from 'lucide-react' + +export function BrandMark({ compact = false }: { compact?: boolean }) { + return ( +
+ + {!compact && ( +
+

灵犀跨境

+

跨境客户沟通工作台

+
+ )} +
+ ) +} + +function ProductMotion() { + return ( +
+
+
+
+
+ + Eternity · Telegram +
+ + 双语视图 + +
+
+
+

English

+

+ Could you confirm the sample delivery date? +

+
+
+ + + + + + + 已翻译 +
+
+

中文参考

+

可以确认一下样品的交付日期吗?

+
+
+ {[ + ['意向', '高'], + ['情绪', '积极'], + ['风险', '低'], + ].map(([label, value]) => ( +
+

{label}

+

{value}

+
+ ))} +
+
+
+
+ + + + 消息已送达 +
+
+ ) +} + +export function AuthShell({ + children, + topAction, + heading = '让每一次跨境沟通,都清楚且及时。', + description = '汇集消息、翻译、客户资料与团队跟进,在一个工作台完成日常客户沟通。', +}: { + children: ReactNode + topAction?: ReactNode + heading?: string + description?: string +}) { + return ( +
+
+ + +
+
+
+
{topAction}
+
+
+
{children}
+
+
+
+
+ ) +} diff --git a/src/components/common/PageHeader.tsx b/src/components/common/PageHeader.tsx index c196e47..3a6ec7c 100644 --- a/src/components/common/PageHeader.tsx +++ b/src/components/common/PageHeader.tsx @@ -15,11 +15,11 @@ export function PageHeader({ className?: string }) { return ( -
+
-

{title}

- {description &&

{description}

} +

{title}

+ {description &&

{description}

}
{actions &&
{actions}
}
diff --git a/src/components/common/StatCard.tsx b/src/components/common/StatCard.tsx index 4ef5637..2d9bea5 100644 --- a/src/components/common/StatCard.tsx +++ b/src/components/common/StatCard.tsx @@ -29,13 +29,13 @@ export function StatCard({
{title} - +
diff --git a/src/components/inbox/AiReplies.tsx b/src/components/inbox/AiReplies.tsx index 8d78779..ee123ae 100644 --- a/src/components/inbox/AiReplies.tsx +++ b/src/components/inbox/AiReplies.tsx @@ -20,17 +20,19 @@ export function AiReplies({ onAdopt, onEdit, onClose, + targetLanguage = 'auto', }: { conversationId: string - onAdopt: (content: string, translationCn: string) => void + onAdopt: (content: string, translationCn: string, suggestionId: string) => void onEdit: (content: string, translationCn: string) => void onClose: () => void + targetLanguage?: string }) { const [items, setItems] = useState(null) useEffect(() => setItems(null), [conversationId]) const mutation = useMutation({ - mutationFn: () => generateReplies(conversationId), + mutationFn: () => generateReplies(conversationId, targetLanguage), onSuccess: (data) => { setItems(Array.isArray(data) ? data : []) toast.success('回复建议已生成', '已参考客户资料、知识库和历史回复') @@ -98,7 +100,7 @@ export function AiReplies({
- 发送给客户 · English + 发送给客户 · {languageName(s.targetLang)}

{s.content}

@@ -145,7 +147,7 @@ export function AiReplies({
- diff --git a/src/components/inbox/Composer.tsx b/src/components/inbox/Composer.tsx index 9a1f380..1fab69e 100644 --- a/src/components/inbox/Composer.tsx +++ b/src/components/inbox/Composer.tsx @@ -10,6 +10,7 @@ import { Shrink, Expand, ChevronDown, + Languages, Reply, X, } from 'lucide-react' @@ -20,6 +21,7 @@ import { Switch } from '@/components/ui/switch' import { Badge } from '@/components/ui/badge' import { toast } from '@/app/store/toast' import type { ChannelType } from '@/types' +import { LANGUAGES, languageName } from '@/config/languages' const QUICK_PHRASES = [ { label: '报价催促', text: 'Friendly reminder: the quotation is valid until this Friday. Please confirm at your earliest convenience.' }, @@ -48,8 +50,11 @@ export function Composer({ replyingTo, onCancelReply, channel = 'telegram', + targetLanguage = 'auto', + detectedLanguage, + onTargetLanguageChange, }: { - onSend: (text: string, autoTranslate?: boolean) => Promise + onSend: (text: string, autoTranslate?: boolean, targetLang?: string) => Promise onMedia?: (files: File[], kind: 'document' | 'photo' | 'audio' | 'voice' | 'sticker') => Promise value?: string onChange?: (text: string) => void @@ -57,6 +62,9 @@ export function Composer({ replyingTo?: { id: string; text: string } | null onCancelReply?: () => void channel?: ChannelType + targetLanguage?: string + detectedLanguage?: string + onTargetLanguageChange?: (language: string) => void }) { const [internalText, setInternalText] = useState('') const [sending, setSending] = useState(false) @@ -92,7 +100,7 @@ export function Composer({ if (!text.trim()) return setSending(true) try { - await onSend(text, autoTranslate) + await onSend(text, autoTranslate, targetLanguage) setText('') } finally { setSending(false) @@ -116,7 +124,7 @@ export function Composer({ setAiLoading(true) try { const { textAction } = await import('@/services/api/ai') - const res = await textAction({ text, action: action as any, tone, targetLang: 'en' }) + const res = await textAction({ text, action: action as any, tone, targetLang: targetLanguage }) setText(res.result) toast.success('AI 处理完成') } catch { @@ -238,11 +246,31 @@ export function Composer({ />
-
+
+ {autoTranslate && ( + + )} {aiLoading && ( 正在调用 AI… diff --git a/src/components/inbox/ConversationInsightBar.tsx b/src/components/inbox/ConversationInsightBar.tsx index 1b762c4..c70a3fe 100644 --- a/src/components/inbox/ConversationInsightBar.tsx +++ b/src/components/inbox/ConversationInsightBar.tsx @@ -1,5 +1,5 @@ -import { Brain, ChevronRight } from 'lucide-react' -import type { Conversation, Customer, CustomerStage } from '@/types' +import { Brain, ChevronRight, LoaderCircle } from 'lucide-react' +import type { AiBackfillStatus, Conversation, Customer, CustomerStage } from '@/types' import { Button } from '@/components/ui/button' import { CustomerStageBadge, @@ -17,10 +17,12 @@ export function ConversationInsightBar({ customer, conversation, onOpenProfile, + aiBackfill, }: { customer: Customer conversation: Conversation onOpenProfile: () => void + aiBackfill?: AiBackfillStatus }) { const stage = validStages.has(customer.stage) ? customer.stage : 'initial' const summary = customer.aiProfile?.summary || conversation.summary || '收到更多客户消息后将补充客户画像' @@ -41,6 +43,15 @@ export function ConversationInsightBar({

{summary}

+ {aiBackfill && ['queued', 'running'].includes(aiBackfill.status) && ( + + + 历史分析 {Math.round(Number(aiBackfill.progress || 0) * 100)}% + + )} 意向 @@ -54,6 +65,12 @@ export function ConversationInsightBar({
+ {aiBackfill && ['queued', 'running'].includes(aiBackfill.status) && ( + + + 历史分析 {Math.round(Number(aiBackfill.progress || 0) * 100)}% + + )} 意向 diff --git a/src/components/inbox/InsightPanel.tsx b/src/components/inbox/InsightPanel.tsx index fb756b3..2c8a840 100644 --- a/src/components/inbox/InsightPanel.tsx +++ b/src/components/inbox/InsightPanel.tsx @@ -46,9 +46,17 @@ export function InsightPanel({ const [analyzing, setAnalyzing] = useState(false) const [analysis, setAnalysis] = useState(customer.aiProfile.summary) const c = countryMeta(customer.country) - const localTime = new Date( - new Date().getTime() + new Date().getTimezoneOffset() * 60000 + c.tzOffset * 3600000, - ).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) + const localTime = Number.isFinite(c.tzOffset) + ? new Date( + new Date().getTime() + new Date().getTimezoneOffset() * 60000 + c.tzOffset * 3600000, + ).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) + : '' + const countrySourceLabel = { + manual: '人工确认', + 'account-profile': '账户资料', + 'phone-prefix': '手机号区号', + unknown: '待确认', + }[customer.countrySource || 'unknown'] const updateMut = useMutation({ mutationFn: (data: Partial) => updateCustomer(customer.id, data), @@ -114,11 +122,14 @@ export function InsightPanel({
+ + {countrySourceLabel} +
-
+ {localTime &&
当地时间:{localTime} -
+
} {customer.email && (
@@ -177,6 +188,51 @@ export function InsightPanel({ {/* AI 分析 */}
+
+
+
+

会话长期记忆

+

+ {conversation.aiContextStatus === 'compressing' + ? '正在压缩较早的会话内容…' + : conversation.aiContextMemory + ? `已压缩 ${conversation.aiContextSourceMessageCount || conversation.aiContextMemory.sourceMessageCount || 0} 条较早消息` + : '会话达到长度阈值后自动生成'} +

+
+ + {conversation.aiContextStatus === 'compressing' + ? '压缩中' + : conversation.aiContextMemory + ? '已启用' + : '等待触发'} + +
+ {conversation.aiContextMemory?.summary && ( +

+ {conversation.aiContextMemory.summary} +

+ )} + {conversation.aiContextMemory?.unresolved?.length ? ( +
+

未决事项

+
+ {conversation.aiContextMemory.unresolved.slice(0, 4).map((item) => ( + + {item} + + ))} +
+
+ ) : null} +
diff --git a/src/components/inbox/MessageBubble.tsx b/src/components/inbox/MessageBubble.tsx index 353e206..ae22b33 100644 --- a/src/components/inbox/MessageBubble.tsx +++ b/src/components/inbox/MessageBubble.tsx @@ -21,6 +21,7 @@ import { LoaderCircle, SmilePlus, Trash2, + Brain, } from 'lucide-react' import { useQuery } from '@tanstack/react-query' import type { ChannelType, Message } from '@/types' @@ -89,6 +90,7 @@ export function MessageBubble({ customerName, customerAvatar, channel, + showIntent = true, }: { msg: Message viewMode: ViewMode @@ -102,6 +104,7 @@ export function MessageBubble({ customerName?: string customerAvatar?: string channel?: ChannelType + showIntent?: boolean }) { const [showActions, setShowActions] = useState(false) const [translationExpanded, setTranslationExpanded] = useState(false) @@ -124,7 +127,15 @@ export function MessageBubble({ const longTranslation = translatedText.length > 260 || translatedText.split('\n').length > 6 const hasKnownLanguage = Boolean(msg.originalLang && !['und', 'auto', 'unknown'].includes(msg.originalLang)) const sourceLanguage = hasKnownLanguage ? languageName(msg.originalLang) : '原文' + const targetLanguage = msg.targetLang && !['und', 'auto', 'unknown'].includes(msg.targetLang) + ? languageName(msg.targetLang) + : '客户语言' const providerName = channel === 'whatsapp' ? 'WhatsApp' : channel === 'email' ? 'Email' : 'Telegram' + const contextualTranslation = msg.translationMethod === 'ai-contextual' + && Number(msg.translationContextMessages || 0) > 0 + const translationTrace = contextualTranslation + ? `AI 结合最近 ${msg.translationContextMessages} 条会话消息${msg.translationConversationSummaryApplied ? '及客户画像摘要' : ''}完成翻译` + : 'AI 直接翻译当前消息' const isMediaMessage = ['image', 'audio', 'file'].includes(msg.type) const hasProviderAttachment = Boolean( msg.telegramFileId @@ -219,7 +230,7 @@ export function MessageBubble({
- 客户收到 · {translatedText ? '英文译文' : '原文'} + 客户收到 · {translatedText ? targetLanguage : '原文'}

{outgoingSentText}

@@ -265,9 +276,12 @@ export function MessageBubble({

{originalText}

- + - {isMe ? '发送内容 · 英文' : 'AI 译文 · 中文'} + {isMe ? `发送内容 · ${targetLanguage}` : contextualTranslation ? 'AI 语境译文 · 中文' : 'AI 译文 · 中文'}
{translationContent()}
@@ -277,9 +291,12 @@ export function MessageBubble({ if (viewMode === 'translated' && translatedText) { return (
- + - {isMe ? `${sourceLanguage} → 英文发送内容` : `${sourceLanguage} → 中文`} + {isMe ? `${sourceLanguage} → ${targetLanguage}` : `${sourceLanguage} → 中文`}
{translationContent(false)}
@@ -377,6 +394,31 @@ export function MessageBubble({
)} {renderContent()} + {!isMe && showIntent && msg.intentAnalysis && ( +
+ + + 意图 · {msg.intentAnalysis.label} + + + 可信度 {Math.round(Number(msg.intentAnalysis.confidence || 0) * 100)}% + + {['high', 'urgent'].includes(msg.intentAnalysis.urgency) && ( + + {msg.intentAnalysis.urgency === 'urgent' ? '紧急' : '高优先级'} + + )} + {msg.intentAnalysis.entities?.slice(0, 3).map((entity) => ( + + {entity} + + ))} +
+ )} {hasProviderAttachment && attachmentLoading && (
正在读取 {providerName} 文件… diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index 70124e9..a103f11 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -9,7 +9,7 @@ export function AppLayout() { const { mobileNavOpen, setMobileNavOpen } = useUiStore() return ( -
+
{/* 桌面端固定侧边栏 */}
@@ -25,7 +25,7 @@ export function AppLayout() {
-
+
diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 27d16f1..4f155d6 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -1,5 +1,5 @@ import { NavLink, useNavigate } from 'react-router-dom' -import { PanelLeftClose, PanelLeftOpen, Sparkles } from 'lucide-react' +import { PanelLeftClose, PanelLeftOpen } from 'lucide-react' import { NAV_ITEMS } from '@/config/navigation' import { useUiStore } from '@/app/store/ui' import { useAuthStore } from '@/app/store/auth' @@ -35,18 +35,16 @@ export function Sidebar() { return (