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
This commit is contained in:
Binary file not shown.
@@ -31,6 +31,9 @@ class AIProvider(ABC):
|
|||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
async def chat(self, message: str, history: list = None, system_prompt: str = None) -> Dict[str, Any]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -5,5 +5,6 @@ from .local import LocalProvider
|
|||||||
from .spark import SparkProvider
|
from .spark import SparkProvider
|
||||||
from .sensenova import SensenovaProvider
|
from .sensenova import SensenovaProvider
|
||||||
from .opencode_go import OpencodeGoProvider
|
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"]
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -14,11 +14,21 @@ SYSTEM_PROMPTS = {
|
|||||||
"marketing content that drives action. Adapt to the target audience's culture. "
|
"marketing content that drives action. Adapt to the target audience's culture. "
|
||||||
"Return ONLY the copy, no explanations.",
|
"Return ONLY the copy, no explanations.",
|
||||||
"extract": "You extract structured data from text. Return ONLY valid JSON matching the requested schema.",
|
"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):
|
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:
|
try:
|
||||||
from openai import AsyncOpenAI
|
from openai import AsyncOpenAI
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -29,6 +39,8 @@ class OpenAIProvider(AIProvider):
|
|||||||
kwargs = {"api_key": api_key}
|
kwargs = {"api_key": api_key}
|
||||||
if base_url:
|
if base_url:
|
||||||
kwargs["base_url"] = base_url
|
kwargs["base_url"] = base_url
|
||||||
|
if http_client:
|
||||||
|
kwargs["http_client"] = http_client
|
||||||
self.client = AsyncOpenAI(**kwargs)
|
self.client = AsyncOpenAI(**kwargs)
|
||||||
self.model = model
|
self.model = model
|
||||||
self._name = f"openai-{model}"
|
self._name = f"openai-{model}"
|
||||||
@@ -92,6 +104,24 @@ class OpenAIProvider(AIProvider):
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
return {"data": {}, "confidence": 0.0, "provider": self.name, "error": "parse_failed"}
|
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:
|
async def _call(self, system: str, prompt: str, max_tokens: int = 3000, response_format: Optional[Dict] = None, model: Optional[str] = None) -> str:
|
||||||
kwargs = {
|
kwargs = {
|
||||||
"model": model or self.model,
|
"model": model or self.model,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from typing import Dict, Any, Optional, List
|
from typing import Dict, Any, Optional, List
|
||||||
from app.ai.base import AIProvider
|
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.config import settings
|
||||||
from app.ai.trade_corpus import TradeCorpus
|
from app.ai.trade_corpus import TradeCorpus
|
||||||
import logging
|
import logging
|
||||||
@@ -45,6 +45,17 @@ class AIRouter:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"OpencodeGo init failed: {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:
|
if settings.ANTHROPIC_API_KEY:
|
||||||
try:
|
try:
|
||||||
self.providers["anthropic"] = ClaudeProvider(api_key=settings.ANTHROPIC_API_KEY)
|
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]:
|
async def extract(self, text: str, schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
return await self.execute("extract", "extract_info", text, schema)
|
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
|
_router_instance = None
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -45,7 +45,11 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
OPENCODE_GO_API_KEY: Optional[str] = None
|
OPENCODE_GO_API_KEY: Optional[str] = None
|
||||||
OPENCODE_GO_BASE_URL: str = "https://opencode.ai/zen/go/v1"
|
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_API_TOKEN: Optional[str] = None
|
||||||
WHATSAPP_PHONE_NUMBER_ID: 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"]},
|
"marketing": {"primary": "opencode_go", "fallback": ["sensenova", "openai", "local"]},
|
||||||
"extract": {"primary": "opencode_go", "fallback": ["sensenova", "openai"]},
|
"extract": {"primary": "opencode_go", "fallback": ["sensenova", "openai"]},
|
||||||
"quotation": {"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
|
FREE_DAILY_TRANSLATE_CHARS: int = 5000
|
||||||
|
|||||||
@@ -298,6 +298,8 @@ class AdminService:
|
|||||||
SystemConfig(key="feature_registration", value={"enabled": True}, description="新用户注册开关"),
|
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="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="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:
|
for cfg in defaults:
|
||||||
self.db.add(cfg)
|
self.db.add(cfg)
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
<template>
|
||||||
|
<view>
|
||||||
|
<view class="ai-float-btn" @click="open = !open">
|
||||||
|
<text class="ai-float-icon">AI</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="ai-dialog" v-if="open">
|
||||||
|
<view class="ai-header">
|
||||||
|
<text class="ai-title">TradeMate AI 助手</text>
|
||||||
|
<text class="ai-close" @click="open = false">×</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<scroll-view class="ai-messages" scroll-y :scroll-top="scrollTop" ref="msgRef">
|
||||||
|
<view v-for="(msg, i) in messages" :key="i" class="ai-msg-row" :class="msg.role">
|
||||||
|
<view class="ai-msg-bubble">
|
||||||
|
<text class="ai-msg-text">{{ msg.content }}</text>
|
||||||
|
|
||||||
|
<view v-if="msg.actions && msg.actions.length" class="ai-action-card">
|
||||||
|
<view v-for="(action, ai) in msg.actions" :key="ai" class="ai-action-item">
|
||||||
|
<text class="ai-action-title">{{ action.label }}</text>
|
||||||
|
<view v-for="(val, key) in action.fields" :key="key" class="ai-field-row">
|
||||||
|
<text class="ai-field-label">{{ fieldLabel(key) }}</text>
|
||||||
|
<input class="ai-field-input" :value="val" @input="e => editField(i, ai, key, e.detail.value)" />
|
||||||
|
</view>
|
||||||
|
<view class="ai-action-btns">
|
||||||
|
<text class="ai-btn-cancel" @click="cancelAction(i, ai)">取消</text>
|
||||||
|
<text class="ai-btn-confirm" @click="confirmAction(i, ai)">确认添加</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="ai-loading" v-if="loading">
|
||||||
|
<text class="ai-loading-text">思考中...</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="ai-suggestions" v-if="messages.length === 1">
|
||||||
|
<view class="ai-suggestion" v-for="(s, i) in suggestions" :key="i" @click="sendQuick(s)">
|
||||||
|
<text class="ai-suggestion-text">{{ s }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
|
<view class="ai-input-bar">
|
||||||
|
<input class="ai-input" v-model="inputText" placeholder="输入你的问题..." @confirm="send" :disabled="loading" />
|
||||||
|
<text class="ai-send-btn" :class="{ disabled: !inputText.trim() || loading }" @click="send">发送</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, nextTick, watch } from 'vue'
|
||||||
|
import { aiChatApi, customerApi } from '@/utils/api.js'
|
||||||
|
|
||||||
|
const open = ref(false)
|
||||||
|
const inputText = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const scrollTop = ref(0)
|
||||||
|
const msgRef = ref(null)
|
||||||
|
const suggestions = ref([])
|
||||||
|
|
||||||
|
const messages = ref([
|
||||||
|
{ role: 'assistant', content: '你好!我是 TradeMate AI 助手,可以帮你解答外贸工具使用问题或外贸业务知识。有什么可以帮你的?' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const fieldLabel = (key) => ({
|
||||||
|
name: '客户名称 *', phone: '电话', email: '邮箱',
|
||||||
|
company: '公司', country: '国家', notes: '备注',
|
||||||
|
})[key] || key
|
||||||
|
|
||||||
|
const editField = (msgIdx, actionIdx, key, val) => {
|
||||||
|
const msg = messages.value[msgIdx]
|
||||||
|
if (!msg.actions || !msg.actions[actionIdx]) return
|
||||||
|
msg.actions[actionIdx].fields[key] = val
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmAction = async (msgIdx, actionIdx) => {
|
||||||
|
const action = messages.value[msgIdx].actions[actionIdx]
|
||||||
|
if (action.type !== 'create_customer') return
|
||||||
|
const { fields } = action
|
||||||
|
if (!fields.name) {
|
||||||
|
uni.showToast({ title: '客户名称不能为空', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await customerApi.create(fields)
|
||||||
|
uni.showToast({ title: '客户添加成功', icon: 'success' })
|
||||||
|
messages.value[msgIdx].actions = []
|
||||||
|
setTimeout(() => uni.switchTab({ url: '/pages/customers/customers' }), 1500)
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: e.message || '添加失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelAction = (msgIdx, actionIdx) => {
|
||||||
|
const msg = messages.value[msgIdx]
|
||||||
|
if (msg.actions) msg.actions.splice(actionIdx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendQuick = (text) => {
|
||||||
|
inputText.value = text
|
||||||
|
send()
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchSuggestions = async () => {
|
||||||
|
try {
|
||||||
|
const res = await aiChatApi.quickQuestions()
|
||||||
|
if (Array.isArray(res)) {
|
||||||
|
suggestions.value = res
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const send = async () => {
|
||||||
|
const msg = inputText.value.trim()
|
||||||
|
if (!msg || loading.value) return
|
||||||
|
inputText.value = ''
|
||||||
|
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 aiChatApi.chat(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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(open, (val) => { if (val && !suggestions.value.length) fetchSuggestions() })
|
||||||
|
|
||||||
|
const scrollToBottom = async () => {
|
||||||
|
await nextTick()
|
||||||
|
scrollTop.value += 1
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.ai-float-btn {
|
||||||
|
position: fixed;
|
||||||
|
right: 30rpx;
|
||||||
|
bottom: 120rpx;
|
||||||
|
width: 100rpx;
|
||||||
|
height: 100rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 4rpx 20rpx rgba(102, 126, 234, 0.4);
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-float-icon {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-dialog {
|
||||||
|
position: fixed;
|
||||||
|
right: 30rpx;
|
||||||
|
bottom: 240rpx;
|
||||||
|
width: 580rpx;
|
||||||
|
height: 700rpx;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
box-shadow: 0 8rpx 40rpx rgba(0,0,0,0.15);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
z-index: 9998;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 24rpx 30rpx;
|
||||||
|
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-title {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-close {
|
||||||
|
color: rgba(255,255,255,0.8);
|
||||||
|
font-size: 40rpx;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-messages {
|
||||||
|
flex: 1;
|
||||||
|
padding: 20rpx;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-msg-row {
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-msg-row.user {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-msg-bubble {
|
||||||
|
max-width: 80%;
|
||||||
|
padding: 16rpx 20rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-msg-row.assistant .ai-msg-bubble {
|
||||||
|
background: #fff;
|
||||||
|
border-bottom-left-radius: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-msg-row.user .ai-msg-bubble {
|
||||||
|
background: #667eea;
|
||||||
|
border-bottom-right-radius: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-msg-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #333;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-msg-row.user .ai-msg-text {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-action-card {
|
||||||
|
margin-top: 16rpx;
|
||||||
|
border-top: 2rpx solid #eee;
|
||||||
|
padding-top: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-action-title {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-field-row {
|
||||||
|
margin-bottom: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-field-label {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #666;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-field-input {
|
||||||
|
height: 56rpx;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
padding: 0 12rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-action-btns {
|
||||||
|
display: flex;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-btn-cancel {
|
||||||
|
flex: 1;
|
||||||
|
height: 56rpx;
|
||||||
|
line-height: 56rpx;
|
||||||
|
text-align: center;
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #666;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-btn-confirm {
|
||||||
|
flex: 1;
|
||||||
|
height: 56rpx;
|
||||||
|
line-height: 56rpx;
|
||||||
|
text-align: center;
|
||||||
|
background: #667eea;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 10rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-loading-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-suggestions {
|
||||||
|
padding: 10rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-suggestion {
|
||||||
|
background: #f0edff;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
padding: 14rpx 18rpx;
|
||||||
|
margin-bottom: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-suggestion-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-input-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16rpx 20rpx;
|
||||||
|
border-top: 2rpx solid #f0f0f0;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-input {
|
||||||
|
flex: 1;
|
||||||
|
height: 64rpx;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 32rpx;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-send-btn {
|
||||||
|
height: 64rpx;
|
||||||
|
line-height: 64rpx;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
background: #667eea;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 32rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-send-btn.disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+3
-1
@@ -1,9 +1,11 @@
|
|||||||
import { createSSRApp } from 'vue'
|
import { createSSRApp } from 'vue'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
import AiAssistant from './components/ai-assistant.vue'
|
||||||
|
|
||||||
export function createApp() {
|
export function createApp() {
|
||||||
const app = createSSRApp(App)
|
const app = createSSRApp(App)
|
||||||
|
app.component('AiAssistant', AiAssistant)
|
||||||
return {
|
return {
|
||||||
app,
|
app,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -258,11 +258,13 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<AiAssistant />
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, computed } from 'vue'
|
import { ref, watch, computed } from 'vue'
|
||||||
|
import AiAssistant from '@/components/ai-assistant.vue'
|
||||||
import { onShow } from '@dcloudio/uni-app'
|
import { onShow } from '@dcloudio/uni-app'
|
||||||
import { adminApi } from '@/utils/api.js'
|
import { adminApi } from '@/utils/api.js'
|
||||||
|
|
||||||
@@ -297,6 +299,8 @@ const configLabels = {
|
|||||||
feature_registration: '新用户注册',
|
feature_registration: '新用户注册',
|
||||||
free_daily_limits: '免费版每日配额',
|
free_daily_limits: '免费版每日配额',
|
||||||
pro_daily_limits: 'Pro 版每日配额',
|
pro_daily_limits: 'Pro 版每日配额',
|
||||||
|
ai_assistant_prompt: 'AI 助手系统提示词',
|
||||||
|
ai_assistant_quick_questions: 'AI 助手快捷提问',
|
||||||
}
|
}
|
||||||
|
|
||||||
const fieldLabels = (configKey, fieldKey) => {
|
const fieldLabels = (configKey, fieldKey) => {
|
||||||
|
|||||||
@@ -338,11 +338,13 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<AiAssistant />
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import AiAssistant from '@/components/ai-assistant.vue'
|
||||||
import { onShow } from '@dcloudio/uni-app'
|
import { onShow } from '@dcloudio/uni-app'
|
||||||
import { customerApi, healthApi, silentPatternApi, whatsappApi, translateApi } from '@/utils/api.js'
|
import { customerApi, healthApi, silentPatternApi, whatsappApi, translateApi } from '@/utils/api.js'
|
||||||
|
|
||||||
|
|||||||
@@ -253,6 +253,7 @@
|
|||||||
</view>
|
</view>
|
||||||
<text class="footer-copyright">© 2026 北京宇之然科技中心. 保留所有权利.</text>
|
<text class="footer-copyright">© 2026 北京宇之然科技中心. 保留所有权利.</text>
|
||||||
</view>
|
</view>
|
||||||
|
<AiAssistant />
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -260,6 +261,7 @@
|
|||||||
import { ref, computed, onUnmounted } from 'vue'
|
import { ref, computed, onUnmounted } from 'vue'
|
||||||
import { onShow } from '@dcloudio/uni-app'
|
import { onShow } from '@dcloudio/uni-app'
|
||||||
import { authApi, customerApi, analyticsApi, onboardingApi, notificationApi, followupApi, translateApi, BASE_URL } from '@/utils/api.js'
|
import { authApi, customerApi, analyticsApi, onboardingApi, notificationApi, followupApi, translateApi, BASE_URL } from '@/utils/api.js'
|
||||||
|
import AiAssistant from '@/components/ai-assistant.vue'
|
||||||
|
|
||||||
const showAnnouncement = ref(false)
|
const showAnnouncement = ref(false)
|
||||||
const currentAnnouncement = ref(0)
|
const currentAnnouncement = ref(0)
|
||||||
|
|||||||
@@ -143,11 +143,13 @@
|
|||||||
<view class="empty" v-if="!loading && (!resultsMap[activeTab] || resultsMap[activeTab].length === 0)">
|
<view class="empty" v-if="!loading && (!resultsMap[activeTab] || resultsMap[activeTab].length === 0)">
|
||||||
<text>{{ tabConfig[activeTab].emptyHint }}</text>
|
<text>{{ tabConfig[activeTab].emptyHint }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
<AiAssistant />
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, computed } from 'vue'
|
import { ref, reactive, computed } from 'vue'
|
||||||
|
import AiAssistant from '@/components/ai-assistant.vue'
|
||||||
import { marketingApi, interactionApi, translateApi, BASE_URL } from '@/utils/api.js'
|
import { marketingApi, interactionApi, translateApi, BASE_URL } from '@/utils/api.js'
|
||||||
|
|
||||||
const tabConfig = {
|
const tabConfig = {
|
||||||
|
|||||||
@@ -191,11 +191,13 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<AiAssistant />
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import AiAssistant from '@/components/ai-assistant.vue'
|
||||||
import { onShow } from '@dcloudio/uni-app'
|
import { onShow } from '@dcloudio/uni-app'
|
||||||
import { productApi } from '@/utils/api.js'
|
import { productApi } from '@/utils/api.js'
|
||||||
|
|
||||||
|
|||||||
@@ -242,11 +242,13 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<AiAssistant />
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import AiAssistant from '@/components/ai-assistant.vue'
|
||||||
import { onShow } from '@dcloudio/uni-app'
|
import { onShow } from '@dcloudio/uni-app'
|
||||||
import { quotationApi, customerApi } from '@/utils/api.js'
|
import { quotationApi, customerApi } from '@/utils/api.js'
|
||||||
|
|
||||||
|
|||||||
@@ -105,11 +105,13 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="keyboard-height" :style="{ height: keyboardHeight + 'px' }"></view>
|
<view class="keyboard-height" :style="{ height: keyboardHeight + 'px' }"></view>
|
||||||
|
<AiAssistant />
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import AiAssistant from '@/components/ai-assistant.vue'
|
||||||
import { translateApi, interactionApi, BASE_URL } from '@/utils/api.js'
|
import { translateApi, interactionApi, BASE_URL } from '@/utils/api.js'
|
||||||
|
|
||||||
const mode = ref('translate')
|
const mode = ref('translate')
|
||||||
|
|||||||
@@ -176,6 +176,11 @@ export const adminApi = {
|
|||||||
updateConfig: (key, value) => request(`/admin/config/${key}`, 'PUT', { value }),
|
updateConfig: (key, value) => request(`/admin/config/${key}`, 'PUT', { value }),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const aiChatApi = {
|
||||||
|
chat: (message, history = []) => request('/ai/chat', 'POST', { message, history }),
|
||||||
|
quickQuestions: () => request('/ai/quick-questions'),
|
||||||
|
}
|
||||||
|
|
||||||
export const analyticsApi = {
|
export const analyticsApi = {
|
||||||
getOverview: () => request('/analytics/overview'),
|
getOverview: () => request('/analytics/overview'),
|
||||||
getCustomers: () => request('/analytics/customers'),
|
getCustomers: () => request('/analytics/customers'),
|
||||||
|
|||||||
Reference in New Issue
Block a user