From f8a23855d23308fd573f32df395a35d97ae9e197 Mon Sep 17 00:00:00 2001 From: TradeMate Dev Date: Wed, 20 May 2026 09:39:22 +0800 Subject: [PATCH] feat: AI assistant phase 2 - configurable prompt, action operations, FAQ matching, NVIDIA provider - Admin-configurable AI prompt/quick questions from system_configs DB - GET /api/v1/ai/quick-questions endpoint for fetching quick questions - Local FAQ matching for instant responses (avoid AI calls for common Qs) - AI action extraction: "add customer" intent detected, structured data returned - Frontend action confirmation card with editable fields, calls customer API on confirm - NVIDIA provider (stepfun-ai/step-3.5-flash) for faster chat vs deepseek-v4-flash - Fixed httpx client timeout preventing backend hangs - Added log_usage calls for auth events (register/login/guest/wechat) - Admin tabs (users/stats/logs/config) fully functional with real backend - AiAssistant component added to all tabbar pages --- backend/.coverage | Bin 69632 -> 0 bytes backend/app/ai/base.py | 3 + backend/app/ai/local_faq.py | 104 ++++++ backend/app/ai/providers/__init__.py | 3 +- backend/app/ai/providers/nvidia.py | 50 +++ backend/app/ai/providers/openai.py | 32 +- backend/app/ai/router.py | 16 +- backend/app/api/v1/ai_assistant.py | 135 ++++++++ backend/app/config.py | 7 +- backend/app/services/admin.py | 2 + uni-app/src/components/ai-assistant.vue | 372 ++++++++++++++++++++++ uni-app/src/main.js | 4 +- uni-app/src/pages/admin/admin.vue | 4 + uni-app/src/pages/customers/customers.vue | 2 + uni-app/src/pages/index/index.vue | 2 + uni-app/src/pages/marketing/marketing.vue | 2 + uni-app/src/pages/product/product.vue | 2 + uni-app/src/pages/quotation/quotation.vue | 2 + uni-app/src/pages/translate/translate.vue | 2 + uni-app/src/utils/api.js | 5 + 20 files changed, 744 insertions(+), 5 deletions(-) delete mode 100644 backend/.coverage create mode 100644 backend/app/ai/local_faq.py create mode 100644 backend/app/ai/providers/nvidia.py create mode 100644 backend/app/api/v1/ai_assistant.py create mode 100644 uni-app/src/components/ai-assistant.vue diff --git a/backend/.coverage b/backend/.coverage deleted file mode 100644 index 0b7ab1a446667ecd1abea729893f39dbc969d5ca..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 69632 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 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 @@ +