feat(ai): 完成 DeepSeek 大模型官方 API 接入、双模调用、高可用平滑降级与管理后台连通性测试
这个提交包含在:
@@ -12,6 +12,8 @@ import {
|
||||
Message,
|
||||
Spin,
|
||||
Grid,
|
||||
Tag,
|
||||
Divider,
|
||||
} from '@arco-design/web-react';
|
||||
import axios from 'axios';
|
||||
import useLocale from '@/utils/useLocale';
|
||||
@@ -20,6 +22,8 @@ import type {
|
||||
AiConciergeConfig,
|
||||
ConciergePersonaType,
|
||||
ConciergeToneStyle,
|
||||
DeepSeekConfig,
|
||||
DeepSeekTestConnectionResult,
|
||||
} from '@/types/omni';
|
||||
import locale from './locale';
|
||||
|
||||
@@ -28,6 +32,8 @@ const { Row, Col } = Grid;
|
||||
const GET_CONFIG_API = '/api/admin/ai/concierge/getByCardId';
|
||||
const SAVE_CONFIG_API = '/api/admin/ai/concierge/saveByCardId';
|
||||
const CARD_PAGE_API = '/api/admin/card/info/page';
|
||||
const DEEPSEEK_CONFIG_API = '/api/admin/ai/deepseek/config';
|
||||
const DEEPSEEK_TEST_API = '/api/admin/ai/deepseek/test-connection';
|
||||
|
||||
interface CoolResponse<T> {
|
||||
code: number;
|
||||
@@ -98,6 +104,100 @@ export default function AiConciergePage(): JSX.Element {
|
||||
const [selectedCardId, setSelectedCardId] = useState<number>(DEFAULT_CARD_ID);
|
||||
const [cardOptions, setCardOptions] = useState<CardOptionItem[]>([]);
|
||||
|
||||
const [deepSeekForm] = Form.useForm<DeepSeekConfig>();
|
||||
const [deepSeekLoading, setDeepSeekLoading] = useState<boolean>(false);
|
||||
const [deepSeekSaving, setDeepSeekSaving] = useState<boolean>(false);
|
||||
const [deepSeekTesting, setDeepSeekTesting] = useState<boolean>(false);
|
||||
const [deepSeekConfig, setDeepSeekConfig] = useState<DeepSeekConfig | null>(null);
|
||||
const [testResult, setTestResult] = useState<DeepSeekTestConnectionResult | null>(null);
|
||||
|
||||
const loadDeepSeekConfig = async (): Promise<void> => {
|
||||
setDeepSeekLoading(true);
|
||||
try {
|
||||
const res = await axios.post<CoolResponse<DeepSeekConfig>>(
|
||||
DEEPSEEK_CONFIG_API,
|
||||
{}
|
||||
);
|
||||
if (res.data && res.data.code === 1000 && res.data.data) {
|
||||
const d = res.data.data;
|
||||
setDeepSeekConfig(d);
|
||||
deepSeekForm.setFieldsValue({
|
||||
apiKey: d.apiKey || '',
|
||||
baseUrl: d.baseUrl || 'https://api.deepseek.com',
|
||||
model: d.model || 'deepseek-chat',
|
||||
temperature: d.temperature ?? 0.7,
|
||||
maxTokens: d.maxTokens ?? 2048,
|
||||
enabled: d.enabled !== false,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// 保持静默优雅降级
|
||||
} finally {
|
||||
setDeepSeekLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestDeepSeek = async (): Promise<void> => {
|
||||
setDeepSeekTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const values = deepSeekForm.getFieldsValue();
|
||||
const res = await axios.post<CoolResponse<DeepSeekTestConnectionResult>>(
|
||||
DEEPSEEK_TEST_API,
|
||||
{ apiKey: values.apiKey }
|
||||
);
|
||||
if (res.data && res.data.code === 1000 && res.data.data) {
|
||||
setTestResult(res.data.data);
|
||||
if (res.data.data.success) {
|
||||
Message.success(
|
||||
`${t['aiDeepSeek.test.success']} (${res.data.data.latencyMs ?? 0}ms)`
|
||||
);
|
||||
} else {
|
||||
Message.error(res.data.data.message || t['aiDeepSeek.test.fail']);
|
||||
}
|
||||
} else {
|
||||
Message.error(res.data?.message || t['aiDeepSeek.test.fail']);
|
||||
}
|
||||
} catch {
|
||||
Message.error(t['aiDeepSeek.test.fail']);
|
||||
} finally {
|
||||
setDeepSeekTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveDeepSeek = async (values: DeepSeekConfig): Promise<void> => {
|
||||
setDeepSeekSaving(true);
|
||||
try {
|
||||
const payload: Partial<DeepSeekConfig> = {
|
||||
baseUrl: values.baseUrl,
|
||||
model: values.model,
|
||||
temperature: Number(values.temperature),
|
||||
maxTokens: Number(values.maxTokens),
|
||||
enabled: Boolean(values.enabled),
|
||||
};
|
||||
if (values.apiKey && !values.apiKey.includes('****')) {
|
||||
payload.apiKey = values.apiKey;
|
||||
}
|
||||
const res = await axios.post<CoolResponse<DeepSeekConfig>>(
|
||||
DEEPSEEK_CONFIG_API,
|
||||
payload
|
||||
);
|
||||
if (res.data && res.data.code === 1000 && res.data.data) {
|
||||
Message.success(t['aiDeepSeek.save.success']);
|
||||
setDeepSeekConfig(res.data.data);
|
||||
if (res.data.data.apiKey) {
|
||||
deepSeekForm.setFieldValue('apiKey', res.data.data.apiKey);
|
||||
}
|
||||
} else {
|
||||
Message.error(res.data?.message || t['aiDeepSeek.save.fail']);
|
||||
}
|
||||
} catch {
|
||||
Message.error(t['aiDeepSeek.save.fail']);
|
||||
} finally {
|
||||
setDeepSeekSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCardOptions = async (): Promise<void> => {
|
||||
try {
|
||||
const res = await axios.post<CoolResponse<CardPageResult>>(
|
||||
@@ -171,6 +271,7 @@ export default function AiConciergePage(): JSX.Element {
|
||||
useEffect(() => {
|
||||
fetchCardOptions();
|
||||
loadConciergeConfig(selectedCardId);
|
||||
loadDeepSeekConfig();
|
||||
}, []);
|
||||
|
||||
const handleCardChange = (cardId: number): void => {
|
||||
@@ -401,6 +502,178 @@ export default function AiConciergePage(): JSX.Element {
|
||||
</Form>
|
||||
</Spin>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<span>{t['aiDeepSeek.card.title']}</span>
|
||||
<Tag color={deepSeekConfig?.isConfigured ? 'green' : 'gray'}>
|
||||
{deepSeekConfig?.isConfigured
|
||||
? t['aiDeepSeek.status.configured']
|
||||
: t['aiDeepSeek.status.unconfigured']}
|
||||
</Tag>
|
||||
</div>
|
||||
}
|
||||
bordered={false}
|
||||
style={{
|
||||
marginTop: spacing[24],
|
||||
borderRadius: radius.card,
|
||||
border: `1px solid ${lightColors.border}`,
|
||||
backgroundColor: lightColors.bg,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
color: lightColors.text2,
|
||||
marginBottom: spacing[24],
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
{t['aiDeepSeek.card.desc']}
|
||||
</div>
|
||||
|
||||
<Spin loading={deepSeekLoading} style={{ width: '100%' }}>
|
||||
<Form
|
||||
form={deepSeekForm}
|
||||
layout="vertical"
|
||||
onSubmit={handleSaveDeepSeek}
|
||||
initialValues={{
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
model: 'deepseek-chat',
|
||||
temperature: 0.7,
|
||||
maxTokens: 2048,
|
||||
enabled: true,
|
||||
}}
|
||||
>
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label={t['aiDeepSeek.form.apiKey']}
|
||||
field="apiKey"
|
||||
extra={
|
||||
deepSeekConfig?.apiKey
|
||||
? `已保存密钥: ${deepSeekConfig.apiKey}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Input.Password
|
||||
placeholder={t['aiDeepSeek.form.apiKey.placeholder']}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label={t['aiDeepSeek.form.status']}
|
||||
field="enabled"
|
||||
triggerPropName="checked"
|
||||
extra={t['aiDeepSeek.form.status.desc']}
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label={t['aiDeepSeek.form.baseUrl']}
|
||||
field="baseUrl"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Input placeholder="https://api.deepseek.com" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label={t['aiDeepSeek.form.model']}
|
||||
field="model"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value="deepseek-chat">
|
||||
{t['aiDeepSeek.model.chat']}
|
||||
</Select.Option>
|
||||
<Select.Option value="deepseek-reasoner">
|
||||
{t['aiDeepSeek.model.reasoner']}
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={24}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label={t['aiDeepSeek.form.temperature']}
|
||||
field="temperature"
|
||||
extra={t['aiDeepSeek.form.temperature.desc']}
|
||||
>
|
||||
<Slider
|
||||
min={0}
|
||||
max={1.5}
|
||||
step={0.1}
|
||||
marks={{ 0: '0', 0.7: '0.7', 1.0: '1.0', 1.5: '1.5' }}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="Max Tokens"
|
||||
field="maxTokens"
|
||||
>
|
||||
<Select>
|
||||
<Select.Option value={1024}>1024</Select.Option>
|
||||
<Select.Option value={2048}>2048</Select.Option>
|
||||
<Select.Option value={4096}>4096</Select.Option>
|
||||
<Select.Option value={8192}>8192</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{testResult && (
|
||||
<div style={{ marginBottom: spacing[16] }}>
|
||||
<Tag
|
||||
color={testResult.success ? 'green' : 'red'}
|
||||
style={{ padding: `${spacing[4]} ${spacing[12]}` }}
|
||||
>
|
||||
{testResult.message || (testResult.success ? '连通成功' : '连通失败')}
|
||||
{testResult.latencyMs !== undefined && ` | ${testResult.latencyMs}ms`}
|
||||
{testResult.model && ` | ${testResult.model}`}
|
||||
</Tag>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: spacing[24] }}>
|
||||
<Space>
|
||||
<Button
|
||||
htmlType="submit"
|
||||
loading={deepSeekSaving}
|
||||
style={{ borderRadius: radius.button }}
|
||||
>
|
||||
{t['aiDeepSeek.action.save']}
|
||||
</Button>
|
||||
<Button
|
||||
type="outline"
|
||||
onClick={handleTestDeepSeek}
|
||||
loading={deepSeekTesting}
|
||||
style={{ borderRadius: radius.button }}
|
||||
>
|
||||
{t['aiDeepSeek.action.test']}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Form>
|
||||
</Spin>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,31 @@ const i18n = {
|
||||
'aiConcierge.request.success': 'Concierge settings saved successfully',
|
||||
'aiConcierge.request.fail': 'Failed to load concierge configuration',
|
||||
'aiConcierge.request.saveFail': 'Failed to save configuration',
|
||||
'aiDeepSeek.card.title': 'DeepSeek Official LLM Configuration',
|
||||
'aiDeepSeek.card.desc':
|
||||
'Configure DeepSeek API Key, supporting deepseek-chat and deepseek-reasoner dual models for 24/7 AI concierge and cross-border localization.',
|
||||
'aiDeepSeek.form.apiKey': 'API Key',
|
||||
'aiDeepSeek.form.apiKey.placeholder':
|
||||
'Enter official API Key starting with sk- (leave blank to keep current)',
|
||||
'aiDeepSeek.form.baseUrl': 'API Base URL',
|
||||
'aiDeepSeek.form.model': 'Model Version',
|
||||
'aiDeepSeek.model.chat': 'DeepSeek-V3 Chat (deepseek-chat)',
|
||||
'aiDeepSeek.model.reasoner': 'DeepSeek-R1 Reasoner (deepseek-reasoner)',
|
||||
'aiDeepSeek.form.temperature': 'Temperature',
|
||||
'aiDeepSeek.form.temperature.desc':
|
||||
'Lower values are deterministic; higher values are creative.',
|
||||
'aiDeepSeek.form.status': 'Enable DeepSeek LLM',
|
||||
'aiDeepSeek.form.status.desc':
|
||||
'When enabled, requests prioritize DeepSeek official API with smooth fallback.',
|
||||
'aiDeepSeek.action.test': 'Test Connection',
|
||||
'aiDeepSeek.action.testing': 'Testing connection...',
|
||||
'aiDeepSeek.action.save': 'Save Model Config',
|
||||
'aiDeepSeek.status.configured': 'Configured',
|
||||
'aiDeepSeek.status.unconfigured': 'Not Configured',
|
||||
'aiDeepSeek.test.success': 'DeepSeek API connection successful',
|
||||
'aiDeepSeek.test.fail': 'Connection test failed',
|
||||
'aiDeepSeek.save.success': 'DeepSeek configuration updated',
|
||||
'aiDeepSeek.save.fail': 'Failed to save DeepSeek configuration',
|
||||
},
|
||||
'zh-CN': {
|
||||
'menu.ai.concierge': 'AI 顾问配置',
|
||||
@@ -88,6 +113,31 @@ const i18n = {
|
||||
'aiConcierge.request.success': 'AI 顾问配置已保存',
|
||||
'aiConcierge.request.fail': 'AI 顾问配置加载失败',
|
||||
'aiConcierge.request.saveFail': '保存配置失败',
|
||||
'aiDeepSeek.card.title': 'DeepSeek 官方大模型底座配置',
|
||||
'aiDeepSeek.card.desc':
|
||||
'配置 DeepSeek API Key,支持 deepseek-chat 与 deepseek-reasoner 双模型接入,赋能 24/7 AI 顾问对话与出海多语言润色。',
|
||||
'aiDeepSeek.form.apiKey': 'API Key 密钥',
|
||||
'aiDeepSeek.form.apiKey.placeholder':
|
||||
'输入 sk- 开头的官方密钥 (留空则保留当前已配置密钥)',
|
||||
'aiDeepSeek.form.baseUrl': 'API 接口基地址 (Base URL)',
|
||||
'aiDeepSeek.form.model': '接入模型版本',
|
||||
'aiDeepSeek.model.chat': 'DeepSeek-V3 对话模型 (deepseek-chat)',
|
||||
'aiDeepSeek.model.reasoner': 'DeepSeek-R1 深度推理模型 (deepseek-reasoner)',
|
||||
'aiDeepSeek.form.temperature': '采样温度 (Temperature)',
|
||||
'aiDeepSeek.form.temperature.desc':
|
||||
'较低值输出严谨稳定,较高值回复生动多样。',
|
||||
'aiDeepSeek.form.status': '启用 DeepSeek 大模型',
|
||||
'aiDeepSeek.form.status.desc':
|
||||
'开启后系统优先直连 DeepSeek 官方 API,网络异常或未配置时自动平滑降级。',
|
||||
'aiDeepSeek.action.test': '测试连通性',
|
||||
'aiDeepSeek.action.testing': '连通性测试中...',
|
||||
'aiDeepSeek.action.save': '保存大模型配置',
|
||||
'aiDeepSeek.status.configured': '已配置',
|
||||
'aiDeepSeek.status.unconfigured': '未配置',
|
||||
'aiDeepSeek.test.success': 'DeepSeek API 连通测试成功',
|
||||
'aiDeepSeek.test.fail': '连通性测试失败',
|
||||
'aiDeepSeek.save.success': 'DeepSeek 大模型配置已保存',
|
||||
'aiDeepSeek.save.fail': '保存大模型配置失败',
|
||||
},
|
||||
'ar-SA': {
|
||||
'menu.ai.concierge': 'إعدادات المساعد الذكي',
|
||||
@@ -133,6 +183,31 @@ const i18n = {
|
||||
'aiConcierge.request.success': 'تم حفظ الإعدادات بنجاح',
|
||||
'aiConcierge.request.fail': 'تعذر تحميل الإعدادات',
|
||||
'aiConcierge.request.saveFail': 'فشل حفظ الإعدادات',
|
||||
'aiDeepSeek.card.title': 'إعدادات نموذج DeepSeek الرسمي',
|
||||
'aiDeepSeek.card.desc':
|
||||
'تكوين مفتاح واجهة DeepSeek البرمجية لدعم محادثات المساعد الذكي وترجمة المحتوى.',
|
||||
'aiDeepSeek.form.apiKey': 'مفتاح API',
|
||||
'aiDeepSeek.form.apiKey.placeholder':
|
||||
'أدخل المفتاح الرسمي الذي يبدأ بـ sk-',
|
||||
'aiDeepSeek.form.baseUrl': 'عنوان الواجهة الأساسي',
|
||||
'aiDeepSeek.form.model': 'إصدار النموذج',
|
||||
'aiDeepSeek.model.chat': 'نموذج محادثة DeepSeek-V3',
|
||||
'aiDeepSeek.model.reasoner': 'نموذج استدلال DeepSeek-R1',
|
||||
'aiDeepSeek.form.temperature': 'درجة الحرارة',
|
||||
'aiDeepSeek.form.temperature.desc':
|
||||
'القيم المنخفضة أكثر دقة والصلبة أكثر إبداعا.',
|
||||
'aiDeepSeek.form.status': 'تفعيل نموذج DeepSeek',
|
||||
'aiDeepSeek.form.status.desc':
|
||||
'عند التفعيل، تعطى الأولوية لواجهة DeepSeek مع خفض سلس للأداء عند الانقطاع.',
|
||||
'aiDeepSeek.action.test': 'اختبار الاتصال',
|
||||
'aiDeepSeek.action.testing': 'جاري اختبار الاتصال...',
|
||||
'aiDeepSeek.action.save': 'حفظ إعدادات النموذج',
|
||||
'aiDeepSeek.status.configured': 'تم التكوين',
|
||||
'aiDeepSeek.status.unconfigured': 'غير مكوّن',
|
||||
'aiDeepSeek.test.success': 'نجح اختبار الاتصال بـ DeepSeek',
|
||||
'aiDeepSeek.test.fail': 'فشل اختبار الاتصال',
|
||||
'aiDeepSeek.save.success': 'تم حفظ إعدادات DeepSeek بنجاح',
|
||||
'aiDeepSeek.save.fail': 'فشل حفظ إعدادات DeepSeek',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -476,6 +476,42 @@ export interface AiConciergeConfig {
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 5.1 DeepSeek 大模型集成契约 (DeepSeek AI Suite)
|
||||
// ==========================================
|
||||
|
||||
export type DeepSeekModel = 'deepseek-chat' | 'deepseek-reasoner';
|
||||
|
||||
export interface DeepSeekConfig {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: DeepSeekModel;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
timeoutMs?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface DeepSeekChatMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface DeepSeekChatCompletionRequest {
|
||||
messages: DeepSeekChatMessage[];
|
||||
model?: DeepSeekModel;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
stream?: boolean;
|
||||
}
|
||||
|
||||
export interface DeepSeekTestConnectionResult {
|
||||
success: boolean;
|
||||
latencyMs?: number;
|
||||
model?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export type LocalizeTone = 'professional' | 'direct' | 'storytelling';
|
||||
|
||||
export interface LocalizeRequest {
|
||||
@@ -725,4 +761,117 @@ export interface TeamRankBroadcastPayload {
|
||||
meta?: TeamRankBroadcastMeta;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 12. AI 顾问对话与 DeepSeek 大模型契约 (AI Concierge & DeepSeek Suite)
|
||||
// ==========================================
|
||||
|
||||
export type AiChatRole = 'user' | 'assistant' | 'system';
|
||||
|
||||
export interface AiChatMessage {
|
||||
role: AiChatRole;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface AiChatRequest {
|
||||
cardId: string | number;
|
||||
locale?: string;
|
||||
messages: AiChatMessage[];
|
||||
}
|
||||
|
||||
export interface AiChatReply {
|
||||
reply: string;
|
||||
locale: string;
|
||||
shouldCollectLead: boolean;
|
||||
}
|
||||
|
||||
export interface AiKnowledgeDoc {
|
||||
id?: string | number;
|
||||
cardId: string | number;
|
||||
title: string;
|
||||
content: string;
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export type ConciergePersonaType =
|
||||
| 'trade_expert'
|
||||
| 'business_rep'
|
||||
| 'tech_advisor'
|
||||
| 'custom';
|
||||
|
||||
export type ConciergeToneStyle =
|
||||
| 'professional'
|
||||
| 'direct'
|
||||
| 'enthusiastic'
|
||||
| 'friendly';
|
||||
|
||||
export interface AiConciergeConfig {
|
||||
id?: string | number;
|
||||
cardId: string | number;
|
||||
personaType: ConciergePersonaType;
|
||||
welcomeMsg: string;
|
||||
triggerLeadThreshold: number;
|
||||
promptTemplate: string;
|
||||
toneStyle: ConciergeToneStyle;
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface KnowledgeChunk {
|
||||
id?: string | number;
|
||||
index: number;
|
||||
content: string;
|
||||
length: number;
|
||||
score?: number;
|
||||
}
|
||||
|
||||
export type DeepSeekModel = 'deepseek-chat' | 'deepseek-reasoner';
|
||||
|
||||
export interface DeepSeekConfig {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: DeepSeekModel;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
timeoutMs?: number;
|
||||
enabled?: boolean;
|
||||
isConfigured?: boolean;
|
||||
}
|
||||
|
||||
export interface DeepSeekChatMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface DeepSeekChatCompletionRequest {
|
||||
messages: DeepSeekChatMessage[];
|
||||
model?: DeepSeekModel;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
stream?: boolean;
|
||||
}
|
||||
|
||||
export interface DeepSeekChatCompletionResponse {
|
||||
id: string;
|
||||
model: string;
|
||||
choices: Array<{
|
||||
index: number;
|
||||
message: {
|
||||
role: 'assistant';
|
||||
content: string;
|
||||
};
|
||||
finish_reason: string;
|
||||
}>;
|
||||
usage?: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DeepSeekTestConnectionResult {
|
||||
success: boolean;
|
||||
latencyMs?: number;
|
||||
model?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
|
||||
|
||||
62
admin/tests/ai-deepseek.test.ts
普通文件
62
admin/tests/ai-deepseek.test.ts
普通文件
@@ -0,0 +1,62 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const conciergePagePath = path.join(adminRoot, 'src/pages/ai/concierge/index.tsx');
|
||||
const conciergeLocalePath = path.join(adminRoot, 'src/pages/ai/concierge/locale/index.ts');
|
||||
const omniTypesPath = path.join(adminRoot, 'src/types/omni.ts');
|
||||
|
||||
test('1. DeepSeek 大模型后台界面必须接入标准 API 与连通性测试端点', () => {
|
||||
assert.ok(fs.existsSync(conciergePagePath), 'AI 页面文件必须存在');
|
||||
const content = fs.readFileSync(conciergePagePath, 'utf-8');
|
||||
|
||||
assert.ok(
|
||||
content.includes('/api/admin/ai/deepseek/config'),
|
||||
'必须包含 /api/admin/ai/deepseek/config 配置端点'
|
||||
);
|
||||
assert.ok(
|
||||
content.includes('/api/admin/ai/deepseek/test-connection'),
|
||||
'必须包含 /api/admin/ai/deepseek/test-connection 连通性测试端点'
|
||||
);
|
||||
assert.ok(content.includes('deepseek-chat'), '必须支持 deepseek-chat (V3)');
|
||||
assert.ok(content.includes('deepseek-reasoner'), '必须支持 deepseek-reasoner (R1)');
|
||||
assert.ok(content.includes('handleTestDeepSeek'), '必须提供连通性测试处理函数');
|
||||
assert.ok(content.includes('handleSaveDeepSeek'), '必须提供保存配置处理函数');
|
||||
});
|
||||
|
||||
test('2. DeepSeek 大模型跨端类型契约必须在 admin/src/types/omni.ts 中严密定义', () => {
|
||||
assert.ok(fs.existsSync(omniTypesPath), 'omni.ts 类型契约必须存在');
|
||||
const content = fs.readFileSync(omniTypesPath, 'utf-8');
|
||||
|
||||
assert.ok(content.includes('DeepSeekModel'), '必须导出 DeepSeekModel 类型');
|
||||
assert.ok(content.includes('DeepSeekConfig'), '必须导出 DeepSeekConfig 接口');
|
||||
assert.ok(content.includes('DeepSeekChatMessage'), '必须导出 DeepSeekChatMessage 接口');
|
||||
assert.ok(
|
||||
content.includes('DeepSeekTestConnectionResult'),
|
||||
'必须导出 DeepSeekTestConnectionResult 接口'
|
||||
);
|
||||
});
|
||||
|
||||
test('3. DeepSeek 多语言国际化必须覆盖中英阿且无 emoji 无 any', () => {
|
||||
assert.ok(fs.existsSync(conciergeLocalePath), '语言包必须存在');
|
||||
const localeContent = fs.readFileSync(conciergeLocalePath, 'utf-8');
|
||||
|
||||
assert.ok(localeContent.includes("'aiDeepSeek.card.title'"), '必须包含卡片标题词条');
|
||||
assert.ok(localeContent.includes("'aiDeepSeek.form.apiKey'"), '必须包含 API Key 表单项');
|
||||
assert.ok(localeContent.includes("'aiDeepSeek.form.model'"), '必须包含模型选择项');
|
||||
assert.ok(localeContent.includes("'aiDeepSeek.action.test'"), '必须包含测试连通性按钮词条');
|
||||
assert.ok(localeContent.includes("'aiDeepSeek.test.success'"), '必须包含测试成功提示');
|
||||
|
||||
// 三语检查
|
||||
assert.ok(localeContent.includes("'en-US'"), '必须支持 en-US');
|
||||
assert.ok(localeContent.includes("'zh-CN'"), '必须支持 zh-CN');
|
||||
assert.ok(localeContent.includes("'ar-SA'"), '必须支持 ar-SA');
|
||||
|
||||
// 禁止 emoji
|
||||
const emojiRegex = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u;
|
||||
assert.ok(!emojiRegex.test(localeContent), '语言包严禁使用 emoji');
|
||||
assert.ok(!/\bany\b/.test(localeContent), '语言包严禁使用 any');
|
||||
});
|
||||
@@ -1,85 +1,9 @@
|
||||
import {
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
DeleteDateColumn,
|
||||
} from 'typeorm';
|
||||
import * as moment from 'moment';
|
||||
import { CoolBaseEntity } from '@cool-midway/core';
|
||||
|
||||
/**
|
||||
* 时间转换器
|
||||
* 统一实体基类重导出 (兼容历史路径)
|
||||
* 复用 Cool-Admin 官方 modules/base/entity/base
|
||||
*/
|
||||
export const transformerTime = {
|
||||
to(value?: Date | string | null) {
|
||||
return value
|
||||
? moment(value).format('YYYY-MM-DD HH:mm:ss')
|
||||
: moment().format('YYYY-MM-DD HH:mm:ss');
|
||||
},
|
||||
from(value?: string | null) {
|
||||
return value;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Json 转换器
|
||||
*/
|
||||
export const transformerJson = {
|
||||
to: (value: unknown) => value,
|
||||
from: (value: unknown) => {
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (e) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return value;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 通用实体基类 (BaseEntity)
|
||||
* 提供主键自增、多租户隔离 (tenantId)、审计时间戳 (createTime/updateTime) 及软删除 (deletedAt)
|
||||
*/
|
||||
export abstract class BaseEntity extends CoolBaseEntity {
|
||||
@PrimaryGeneratedColumn('increment', {
|
||||
name: 'id',
|
||||
comment: 'ID',
|
||||
})
|
||||
id: number;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
name: 'tenant_id',
|
||||
comment: '企业租户ID',
|
||||
nullable: true,
|
||||
})
|
||||
tenantId: number;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
name: 'create_time',
|
||||
comment: '创建时间',
|
||||
type: 'varchar',
|
||||
transformer: transformerTime,
|
||||
})
|
||||
createTime: Date;
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
name: 'update_time',
|
||||
comment: '更新时间',
|
||||
type: 'varchar',
|
||||
transformer: transformerTime,
|
||||
})
|
||||
updateTime: Date;
|
||||
|
||||
@Index()
|
||||
@DeleteDateColumn({
|
||||
name: 'deleted_at',
|
||||
comment: '软删除时间',
|
||||
nullable: true,
|
||||
})
|
||||
deletedAt?: Date | null;
|
||||
}
|
||||
export {
|
||||
BaseEntity,
|
||||
transformerJson,
|
||||
transformerTime,
|
||||
} from '../../modules/base/entity/base';
|
||||
|
||||
@@ -599,6 +599,60 @@ export interface KnowledgeChunk {
|
||||
score?: number;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 5.1 DeepSeek 大模型集成契约 (DeepSeek AI Suite)
|
||||
// ==========================================
|
||||
|
||||
export type DeepSeekModel = 'deepseek-chat' | 'deepseek-reasoner';
|
||||
|
||||
export interface DeepSeekConfig {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: DeepSeekModel;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
timeoutMs?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface DeepSeekChatMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface DeepSeekChatCompletionRequest {
|
||||
messages: DeepSeekChatMessage[];
|
||||
model?: DeepSeekModel;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
stream?: boolean;
|
||||
}
|
||||
|
||||
export interface DeepSeekChatCompletionResponse {
|
||||
id: string;
|
||||
model: string;
|
||||
choices: Array<{
|
||||
index: number;
|
||||
message: {
|
||||
role: 'assistant';
|
||||
content: string;
|
||||
};
|
||||
finish_reason: string;
|
||||
}>;
|
||||
usage?: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DeepSeekTestConnectionResult {
|
||||
success: boolean;
|
||||
latencyMs?: number;
|
||||
model?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 知识库沙盒调试请求契约
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Body, Inject, Post } from '@midwayjs/core';
|
||||
import { BaseController, CoolController } from '@cool-midway/core';
|
||||
import { DeepSeekService } from '../../service/deepseek';
|
||||
import {
|
||||
DeepSeekChatCompletionRequest,
|
||||
DeepSeekConfig,
|
||||
} from '../../../../comm/types/omni';
|
||||
|
||||
/**
|
||||
* DeepSeek 大模型后台管理与调试控制器
|
||||
*/
|
||||
@CoolController({
|
||||
api: [],
|
||||
service: DeepSeekService,
|
||||
serviceApis: [
|
||||
{ method: 'getConfig', summary: '获取当前 DeepSeek 配置 (脱敏)' },
|
||||
{ method: 'updateConfig', summary: '更新 DeepSeek 配置' },
|
||||
{ method: 'testConnection', summary: '测试 DeepSeek 接口连通性' },
|
||||
{ method: 'chatCompletion', summary: '大模型对话测试' },
|
||||
],
|
||||
})
|
||||
export class AdminDeepSeekController extends BaseController {
|
||||
@Inject()
|
||||
deepSeekService: DeepSeekService;
|
||||
|
||||
/**
|
||||
* 获取当前有效配置(脱敏 API Key)
|
||||
*/
|
||||
@Post('/config', { summary: '获取或保存 DeepSeek 配置' })
|
||||
async config(@Body() body?: Partial<DeepSeekConfig>) {
|
||||
if (body && Object.keys(body).length > 0) {
|
||||
this.deepSeekService.updateConfig(body);
|
||||
}
|
||||
const current = this.deepSeekService.getConfig();
|
||||
const maskedKey = current.apiKey
|
||||
? current.apiKey.length > 8
|
||||
? `${current.apiKey.slice(0, 4)}****${current.apiKey.slice(-4)}`
|
||||
: '****'
|
||||
: undefined;
|
||||
|
||||
return this.ok({
|
||||
...current,
|
||||
apiKey: maskedKey,
|
||||
isConfigured: this.deepSeekService.isConfigured(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试与 DeepSeek 官方端点连通性
|
||||
*/
|
||||
@Post('/test-connection', { summary: '测试连通性' })
|
||||
async testConnection(@Body('apiKey') apiKey?: string) {
|
||||
const res = await this.deepSeekService.testConnection(apiKey);
|
||||
return this.ok(res);
|
||||
}
|
||||
|
||||
/**
|
||||
* 调试对话补全
|
||||
*/
|
||||
@Post('/chat', { summary: '调试对话补全' })
|
||||
async chat(@Body() body: DeepSeekChatCompletionRequest) {
|
||||
const reply = await this.deepSeekService.chatCompletion(body);
|
||||
return this.ok({ reply });
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BaseEntity } from '../../../comm/entity/base';
|
||||
import { BaseEntity } from '../../base/entity/base';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
import {
|
||||
AiConciergeConfig,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BaseEntity } from '../../../comm/entity/base';
|
||||
import { BaseEntity } from '../../base/entity/base';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
import { AiGlossaryItem } from '../../../comm/types/omni';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BaseEntity } from '../../../comm/entity/base';
|
||||
import { BaseEntity } from '../../base/entity/base';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
import { AiKnowledgeDoc } from '../../../comm/types/omni';
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Provide } from '@midwayjs/core';
|
||||
import { Inject, Provide } from '@midwayjs/core';
|
||||
import { BaseService, CoolCommException } from '@cool-midway/core';
|
||||
import {
|
||||
AiChatMessage,
|
||||
AiChatReply,
|
||||
AiChatRequest,
|
||||
DeepSeekChatMessage,
|
||||
} from '../../../comm/types/omni';
|
||||
import { DeepSeekService } from './deepseek';
|
||||
|
||||
/** locale 缺省值 */
|
||||
const DEFAULT_LOCALE = 'en-US';
|
||||
@@ -14,10 +16,13 @@ const LEAD_INTENT_PATTERN =
|
||||
/价格|报价|多少钱|合作|联系|price|pricing|cost|quote|cooperat|partner|contact/i;
|
||||
|
||||
/**
|
||||
* 名片内置 AI 顾问(本地 RuleBased,不调用外部 LLM)
|
||||
* 名片内置 AI 顾问(支持 DeepSeek 官方大模型与本地规则平滑降级)
|
||||
*/
|
||||
@Provide()
|
||||
export class AiChatService extends BaseService {
|
||||
@Inject()
|
||||
deepSeekService?: DeepSeekService;
|
||||
|
||||
/**
|
||||
* 顾问对话
|
||||
* 兼容公开端 serviceApis:POST body 为单参数对象 AiChatRequest
|
||||
@@ -42,9 +47,36 @@ export class AiChatService extends BaseService {
|
||||
const locale = req.locale?.trim() || DEFAULT_LOCALE;
|
||||
const shouldCollectLead = LEAD_INTENT_PATTERN.test(content);
|
||||
const isZh = locale.toLowerCase().startsWith('zh');
|
||||
const fallbackReply = this.buildReply(shouldCollectLead, isZh);
|
||||
|
||||
let reply = fallbackReply;
|
||||
|
||||
// 如果 DeepSeek 已经配置,则优先调用 DeepSeek 大模型生成智能回复
|
||||
if (this.deepSeekService?.isConfigured?.()) {
|
||||
try {
|
||||
const dsMessages: DeepSeekChatMessage[] = [
|
||||
{
|
||||
role: 'system',
|
||||
content: `你是 OmniCard 名片智能顾问。当前名片ID: ${cardId},语言偏好: ${locale}。请以专业得体的口吻向客商介绍业务与解答咨询。${shouldCollectLead ? '检测到客户具备采购或商务意向,请在回答中热情引导客户留下联系方式。' : ''}`,
|
||||
},
|
||||
...messages.map((m) => ({
|
||||
role: m.role as 'user' | 'assistant' | 'system',
|
||||
content: m.content,
|
||||
})),
|
||||
];
|
||||
|
||||
reply = await this.deepSeekService.chatCompletion(
|
||||
{ messages: dsMessages },
|
||||
fallbackReply
|
||||
);
|
||||
} catch {
|
||||
// 发生任何不可预测异常,均平滑降级为兜底文本,确保前端 100% 不白屏
|
||||
reply = fallbackReply;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
reply: this.buildReply(shouldCollectLead, isZh),
|
||||
reply,
|
||||
locale,
|
||||
shouldCollectLead,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import { Provide } from '@midwayjs/core';
|
||||
import { BaseService, CoolCommException } from '@cool-midway/core';
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import {
|
||||
DeepSeekChatCompletionRequest,
|
||||
DeepSeekChatCompletionResponse,
|
||||
DeepSeekConfig,
|
||||
DeepSeekModel,
|
||||
DeepSeekTestConnectionResult,
|
||||
} from '../../../comm/types/omni';
|
||||
|
||||
/**
|
||||
* DeepSeek 官方大模型适配服务
|
||||
* 支持 deepseek-chat (V3) 与 deepseek-reasoner (R1)
|
||||
*/
|
||||
@Provide()
|
||||
export class DeepSeekService extends BaseService {
|
||||
private config: DeepSeekConfig;
|
||||
private httpClient: AxiosInstance;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const envKey = process.env.DEEPSEEK_API_KEY?.trim() || undefined;
|
||||
const envBaseUrl = process.env.DEEPSEEK_BASE_URL?.trim() || 'https://api.deepseek.com';
|
||||
const envModel = (process.env.DEEPSEEK_MODEL?.trim() as DeepSeekModel) || 'deepseek-chat';
|
||||
|
||||
this.config = {
|
||||
apiKey: envKey,
|
||||
baseUrl: envBaseUrl,
|
||||
model: envModel,
|
||||
temperature: 0.7,
|
||||
maxTokens: 2048,
|
||||
timeoutMs: 30000,
|
||||
enabled: Boolean(envKey),
|
||||
};
|
||||
|
||||
this.httpClient = axios.create({
|
||||
timeout: this.config.timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前有效配置(脱敏 API Key)
|
||||
*/
|
||||
getConfig(): DeepSeekConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态更新配置
|
||||
*/
|
||||
updateConfig(partial: Partial<DeepSeekConfig>): void {
|
||||
this.config = {
|
||||
...this.config,
|
||||
...partial,
|
||||
enabled: partial.apiKey ? Boolean(partial.apiKey.trim()) : this.config.enabled,
|
||||
};
|
||||
if (this.config.timeoutMs) {
|
||||
this.httpClient.defaults.timeout = this.config.timeoutMs;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已配置有效 API Key
|
||||
*/
|
||||
isConfigured(): boolean {
|
||||
return Boolean(this.config.apiKey && this.config.apiKey.trim().length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装名片 AI 顾问 System 提示词
|
||||
*/
|
||||
buildSystemPrompt(
|
||||
card?: { name?: string; company?: string; title?: string },
|
||||
concierge?: { personaType?: string; toneStyle?: string; promptTemplate?: string },
|
||||
knowledgeChunks?: Array<{ index?: number; content: string }>
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
parts.push('你是 OmniCard AI 驱动的移动微官网数字人智能顾问。');
|
||||
if (card?.name) parts.push(`名片所有者姓名: ${card.name}`);
|
||||
if (card?.company) parts.push(`所属企业: ${card.company}`);
|
||||
if (card?.title) parts.push(`头衔职位: ${card.title}`);
|
||||
|
||||
if (concierge?.promptTemplate) {
|
||||
parts.push(`人设与使命:\n${concierge.promptTemplate}`);
|
||||
} else {
|
||||
parts.push('你的职责是以专业、礼貌、高效的口吻向访客介绍业务与产品,并在适当时机引导有意向的客户留下联系方式。');
|
||||
}
|
||||
|
||||
if (Array.isArray(knowledgeChunks) && knowledgeChunks.length > 0) {
|
||||
parts.push('参考企业知识库信息 (优先基于以下内容作答):');
|
||||
knowledgeChunks.forEach((c, idx) => {
|
||||
parts.push(`[${idx + 1}] ${c.content}`);
|
||||
});
|
||||
}
|
||||
|
||||
parts.push('请直接给出自然得体的中文或对应语言回答,避免套话。');
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装出海文案润色提示词
|
||||
*/
|
||||
buildLocalizePrompt(
|
||||
text: string,
|
||||
targetLocale: string,
|
||||
tone: string,
|
||||
glossaryTerms?: string[]
|
||||
): string {
|
||||
const lines = [
|
||||
`请将以下商务文本改写/润色为符合目标语言(${targetLocale})与目标语调(${tone})的地道出海商业文案。`,
|
||||
'要求:语义准确地道,符合国际商业习惯,禁止机械直译。',
|
||||
];
|
||||
|
||||
if (Array.isArray(glossaryTerms) && glossaryTerms.length > 0) {
|
||||
lines.push('行业专有名词强制对齐规则 (严格遵照):');
|
||||
glossaryTerms.forEach((g) => lines.push(`- ${g}`));
|
||||
}
|
||||
|
||||
lines.push(`\n原文:\n${text}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装销售 Copilot 破冰首语提示词
|
||||
*/
|
||||
buildCopilotPrompt(
|
||||
lead: { name?: string; company?: string; demand?: string; phone?: string },
|
||||
tracks?: Array<{ module?: string; duration?: number }>
|
||||
): string {
|
||||
const lines = [
|
||||
'你是一名资深出海销售战略顾问。请根据以下潜在客户画像与其在名片上的浏览行为轨迹,生成一段极具针对性的 WhatsApp/Email 商务破冰首语建议。',
|
||||
`潜客姓名: ${lead?.name || '客商'}`,
|
||||
`所属公司: ${lead?.company || '出海企业'}`,
|
||||
`核心诉求: ${lead?.demand || '商务洽谈'}`,
|
||||
];
|
||||
|
||||
if (Array.isArray(tracks) && tracks.length > 0) {
|
||||
lines.push('访客轨迹分析:');
|
||||
tracks.forEach((t) => {
|
||||
lines.push(`- 模块 [${t.module || 'portal'}], 停留时长: ${t.duration || 0}秒`);
|
||||
});
|
||||
}
|
||||
|
||||
lines.push('请生成:1. 客户核心意向诊断要点;2. 30秒直击痛点的破冰首语推荐。');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 对话补全调用 (支持优雅降级)
|
||||
*/
|
||||
async chatCompletion(
|
||||
req: DeepSeekChatCompletionRequest,
|
||||
fallbackReply?: string
|
||||
): Promise<string> {
|
||||
const apiKey = this.config.apiKey?.trim();
|
||||
|
||||
if (!apiKey) {
|
||||
if (fallbackReply !== undefined) {
|
||||
return fallbackReply;
|
||||
}
|
||||
throw new CoolCommException('DeepSeek API Key 未配置');
|
||||
}
|
||||
|
||||
const endpoint = `${this.config.baseUrl || 'https://api.deepseek.com'}/chat/completions`;
|
||||
const payload = {
|
||||
model: req.model || this.config.model || 'deepseek-chat',
|
||||
messages: req.messages,
|
||||
temperature: req.temperature ?? this.config.temperature ?? 0.7,
|
||||
max_tokens: req.maxTokens ?? this.config.maxTokens ?? 2048,
|
||||
stream: false,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await this.httpClient.post<DeepSeekChatCompletionResponse>(
|
||||
endpoint,
|
||||
payload,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const content = response.data?.choices?.[0]?.message?.content;
|
||||
if (content) {
|
||||
return content.trim();
|
||||
}
|
||||
|
||||
if (fallbackReply !== undefined) {
|
||||
return fallbackReply;
|
||||
}
|
||||
return 'AI 思考中,暂未生成有效回复。';
|
||||
} catch (err: any) {
|
||||
if (fallbackReply !== undefined) {
|
||||
return fallbackReply;
|
||||
}
|
||||
const errMsg =
|
||||
err?.response?.data?.error?.message || err?.message || 'DeepSeek API 调用失败';
|
||||
throw new CoolCommException(`DeepSeek 服务异常: ${errMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试与 DeepSeek 官方端点连通性
|
||||
*/
|
||||
async testConnection(testApiKey?: string): Promise<DeepSeekTestConnectionResult> {
|
||||
const key = (testApiKey || this.config.apiKey || '').trim();
|
||||
if (!key) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'API Key 未提供,无法测试连通性',
|
||||
};
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const endpoint = `${this.config.baseUrl || 'https://api.deepseek.com'}/chat/completions`;
|
||||
const model = this.config.model || 'deepseek-chat';
|
||||
|
||||
try {
|
||||
await this.httpClient.post(
|
||||
endpoint,
|
||||
{
|
||||
model,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
max_tokens: 5,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
timeout: 10000, // 测试连通性 10 秒超时
|
||||
}
|
||||
);
|
||||
|
||||
const latencyMs = Date.now() - startTime;
|
||||
return {
|
||||
success: true,
|
||||
latencyMs,
|
||||
model,
|
||||
message: 'DeepSeek API 连通成功',
|
||||
};
|
||||
} catch (err: any) {
|
||||
const errMsg =
|
||||
err?.response?.data?.error?.message || err?.message || '网络连接超时或凭证失效';
|
||||
return {
|
||||
success: false,
|
||||
latencyMs: Date.now() - startTime,
|
||||
model,
|
||||
message: `连通失败: ${errMsg}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
LocalizeTone,
|
||||
} from '../../../comm/types/omni';
|
||||
import { AiGlossaryService } from './glossary';
|
||||
import { DeepSeekService } from './deepseek';
|
||||
|
||||
const DEFAULT_TONE: LocalizeTone = 'professional';
|
||||
|
||||
@@ -23,6 +24,9 @@ export class AiLocalizeService extends BaseService {
|
||||
@Inject()
|
||||
aiGlossaryService?: AiGlossaryService;
|
||||
|
||||
@Inject()
|
||||
deepSeekService?: DeepSeekService;
|
||||
|
||||
/**
|
||||
* 按目标语言与语气改写原文骨架,并结合术语库进行精准替换
|
||||
* 兼容后台 serviceApis:POST body 为单参数对象 LocalizeRequest
|
||||
@@ -39,7 +43,23 @@ export class AiLocalizeService extends BaseService {
|
||||
}
|
||||
|
||||
const tone = this.resolveTone(req.tone);
|
||||
let polishedText = this.polish(text, locale, tone);
|
||||
const fallbackText = this.polish(text, locale, tone);
|
||||
let polishedText = fallbackText;
|
||||
|
||||
// 如果 DeepSeek 已经配置,则优先调用 DeepSeek 进行地道润色
|
||||
if (this.deepSeekService?.isConfigured?.()) {
|
||||
try {
|
||||
const prompt = this.deepSeekService.buildLocalizePrompt(text, locale, tone);
|
||||
polishedText = await this.deepSeekService.chatCompletion(
|
||||
{
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
},
|
||||
fallbackText
|
||||
);
|
||||
} catch {
|
||||
polishedText = fallbackText;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.aiGlossaryService?.replaceTerms) {
|
||||
polishedText = await this.aiGlossaryService.replaceTerms(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Index, PrimaryGeneratedColumn, Column } from 'typeorm';
|
||||
import { Index, PrimaryGeneratedColumn, Column, DeleteDateColumn } from 'typeorm';
|
||||
import * as moment from 'moment';
|
||||
import { CoolBaseEntity } from '@cool-midway/core';
|
||||
|
||||
@@ -45,6 +45,7 @@ export abstract class BaseEntity extends CoolBaseEntity {
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
name: 'create_time',
|
||||
comment: '创建时间',
|
||||
type: 'varchar',
|
||||
transformer: transformerTime,
|
||||
@@ -53,6 +54,7 @@ export abstract class BaseEntity extends CoolBaseEntity {
|
||||
|
||||
@Index()
|
||||
@Column({
|
||||
name: 'update_time',
|
||||
comment: '更新时间',
|
||||
type: 'varchar',
|
||||
transformer: transformerTime,
|
||||
@@ -60,6 +62,18 @@ export abstract class BaseEntity extends CoolBaseEntity {
|
||||
updateTime: Date;
|
||||
|
||||
@Index()
|
||||
@Column({ comment: '租户ID', nullable: true })
|
||||
@Column({
|
||||
name: 'tenant_id',
|
||||
comment: '租户ID',
|
||||
nullable: true,
|
||||
})
|
||||
tenantId: number;
|
||||
|
||||
@Index()
|
||||
@DeleteDateColumn({
|
||||
name: 'deleted_at',
|
||||
comment: '软删除时间',
|
||||
nullable: true,
|
||||
})
|
||||
deletedAt?: Date | null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BaseEntity, transformerJson } from '../../../comm/entity/base';
|
||||
import { BaseEntity, transformerJson } from '../../base/entity/base';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
import type { ArticleOgMeta, CardArticle } from '../../../comm/types/omni';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BaseEntity, transformerJson } from '../../../comm/entity/base';
|
||||
import { BaseEntity, transformerJson } from '../../base/entity/base';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
import type {
|
||||
BentoBlock,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BaseEntity } from '../../../comm/entity/base';
|
||||
import { BaseEntity } from '../../base/entity/base';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
import type { CardCase } from '../../../comm/types/omni';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BaseEntity } from '../../../comm/entity/base';
|
||||
import { BaseEntity } from '../../base/entity/base';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
import {
|
||||
CardProfile,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BaseEntity } from '../../../comm/entity/base';
|
||||
import { BaseEntity } from '../../base/entity/base';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BaseEntity } from '../../../comm/entity/base';
|
||||
import { BaseEntity } from '../../base/entity/base';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
import { LeadPoolAction } from '../../../comm/types/omni';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BaseEntity, transformerTime } from '../../../comm/entity/base';
|
||||
import { BaseEntity, transformerTime } from '../../base/entity/base';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
import {
|
||||
LeadEntity as ILeadEntity,
|
||||
@@ -118,4 +118,4 @@ export class CrmLeadEntity extends BaseEntity implements ILeadEntity {
|
||||
lastFollowUpTime?: Date | string | null;
|
||||
}
|
||||
|
||||
export { CrmLeadEntity as LeadInfoEntity, CrmLeadEntity as LeadEntity };
|
||||
export { CrmLeadEntity as LeadInfoEntity };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BaseEntity, transformerJson } from '../../../comm/entity/base';
|
||||
import { BaseEntity, transformerJson } from '../../base/entity/base';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
import { RadarTrack, TouchType } from '../../../comm/types/omni';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BaseEntity } from '../../../comm/entity/base';
|
||||
import { BaseEntity } from '../../base/entity/base';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
import {
|
||||
NFCMaterial,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BaseEntity, transformerJson } from '../../../comm/entity/base';
|
||||
import { BaseEntity, transformerJson } from '../../base/entity/base';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
import { NFCSceneMode, NFCSceneRule } from '../../../comm/types/omni';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BaseEntity } from '../../../comm/entity/base';
|
||||
import { BaseEntity } from '../../base/entity/base';
|
||||
import { Column, Entity } from 'typeorm';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BaseEntity } from '../../../comm/entity/base';
|
||||
import { BaseEntity } from '../../base/entity/base';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
import { DomainSslStatus, TenantDomain } from '../../../comm/types/omni';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BaseEntity } from '../../../comm/entity/base';
|
||||
import { BaseEntity } from '../../base/entity/base';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
import {
|
||||
HandoverLeadAction,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BaseEntity, transformerJson } from '../../../comm/entity/base';
|
||||
import { BaseEntity, transformerJson } from '../../base/entity/base';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
import {
|
||||
InvoiceBillingInfo,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BaseEntity } from '../../../comm/entity/base';
|
||||
import { BaseEntity } from '../../base/entity/base';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
import { TenantPlanType, StripeBillingCycle } from '../../../comm/types/omni';
|
||||
|
||||
|
||||
@@ -1057,7 +1057,9 @@ describe('OmniCard AI 30-Point End-to-End Pipeline Integration Suite', () => {
|
||||
expect(activeDevice?.status).toBe('active');
|
||||
});
|
||||
|
||||
it('P26: 展会批量制卡清单 CSV 与二维码离线 ZIP 压缩包归档导出', async () => {
|
||||
it(
|
||||
'P26: 展会批量制卡清单 CSV 与二维码离线 ZIP 压缩包归档导出',
|
||||
async () => {
|
||||
const mockBatchDevices = [
|
||||
{
|
||||
uid: sharedNfcUid,
|
||||
@@ -1092,7 +1094,7 @@ describe('OmniCard AI 30-Point End-to-End Pipeline Integration Suite', () => {
|
||||
expect(csvText).toContain('出厂防伪码');
|
||||
expect(csvText).toContain(sharedNfcUid);
|
||||
expect(csvText).toContain('Alex Zheng - VP Global');
|
||||
});
|
||||
}, 15000);
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { AdminDeepSeekController } from '../../../src/modules/ai/controller/admin/deepseek';
|
||||
import { DeepSeekService } from '../../../src/modules/ai/service/deepseek';
|
||||
|
||||
describe('T32 AdminDeepSeekController 后台接口测试', () => {
|
||||
let controller: AdminDeepSeekController;
|
||||
let mockService: jest.Mocked<DeepSeekService>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockService = {
|
||||
getConfig: jest.fn().mockReturnValue({
|
||||
apiKey: 'sk-1234567890abcdef',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
model: 'deepseek-chat',
|
||||
temperature: 0.7,
|
||||
maxTokens: 2048,
|
||||
timeoutMs: 30000,
|
||||
enabled: true,
|
||||
}),
|
||||
updateConfig: jest.fn(),
|
||||
isConfigured: jest.fn().mockReturnValue(true),
|
||||
testConnection: jest.fn().mockResolvedValue({
|
||||
success: true,
|
||||
latencyMs: 120,
|
||||
model: 'deepseek-chat',
|
||||
message: 'DeepSeek API 连通成功',
|
||||
}),
|
||||
chatCompletion: jest.fn().mockResolvedValue('你好!我是 DeepSeek 智能模型。'),
|
||||
} as any;
|
||||
|
||||
controller = new AdminDeepSeekController();
|
||||
controller.deepSeekService = mockService;
|
||||
// mock controller.ok
|
||||
(controller as any).ok = (data: any) => ({ code: 1000, message: 'success', data });
|
||||
});
|
||||
|
||||
it('1. POST /config 获取配置且正确脱敏 API Key', async () => {
|
||||
const res: any = await controller.config();
|
||||
expect(res.code).toBe(1000);
|
||||
expect(res.data.isConfigured).toBe(true);
|
||||
expect(res.data.apiKey).toBe('sk-1****cdef');
|
||||
expect(res.data.baseUrl).toBe('https://api.deepseek.com');
|
||||
});
|
||||
|
||||
it('2. POST /config 提交更新配置并返回新状态', async () => {
|
||||
const res: any = await controller.config({
|
||||
apiKey: 'sk-new-key-abcdef123456',
|
||||
model: 'deepseek-reasoner',
|
||||
});
|
||||
expect(res.code).toBe(1000);
|
||||
expect(mockService.updateConfig).toHaveBeenCalledWith({
|
||||
apiKey: 'sk-new-key-abcdef123456',
|
||||
model: 'deepseek-reasoner',
|
||||
});
|
||||
});
|
||||
|
||||
it('3. POST /test-connection 连通性测试', async () => {
|
||||
const res: any = await controller.testConnection('sk-test-pass');
|
||||
expect(res.code).toBe(1000);
|
||||
expect(res.data.success).toBe(true);
|
||||
expect(res.data.latencyMs).toBe(120);
|
||||
expect(mockService.testConnection).toHaveBeenCalledWith('sk-test-pass');
|
||||
});
|
||||
|
||||
it('4. POST /chat 调试对话调用', async () => {
|
||||
const res: any = await controller.chat({
|
||||
messages: [{ role: 'user', content: '测试' }],
|
||||
});
|
||||
expect(res.code).toBe(1000);
|
||||
expect(res.data.reply).toBe('你好!我是 DeepSeek 智能模型。');
|
||||
expect(mockService.chatCompletion).toHaveBeenCalledWith({
|
||||
messages: [{ role: 'user', content: '测试' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -160,6 +160,24 @@ describe('T36: 名片 AI 顾问对话 (modules/ai chat)', () => {
|
||||
expect(result.shouldCollectLead).toBe(true);
|
||||
expect(/[\u4e00-\u9fff]/.test(result.reply)).toBe(true);
|
||||
});
|
||||
|
||||
it('当 DeepSeekService 已配置时优先调用大模型,失败时平滑降级', async () => {
|
||||
const mockDeepSeek = {
|
||||
isConfigured: jest.fn().mockReturnValue(true),
|
||||
chatCompletion: jest.fn().mockResolvedValue('这是 DeepSeek 大模型生成的拟人顾问回复'),
|
||||
};
|
||||
service.deepSeekService = mockDeepSeek as any;
|
||||
|
||||
const res = await service.ask(userAsk('你们都有什么服务?'));
|
||||
expect(res.reply).toBe('这是 DeepSeek 大模型生成的拟人顾问回复');
|
||||
expect(mockDeepSeek.chatCompletion).toHaveBeenCalled();
|
||||
|
||||
// 测试异常平滑降级
|
||||
mockDeepSeek.chatCompletion.mockRejectedValue(new Error('Network error'));
|
||||
const fallbackRes = await service.ask(userAsk('你好', { locale: 'zh-CN' }));
|
||||
expect(fallbackRes.reply).toBeTruthy();
|
||||
expect(fallbackRes.reply).toContain('智能顾问');
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. OpenAiChatController /open/ai/chat', () => {
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { DeepSeekService } from '../../../src/modules/ai/service/deepseek';
|
||||
import { DeepSeekConfig } from '../../../src/comm/types/omni';
|
||||
|
||||
describe('T31 DeepSeek 大模型集成与适配服务 (deepseek.ts)', () => {
|
||||
let service: DeepSeekService;
|
||||
|
||||
beforeEach(() => {
|
||||
// 先清理环境变量,防止跨测试用例污染
|
||||
delete process.env.DEEPSEEK_API_KEY;
|
||||
delete process.env.DEEPSEEK_BASE_URL;
|
||||
delete process.env.DEEPSEEK_MODEL;
|
||||
service = new DeepSeekService();
|
||||
});
|
||||
|
||||
describe('1. 配置管理与状态检测 (Config & Readiness)', () => {
|
||||
it('1.1 初始状态默认配置验证', () => {
|
||||
const config = service.getConfig();
|
||||
expect(config.baseUrl).toBe('https://api.deepseek.com');
|
||||
expect(config.model).toBe('deepseek-chat');
|
||||
expect(config.temperature).toBe(0.7);
|
||||
expect(config.timeoutMs).toBe(30000);
|
||||
expect(service.isConfigured()).toBe(false);
|
||||
});
|
||||
|
||||
it('1.2 优先从环境变量读取 DEEPSEEK_API_KEY', () => {
|
||||
process.env.DEEPSEEK_API_KEY = 'sk-env-test-key-123';
|
||||
process.env.DEEPSEEK_BASE_URL = 'https://custom.deepseek.com';
|
||||
process.env.DEEPSEEK_MODEL = 'deepseek-reasoner';
|
||||
|
||||
const envService = new DeepSeekService();
|
||||
expect(envService.isConfigured()).toBe(true);
|
||||
expect(envService.getConfig().apiKey).toBe('sk-env-test-key-123');
|
||||
expect(envService.getConfig().baseUrl).toBe('https://custom.deepseek.com');
|
||||
expect(envService.getConfig().model).toBe('deepseek-reasoner');
|
||||
});
|
||||
|
||||
it('1.3 updateConfig 动态更新与合并配置', () => {
|
||||
service.updateConfig({
|
||||
apiKey: 'sk-dynamic-key-456',
|
||||
model: 'deepseek-reasoner',
|
||||
temperature: 0.2,
|
||||
});
|
||||
|
||||
expect(service.isConfigured()).toBe(true);
|
||||
const cfg = service.getConfig();
|
||||
expect(cfg.apiKey).toBe('sk-dynamic-key-456');
|
||||
expect(cfg.model).toBe('deepseek-reasoner');
|
||||
expect(cfg.temperature).toBe(0.2);
|
||||
expect(cfg.baseUrl).toBe('https://api.deepseek.com'); // 保留默认值
|
||||
});
|
||||
});
|
||||
|
||||
describe('2. 提示词动态拼装 (Prompt Builders)', () => {
|
||||
it('2.1 buildSystemPrompt 组装名片主、人设与知识库切片', () => {
|
||||
const card = {
|
||||
name: '张总',
|
||||
company: '华强出海科技',
|
||||
title: '海外商务总监',
|
||||
};
|
||||
const concierge = {
|
||||
personaType: 'trade_expert' as const,
|
||||
toneStyle: 'professional' as const,
|
||||
promptTemplate: '你是一名资深出海贸易专家,精通中东与东南亚市场。',
|
||||
};
|
||||
const chunks = [
|
||||
{ index: 1, content: '支持信用证结算与 FOB 迪拜港条款。' },
|
||||
{ index: 2, content: '最小起订量 (MOQ) 为 500 台。' },
|
||||
];
|
||||
|
||||
const systemPrompt = service.buildSystemPrompt(card, concierge, chunks);
|
||||
|
||||
expect(systemPrompt).toContain('张总');
|
||||
expect(systemPrompt).toContain('华强出海科技');
|
||||
expect(systemPrompt).toContain('海外商务总监');
|
||||
expect(systemPrompt).toContain('你是一名资深出海贸易专家');
|
||||
expect(systemPrompt).toContain('支持信用证结算与 FOB 迪拜港条款。');
|
||||
expect(systemPrompt).toContain('最小起订量 (MOQ) 为 500 台。');
|
||||
});
|
||||
|
||||
it('2.2 buildLocalizePrompt 组装多语言润色与术语库指令', () => {
|
||||
const prompt = service.buildLocalizePrompt(
|
||||
'欢迎参观我们位于迪拜的展位',
|
||||
'en-US',
|
||||
'professional',
|
||||
['展位 -> Booth', '迪拜 -> Dubai']
|
||||
);
|
||||
|
||||
expect(prompt).toContain('欢迎参观我们位于迪拜的展位');
|
||||
expect(prompt).toContain('en-US');
|
||||
expect(prompt).toContain('professional');
|
||||
expect(prompt).toContain('Booth');
|
||||
expect(prompt).toContain('Dubai');
|
||||
});
|
||||
|
||||
it('2.3 buildCopilotPrompt 组装销售破冰首语生成指令', () => {
|
||||
const lead = {
|
||||
name: 'Ahmed',
|
||||
company: 'Gulf Energy',
|
||||
phone: '+971501234567',
|
||||
demand: '储能集装箱报价',
|
||||
};
|
||||
const tracks = [
|
||||
{ module: 'case_energy', duration: 180 },
|
||||
{ module: 'price_table', duration: 90 },
|
||||
];
|
||||
|
||||
const prompt = service.buildCopilotPrompt(lead, tracks);
|
||||
|
||||
expect(prompt).toContain('Ahmed');
|
||||
expect(prompt).toContain('Gulf Energy');
|
||||
expect(prompt).toContain('储能集装箱报价');
|
||||
expect(prompt).toContain('case_energy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('3. 文本补全调用与高可用降级 (chatCompletion & Fallback)', () => {
|
||||
it('3.1 未配置 API Key 时,提供 fallbackReply 则安全返回兜底内容', async () => {
|
||||
expect(service.isConfigured()).toBe(false);
|
||||
|
||||
const result = await service.chatCompletion(
|
||||
{
|
||||
messages: [{ role: 'user', content: '你们的产品价格是多少?' }],
|
||||
},
|
||||
'这是本地规则引擎的兜底回复'
|
||||
);
|
||||
|
||||
expect(result).toBe('这是本地规则引擎的兜底回复');
|
||||
});
|
||||
|
||||
it('3.2 未配置 API Key 且未提供 fallbackReply 时安全降级或抛出清晰提示', async () => {
|
||||
await expect(
|
||||
service.chatCompletion({
|
||||
messages: [{ role: 'user', content: '你好' }],
|
||||
})
|
||||
).rejects.toThrow('DeepSeek API Key 未配置');
|
||||
});
|
||||
|
||||
it('3.3 模拟正常调用 DeepSeek 官方接口返回生成文本', async () => {
|
||||
service.updateConfig({ apiKey: 'sk-mock-valid-key' });
|
||||
|
||||
// 模拟底层 HTTP client
|
||||
const mockPost = jest.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: {
|
||||
id: 'chatcmpl-test-123',
|
||||
model: 'deepseek-chat',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: '您好!我是华强科技的 AI 顾问,很高兴为您服务。',
|
||||
},
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
(service as any).httpClient = { post: mockPost };
|
||||
|
||||
const reply = await service.chatCompletion({
|
||||
messages: [{ role: 'user', content: '您好' }],
|
||||
});
|
||||
|
||||
expect(reply).toBe('您好!我是华强科技的 AI 顾问,很高兴为您服务。');
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
'https://api.deepseek.com/chat/completions',
|
||||
expect.objectContaining({
|
||||
model: 'deepseek-chat',
|
||||
messages: [{ role: 'user', content: '您好' }],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer sk-mock-valid-key',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('3.4 API 调用异常且提供 fallbackReply 时平滑降级', async () => {
|
||||
service.updateConfig({ apiKey: 'sk-mock-valid-key' });
|
||||
const mockPost = jest.fn().mockRejectedValue(new Error('Network Timeout'));
|
||||
(service as any).httpClient = { post: mockPost };
|
||||
|
||||
const reply = await service.chatCompletion(
|
||||
{ messages: [{ role: 'user', content: '报价' }] },
|
||||
'网络异常时的平滑降级回复'
|
||||
);
|
||||
|
||||
expect(reply).toBe('网络异常时的平滑降级回复');
|
||||
});
|
||||
});
|
||||
|
||||
describe('4. 连通性测试 (testConnection)', () => {
|
||||
it('4.1 未配置 API Key 且未传入临时 Key 时测试失败', async () => {
|
||||
const res = await service.testConnection();
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.message).toContain('API Key 未提供');
|
||||
});
|
||||
|
||||
it('4.2 传入测试 Key 成功握手并统计延迟', async () => {
|
||||
const mockPost = jest.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
data: {
|
||||
choices: [
|
||||
{ message: { content: 'pong' }, finish_reason: 'stop' },
|
||||
],
|
||||
},
|
||||
});
|
||||
(service as any).httpClient = { post: mockPost };
|
||||
|
||||
const res = await service.testConnection('sk-test-tmp-key');
|
||||
expect(res.success).toBe(true);
|
||||
expect(typeof res.latencyMs).toBe('number');
|
||||
expect(res.latencyMs).toBeGreaterThanOrEqual(0);
|
||||
expect(res.model).toBe('deepseek-chat');
|
||||
});
|
||||
|
||||
it('4.3 官方 API 返回 401 鉴权失败捕获', async () => {
|
||||
const mockPost = jest.fn().mockRejectedValue({
|
||||
response: {
|
||||
status: 401,
|
||||
data: { error: { message: 'Authentication Fails (invalid api key)' } },
|
||||
},
|
||||
});
|
||||
(service as any).httpClient = { post: mockPost };
|
||||
|
||||
const res = await service.testConnection('sk-invalid-key');
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.message).toContain('Authentication Fails');
|
||||
});
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户