diff --git a/backend/.coverage b/backend/.coverage
deleted file mode 100644
index 0b7ab1a..0000000
Binary files a/backend/.coverage and /dev/null differ
diff --git a/backend/app/ai/base.py b/backend/app/ai/base.py
index 65d66ba..ea18a45 100644
--- a/backend/app/ai/base.py
+++ b/backend/app/ai/base.py
@@ -31,6 +31,9 @@ class AIProvider(ABC):
) -> Dict[str, Any]:
pass
+ async def chat(self, message: str, history: list = None, system_prompt: str = None) -> Dict[str, Any]:
+ raise NotImplementedError
+
@property
@abstractmethod
def name(self) -> str:
diff --git a/backend/app/ai/local_faq.py b/backend/app/ai/local_faq.py
new file mode 100644
index 0000000..205039d
--- /dev/null
+++ b/backend/app/ai/local_faq.py
@@ -0,0 +1,104 @@
+from typing import Optional, List, Tuple
+import re
+
+FAQ = [
+ {
+ "keywords": ["有哪些功能", "能做什么", "有什么功能", "功能介绍", "可以做什么"],
+ "answer": "TradeMate(外贸小助手)主要有以下功能模块:\n\n"
+ "1. **翻译** — 多语言翻译,支持外贸专业术语\n"
+ "2. **客户管理** — 添加、编辑、分类客户,跟进记录\n"
+ "3. **产品管理** — 产品库管理,支持分类和搜索\n"
+ "4. **报价单** — 在线生成报价单,导出 PDF\n"
+ "5. **营销文案** — AI 生成营销文案和开发信\n"
+ "6. **WhatsApp 集成** — 发送消息、管理对话\n"
+ "7. **数据看板** — 销售漏斗、客户分析\n\n"
+ "你想了解哪个功能的详细用法?",
+ },
+ {
+ "keywords": ["添加客户", "新增客户", "创建客户", "怎么加客户", "客户录入"],
+ "answer": "添加客户很简单:\n\n"
+ "1. 点击底部导航栏 **「客户」** 进入客户列表\n"
+ "2. 点击右上角 **「+」** 按钮\n"
+ "3. 填写客户信息(名称、电话、邮箱、公司、国家等)\n"
+ "4. 点击 **保存** 即可\n\n"
+ "你也可以直接对我说「添加客户张三,电话13800138000」,我会帮你自动提取信息并创建客户。",
+ },
+ {
+ "keywords": ["生成报价单", "创建报价单", "怎么报价", "如何报价"],
+ "answer": "生成报价单的步骤:\n\n"
+ "1. 点击底部导航栏 **「报价」** 进入报价单列表\n"
+ "2. 点击 **「新建报价单」**\n"
+ "3. 选择客户(可从客户管理导入)\n"
+ "4. 添加产品(从产品库选择或手动输入)\n"
+ "5. 设置价格、数量、折扣等\n"
+ "6. 点击 **生成**,系统会自动生成报价单\n"
+ "7. 可以导出为 PDF 发送给客户",
+ },
+ {
+ "keywords": ["导出客户", "导出数据", "怎么导出", "下载客户"],
+ "answer": "导出客户数据的方法:\n\n"
+ "1. 进入 **「客户」** 页面\n"
+ "2. 点击右上角菜单,选择 **「导出」**\n"
+ "3. 支持 CSV 和 Excel 两种格式\n"
+ "4. 选择导出范围(全部/当前筛选)\n"
+ "5. 点击确认即可下载\n\n"
+ "导出的文件包含客户名称、电话、邮箱、公司、国家等字段。",
+ },
+ {
+ "keywords": ["营销文案", "营销", "开发信", "怎么写开发信", "营销文案生成"],
+ "answer": "生成营销文案的步骤:\n\n"
+ "1. 进入 **「营销」** 页面\n"
+ "2. 点击 **「新建营销文案」**\n"
+ "3. 选择产品和目标客户\n"
+ "4. 选择风格(专业/友好/促销等)和语言\n"
+ "5. AI 会自动生成多版文案供你选择\n"
+ "6. 你可以编辑修改后直接使用或保存",
+ },
+ {
+ "keywords": ["FOB", "CIF", "贸易术语", "EXW", "DDP"],
+ "answer": "**FOB(Free On Board,离岸价)**\n"
+ "卖方负责将货物运至装运港并装上船,风险在装运港越过船舷时转移给买方。\n\n"
+ "**CIF(Cost, Insurance and Freight,到岸价)**\n"
+ "卖方承担运费和保险费,将货物运至目的港,风险在装运港越过船舷时转移。\n\n"
+ "主要区别:\n"
+ "- FOB:买方负责运费和保险\n"
+ "- CIF:卖方负责运费和保险\n\n"
+ "选择哪种取决于你和客户的谈判约定。",
+ },
+ {
+ "keywords": ["忘记密码", "重置密码", "修改密码"],
+ "answer": "目前 TradeMate 支持通过手机号验证码重置密码:\n\n"
+ "1. 在登录页面点击 **「忘记密码」**\n"
+ "2. 输入注册手机号\n"
+ "3. 点击 **获取验证码**\n"
+ "4. 输入验证码后设置新密码\n\n"
+ "如有问题请联系管理员。",
+ },
+ {
+ "keywords": ["怎么登录", "无法登录", "登录不了"],
+ "answer": "登录方式:\n\n"
+ "1. **手机号登录** — 输入手机号和密码\n"
+ "2. **微信登录** — 点击微信图标快速登录\n"
+ "3. **游客模式** — 无需注册即可体验部分功能\n\n"
+ "如果无法登录,请检查网络连接,或尝试重置密码。",
+ },
+]
+
+
+def match_faq(query: str) -> Optional[str]:
+ query_lower = query.lower().strip()
+ best_score = 0
+ best_answer = None
+
+ for item in FAQ:
+ score = 0
+ for kw in item["keywords"]:
+ if kw.lower() in query_lower:
+ score += 1
+ if score > best_score:
+ best_score = score
+ best_answer = item["answer"]
+
+ if best_score >= 1:
+ return best_answer
+ return None
diff --git a/backend/app/ai/providers/__init__.py b/backend/app/ai/providers/__init__.py
index 922edf3..0baba13 100644
--- a/backend/app/ai/providers/__init__.py
+++ b/backend/app/ai/providers/__init__.py
@@ -5,5 +5,6 @@ from .local import LocalProvider
from .spark import SparkProvider
from .sensenova import SensenovaProvider
from .opencode_go import OpencodeGoProvider
+from .nvidia import NvidiaProvider
-__all__ = ["OpenAIProvider", "ClaudeProvider", "DeepLProvider", "LocalProvider", "SparkProvider", "SensenovaProvider", "OpencodeGoProvider"]
+__all__ = ["OpenAIProvider", "ClaudeProvider", "DeepLProvider", "LocalProvider", "SparkProvider", "SensenovaProvider", "OpencodeGoProvider", "NvidiaProvider"]
diff --git a/backend/app/ai/providers/nvidia.py b/backend/app/ai/providers/nvidia.py
new file mode 100644
index 0000000..bb53732
--- /dev/null
+++ b/backend/app/ai/providers/nvidia.py
@@ -0,0 +1,50 @@
+from typing import Dict, Any, Optional, List
+from app.ai.providers.openai import OpenAIProvider, SYSTEM_PROMPTS
+import logging
+import time
+import httpx
+
+logger = logging.getLogger(__name__)
+
+
+class NvidiaProvider(OpenAIProvider):
+ def __init__(self, api_key: str, model: str = "stepfun-ai/step-3.5-flash", base_url: str = "https://integrate.api.nvidia.com/v1"):
+ super().__init__(
+ api_key=api_key,
+ model=model,
+ base_url=base_url,
+ http_client=httpx.AsyncClient(timeout=httpx.Timeout(60.0)),
+ )
+ self._name = f"nvidia-{model}"
+
+ async def chat(self, message: str, history: list = None, system_prompt: str = None) -> Dict[str, Any]:
+ t0 = time.time()
+
+ system = system_prompt or SYSTEM_PROMPTS["chat"]
+ messages = [{"role": "system", "content": system}]
+ if history:
+ for h in history[-10:]:
+ messages.append(h)
+ messages.append({"role": "user", "content": message})
+ t1 = time.time()
+
+ kwargs = {
+ "model": self.model,
+ "messages": messages,
+ "max_tokens": 300,
+ "temperature": 0.3,
+ }
+ resp = await self.client.chat.completions.create(**kwargs)
+ t2 = time.time()
+
+ content = resp.choices[0].message.content or ""
+ if not content and hasattr(resp.choices[0].message, "reasoning"):
+ content = resp.choices[0].message.reasoning
+ t3 = time.time()
+
+ logger.info(
+ f"NVIDIA timing: build_msgs={t1-t0:.1f}s api_call={t2-t1:.1f}s process={t3-t2:.1f}s "
+ f"chars_in={sum(len(m.get('content','')) for m in messages)} chars_out={len(content)}"
+ )
+
+ return {"reply": content, "provider": self.name, "model": self.model}
diff --git a/backend/app/ai/providers/openai.py b/backend/app/ai/providers/openai.py
index 547dfd9..8603e82 100644
--- a/backend/app/ai/providers/openai.py
+++ b/backend/app/ai/providers/openai.py
@@ -14,11 +14,21 @@ SYSTEM_PROMPTS = {
"marketing content that drives action. Adapt to the target audience's culture. "
"Return ONLY the copy, no explanations.",
"extract": "You extract structured data from text. Return ONLY valid JSON matching the requested schema.",
+ "chat": "你是 TradeMate(外贸小助手)的 AI 助手。你的职责是帮助外贸从业者解答关于本工具使用的问题,以及提供外贸业务建议。\n"
+ "你可以回答的问题包括:\n"
+ "- 功能介绍:翻译、客户管理、产品管理、报价单、营销文案、WhatsApp 集成等\n"
+ "- 使用帮助:如何添加客户、如何生成报价单、如何导出数据等\n"
+ "- 外贸知识:贸易术语(FOB、CIF 等)、谈判技巧、跟进策略等\n\n"
+ "回答要求:\n"
+ "- 简洁扼要,用中文回答\n"
+ "- 涉及操作步骤时用数字列表说明\n"
+ "- 不确定的问题不要编造,直接说需要查证\n"
+ "- 语气友好专业",
}
class OpenAIProvider(AIProvider):
- def __init__(self, api_key: str, model: str = "gpt-4o", base_url: Optional[str] = None):
+ def __init__(self, api_key: str, model: str = "gpt-4o", base_url: Optional[str] = None, http_client=None):
try:
from openai import AsyncOpenAI
except ImportError:
@@ -29,6 +39,8 @@ class OpenAIProvider(AIProvider):
kwargs = {"api_key": api_key}
if base_url:
kwargs["base_url"] = base_url
+ if http_client:
+ kwargs["http_client"] = http_client
self.client = AsyncOpenAI(**kwargs)
self.model = model
self._name = f"openai-{model}"
@@ -92,6 +104,24 @@ class OpenAIProvider(AIProvider):
except json.JSONDecodeError:
return {"data": {}, "confidence": 0.0, "provider": self.name, "error": "parse_failed"}
+ async def chat(self, message: str, history: list = None, system_prompt: str = None) -> Dict[str, Any]:
+ system = system_prompt or SYSTEM_PROMPTS["chat"]
+ messages = [{"role": "system", "content": system}]
+ if history:
+ for h in history[-10:]:
+ messages.append(h)
+ messages.append({"role": "user", "content": message})
+
+ kwargs = {
+ "model": self._cheap_model,
+ "messages": messages,
+ "max_tokens": 2000,
+ "temperature": 0.7,
+ }
+ resp = await self.client.chat.completions.create(**kwargs)
+ content = resp.choices[0].message.content or ""
+ return {"reply": content, "provider": self.name, "model": self.model}
+
async def _call(self, system: str, prompt: str, max_tokens: int = 3000, response_format: Optional[Dict] = None, model: Optional[str] = None) -> str:
kwargs = {
"model": model or self.model,
diff --git a/backend/app/ai/router.py b/backend/app/ai/router.py
index bea0ef4..1769f78 100644
--- a/backend/app/ai/router.py
+++ b/backend/app/ai/router.py
@@ -1,6 +1,6 @@
from typing import Dict, Any, Optional, List
from app.ai.base import AIProvider
-from app.ai.providers import OpenAIProvider, ClaudeProvider, DeepLProvider, LocalProvider, SparkProvider, SensenovaProvider, OpencodeGoProvider
+from app.ai.providers import OpenAIProvider, ClaudeProvider, DeepLProvider, LocalProvider, SparkProvider, SensenovaProvider, OpencodeGoProvider, NvidiaProvider
from app.config import settings
from app.ai.trade_corpus import TradeCorpus
import logging
@@ -45,6 +45,17 @@ class AIRouter:
except Exception as e:
logger.warning(f"OpencodeGo init failed: {e}")
+ if settings.NVIDIA_API_KEY:
+ try:
+ self.providers["nvidia"] = NvidiaProvider(
+ api_key=settings.NVIDIA_API_KEY,
+ model=settings.NVIDIA_MODEL,
+ base_url=settings.NVIDIA_BASE_URL,
+ )
+ logger.info("Nvidia provider ready")
+ except Exception as e:
+ logger.warning(f"Nvidia init failed: {e}")
+
if settings.ANTHROPIC_API_KEY:
try:
self.providers["anthropic"] = ClaudeProvider(api_key=settings.ANTHROPIC_API_KEY)
@@ -132,6 +143,9 @@ class AIRouter:
async def extract(self, text: str, schema: Dict[str, Any]) -> Dict[str, Any]:
return await self.execute("extract", "extract_info", text, schema)
+ async def chat(self, message: str, history: list = None, system_prompt: str = None) -> Dict[str, Any]:
+ return await self.execute("chat", "chat", message, history, system_prompt)
+
_router_instance = None
diff --git a/backend/app/api/v1/ai_assistant.py b/backend/app/api/v1/ai_assistant.py
new file mode 100644
index 0000000..2d76f68
--- /dev/null
+++ b/backend/app/api/v1/ai_assistant.py
@@ -0,0 +1,135 @@
+from fastapi import APIRouter, Depends, HTTPException, Request
+from pydantic import BaseModel
+from typing import Optional, List, Dict, Any
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+from app.database import get_db
+from app.ai.router import get_ai_router
+from app.ai.local_faq import match_faq
+from app.api.v1.deps import get_current_user_id
+from app.models.system_config import SystemConfig
+from app.services.admin import AdminService
+import logging
+import time
+import re
+import json
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+
+
+ACTION_INSTRUCTIONS = """
+当用户想要执行操作时(如添加客户、创建产品、生成报价单等),请执行以下步骤:
+1. 从用户消息中提取所有必要的信息
+2. 在回复末尾附上 JSON 格式的动作块,格式如下:
+
+```actions
+[{"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。
+如果用户明确表示要执行操作但缺少信息,生成 action 但标注缺失的字段。
+"""
+
+
+class ChatRequest(BaseModel):
+ message: str
+ history: Optional[List[Dict[str, str]]] = None
+
+
+@router.get("/quick-questions")
+async def get_quick_questions(
+ db: AsyncSession = Depends(get_db),
+):
+ result = await db.execute(
+ select(SystemConfig).where(SystemConfig.key == "ai_assistant_quick_questions")
+ )
+ cfg = result.scalar_one_or_none()
+ return cfg.value if cfg and cfg.value else []
+
+
+@router.post("/chat")
+async def chat(
+ data: ChatRequest,
+ request: Request,
+ user_id: str = Depends(get_current_user_id),
+ db: AsyncSession = Depends(get_db),
+):
+ t_start = time.time()
+
+ if not data.message.strip():
+ raise HTTPException(status_code=422, detail="Message is required")
+
+ t0 = time.time()
+ prompt_config = await db.execute(
+ select(SystemConfig).where(SystemConfig.key == "ai_assistant_prompt")
+ )
+ quick_config = await db.execute(
+ select(SystemConfig).where(SystemConfig.key == "ai_assistant_quick_questions")
+ )
+ t1 = time.time()
+
+ prompt_row = prompt_config.scalar_one_or_none()
+ base_prompt = prompt_row.value if prompt_row else None
+ quick_row = quick_config.scalar_one_or_none()
+ quick_questions = quick_row.value if quick_row else None
+ t2 = time.time()
+
+ action_keywords = ["帮我添加", "帮我创建", "帮我新增", "帮我新建", "帮我录入", "帮.+加", "添加客户.*电话", "创建客户.*电话"]
+ needs_action = any(re.search(k, data.message) for k in action_keywords)
+ system_prompt = base_prompt or ""
+ if needs_action:
+ system_prompt += "\n\n" + ACTION_INSTRUCTIONS
+
+ faq_answer = match_faq(data.message)
+ if faq_answer and not needs_action:
+ reply = faq_answer
+ provider = "local-faq"
+ actions = []
+ t4 = time.time()
+ logger.info(
+ f"CHAT [{user_id[:8]}] FAQ match | "
+ f"db={t1-t0:.2f}s orm={t2-t1:.2f}s total={t4-t_start:.2f}s"
+ )
+ else:
+ t3 = time.time()
+ ai = get_ai_router()
+ result = await ai.chat(data.message, data.history or [], system_prompt)
+ t4 = time.time()
+
+ reply = result.get("reply", "")
+ provider = result.get("provider_used", "")
+
+ actions = []
+ action_match = re.search(r'```actions\s*\n(.*?)\n```', reply, re.DOTALL)
+ if action_match:
+ try:
+ actions = json.loads(action_match.group(1))
+ reply = reply[:action_match.start()].strip()
+ except (json.JSONDecodeError, Exception) as e:
+ logger.warning(f"Failed to parse actions: {e}")
+
+ logger.info(
+ f"CHAT [{user_id[:8]}] AI | "
+ f"db={t1-t0:.2f}s orm={t2-t1:.2f}s setup={t3-t2:.2f}s ai={t4-t3:.2f}s total={t4-t_start:.2f}s"
+ )
+
+ client_ip = request.client.host if request.client else None
+ await AdminService(db).log_usage(
+ user_id, "ai.chat",
+ {"message": data.message[:200], "reply_length": len(reply), "provider": provider},
+ ip=client_ip,
+ )
+ logger.info(f"CHAT [{user_id[:8]}] done total={time.time()-t_start:.2f}s")
+
+ return {
+ "reply": reply,
+ "provider": provider,
+ "quick_questions": quick_questions,
+ "actions": actions,
+ }
diff --git a/backend/app/config.py b/backend/app/config.py
index 3edf264..045ffc2 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -45,7 +45,11 @@ class Settings(BaseSettings):
OPENCODE_GO_API_KEY: Optional[str] = None
OPENCODE_GO_BASE_URL: str = "https://opencode.ai/zen/go/v1"
- OPENCODE_GO_MODEL: str = "deepseek-v4-flash"
+ OPENCODE_GO_MODEL: str = "minimax-m2.7"
+
+ NVIDIA_API_KEY: Optional[str] = None
+ NVIDIA_BASE_URL: str = "https://integrate.api.nvidia.com/v1"
+ NVIDIA_MODEL: str = "stepfun-ai/step-3.5-flash"
WHATSAPP_API_TOKEN: Optional[str] = None
WHATSAPP_PHONE_NUMBER_ID: Optional[str] = None
@@ -72,6 +76,7 @@ class Settings(BaseSettings):
"marketing": {"primary": "opencode_go", "fallback": ["sensenova", "openai", "local"]},
"extract": {"primary": "opencode_go", "fallback": ["sensenova", "openai"]},
"quotation": {"primary": "opencode_go", "fallback": ["sensenova", "openai"]},
+ "chat": {"primary": "nvidia", "fallback": ["opencode_go", "openai", "sensenova"]},
}
FREE_DAILY_TRANSLATE_CHARS: int = 5000
diff --git a/backend/app/services/admin.py b/backend/app/services/admin.py
index c271d3d..a57e4ce 100644
--- a/backend/app/services/admin.py
+++ b/backend/app/services/admin.py
@@ -298,6 +298,8 @@ class AdminService:
SystemConfig(key="feature_registration", value={"enabled": True}, description="新用户注册开关"),
SystemConfig(key="free_daily_limits", value={"translate_chars": 5000, "replies": 20, "marketing": 5, "customers": 5, "products": 1, "quotations": 3}, description="免费版每日配额"),
SystemConfig(key="pro_daily_limits", value={"translate_chars": 50000, "replies": 200, "marketing": 50, "customers": 100, "products": 20, "quotations": 30}, description="Pro 版每日配额"),
+ SystemConfig(key="ai_assistant_prompt", value="你是 TradeMate(外贸小助手)的 AI 助手。你的职责是帮助外贸从业者解答关于本工具使用的问题,以及提供外贸业务建议。\n你可以回答的问题包括:\n- 功能介绍:翻译、客户管理、产品管理、报价单、营销文案、WhatsApp 集成等\n- 使用帮助:如何添加客户、如何生成报价单、如何导出数据等\n- 外贸知识:贸易术语(FOB、CIF 等)、谈判技巧、跟进策略等\n\n回答要求:\n- 简洁扼要,用中文回答\n- 涉及操作步骤时用数字列表说明\n- 不确定的问题不要编造,直接说需要查证\n- 语气友好专业", description="AI 助手系统提示词"),
+ SystemConfig(key="ai_assistant_quick_questions", value=["TradeMate 有哪些功能?", "如何添加客户?", "如何生成报价单?", "怎么导出客户数据?", "营销文案怎么生成?", "什么是 FOB、CIF?"], description="AI 助手快捷提问列表"),
]
for cfg in defaults:
self.db.add(cfg)
diff --git a/uni-app/src/components/ai-assistant.vue b/uni-app/src/components/ai-assistant.vue
new file mode 100644
index 0000000..fc4926c
--- /dev/null
+++ b/uni-app/src/components/ai-assistant.vue
@@ -0,0 +1,372 @@
+
+
+
+ AI
+
+
+
+
+
+
+
+
+ {{ msg.content }}
+
+
+
+ {{ action.label }}
+
+ {{ fieldLabel(key) }}
+ editField(i, ai, key, e.detail.value)" />
+
+
+ 取消
+ 确认添加
+
+
+
+
+
+
+
+ 思考中...
+
+
+
+
+ {{ s }}
+
+
+
+
+
+
+ 发送
+
+
+
+
+
+
+
+
diff --git a/uni-app/src/main.js b/uni-app/src/main.js
index 8606d48..864e222 100644
--- a/uni-app/src/main.js
+++ b/uni-app/src/main.js
@@ -1,9 +1,11 @@
import { createSSRApp } from 'vue'
import App from './App.vue'
+import AiAssistant from './components/ai-assistant.vue'
export function createApp() {
const app = createSSRApp(App)
+ app.component('AiAssistant', AiAssistant)
return {
app,
}
-}
\ No newline at end of file
+}
diff --git a/uni-app/src/pages/admin/admin.vue b/uni-app/src/pages/admin/admin.vue
index 2b2209b..993dc4f 100644
--- a/uni-app/src/pages/admin/admin.vue
+++ b/uni-app/src/pages/admin/admin.vue
@@ -258,11 +258,13 @@
+