From f17a6ccbacafa755106346d372c7d5eb64b93aa1 Mon Sep 17 00:00:00 2001 From: TradeMate Dev Date: Tue, 2 Jun 2026 15:40:02 +0800 Subject: [PATCH] chore: post-deployment cleanup and docs update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Make AI routing rules DB-driven (read from system_configs, removed from config.py) - Add translation quota tracking to LLM translation (OpenAIProvider) - Add Alibaba MT ECS RAM role support (STS token, no AccessKey needed) - Fix admin sidebar link for AI模型配置 page - Fix Quota.vue API path (quotas → translation-quotas) - Fix login auto-redirect to dashboard - Add provider dropdown selects to AI routing config UI - Clean up stale ai_provider_* system_configs records - Remove OpencodeGo, Spark providers (code + DB) - Update deploy config: nginx port 8000, systemd cwd --- AGENTS.md | 32 +- PROGRESS.md | 124 +++++- admin-frontend/src/api/index.js | 9 +- admin-frontend/src/components/AiAssistant.vue | 363 +++++++++++++++++ admin-frontend/src/layouts/AdminLayout.vue | 4 +- admin-frontend/src/router/index.js | 2 +- admin-frontend/src/views/Config.vue | 51 ++- admin-frontend/src/views/Landing.vue | 2 +- admin-frontend/src/views/Quota.vue | 2 +- admin-frontend/vite.config.js | 2 +- .../add_payment_transactions_table.py | 2 +- backend/app/ai/providers/__init__.py | 4 +- backend/app/ai/providers/alibaba.py | 50 ++- backend/app/ai/providers/openai.py | 14 + backend/app/ai/providers/opencode_go.py | 7 - backend/app/ai/providers/spark.py | 90 ----- backend/app/ai/router.py | 89 ++-- backend/app/api/v1/admin_ai.py | 2 - backend/app/api/v1/ai_assistant.py | 19 +- backend/app/config.py | 15 - backend/app/services/admin.py | 54 ++- backend/app/services/translation_quota.py | 9 +- backend/tests/test_config.py | 11 +- user-frontend/src/api/index.js | 3 + user-frontend/src/components/AiAssistant.vue | 382 ++++++++++++++++++ user-frontend/src/layouts/UserLayout.vue | 2 + user-frontend/src/views/WorkspaceLanding.vue | 3 +- user-frontend/vite.config.js | 2 +- 28 files changed, 1140 insertions(+), 209 deletions(-) create mode 100644 admin-frontend/src/components/AiAssistant.vue delete mode 100644 backend/app/ai/providers/opencode_go.py delete mode 100644 backend/app/ai/providers/spark.py create mode 100644 user-frontend/src/components/AiAssistant.vue diff --git a/AGENTS.md b/AGENTS.md index 6ccd2ad..ea4c9b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,20 +1,31 @@ # TradeMate (外贸小助手) — Agent Guide +## AI Assistant (Frontend AI Chatbot) + +- **Components**: `user-frontend/src/components/AiAssistant.vue` + `admin-frontend/src/components/AiAssistant.vue` +- **Backend**: `backend/app/api/v1/ai_assistant.py` — `POST /api/v1/ai/chat`, `GET /api/v1/ai/quick-questions` +- **Action types** (configurable via `ACTION_INSTRUCTIONS`): create_customer, create_product, create_quotation, scan_followups, generate_marketing, discovery_search, navigate, search_users, update_user, update_config, review_certification, process_invoice +- **Frontend action dispatch**: `AiAssistant.vue` switch/case calls corresponding API from `@/api` +- **Layout integration**: `` in `UserLayout.vue` + `AdminLayout.vue`, floating button bottom-right +- **Quick questions**: configurable via `ai_assistant_quick_questions` `SystemConfig` key +- **System prompt**: configurable via `ai_assistant_prompt` `SystemConfig` key + ## Architecture - **Backend**: `backend/` — FastAPI + SQLAlchemy 1.4 async + asyncpg, single `app.main:app` - **Frontends**: `uni-app/` (mobile H5/mini-program), `admin-frontend/` (PC admin), `user-frontend/` (PC workspace) - **Config**: `backend/app/config.py` reads from `/.env` (project root) via pydantic BaseSettings - **Auth**: JWT (python-jose). Default dep `get_current_user_id` in `backend/app/api/v1/deps.py` -- **AI Router**: `backend/app/ai/router.py` — singleton `AIRouter`, DB-driven providers. Primary = sensenova, fallbacks = alibaba-mt / opencode_go / nvidia / spark +- **AI Router**: `backend/app/ai/router.py` — singleton `AIRouter`, DB-driven providers + DB-driven routing rules. Routing reads `ai_routing` from `system_configs` table. - **Database**: PostgreSQL via `asyncpg`, pool_size=20 ## AI Providers -- **Active**: Sensenova (商汤), OpencodeGo, NVIDIA, 讯飞 Spark, 阿里机器翻译 (alibaba-mt) -- **Removed (dead code)**: Claude (`claude.py`), DeepL (`deepl.py`), Local (`local.py`) — git rm'd, not yet committed +- **Active**: Sensenova (商汤), NVIDIA, 阿里机器翻译 (alibaba-mt) — 5 providers in DB +- **Removed**: Claude, DeepL, Local, OpencodeGo, 讯飞 Spark — all git rm'd - **DB-driven**: `AIProvider` model + `admin_ai.py` API — manage providers at runtime. `router.seed_from_env()` loads from `.env` on startup -- **Provider type mapping** in `router.py._build_provider()`: sensenova, opencode_go, nvidia, spark, alibaba-mt +- **ECS RAM role**: 阿里翻译使用 ECS 实例 RAM 角色 `trademate-translate` 获取 STS 临时凭证 +- **Provider type mapping** in `router.py._build_provider()`: sensenova, nvidia, alibaba-mt ## Security @@ -32,7 +43,10 @@ ```bash # Backend (from project root — .env is there) +# Dev: matches Vite proxy (both frontends proxy /api → localhost:8000) cd backend && source venv/bin/activate && uvicorn app.main:app --reload --port 8000 +# Production: nginx proxies /api/ → localhost:8002 +cd backend && source venv/bin/activate && uvicorn app.main:app --port 8002 # Mobile H5 cd uni-app && npm run dev:h5 @@ -66,7 +80,7 @@ alembic revision --autogenerate -m "desc" - **Workspace**: `trade.yuzhiran.com/workspace/` — Vue 3 + Element Plus (standalone) - **Nginx**: SPA fallbacks for `/app/`, `/admin/`, `/workspace/` - **vite config**: each project has its own `base` path and dev port -- **API**: proxied via nginx `location /api/` to `127.0.0.1:8002` +- **API**: proxied via nginx `location /api/` to `127.0.0.1:8000` ## Critical Quirks @@ -80,6 +94,14 @@ alembic revision --autogenerate -m "desc" - **AI Router reload**: After modifying AI providers via admin API, call `POST /api/v1/admin/ai/reload` to refresh in-memory providers. - **Payment**: Uses unified `pay-api` gateway (`UnifiedPayService`). NOT direct WeChat/Alipay integration. Credentials: `PAY_API_KEY`/`PAY_API_SECRET` from `.env`. HMAC-SHA256 auth. Webhook at `POST /api/v1/payment/webhook` skips CSRF. - **Payment gateway config**: `PAY_API_KEY`, `PAY_API_SECRET`, `PAY_API_BASE_URL`, `PAY_WEBHOOK_URL` in `config.py`. `pay_type` param: `"alipay"` (returns `pay_url`) or `"wechat"` (returns `code_url`). `UnifiedPayService` normalizes legacy `native`/`jsapi`/`pc` to `wechat`/`alipay`. +- **Manual auth on some endpoints**: `keywords` and `competitor-analysis` endpoints use `authorization: str = Header(None)` instead of `Depends(get_current_user_id)`. +- **MarketingService fallback**: When no AI providers initialized, returns template content instead of crashing. +- **Onboarding service**: calls `mkt.generate(product_info={"name": ..., ...})`, not keyword args. Check `onboarding.py` for the exact dict shape. +- **CustomerHealthService**: `get_health_overview` endpoint must use `CustomerHealthService(db)` not `CustomerService(db)`. +- **CSRF**: Sensitive endpoints (auth/payment/profile) require `X-CSRF-Token` header. Token available via `csrf_token` cookie / `X-CSRF-Token` response header. +- **AI Router reload**: After modifying AI providers via admin API, call `POST /api/v1/admin/ai/reload` to refresh in-memory providers. +- **Payment**: Uses unified `pay-api` gateway (`UnifiedPayService`). NOT direct WeChat/Alipay integration. Credentials: `PAY_API_KEY`/`PAY_API_SECRET` from `.env`. HMAC-SHA256 auth. Webhook at `POST /api/v1/payment/webhook` skips CSRF. +- **Payment gateway config**: `PAY_API_KEY`, `PAY_API_SECRET`, `PAY_API_BASE_URL`, `PAY_WEBHOOK_URL` in `config.py`. `pay_type` param: `"alipay"` (returns `pay_url`) or `"wechat"` (returns `code_url`). `UnifiedPayService` normalizes legacy `native`/`jsapi`/`pc` to `wechat`/`alipay`. ## Project Conventions diff --git a/PROGRESS.md b/PROGRESS.md index b946ec8..deaa5f2 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,7 +1,7 @@ # TradeMate (外贸小助手) - 项目进度文档 -**更新时间**: 2026-05-29 12:00 -**状态**: ✅ 生产分支活跃开发 — 安全加固完成 + 4 前端项目并行 + 客户挖掘+AI 模型管理 +**更新时间**: 2026-06-02 12:00 +**状态**: ✅ 生产环境运行中 — AI 路由 DB 驱动 + 翻译配额全链路 + ECS RAM 角色认证 --- @@ -11,9 +11,11 @@ |------|------|------| | 后端 API | http://localhost:8000 | ✅ 运行中 | | API 文档 | http://localhost:8000/docs | ✅ 可用 | -| 前端 H5 (uni-app) | http://localhost:5173 | ✅ 运行中 | -| 管理后台 (admin-frontend) | http://localhost:5173 | ✅ 运行中 | -| 用户工作台 (user-frontend) | http://localhost:5174 | ✅ 运行中 | +| 主站 — 营销落地页 | https://trade.yuzhiran.com/ | ✅ 已部署 | +| 主站 — 移动端 H5 | https://trade.yuzhiran.com/app/ | ✅ 已部署 | +| 主站 — 管理后台 | https://trade.yuzhiran.com/admin/ | ✅ 已部署 | +| 主站 — 用户工作台 | https://trade.yuzhiran.com/workspace/ | ✅ 已部署 | +| 镜像站 | https://www.yuzhiran.com.cn/ | ✅ 同步部署 | | Redis | localhost:6379 | ✅ 运行中 | | PostgreSQL | localhost:5432 | ✅ 运行中 | @@ -40,21 +42,30 @@ |------|------|------| | **CORS 白名单** | `app/core/middleware.py` | 限制特定前端域名、方法和请求头 | | **速率限制** | `app/core/middleware.py` | 登录 5次/分、注册 3次/时、改密 3次/5分、支付 20次/分、Admin 30次/分 | -| **CSRF 防护** | `app/core/csrf.py` | 双提交 Cookie 模式中间件;auth/payment/profile 等敏感端点必检;webhook 跳过 | +| **CSRF 防护** | `app/core/csrf.py` | 双提交 Cookie 模式中间件;payment/profile 等敏感端点必检;login/register/webhook 跳过 | | **敏感日志清理** | 全代码审计 | 移除 Token/Key 等敏感信息打印 (T-002) | ### 3. AI 提供商重构 ✅ | 变更 | 说明 | |------|------| -| **移除** | Claude (`claude.py`)、DeepL (`deepl.py`)、本地模型 (`local.py`) | -| **新增** | 阿里机器翻译 (`alibaba.py`)、NVIDIA (`nvidia.py`) | -| **主提供商** | Sensenova (商汤, `sensenova.py`) | -| **Fallback** | OpencodeGo → NVIDIA → 讯飞 Spark | -| **DB 驱动配置** | 新增 `AIProvider` 模型 + `admin_ai.py` API → 管理后台在线增删改 AI 模型 | -| **路由规则** | `router.py` 支持 `reload_from_db()` 从数据库实时加载配置 | +| **移除** | Claude (`claude.py`)、DeepL (`deepl.py`)、本地模型 (`local.py`)、OpencodeGo (`opencode_go.py`)、讯飞 Spark (`spark.py`) | +| **当前提供商** | Sensenova (商汤) + NVIDIA + 阿里机器翻译 (ECS RAM 角色) | +| **DB 驱动配置** | `AIProvider` 模型 + `admin_ai.py` API → 管理后台在线增删改 AI 模型 | +| **路由规则 DB 化** | 从 `config.py` 硬编码改为 `system_configs` 表读取 (key: `ai_routing`),保存后自动重载 | +| **ECS RAM 角色** | 阿里翻译无需 AccessKey,优先使用 ECS 实例 RAM 角色获取 STS 临时凭证 | | **Env 自动种子** | 启动时从 `.env` 读取 API Key 自动写入 DB | +### 3.1 当前 AI 提供商 + +| 名称 | 类型 | 模型 | 用途 | +|------|------|------|------| +| Sensenova (商汤) | sensenova | deepseek-v4-flash | 翻译/回复/营销/提取/聊天 | +| NVIDIA | nvidia | stepfun-ai/step-3.7-flash | 翻译/回复/营销/提取/聊天 | +| Sensenova (商汤) lite | sensenova | sensenova-6.7-flash-lite | 轻量备用 | +| NVIDIA qwen | nvidia | qwen/qwen3.5-397b-a17b | 备用 | +| 阿里翻译 | alibaba-mt | alibaba-mt | 专用翻译 (ecommerce/general) | + ### 4. 客户挖掘 (Discovery) ✅ | 功能 | 文件 | 说明 | @@ -73,6 +84,26 @@ | 年费定价 | `payment.py` — 新增 yearly 套餐选项 | | 搜索 API 管理 | `admin_search.py` — 管理后台配置搜索提供商 | +### 5.1 支付系统 ✅ + +| 组件 | 文件 | 说明 | +|------|------|------| +| **统一网关** | `services/unified_pay.py` | 对接 宇之然 pay-api,HMAC-SHA256 签名 | +| **网关抽象** | `services/payment_gateway.py` | `PaymentGateway` 抽象基类 | +| **支付服务** | `services/payment.py` | 多套餐创建/查询/退款/回调 | +| **交易记录** | `models/payment_transaction.py` | 订单号/金额/状态/退款流水 | +| **支付 API** | `api/v1/payment.py` | 下单/查询/退款/Webhook 统一入口 | +| **管理接口** | `api/v1/admin.py` | 交易列表/统计/后台退款 | +| **数据迁移** | `alembic/versions/add_payment_transactions_table.py` | `payment_transactions` 表 | + +**支付流程**: +1. 前端 POST `/api/v1/payment/create-order` (`plan` + `pay_type`: `alipay` / `wechat`) +2. 后端调用 unified pay-api 创建订单,返回 `pay_url`(支付宝) 或 `code_url`(微信扫码) +3. 用户完成支付后,pay-api 回调 `POST /api/v1/payment/webhook` +4. 后端更新订单状态 + 激活用户套餐 + +**凭证**: `PAY_API_KEY` / `PAY_API_SECRET` 从 `.env` 读取(外贸助手密钥),HMAC-SHA256 认证 + ### 6. PC 桌面端布局 ✅ | 功能 | 说明 | @@ -99,6 +130,8 @@ | 11 | `app/api/v1/auth.py` + `deps.py` | API 500 根因 — 游客 UUID 格式问题 | ✅ 已修复 | | 12 | `backend/.env` + `app/main.py` | CORS 配置不当 | ✅ 已修复 | | 13 | `uni-app/src/utils/api.js` | 前端直连后端端口 → 跨域 | ✅ 已修复 | +| 14 | `app/api/v1/auth.py` | 登录/注册 CSRF 鸡生蛋 — 匿名用户无 cookie 导致 403 | ✅ 已修复 | +| 15 | 4 个前端登录/注册页面 | 后端错误英文直接展示给用户 | ✅ 已修复 | ### 8. 游客模式 (Guest Mode) ✅ @@ -119,7 +152,26 @@ | AI 模型配置 | 在线增删改 AI 提供商、重载配置、启停控制 | | 搜索配置 | 搜索提供商管理 | -### 10. 其他增强 +### 10. 翻译配额全链路 ✅ + +| 组件 | 说明 | +|------|------| +| `TranslationQuota` 模型 | 按月配额管理,支持 ecommerce/general/llm 三个版本 | +| `TranslationQuotaService` | 自动按月重置、配额检查/扣减/查询/管理 | +| `AlibabaMTProvider.translate()` | 调用前检查配额,调用后扣减字符 | +| `OpenAIProvider.translate()` | LLM 翻译也走配额检查 (`llm` 版本) | +| 后台 `Quota.vue` | 配额管理页 (月限额/启用/重置),修复了 API 路径 bug | + +### 11. 管理后台增强 + +| 功能 | 说明 | +|------|------| +| AI 路由规则 | 配置页新增"AI路由规则"卡片,下拉选择已启用供应商 | +| 翻译配额页 | 修复路径 bug (`quotas` → `translation-quotas`),新增 `llm` 配额 | +| AI 模型配置 | 修复侧边栏链接路径 | +| 登录跳转 | 登录后自动跳转仪表盘 | + +### 12. 其他增强 | 功能 | 说明 | |------|------| @@ -176,13 +228,14 @@ ### 4.2 AI 提供商 -| 提供商 | 类型 | 用途 | -|--------|------|------| -| Sensenova (商汤) | 主提供商 | 翻译/回复/营销/提取 | -| OpencodeGo | Fallback | 翻译/回复/营销/提取 | -| NVIDIA | Fallback | 通用对话 | -| 讯飞 Spark | Fallback | 通用对话 | -| 阿里机器翻译 | 专用 | 翻译 (translate) | +| 提供商 | 类型 | 模型 | 用途 | +|--------|------|------|------| +| Sensenova (商汤) | 主提供商 | deepseek-v4-flash | 翻译/回复/营销/提取/聊天 | +| NVIDIA | Fallback | stepfun-ai/step-3.7-flash | 翻译/回复/营销/提取/聊天 | +| Sensenova lite | 轻量备用 | sensenova-6.7-flash-lite | 备用 | +| NVIDIA qwen | 备用 | qwen/qwen3.5-397b-a17b | 备用 | +| 阿里机器翻译 | 专用翻译 | alibaba-mt | 翻译 (ecommerce/general) | +| **已删除** | — | — | OpencodeGo / 讯飞 Spark / Claude / DeepL / 本地模型 | ### 4.3 前端 @@ -273,13 +326,39 @@ cd backend && alembic upgrade head ## 八、部署架构 +### 8.1 域名 + +| 域名 | 用途 | +|------|------| +| `trade.yuzhiran.com` | 主站 — 营销落地页 + Web 应用(当前生产域名) | +| `www.yuzhiran.com.cn` | 之前用作镜像站 AI 教育,外贸项目不再使用 | + +### 8.2 目录结构 + ``` trade.yuzhiran.com/ ├── / → 静态营销落地页 -├── /app/ → uni-app 构建 (移动端 H5) +├── /app/ → uni-app 构建 (移动端 H5,当前未部署) ├── /admin/ → admin-frontend 构建 (管理后台) ├── /workspace/ → user-frontend 构建 (用户工作台) -└── /api/ → Nginx proxy → 127.0.0.1:8002 +└── /api/ → Nginx proxy → 127.0.0.1:8000 (systemd 管理) +``` + +### 8.3 部署流程 + +```bash +# 前端构建 & 部署 +cd user-frontend && npm run build && cp -r dist/* /www/wwwroot/trade.yuzhiran.com/workspace/ +cd admin-frontend && npm run build && cp -r dist/* /www/wwwroot/trade.yuzhiran.com/admin/ + +# 后端重启 (systemd) +sudo systemctl restart ftrade-backend.service + +# 查看启动日志 +sudo journalctl -u ftrade-backend.service -n 20 + +# 本地开发启动 +cd backend && source venv/bin/activate && uvicorn app.main:app --reload --port 8000 ``` --- @@ -288,6 +367,7 @@ trade.yuzhiran.com/ | 日期 | 变更内容 | |------|----------| +| 2026-06-02 | 生产环境部署 + AI 路由 DB 驱动 + 翻译配额扩展至 LLM + ECS RAM 角色认证 + 删除 OpencodeGo/Spark | | 2026-05-29 | 安全加固 (T-005): 限流/CSRF/CORS + AI 提供商 DB 管理 + 客户挖掘联系人提取 | | 2026-05-28 | 加载反馈 + 搜索历史自动保存 + 超时修复 | | 2026-05-26 | 落地页 + 推荐系统 + 用量配额 + 搜索 API 管理 + 年费定价 | diff --git a/admin-frontend/src/api/index.js b/admin-frontend/src/api/index.js index 34343ae..be6a1a2 100644 --- a/admin-frontend/src/api/index.js +++ b/admin-frontend/src/api/index.js @@ -37,9 +37,9 @@ export function listLogs(params) { return http.get('/admin/logs', { params }) } export function listConfig() { return http.get('/admin/config') } export function updateConfig(key, value) { return http.put(`/admin/config/${key}`, { value }) } -export function listQuotas() { return http.get('/admin/quotas') } -export function updateQuota(version, data) { return http.put(`/admin/quotas/${version}`, data) } -export function resetQuota(version) { return http.post(`/admin/quotas/${version}/reset`) } +export function listQuotas() { return http.get('/admin/translation-quotas') } +export function updateQuota(version, data) { return http.put(`/admin/translation-quotas/${version}`, data) } +export function resetQuota(version) { return http.post(`/admin/translation-quotas/${version}/reset`) } export function listCertifications(page = 1, size = 50, status = '') { return http.get('/admin/certifications', { params: { page, size, status: status || undefined } }) @@ -55,4 +55,7 @@ export function processInvoice(id, action) { return http.post(`/admin/invoices/${id}/process`, { action }) } +export function aiChat(message, history = []) { return http.post('/ai/chat', { message, history }) } +export function aiQuickQuestions() { return http.get('/ai/quick-questions') } + export default http diff --git a/admin-frontend/src/components/AiAssistant.vue b/admin-frontend/src/components/AiAssistant.vue new file mode 100644 index 0000000..90f960a --- /dev/null +++ b/admin-frontend/src/components/AiAssistant.vue @@ -0,0 +1,363 @@ + + + + + diff --git a/admin-frontend/src/layouts/AdminLayout.vue b/admin-frontend/src/layouts/AdminLayout.vue index 74bce65..8849d51 100644 --- a/admin-frontend/src/layouts/AdminLayout.vue +++ b/admin-frontend/src/layouts/AdminLayout.vue @@ -51,7 +51,7 @@ 搜索配置 - + AI 模型配置 @@ -128,6 +128,7 @@ + @@ -135,6 +136,7 @@ import { ref, computed } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useAuthStore } from '@/stores/auth' +import AiAssistant from '@/components/AiAssistant.vue' const route = useRoute() const router = useRouter() diff --git a/admin-frontend/src/router/index.js b/admin-frontend/src/router/index.js index ebec3dd..19ffd47 100644 --- a/admin-frontend/src/router/index.js +++ b/admin-frontend/src/router/index.js @@ -2,7 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router' import AdminLayout from '@/layouts/AdminLayout.vue' const routes = [ - { path: '/login', redirect: '/' }, + { path: '/login', redirect: to => ({ path: '/', query: to.query }) }, { path: '/', name: 'Landing', component: () => import('@/views/Landing.vue') }, { path: '/dashboard', diff --git a/admin-frontend/src/views/Config.vue b/admin-frontend/src/views/Config.vue index a466405..d7f217a 100644 --- a/admin-frontend/src/views/Config.vue +++ b/admin-frontend/src/views/Config.vue @@ -7,7 +7,24 @@ {{ cfg.description }} -
+
+
+
{{ fieldLabelsMap.ai_routing?.[taskKey] || taskKey }}
+
+ 主选 + + + +
+
+ 备用 + + + +
+
+
+
{{ fieldLabel(cfg.key, k) }} @@ -34,12 +51,33 @@ import { ref, reactive, onMounted } from 'vue' import { ElMessage } from 'element-plus' import { listConfig, updateConfig } from '@/api' +import http from '@/api' const configs = ref([]) const edits = reactive({}) -const configLabels = { system_maintenance: '系统维护', feature_flags: '功能开关', translation_providers: '翻译服务', ai_model_config: 'AI模型', cache_ttl: '缓存时间' } -const fieldLabelsMap = { system_maintenance: { maintenance_mode: '维护模式', maintenance_message: '维护消息' }, feature_flags: { feature_wechat_login: '微信登录', feature_export: '数据导出' }, translation_providers: { primary: '首选服务', fallback: '备用服务' }, ai_model_config: { default_model: '默认模型', max_tokens: '最大Token' } } -function fieldLabel(key, k) { return fieldLabelsMap[key]?.[k] || k } +const providers = ref([]) +const configLabels = { system_maintenance: '系统维护', feature_flags: '功能开关', translation_providers: '翻译服务', ai_model_config: 'AI模型', cache_ttl: '缓存时间', ai_routing: 'AI路由规则' } +const fieldLabelsMap = { system_maintenance: { maintenance_mode: '维护模式', maintenance_message: '维护消息' }, feature_flags: { feature_wechat_login: '微信登录', feature_export: '数据导出' }, translation_providers: { primary: '首选服务', fallback: '备用服务' }, ai_model_config: { default_model: '默认模型', max_tokens: '最大Token' }, ai_routing: { translate: '翻译', reply: '回复建议', marketing: '营销文案', extract: '信息提取', quotation: '报价单', chat: 'AI助手' } } +const taskFieldLabels = { primary: '主选', fallback: '备用' } +function fieldLabel(key, k) { + if (key === 'ai_routing') return configLabels[key] + ' > ' + (taskFieldLabels[k] || k) + return fieldLabelsMap[key]?.[k] || k +} +function taskFieldLabel(cfgKey, taskKey, subKey) { + if (cfgKey === 'ai_routing') { + const taskLabel = fieldLabelsMap.ai_routing?.[taskKey] || taskKey + const subLabel = taskFieldLabels[subKey] || subKey + return taskLabel + ' > ' + subLabel + } + return subKey || taskKey +} + +async function loadProviders() { + try { + const res = await http.get('/admin/ai-providers') + providers.value = (res.items || []).filter(p => p.enabled) + } catch (_) {} +} async function load() { try { @@ -48,13 +86,14 @@ async function load() { for (const cfg of configs.value) { edits[cfg.key] = JSON.parse(JSON.stringify(cfg.value)) } + await loadProviders() } catch (e) { console.error(e) } } async function save(key) { try { await updateConfig(key, edits[key]) - ElMessage.success('已保存') + ElMessage.success('已保存,AI 路由器已重载') } catch (e) { ElMessage.error(e?.detail || '保存失败') } } @@ -66,5 +105,7 @@ onMounted(load) .cfg-header { display: flex; align-items: center; gap: 12px; font-weight: 600; } .cfg-field { display: flex; align-items: center; gap: 12px; padding: 8px 0; } .cfg-label { width: 160px; font-size: 13px; color: #666; flex-shrink: 0; } +.cfg-nested-group { margin-bottom: 12px; padding: 8px 12px; background: #f9fafb; border-radius: 6px; } +.cfg-group-title { font-weight: 600; font-size: 13px; color: #333; margin-bottom: 4px; padding-bottom: 4px; border-bottom: 1px solid #e5e7eb; } .cfg-actions { margin-top: 12px; } diff --git a/admin-frontend/src/views/Landing.vue b/admin-frontend/src/views/Landing.vue index 3dc6c62..51d2c10 100644 --- a/admin-frontend/src/views/Landing.vue +++ b/admin-frontend/src/views/Landing.vue @@ -88,7 +88,7 @@ async function submit() { const res = await loginApi(form) localStorage.setItem('admin_token', res.access_token) localStorage.setItem('admin_user', JSON.stringify(res.user || {})) - ElMessage.success('登录成功') + router.push('/dashboard') } catch (e) { const map = { 'Invalid credentials': '用户名或密码错误' } error.value = map[e?.detail] || e?.detail || '登录失败' diff --git a/admin-frontend/src/views/Quota.vue b/admin-frontend/src/views/Quota.vue index b7fefa2..26438e1 100644 --- a/admin-frontend/src/views/Quota.vue +++ b/admin-frontend/src/views/Quota.vue @@ -4,7 +4,7 @@ diff --git a/admin-frontend/vite.config.js b/admin-frontend/vite.config.js index d156d8a..23a1b2b 100644 --- a/admin-frontend/vite.config.js +++ b/admin-frontend/vite.config.js @@ -15,7 +15,7 @@ export default defineConfig({ server: { port: 5173, proxy: { - '/api': { target: 'http://localhost:8002', changeOrigin: true } + '/api': { target: 'http://localhost:8000', changeOrigin: true } } } }) diff --git a/backend/alembic/versions/add_payment_transactions_table.py b/backend/alembic/versions/add_payment_transactions_table.py index 342acfe..9d095cd 100644 --- a/backend/alembic/versions/add_payment_transactions_table.py +++ b/backend/alembic/versions/add_payment_transactions_table.py @@ -9,7 +9,7 @@ import sqlalchemy as sa from sqlalchemy.dialects.postgresql import UUID revision = "add_payment_transactions" -down_revision = "add_ai_providers_table" +down_revision = "add_ai_providers" branch_labels = None depends_on = None diff --git a/backend/app/ai/providers/__init__.py b/backend/app/ai/providers/__init__.py index 4d8bea5..239d01b 100644 --- a/backend/app/ai/providers/__init__.py +++ b/backend/app/ai/providers/__init__.py @@ -1,8 +1,6 @@ from .openai import OpenAIProvider -from .spark import SparkProvider from .sensenova import SensenovaProvider -from .opencode_go import OpencodeGoProvider from .nvidia import NvidiaProvider from .alibaba import AlibabaMTProvider -__all__ = ["OpenAIProvider", "SparkProvider", "SensenovaProvider", "OpencodeGoProvider", "NvidiaProvider", "AlibabaMTProvider"] +__all__ = ["OpenAIProvider", "SensenovaProvider", "NvidiaProvider", "AlibabaMTProvider"] diff --git a/backend/app/ai/providers/alibaba.py b/backend/app/ai/providers/alibaba.py index eb475c7..e72ed8c 100644 --- a/backend/app/ai/providers/alibaba.py +++ b/backend/app/ai/providers/alibaba.py @@ -1,11 +1,13 @@ from typing import Dict, Any, Optional from aliyunsdkcore.client import AcsClient +from aliyunsdkcore.auth.credentials import StsTokenCredential from aliyunsdkalimt.request.v20181012 import TranslateGeneralRequest, TranslateECommerceRequest from app.services.translation_quota import TranslationQuotaService from app.database import AsyncSessionLocal import asyncio import json import logging +import os logger = logging.getLogger(__name__) @@ -16,11 +18,55 @@ ALIBABA_LANG_MAP = { "id": "id", "ms": "ms", "tl": "tl", "hi": "hi", } +ECS_METADATA_URL = "http://100.100.100.200/latest/meta-data/ram/security-credentials/" + + +def _fetch_ecs_ram_credentials(): + try: + import urllib.request + req = urllib.request.Request(ECS_METADATA_URL, method="GET") + with urllib.request.urlopen(req, timeout=2) as resp: + role_name = resp.read().decode().strip() + if not role_name: + logger.warning("ECS metadata returned empty role name") + return None + url = f"{ECS_METADATA_URL}{role_name}" + req = urllib.request.Request(url, method="GET") + with urllib.request.urlopen(req, timeout=2) as resp: + data = json.loads(resp.read().decode()) + if data.get("Code") == "Success": + logger.info(f"Fetched STS token for role {role_name}, expires {data.get('Expiration')}") + return (data["AccessKeyId"], data["AccessKeySecret"], data["SecurityToken"]) + else: + logger.warning(f"ECS metadata returned non-success: {data.get('Code')}") + except Exception as e: + logger.debug(f"ECS metadata fetch failed: {e}") + return None + + +def _build_acs_client(access_key_id: str = "", access_key_secret: str = "", + region_id: str = "cn-hangzhou") -> AcsClient: + creds = _fetch_ecs_ram_credentials() + if creds: + ak, sk, token = creds + sts_cred = StsTokenCredential(ak, sk, token) + client = AcsClient(credential=sts_cred, region_id=region_id) + logger.info("Alibaba MT using ECS RAM role (STS token)") + return client + + ak = access_key_id or os.getenv("ALIBABA_ACCESS_KEY_ID", "") + sk = access_key_secret or os.getenv("ALIBABA_ACCESS_KEY_SECRET", "") + if ak and sk: + logger.info("Alibaba MT using AccessKey credentials") + return AcsClient(ak, sk, region_id) + + raise ValueError("No Alibaba Cloud credentials found (neither ECS RAM role nor AccessKey)") + class AlibabaMTProvider: - def __init__(self, access_key_id: str, access_key_secret: str, + def __init__(self, access_key_id: str = "", access_key_secret: str = "", region_id: str = "cn-hangzhou"): - self.client = AcsClient(access_key_id, access_key_secret, region_id) + self.client = _build_acs_client(access_key_id, access_key_secret, region_id) self._name = "alibaba-mt" async def translate(self, text: str, source_lang: Optional[str], diff --git a/backend/app/ai/providers/openai.py b/backend/app/ai/providers/openai.py index 8603e82..fbffc97 100644 --- a/backend/app/ai/providers/openai.py +++ b/backend/app/ai/providers/openai.py @@ -51,6 +51,20 @@ class OpenAIProvider(AIProvider): self._cheap_model = "gpt-4o-mini" if model == "gpt-4o" else model async def translate(self, text: str, source_lang: Optional[str], target_lang: str, context: Optional[str] = None) -> Dict[str, Any]: + from app.services.translation_quota import TranslationQuotaService + from app.database import AsyncSessionLocal + + async with AsyncSessionLocal() as db: + quota_svc = TranslationQuotaService(db) + if not await quota_svc.check_quota("llm"): + raise Exception("LLM translation quota exhausted or disabled") + result = await self._do_translate(text, source_lang, target_lang, context) + if result and result.get("translated_text"): + await quota_svc.consume("llm", len(text)) + await db.commit() + return result + + async def _do_translate(self, text: str, source_lang: Optional[str], target_lang: str, context: Optional[str] = None) -> Dict[str, Any]: system = SYSTEM_PROMPTS["translate"] if context: system += f"\nContext: this is about {context}" diff --git a/backend/app/ai/providers/opencode_go.py b/backend/app/ai/providers/opencode_go.py deleted file mode 100644 index 21775bd..0000000 --- a/backend/app/ai/providers/opencode_go.py +++ /dev/null @@ -1,7 +0,0 @@ -from app.ai.providers.openai import OpenAIProvider - - -class OpencodeGoProvider(OpenAIProvider): - def __init__(self, api_key: str, model: str = "deepseek-v4-flash", base_url: str = "https://opencode.ai/zen/go/v1"): - super().__init__(api_key=api_key, model=model, base_url=base_url) - self._name = f"opencode-go-{model}" diff --git a/backend/app/ai/providers/spark.py b/backend/app/ai/providers/spark.py deleted file mode 100644 index 2ec0384..0000000 --- a/backend/app/ai/providers/spark.py +++ /dev/null @@ -1,90 +0,0 @@ -from typing import Dict, Any, Optional -import json -from app.ai.base import AIProvider - - -SYSTEM_PROMPTS = { - "translate": "You are a professional translator specialized in foreign trade. " - "Translate business terms accurately. Return ONLY the translated text.", - "reply": "You are an experienced foreign trade sales expert. Write professional, " - "clear business replies. Return ONLY the reply text.", - "marketing": "You are a creative copywriter for international trade. " - "Return ONLY the marketing copy, no explanations.", - "extract": "Extract structured data from text. Return ONLY valid JSON.", -} - - -class SparkProvider(AIProvider): - def __init__(self, api_key: str, model: str = "astron-code-latest", base_url: str = None): - from app.config import settings - try: - from openai import AsyncOpenAI - except ImportError: - raise ImportError("openai>=1.0 is required for SparkProvider") - self.client = AsyncOpenAI( - api_key=api_key, - base_url=base_url or settings.IFLYTEK_API_BASE, - ) - self.model = model - self._name = f"spark-{model}" - - async def translate(self, text: str, source_lang: Optional[str], target_lang: str, context: Optional[str] = None) -> Dict[str, Any]: - system = SYSTEM_PROMPTS["translate"] - if context: - system += f"\nContext: {context}" - prompt = f"Translate {f'from {source_lang} ' if source_lang and source_lang != 'auto' else ''}to {target_lang}:\n\n{text}" - content = await self._call(system, prompt) - return {"translated_text": content, "provider": self.name} - - async def reply(self, inquiry: str, context: Optional[Dict[str, Any]] = None, tone: str = "professional", preference_context: Optional[str] = None) -> Dict[str, Any]: - system = SYSTEM_PROMPTS["reply"] + f"\nTone: {tone}" - if preference_context: - system += f"\nUser preference: {preference_context}" - ctx = "" - if context: - ctx = "\n".join(f"{k}: {v}" for k, v in context.items() if v) - prompt = f"{ctx}\nCustomer inquiry:\n{inquiry}\n\nWrite a reply:" - content = await self._call(system, prompt) - return {"reply": content, "provider": self.name} - - async def generate_marketing(self, product_info: Dict[str, Any], target: str, style: str = "professional", language: str = "en", preference_context: Optional[str] = None) -> Dict[str, Any]: - system = SYSTEM_PROMPTS["marketing"] + f"\nStyle: {style}\nAudience: {target}\nLanguage: {language}" - if preference_context: - system += f"\nUser preference: {preference_context}" - info = json.dumps(product_info, ensure_ascii=False) - prompt = f"Product:\n{info}\n\nGenerate marketing copy:" - content = await self._call(system, prompt, max_tokens=1500) - return {"content": content, "provider": self.name} - - async def extract_info(self, text: str, schema: Dict[str, Any]) -> Dict[str, Any]: - system = SYSTEM_PROMPTS["extract"] - prompt = f"Schema:\n{json.dumps(schema, indent=2)}\n\nText:\n{text}\n\nJSON:" - content = await self._call(system, prompt, response_format={"type": "json_object"}) - try: - data = json.loads(content) - return {"data": data, "confidence": 0.9, "provider": self.name} - except json.JSONDecodeError: - return {"data": {}, "confidence": 0.0, "provider": self.name} - - async def _call(self, system: str, prompt: str, max_tokens: int = 1000, response_format: Optional[Dict] = None) -> str: - kwargs = { - "model": self.model, - "messages": [ - {"role": "system", "content": system}, - {"role": "user", "content": prompt}, - ], - "max_tokens": max_tokens, - "temperature": 0.7, - } - if response_format: - kwargs["response_format"] = response_format - resp = await self.client.chat.completions.create(**kwargs) - return resp.choices[0].message.content - - @property - def name(self) -> str: - return self._name - - @property - def cost_per_1k_tokens(self) -> float: - return 0.0 diff --git a/backend/app/ai/router.py b/backend/app/ai/router.py index 5db50bf..889340c 100644 --- a/backend/app/ai/router.py +++ b/backend/app/ai/router.py @@ -1,17 +1,26 @@ from typing import Dict, Any, Optional, List from app.ai.base import AIProvider -from app.ai.providers import SparkProvider, SensenovaProvider, OpencodeGoProvider, NvidiaProvider, AlibabaMTProvider -from app.config import settings +from app.ai.providers import SensenovaProvider, NvidiaProvider, AlibabaMTProvider from app.ai.trade_corpus import TradeCorpus +from app.config import settings import logging logger = logging.getLogger(__name__) +DEFAULT_ROUTING: Dict[str, dict] = { + "translate": {"primary": "sensenova", "fallback": ["alibaba-mt", "nvidia"]}, + "reply": {"primary": "sensenova", "fallback": ["nvidia"]}, + "marketing": {"primary": "sensenova", "fallback": ["nvidia"]}, + "extract": {"primary": "sensenova", "fallback": ["nvidia"]}, + "quotation": {"primary": "sensenova", "fallback": ["nvidia"]}, + "chat": {"primary": "sensenova", "fallback": ["nvidia"]}, +} + class AIRouter: def __init__(self): self.providers: Dict[str, AIProvider] = {} - self.routing_rules = settings.AI_ROUTING + self.routing_rules = dict(DEFAULT_ROUTING) self.corpus = TradeCorpus() async def reload_from_db(self, db_session) -> int: @@ -38,8 +47,47 @@ class AIRouter: else: logger.warning("No enabled AI providers found in DB") + await self._load_routing_rules(db_session) return len(rows) + async def _load_routing_rules(self, db_session): + from app.models.system_config import SystemConfig + from sqlalchemy import select + + # Try consolidated key first + result = await db_session.execute( + select(SystemConfig).where(SystemConfig.key == "ai_routing") + ) + cfg = result.scalar_one_or_none() + if cfg and isinstance(cfg.value, dict): + self.routing_rules = {**DEFAULT_ROUTING, **cfg.value} + logger.info("Loaded routing rules from system_configs (ai_routing)") + return + + # Fallback: load individual per-task keys + task_keys = { + "translate": "ai_provider_translate", + "reply": "ai_provider_reply", + "marketing": "ai_provider_marketing", + "extract": "ai_provider_extract", + "quotation": "ai_provider_quotation", + } + loaded = {} + for task, key in task_keys.items(): + result = await db_session.execute( + select(SystemConfig).where(SystemConfig.key == key) + ) + cfg = result.scalar_one_or_none() + if cfg and isinstance(cfg.value, dict): + loaded[task] = cfg.value + + if loaded: + self.routing_rules = {**DEFAULT_ROUTING, **loaded} + logger.info(f"Loaded routing rules from system_configs (individual keys): {list(loaded.keys())}") + else: + self.routing_rules = dict(DEFAULT_ROUTING) + logger.info("No routing rules in system_configs, using defaults") + async def seed_from_env(self, db_session) -> int: from app.models.ai_provider import AIProvider @@ -53,34 +101,19 @@ class AIRouter: base_url=settings.SENSENOVA_BASE_URL, model_name=settings.SENSENOVA_MODEL, priority=0, enabled=True, )) - if settings.OPENCODE_GO_API_KEY: - seeds.append(AIProvider( - name="OpencodeGo", provider_type="opencode_go", - api_key=settings.OPENCODE_GO_API_KEY, - base_url=settings.OPENCODE_GO_BASE_URL, - model_name=settings.OPENCODE_GO_MODEL, priority=1, enabled=True, - )) if settings.NVIDIA_API_KEY: seeds.append(AIProvider( name="NVIDIA", provider_type="nvidia", api_key=settings.NVIDIA_API_KEY, base_url=settings.NVIDIA_BASE_URL, - model_name=settings.NVIDIA_MODEL, priority=2, enabled=True, - )) - if settings.IFLYTEK_API_KEY: - seeds.append(AIProvider( - name="讯飞 Spark", provider_type="spark", - api_key=settings.IFLYTEK_API_KEY, - base_url=settings.IFLYTEK_API_BASE, - model_name=settings.IFLYTEK_MODEL, priority=3, enabled=True, - )) - if settings.ALIBABA_ACCESS_KEY_ID and settings.ALIBABA_ACCESS_KEY_SECRET: - seeds.append(AIProvider( - name="阿里翻译", provider_type="alibaba-mt", - api_key=settings.ALIBABA_ACCESS_KEY_ID, - api_secret=settings.ALIBABA_ACCESS_KEY_SECRET, - model_name="alibaba-mt", priority=4, enabled=True, + model_name=settings.NVIDIA_MODEL, priority=1, enabled=True, )) + seeds.append(AIProvider( + name="阿里翻译", provider_type="alibaba-mt", + api_key=settings.ALIBABA_ACCESS_KEY_ID or "", + api_secret=settings.ALIBABA_ACCESS_KEY_SECRET or "", + model_name="alibaba-mt", priority=3, enabled=True, + )) for p in seeds: db_session.add(p) @@ -99,12 +132,8 @@ class AIRouter: t = p.provider_type if t == "sensenova": return SensenovaProvider(api_key=p.api_key, model=p.model_name, base_url=p.base_url) - elif t == "opencode_go": - return OpencodeGoProvider(api_key=p.api_key, model=p.model_name, base_url=p.base_url) elif t == "nvidia": return NvidiaProvider(api_key=p.api_key, model=p.model_name, base_url=p.base_url) - elif t == "spark": - return SparkProvider(api_key=p.api_key, model=p.model_name, base_url=p.base_url) elif t == "alibaba-mt": return AlibabaMTProvider(access_key_id=p.api_key, access_key_secret=p.api_secret or "") else: @@ -117,7 +146,7 @@ class AIRouter: def get_providers_for_task(self, task_type: str) -> List[AIProvider]: rules = self.routing_rules.get( task_type, - {"primary": "sensenova", "fallback": ["opencode_go"]}, + {"primary": "sensenova", "fallback": ["nvidia"]}, ) ordered = [] seen = set() diff --git a/backend/app/api/v1/admin_ai.py b/backend/app/api/v1/admin_ai.py index b3a3388..2c45c1c 100644 --- a/backend/app/api/v1/admin_ai.py +++ b/backend/app/api/v1/admin_ai.py @@ -42,9 +42,7 @@ class AIProviderUpdate(BaseModel): PROVIDER_TYPE_LABELS = { "sensenova": "Sensenova (商汤)", - "opencode_go": "OpencodeGo", "nvidia": "NVIDIA", - "spark": "讯飞 Spark", "alibaba-mt": "阿里翻译", } diff --git a/backend/app/api/v1/ai_assistant.py b/backend/app/api/v1/ai_assistant.py index 2d76f68..a4f87eb 100644 --- a/backend/app/api/v1/ai_assistant.py +++ b/backend/app/api/v1/ai_assistant.py @@ -20,7 +20,7 @@ router = APIRouter() ACTION_INSTRUCTIONS = """ -当用户想要执行操作时(如添加客户、创建产品、生成报价单等),请执行以下步骤: +当用户想要执行操作时(如添加客户、创建产品、生成报价单、发送跟进、营销生成等),请执行以下步骤: 1. 从用户消息中提取所有必要的信息 2. 在回复末尾附上 JSON 格式的动作块,格式如下: @@ -28,9 +28,20 @@ ACTION_INSTRUCTIONS = """ [{"type": "create_customer", "label": "添加客户", "fields": {"name": "...", "phone": "...", "email": "...", "company": "...", "country": "...", "notes": "..."}}] ``` -支持的 action type: -- create_customer:添加客户,fields 支持 name(必填), phone, email, company, country, notes -- create_product:添加产品(开发中) +支持的 action type 及字段说明: +- create_customer:添加客户,fields 支持 name(必填), phone, email, company, country, website, notes +- create_product:添加产品,fields 支持 name(必填), name_en, description, description_en, category, price, price_unit(默认USD), moq, keywords(逗号分隔) +- create_quotation:生成报价单,fields 支持 customer_name(必填), product_info(必填), quantity(必填), price, terms +- scan_followups:扫描待跟进客户,fields 不需要(空对象) +- send_followup:发送跟进消息,fields 支持 customer_name(必填), message(必填) +- generate_marketing:生成营销素材,fields 支持 product_name(必填), target_market, tone(如professional/casual), language +- discovery_search:搜索潜在客户,fields 支持 keywords(必填), country, industry +- navigate:跳转到指定页面,fields 支持 path(必填, 如 /customers /products /quotations /marketing /discovery /followup /translate /team /analytics) +- search_users:搜索用户,fields 支持 query(必填) +- update_user:修改用户信息,fields 支持 user_id(必填), username, phone, email, role, status +- update_config:更新系统配置,fields 支持 key(必填), value(必填) +- review_certification:审核认证,fields 支持 id(必填), action(approved/rejected), reason +- process_invoice:处理发票,fields 支持 id(必填), action(approve/reject) 如果用户没有提供足够信息,请先询问缺少的字段,不要生成 action。 如果用户明确表示要执行操作但缺少信息,生成 action 但标注缺失的字段。 diff --git a/backend/app/config.py b/backend/app/config.py index de70305..acb4027 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -32,13 +32,7 @@ class Settings(BaseSettings): SENSENOVA_BASE_URL: str = "https://token.sensenova.cn/v1" SENSENOVA_MODEL: str = "deepseek-v4-flash" - IFLYTEK_API_KEY: Optional[str] = None - IFLYTEK_API_BASE: str = "https://maas-api.cn-huabei-1.xf-yun.com/v2" - IFLYTEK_MODEL: str = "astron-code-latest" - OPENCODE_GO_API_KEY: Optional[str] = None - OPENCODE_GO_BASE_URL: str = "https://opencode.ai/zen/go/v1" - OPENCODE_GO_MODEL: str = "minimax-m2.7" NVIDIA_API_KEY: Optional[str] = None NVIDIA_BASE_URL: str = "https://integrate.api.nvidia.com/v1" @@ -74,15 +68,6 @@ class Settings(BaseSettings): SENTRY_DSN: Optional[str] = None DEBUG: bool = True - AI_ROUTING: dict = { - "translate": {"primary": "sensenova", "fallback": ["alibaba-mt", "opencode_go"]}, - "reply": {"primary": "sensenova", "fallback": ["opencode_go"]}, - "marketing": {"primary": "sensenova", "fallback": ["opencode_go"]}, - "extract": {"primary": "sensenova", "fallback": ["opencode_go"]}, - "quotation": {"primary": "sensenova", "fallback": ["opencode_go"]}, - "chat": {"primary": "sensenova", "fallback": ["opencode_go", "nvidia"]}, - } - FREE_DAILY_TRANSLATE_CHARS: int = 5000 FREE_DAILY_REPLIES: int = 20 FREE_DAILY_MARKETING: int = 5 diff --git a/backend/app/services/admin.py b/backend/app/services/admin.py index 034f14a..f64697f 100644 --- a/backend/app/services/admin.py +++ b/backend/app/services/admin.py @@ -288,11 +288,14 @@ class AdminService: async def _seed_default_configs(self): defaults = [ - SystemConfig(key="ai_provider_translate", value={"primary": "sensenova", "fallback": ["alibaba-mt", "opencode_go"]}, description="翻译任务 AI 模型选择"), - SystemConfig(key="ai_provider_reply", value={"primary": "sensenova", "fallback": ["opencode_go"]}, description="回复建议 AI 模型选择"), - SystemConfig(key="ai_provider_marketing", value={"primary": "sensenova", "fallback": ["opencode_go"]}, description="营销文案 AI 模型选择"), - SystemConfig(key="ai_provider_extract", value={"primary": "sensenova", "fallback": ["opencode_go"]}, description="信息提取 AI 模型选择"), - SystemConfig(key="ai_provider_quotation", value={"primary": "sensenova", "fallback": ["opencode_go"]}, description="报价单 AI 模型选择"), + SystemConfig(key="ai_routing", value={ + "translate": {"primary": "sensenova", "fallback": ["alibaba-mt", "nvidia"]}, + "reply": {"primary": "sensenova", "fallback": ["nvidia"]}, + "marketing": {"primary": "sensenova", "fallback": ["nvidia"]}, + "extract": {"primary": "sensenova", "fallback": ["nvidia"]}, + "quotation": {"primary": "sensenova", "fallback": ["nvidia"]}, + "chat": {"primary": "sensenova", "fallback": ["nvidia"]}, + }, description="AI 路由规则:各任务的主选/备用供应商"), SystemConfig(key="feature_guest_mode", value={"enabled": True}, description="游客模式开关"), SystemConfig(key="feature_wechat_login", value={"enabled": False}, description="微信登录开关"), SystemConfig(key="feature_registration", value={"enabled": True}, description="新用户注册开关"), @@ -305,6 +308,19 @@ class AdminService: self.db.add(cfg) await self.db.flush() + async def _migrate_routing_configs(self): + from sqlalchemy import delete + # Remove stale individual routing keys (replaced by consolidated ai_routing) + stale_prefixes = ["ai_provider_translate", "ai_provider_reply", "ai_provider_marketing", + "ai_provider_extract", "ai_provider_quotation"] + for key in stale_prefixes: + await self.db.execute( + delete(SystemConfig).where(SystemConfig.key == key) + ) + if stale_prefixes: + await self.db.flush() + logger.info("Cleaned up stale ai_provider_* routing configs") + async def list_config(self) -> List[Dict[str, Any]]: result = await self.db.execute( select(func.count(SystemConfig.id)) @@ -312,6 +328,28 @@ class AdminService: if result.scalar() == 0: await self._seed_default_configs() + await self._migrate_routing_configs() + + # Ensure consolidated ai_routing exists + result = await self.db.execute( + select(SystemConfig).where(SystemConfig.key == "ai_routing") + ) + if not result.scalar_one_or_none(): + self.db.add(SystemConfig( + key="ai_routing", + value={ + "translate": {"primary": "sensenova", "fallback": ["alibaba-mt", "nvidia"]}, + "reply": {"primary": "sensenova", "fallback": ["nvidia"]}, + "marketing": {"primary": "sensenova", "fallback": ["nvidia"]}, + "extract": {"primary": "sensenova", "fallback": ["nvidia"]}, + "quotation": {"primary": "sensenova", "fallback": ["nvidia"]}, + "chat": {"primary": "sensenova", "fallback": ["nvidia"]}, + }, + description="AI 路由规则:各任务的主选/备用供应商", + )) + await self.db.flush() + logger.info("Seeded ai_routing config") + result = await self.db.execute( select(SystemConfig).order_by(SystemConfig.key) ) @@ -336,6 +374,12 @@ class AdminService: config.value = value config.updated_at = datetime.utcnow() await self.db.flush() + + if key == "ai_routing": + from app.ai.router import get_ai_router + await get_ai_router().reload_from_db(self.db) + logger.info("AI router reloaded after ai_routing config update") + return { "key": config.key, "value": config.value, diff --git a/backend/app/services/translation_quota.py b/backend/app/services/translation_quota.py index f173847..c38c501 100644 --- a/backend/app/services/translation_quota.py +++ b/backend/app/services/translation_quota.py @@ -12,6 +12,10 @@ class TranslationQuotaService: def __init__(self, db: AsyncSession): self.db = db + def _default_desc(self, version: str) -> str: + labels = {"ecommerce": "阿里云翻译电商版", "general": "阿里云翻译通用版", "llm": "AI模型翻译"} + return labels.get(version, f"阿里云翻译{version}版") + async def _get_or_create(self, version: str) -> TranslationQuota: result = await self.db.execute( select(TranslationQuota).where(TranslationQuota.version == version) @@ -25,7 +29,7 @@ class TranslationQuotaService: used_chars=0, current_month=now.strftime("%Y-%m"), enabled=True, - description=f"阿里云翻译{version}版", + description=self._default_desc(version), ) self.db.add(quota) await self.db.flush() @@ -57,8 +61,7 @@ class TranslationQuotaService: return remaining async def get_all_quotas(self) -> list: - default_versions = ["ecommerce", "general"] - for v in default_versions: + for v in ("ecommerce", "general", "llm"): await self._get_or_create(v) result = await self.db.execute(select(TranslationQuota).order_by(TranslationQuota.version)) diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py index 2cc07b3..96f3384 100644 --- a/backend/tests/test_config.py +++ b/backend/tests/test_config.py @@ -14,11 +14,12 @@ class TestConfig: assert settings.REFRESH_TOKEN_EXPIRE_DAYS == 30 def test_ai_routing_config(self): - assert "translate" in settings.AI_ROUTING - assert "reply" in settings.AI_ROUTING - assert "marketing" in settings.AI_ROUTING - assert "extract" in settings.AI_ROUTING - assert "primary" in settings.AI_ROUTING["translate"] + from app.ai.router import DEFAULT_ROUTING + assert "translate" in DEFAULT_ROUTING + assert "reply" in DEFAULT_ROUTING + assert "marketing" in DEFAULT_ROUTING + assert "extract" in DEFAULT_ROUTING + assert "primary" in DEFAULT_ROUTING["translate"] def test_free_tier_limits(self): assert settings.FREE_DAILY_TRANSLATE_CHARS == 5000 diff --git a/user-frontend/src/api/index.js b/user-frontend/src/api/index.js index 45c716f..432723b 100644 --- a/user-frontend/src/api/index.js +++ b/user-frontend/src/api/index.js @@ -116,4 +116,7 @@ export function sendWhatsApp(data) { return http.post('/whatsapp/send', data) } export function getUsageStats() { return http.get('/usage/stats') } +export function aiChat(message, history = []) { return http.post('/ai/chat', { message, history }) } +export function aiQuickQuestions() { return http.get('/ai/quick-questions') } + export default http diff --git a/user-frontend/src/components/AiAssistant.vue b/user-frontend/src/components/AiAssistant.vue new file mode 100644 index 0000000..3acbfe8 --- /dev/null +++ b/user-frontend/src/components/AiAssistant.vue @@ -0,0 +1,382 @@ + + + + + diff --git a/user-frontend/src/layouts/UserLayout.vue b/user-frontend/src/layouts/UserLayout.vue index f189eda..6681591 100644 --- a/user-frontend/src/layouts/UserLayout.vue +++ b/user-frontend/src/layouts/UserLayout.vue @@ -102,6 +102,7 @@
+
@@ -110,6 +111,7 @@ import { ref, computed, onMounted } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useAuthStore } from '@/stores/auth' import { getUnreadCount } from '@/api' +import AiAssistant from '@/components/AiAssistant.vue' const route = useRoute() const router = useRouter() diff --git a/user-frontend/src/views/WorkspaceLanding.vue b/user-frontend/src/views/WorkspaceLanding.vue index a1fc044..20f11e6 100644 --- a/user-frontend/src/views/WorkspaceLanding.vue +++ b/user-frontend/src/views/WorkspaceLanding.vue @@ -193,7 +193,8 @@ async function register() { regForm.password = '' } catch (e) { const map = { 'Phone already registered': '该手机号已被注册', 'Invalid credentials': '手机号或密码错误' } - regError.value = map[e?.detail] || e?.detail || '注册失败' + const detail = typeof e === 'string' ? e : e?.detail + regError.value = map[detail] || detail || '注册失败,请检查网络连接' } finally { regLoading.value = false } diff --git a/user-frontend/vite.config.js b/user-frontend/vite.config.js index 57d0bf0..bb981d1 100644 --- a/user-frontend/vite.config.js +++ b/user-frontend/vite.config.js @@ -15,7 +15,7 @@ export default defineConfig({ server: { port: 5174, proxy: { - '/api': { target: 'http://localhost:8002', changeOrigin: true } + '/api': { target: 'http://localhost:8000', changeOrigin: true } } } })