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 @@ + + + + + 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 @@ +