chore: post-deployment cleanup and docs update

- 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
This commit is contained in:
TradeMate Dev
2026-06-02 15:40:02 +08:00
parent fa3050a17c
commit f17a6ccbac
28 changed files with 1140 additions and 209 deletions
+27 -5
View File
@@ -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**: `<AiAssistant />` 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
+102 -22
View File
@@ -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-apiHMAC-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 管理 + 年费定价 |
+6 -3
View File
@@ -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
@@ -0,0 +1,363 @@
<template>
<div>
<div class="ai-float-btn" @click="visible = true">
<span class="ai-float-icon">AI</span>
</div>
<el-dialog
v-model="visible"
title="TradeMate AI 助手"
width="480px"
:close-on-click-modal="false"
class="ai-dialog"
top="5vh"
@opened="onOpened"
>
<div class="ai-messages" ref="msgContainer">
<div v-for="(msg, i) in messages" :key="i" class="ai-msg-row" :class="msg.role">
<div class="ai-avatar" v-if="msg.role === 'assistant'">
<el-icon :size="18" color="#667eea"><Cpu /></el-icon>
</div>
<div class="ai-msg-body">
<div class="ai-msg-bubble">
<div class="ai-msg-text">{{ msg.content }}</div>
<div v-if="msg.actions && msg.actions.length" class="ai-action-card">
<div v-for="(action, ai) in msg.actions" :key="ai" class="ai-action-item">
<div class="ai-action-title">{{ action.label }}</div>
<div v-if="action.type === 'navigate'" class="ai-navigate-hint">
<el-tag type="info">跳转至 {{ action.fields?.path }}</el-tag>
</div>
<div v-for="(val, key) in action.fields" :key="key" class="ai-field-row" v-if="action.type !== 'navigate'">
<span class="ai-field-label">{{ fieldLabel(action.type, key) }}</span>
<el-input
v-model="action.fields[key]"
:placeholder="fieldPlaceholder(action.type, key)"
size="small"
/>
</div>
<div class="ai-action-btns">
<el-button size="small" @click="cancelAction(i, ai)">取消</el-button>
<el-button size="small" type="primary" @click="confirmAction(i, ai)">确认执行</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="loading" class="ai-loading">
<el-icon class="is-loading" :size="18"><Loading /></el-icon>
<span>思考中...</span>
</div>
<div v-if="showSuggestions" class="ai-suggestions">
<div
v-for="(s, i) in suggestions"
:key="i"
class="ai-suggestion"
@click="sendQuick(s)"
>
{{ s }}
</div>
</div>
</div>
<template #footer>
<div class="ai-input-bar">
<el-input
v-model="inputText"
placeholder="输入你的问题..."
:disabled="loading"
size="default"
@keyup.enter="send"
/>
<el-button type="primary" :disabled="!inputText.trim() || loading" @click="send">发送</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { Cpu, Loading } from '@element-plus/icons-vue'
import { aiChat, aiQuickQuestions, searchUsers, updateUser, updateConfig, reviewCertification, processInvoice } from '@/api'
const visible = ref(false)
const inputText = ref('')
const loading = ref(false)
const suggestions = ref([])
const msgContainer = ref(null)
const messages = ref([
{ role: 'assistant', content: '你好!我是管理后台 AI 助手,可以帮你管理用户、配置、认证审核、发票等。你可以说:"查找用户"、"审核认证"等。' },
])
const showSuggestions = ref(false)
const fieldLabel = (type, key) => {
const labels = {
name: '名称', phone: '电话', email: '邮箱', company: '公司',
country: '国家', status: '状态', notes: '备注',
key: '配置键', value: '配置值',
action: '操作', reason: '原因',
username: '用户名', role: '角色',
version: '版本', quota: '额度',
path: '目标页面',
}
return labels[key] || key
}
const fieldPlaceholder = (type, key) => {
const placeholders = {
reason: '审核备注(可选)',
action: 'approved / rejected',
role: 'admin / user',
value: '配置值',
quota: '如 100',
path: '如 /users, /config, /certifications',
}
return placeholders[key] || ''
}
const cancelAction = (msgIdx, actionIdx) => {
const msg = messages.value[msgIdx]
if (msg.actions) msg.actions.splice(actionIdx, 1)
}
const confirmAction = async (msgIdx, actionIdx) => {
const action = messages.value[msgIdx].actions[actionIdx]
const { type, fields } = action
loading.value = true
try {
switch (type) {
case 'navigate':
if (fields.path) {
const router = (await import('@/router')).default
router.push(fields.path)
ElMessage.success(`正在跳转到 ${fields.path}`)
messages.value[msgIdx].actions = []
}
return
case 'search_users':
if (!fields.query) { ElMessage.warning('搜索关键词不能为空'); return }
await searchUsers(fields.query)
break
case 'update_user':
if (!fields.user_id && !fields.username) { ElMessage.warning('用户标识不能为空'); return }
await updateUser(fields.user_id || fields.username, fields)
break
case 'update_config':
if (!fields.key) { ElMessage.warning('配置键不能为空'); return }
await updateConfig(fields.key, fields.value)
break
case 'review_certification':
if (!fields.id) { ElMessage.warning('认证ID不能为空'); return }
await reviewCertification(fields.id, fields.action || 'approved', fields.reason)
break
case 'process_invoice':
if (!fields.id) { ElMessage.warning('发票ID不能为空'); return }
await processInvoice(fields.id, fields.action || 'approve')
break
default:
ElMessage.warning(`未知操作类型: ${type}`)
return
}
ElMessage.success('操作成功')
messages.value[msgIdx].actions = []
} catch (e) {
ElMessage.error(e.message || e.detail || '操作失败')
} finally {
loading.value = false
}
}
const sendQuick = (text) => {
inputText.value = text
send()
}
const fetchSuggestions = async () => {
try {
const res = await aiQuickQuestions()
if (Array.isArray(res)) suggestions.value = res
} catch {}
}
const onOpened = () => {
if (suggestions.value.length === 0) fetchSuggestions()
showSuggestions.value = messages.value.length <= 1
scrollToBottom()
}
const send = async () => {
const msg = inputText.value.trim()
if (!msg || loading.value) return
inputText.value = ''
showSuggestions.value = false
messages.value.push({ role: 'user', content: msg })
loading.value = true
scrollToBottom()
try {
const hist = messages.value.map(m => ({ role: m.role, content: m.content }))
const res = await aiChat(msg, hist.slice(0, -1))
const newMsg = { role: 'assistant', content: res.reply || '抱歉,我没有理解,请重新描述一下你的问题。' }
if (res.actions && res.actions.length) {
newMsg.actions = res.actions
}
messages.value.push(newMsg)
} catch {
messages.value.push({ role: 'assistant', content: '请求失败,请稍后重试。' })
} finally {
loading.value = false
scrollToBottom()
}
}
const scrollToBottom = async () => {
await nextTick()
const el = msgContainer.value
if (el) el.scrollTop = el.scrollHeight
}
</script>
<style scoped>
.ai-float-btn {
position: fixed;
right: 30px;
bottom: 30px;
width: 52px;
height: 52px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea, #764ba2);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.4);
z-index: 9999;
cursor: pointer;
transition: transform 0.2s;
}
.ai-float-btn:hover {
transform: scale(1.08);
}
.ai-float-icon {
color: #fff;
font-size: 18px;
font-weight: bold;
}
.ai-dialog {
--el-dialog-content-padding: 0;
}
.ai-dialog :deep(.el-dialog__body) {
padding: 0;
height: 480px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.ai-messages {
flex: 1;
padding: 16px;
overflow-y: auto;
background: #f5f5f5;
}
.ai-msg-row {
margin-bottom: 14px;
display: flex;
gap: 8px;
}
.ai-msg-row.user {
flex-direction: row-reverse;
}
.ai-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: #f0edff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.ai-msg-body {
max-width: 80%;
}
.ai-msg-bubble {
padding: 10px 14px;
border-radius: 8px;
background: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.ai-msg-row.user .ai-msg-bubble {
background: #667eea;
color: #fff;
}
.ai-msg-text {
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.ai-msg-row.user .ai-msg-text {
color: #fff;
}
.ai-action-card {
margin-top: 12px;
border-top: 1px solid #eee;
padding-top: 10px;
}
.ai-action-title {
font-size: 14px;
font-weight: 600;
color: #333;
margin-bottom: 10px;
}
.ai-field-row {
margin-bottom: 8px;
}
.ai-field-label {
display: block;
font-size: 12px;
color: #666;
margin-bottom: 3px;
}
.ai-action-btns {
display: flex;
gap: 8px;
margin-top: 10px;
}
.ai-loading {
text-align: center;
padding: 12px 0;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
color: #999;
font-size: 13px;
}
.ai-suggestions {
padding: 8px 0;
}
.ai-suggestion {
background: #f0edff;
border-radius: 6px;
padding: 8px 12px;
margin-bottom: 8px;
font-size: 13px;
color: #667eea;
cursor: pointer;
transition: background 0.15s;
}
.ai-suggestion:hover {
background: #e0dbff;
}
.ai-input-bar {
display: flex;
gap: 8px;
padding: 10px 16px;
border-top: 1px solid #f0f0f0;
}
</style>
+3 -1
View File
@@ -51,7 +51,7 @@
<el-icon><Search /></el-icon>
<span>搜索配置</span>
</el-menu-item>
<el-menu-item index="/system/ai-providers">
<el-menu-item index="/system/search-config/ai-providers">
<el-icon><Cpu /></el-icon>
<span>AI 模型配置</span>
</el-menu-item>
@@ -128,6 +128,7 @@
</div>
</el-footer>
</el-container>
<AiAssistant />
</el-container>
</template>
@@ -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()
+1 -1
View File
@@ -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',
+46 -5
View File
@@ -7,7 +7,24 @@
<el-tag size="small" v-if="cfg.description">{{ cfg.description }}</el-tag>
</div>
</template>
<div v-if="typeof cfg.value === 'object' && !Array.isArray(cfg.value)">
<div v-if="cfg.key === 'ai_routing'">
<div v-for="(taskVal, taskKey) in cfg.value" :key="taskKey" class="cfg-nested-group">
<div class="cfg-group-title">{{ fieldLabelsMap.ai_routing?.[taskKey] || taskKey }}</div>
<div class="cfg-field">
<span class="cfg-label">主选</span>
<el-select v-model="edits[cfg.key][taskKey].primary" size="small" style="width:300px" filterable>
<el-option v-for="p in providers" :key="p.provider_type" :value="p.provider_type" :label="p.name + ' — ' + p.model_name" />
</el-select>
</div>
<div class="cfg-field">
<span class="cfg-label">备用</span>
<el-select v-model="edits[cfg.key][taskKey].fallback" size="small" style="width:400px" multiple filterable collapse-tags>
<el-option v-for="p in providers" :key="p.provider_type" :value="p.provider_type" :label="p.name + ' — ' + p.model_name" />
</el-select>
</div>
</div>
</div>
<div v-else-if="typeof cfg.value === 'object' && !Array.isArray(cfg.value)">
<div class="cfg-field" v-for="(v, k) in cfg.value" :key="k">
<span class="cfg-label">{{ fieldLabel(cfg.key, k) }}</span>
<el-input v-if="typeof v === 'string'" v-model="edits[cfg.key][k]" size="small" style="width:300px" />
@@ -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; }
</style>
+1 -1
View File
@@ -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 || '登录失败'
+1 -1
View File
@@ -4,7 +4,7 @@
<el-card shadow="never">
<template #header>
<div class="quota-header">
<span class="quota-version">{{ q.version === 'ecommerce' ? '电商版' : '通用版' }}</span>
<span class="quota-version">{{ { ecommerce: '电商版', general: '通用版', llm: 'AI模型翻译' }[q.version] || q.version }}</span>
<el-tag size="small" v-if="q.description">{{ q.description }}</el-tag>
</div>
</template>
+1 -1
View File
@@ -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 }
}
}
})
@@ -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
+1 -3
View File
@@ -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"]
+48 -2
View File
@@ -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],
+14
View File
@@ -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}"
-7
View File
@@ -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}"
-90
View File
@@ -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
+56 -27
View File
@@ -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,33 +101,18 @@ 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,
model_name=settings.NVIDIA_MODEL, priority=1, 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,
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:
@@ -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()
-2
View File
@@ -42,9 +42,7 @@ class AIProviderUpdate(BaseModel):
PROVIDER_TYPE_LABELS = {
"sensenova": "Sensenova (商汤)",
"opencode_go": "OpencodeGo",
"nvidia": "NVIDIA",
"spark": "讯飞 Spark",
"alibaba-mt": "阿里翻译",
}
+15 -4
View File
@@ -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 但标注缺失的字段。
-15
View File
@@ -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
+49 -5
View File
@@ -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,
+6 -3
View File
@@ -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))
+6 -5
View File
@@ -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
+3
View File
@@ -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
@@ -0,0 +1,382 @@
<template>
<div>
<div class="ai-float-btn" @click="visible = true">
<span class="ai-float-icon">AI</span>
</div>
<el-dialog
v-model="visible"
title="TradeMate AI 助手"
width="480px"
:close-on-click-modal="false"
class="ai-dialog"
top="5vh"
@opened="onOpened"
>
<div class="ai-messages" ref="msgContainer">
<div v-for="(msg, i) in messages" :key="i" class="ai-msg-row" :class="msg.role">
<div class="ai-avatar" v-if="msg.role === 'assistant'">
<el-icon :size="18" color="#667eea"><Cpu /></el-icon>
</div>
<div class="ai-msg-body">
<div class="ai-msg-bubble">
<div class="ai-msg-text">{{ msg.content }}</div>
<div v-if="msg.actions && msg.actions.length" class="ai-action-card">
<div v-for="(action, ai) in msg.actions" :key="ai" class="ai-action-item">
<div class="ai-action-title">{{ action.label }}</div>
<div v-for="(val, key) in action.fields" :key="key" class="ai-field-row">
<span class="ai-field-label">{{ fieldLabel(action.type, key) }}</span>
<el-input
v-model="action.fields[key]"
:placeholder="fieldPlaceholder(action.type, key)"
size="small"
/>
</div>
<div class="ai-action-btns">
<el-button size="small" @click="cancelAction(i, ai)">取消</el-button>
<el-button size="small" type="primary" @click="confirmAction(i, ai)">确认执行</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="loading" class="ai-loading">
<el-icon class="is-loading" :size="18"><Loading /></el-icon>
<span>思考中...</span>
</div>
<div v-if="showSuggestions" class="ai-suggestions">
<div
v-for="(s, i) in suggestions"
:key="i"
class="ai-suggestion"
@click="sendQuick(s)"
>
{{ s }}
</div>
</div>
</div>
<template #footer>
<div class="ai-input-bar">
<el-input
v-model="inputText"
placeholder="输入你的问题..."
:disabled="loading"
size="default"
@keyup.enter="send"
/>
<el-button type="primary" :disabled="!inputText.trim() || loading" @click="send">发送</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { Cpu, Loading } from '@element-plus/icons-vue'
import {
aiChat, aiQuickQuestions,
createCustomer, createProduct, createQuotation,
scanFollowups, generateMarketing, discoverySearch,
} from '@/api'
const visible = ref(false)
const inputText = ref('')
const loading = ref(false)
const suggestions = ref([])
const msgContainer = ref(null)
const messages = ref([
{ role: 'assistant', content: '你好!我是 TradeMate AI 助手,可以帮你操作外贸工具的各项功能。你可以说:"帮我添加一个客户"、"创建一个产品"、"生成报价单"等。' },
])
const showSuggestions = ref(false)
const fieldLabel = (type, key) => {
const labels = {
name: '名称', phone: '电话', email: '邮箱', company: '公司',
country: '国家', website: '网站', notes: '备注',
name_en: '英文名称', description: '描述', description_en: '英文描述',
category: '分类', price: '价格', price_unit: '货币', moq: 'MOQ',
keywords: '关键词',
customer_name: '客户名称', product_info: '产品信息', quantity: '数量',
terms: '交易条款',
message: '消息内容',
product_name: '产品名称', target_market: '目标市场', tone: '语气',
language: '语言',
industry: '行业',
}
return labels[key] || key
}
const fieldPlaceholder = (type, key) => {
const placeholders = {
phone: '如 +86-13800138000',
email: '如 contact@example.com',
price: '如 12.50',
price_unit: '默认 USD',
keywords: '逗号分隔',
quantity: '如 1000 pcs',
target_market: '如 美国、欧洲',
}
return placeholders[key] || ''
}
const cancelAction = (msgIdx, actionIdx) => {
const msg = messages.value[msgIdx]
if (msg.actions) msg.actions.splice(actionIdx, 1)
}
const confirmAction = async (msgIdx, actionIdx) => {
const action = messages.value[msgIdx].actions[actionIdx]
const { type, fields } = action
loading.value = true
try {
switch (type) {
case 'create_customer':
if (!fields.name) { ElMessage.warning('客户名称不能为空'); return }
await createCustomer(fields)
break
case 'create_product':
if (!fields.name) { ElMessage.warning('产品名称不能为空'); return }
if (fields.keywords && typeof fields.keywords === 'string') {
fields.keywords = fields.keywords.split(/[,]/).map(s => s.trim()).filter(Boolean)
}
await createProduct(fields)
break
case 'create_quotation':
if (!fields.customer_name) { ElMessage.warning('客户名称不能为空'); return }
if (!fields.product_info) { ElMessage.warning('产品信息不能为空'); return }
if (!fields.quantity) { ElMessage.warning('数量不能为空'); return }
await createQuotation({
customer_name: fields.customer_name,
items: [{ description: fields.product_info, quantity: fields.quantity, price: fields.price || '0' }],
terms: fields.terms || '',
})
break
case 'scan_followups':
await scanFollowups()
break
case 'generate_marketing':
if (!fields.product_name) { ElMessage.warning('产品名称不能为空'); return }
await generateMarketing({
product_info: { name: fields.product_name },
target_market: fields.target_market,
tone: fields.tone,
language: fields.language,
})
break
case 'discovery_search':
if (!fields.keywords) { ElMessage.warning('关键词不能为空'); return }
await discoverySearch({
keywords: fields.keywords.split(/[,]/).map(s => s.trim()).filter(Boolean),
country: fields.country,
industry: fields.industry,
})
break
default:
ElMessage.warning(`未知操作类型: ${type}`)
return
}
ElMessage.success('操作成功')
messages.value[msgIdx].actions = []
} catch (e) {
ElMessage.error(e.message || e.detail || '操作失败')
} finally {
loading.value = false
}
}
const sendQuick = (text) => {
inputText.value = text
send()
}
const fetchSuggestions = async () => {
try {
const res = await aiQuickQuestions()
if (Array.isArray(res)) suggestions.value = res
} catch {}
}
const onOpened = () => {
if (suggestions.value.length === 0) fetchSuggestions()
showSuggestions.value = messages.value.length <= 1
scrollToBottom()
}
const send = async () => {
const msg = inputText.value.trim()
if (!msg || loading.value) return
inputText.value = ''
showSuggestions.value = false
messages.value.push({ role: 'user', content: msg })
loading.value = true
scrollToBottom()
try {
const hist = messages.value.map(m => ({ role: m.role, content: m.content }))
const res = await aiChat(msg, hist.slice(0, -1))
const newMsg = { role: 'assistant', content: res.reply || '抱歉,我没有理解,请重新描述一下你的问题。' }
if (res.actions && res.actions.length) {
newMsg.actions = res.actions
}
messages.value.push(newMsg)
} catch {
messages.value.push({ role: 'assistant', content: '请求失败,请稍后重试。' })
} finally {
loading.value = false
scrollToBottom()
}
}
const scrollToBottom = async () => {
await nextTick()
const el = msgContainer.value
if (el) el.scrollTop = el.scrollHeight
}
</script>
<style scoped>
.ai-float-btn {
position: fixed;
right: 30px;
bottom: 30px;
width: 52px;
height: 52px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea, #764ba2);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.4);
z-index: 9999;
cursor: pointer;
transition: transform 0.2s;
}
.ai-float-btn:hover {
transform: scale(1.08);
}
.ai-float-icon {
color: #fff;
font-size: 18px;
font-weight: bold;
}
.ai-dialog {
--el-dialog-content-padding: 0;
}
.ai-dialog :deep(.el-dialog__body) {
padding: 0;
height: 480px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.ai-messages {
flex: 1;
padding: 16px;
overflow-y: auto;
background: #f5f5f5;
}
.ai-msg-row {
margin-bottom: 14px;
display: flex;
gap: 8px;
}
.ai-msg-row.user {
flex-direction: row-reverse;
}
.ai-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: #f0edff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.ai-msg-body {
max-width: 80%;
}
.ai-msg-bubble {
padding: 10px 14px;
border-radius: 8px;
background: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.ai-msg-row.user .ai-msg-bubble {
background: #667eea;
color: #fff;
}
.ai-msg-text {
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.ai-msg-row.user .ai-msg-text {
color: #fff;
}
.ai-action-card {
margin-top: 12px;
border-top: 1px solid #eee;
padding-top: 10px;
}
.ai-action-title {
font-size: 14px;
font-weight: 600;
color: #333;
margin-bottom: 10px;
}
.ai-field-row {
margin-bottom: 8px;
}
.ai-field-label {
display: block;
font-size: 12px;
color: #666;
margin-bottom: 3px;
}
.ai-action-btns {
display: flex;
gap: 8px;
margin-top: 10px;
}
.ai-loading {
text-align: center;
padding: 12px 0;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
color: #999;
font-size: 13px;
}
.ai-suggestions {
padding: 8px 0;
}
.ai-suggestion {
background: #f0edff;
border-radius: 6px;
padding: 8px 12px;
margin-bottom: 8px;
font-size: 13px;
color: #667eea;
cursor: pointer;
transition: background 0.15s;
}
.ai-suggestion:hover {
background: #e0dbff;
}
.ai-input-bar {
display: flex;
gap: 8px;
padding: 10px 16px;
border-top: 1px solid #f0f0f0;
}
</style>
+2
View File
@@ -102,6 +102,7 @@
</div>
</footer>
</div>
<AiAssistant />
</div>
</template>
@@ -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()
+2 -1
View File
@@ -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
}
+1 -1
View File
@@ -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 }
}
}
})