Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
Q
qianjiangb2b
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
AI-甘富林
qianjiangb2b
Commits
7609888d
Commit
7609888d
authored
Jun 18, 2026
by
AI-甘富林
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
多轮对话支付下单修改
parent
8ce2079e
Changes
1
Hide whitespace changes
Inline
Side-by-side
Showing
1 changed file
with
513 additions
and
12 deletions
+513
-12
index.ts
supabase/functions/dify-chat-stream/index.ts
+513
-12
No files found.
supabase/functions/dify-chat-stream/index.ts
View file @
7609888d
/**
* ai-chat-stream Edge Function
* C端消费者聊天 - 直接大模型流式输出(支持多轮对话记忆 + 主动澄清)
* C端消费者聊天 - 直接大模型流式输出(支持多轮对话记忆 + 主动澄清
+ 订单/售后/FAQ查询
)
*
* 通过 AI Hub(OpenAI 兼容 API)直接调用大模型,不再经过 Dify/Coze 中间层。
* 无 AI Hub 时回退到 Lovable Gateway。
*
* v4 更新(2026-06-18):
* - done 事件新增 action/actionPayload 字段,支持触发前端行为(弹出售后表单等)
* - done 事件新增 components 数组,支持返回 UI 组件调度指令(与 B端 GenUI 对齐)
* - System Prompt 增强:AFTERSALES 场景引导 LLM 返回 action 和 actionPayload
*
* v3 更新(2026-06-18):
* - 订单号自动提取(正则匹配多种格式)并从 orders 表查询真实订单数据
* - 售后查询:按订单ID + 用户ID 从 after_sales_requests 表查询售后记录
* - FAQ 知识库搜索:从 knowledge_base 表 ilike 全文匹配常见问题
* - 预查询+上下文注入模式:LLM 调用前并行查询数据,注入 system prompt 防幻觉
*
* v2 更新:
* - 从 messages 表加载对话历史,注入 LLM 上下文(多轮记忆)
* - 系统提示词增强:意图模糊时主动追问澄清(CLARIFY intent)
...
...
@@ -49,7 +60,7 @@ const USE_AI_HUB = !!(AI_HUB_KEY && AI_HUB_BASE);
type
HistoryMessage
=
{
role
:
'user'
|
'assistant'
;
content
:
string
};
// ─── 系统提示词构建 ───
function
buildSystemPrompt
(
catalog
:
any
[],
userContext
:
string
):
string
{
function
buildSystemPrompt
(
catalog
:
any
[],
userContext
:
string
,
dataContext
=
''
):
string
{
const
catalogSection
=
catalog
.
length
>
0
?
`\n\n## 可用产品目录(共
${
catalog
.
length
}
个,展示前30个)\n
${
catalog
.
slice
(
0
,
30
).
map
(
p
=>
...
...
@@ -62,25 +73,33 @@ function buildSystemPrompt(catalog: any[], userContext: string): string {
?
`\n\n## 用户上下文\n
${
userContext
}
`
:
''
;
return
`你是"小智",千江商城的智能购物顾问。
const
dataContextSection
=
dataContext
?
`\n\n
${
dataContext
}
\n\n**重要:以上订单/售后/FAQ数据均为真实数据,你必须据此回复,禁止编造任何订单号、状态、金额!**`
:
''
;
return
`你是"小智",千匠商城的智能购物顾问。
## 核心规则
1. 产品ID必须从产品目录中
精确复制,严禁编造或生成
1. 产品ID必须从产品目录中
逐字符精确复制,严禁编造或生成——你无法凭空生成有效的UUID,编造必然导致错误
2. 所有价格、库存以目录为准,不可臆造
3. 回复使用友好、专业、简洁的中文
4. 不透露你是AI
5. 理解对话历史,记住用户之前说过的话——结合上下文做推荐和回复
6. 当系统提供了用户的订单/售后数据时,直接引用真实数据回复;当没有数据时,引导用户提供订单号查询
7. **禁止生成任何URL/链接**:回复正文中绝对不能出现任何链接地址。产品推荐统一通过下方JSON的picks字段返回,前端会自动渲染产品卡片。绝对禁止输出任何形式的网址、域名或链接
8. **禁止编造标识符**:你无法生成有效的UUID、订单号、物流单号。这些必须从系统提供的目录/数据中精确复制。如果目录中没有合适的UUID,picks留空即可,不要编造
${
catalogSection
}${
contextSection
}
${
catalogSection
}${
contextSection
}
${
dataContextSection
}
## 澄清规则(重要)
当用户意图模糊、信息不足时,你必须**主动追问澄清**,而不是盲目推荐。需要澄清的典型场景:
1. **购物意图模糊**:用户说"想买个手机"但没提预算、品牌偏好、用途 → 追问预算范围、偏好品牌/功能、主要用途
2. **对比请求缺标准**:用户说"哪个更好"但没说看重什么 → 追问用户最在意的因素(价格/性能/外观/售后)
3. **售后请求缺信息**:用户提退货/维修但没说订单号 → 友好索要订单号
3. **售后请求缺信息**:用户提退货/维修但没说订单号 → 友好索要订单号
;如果系统已经查询到订单数据则直接基于数据回复
4. **预算不明确**:用户只说"便宜的"/"好一点的" → 追问具体预算区间
5. **模糊指代**:用户说"刚才那个"、"太贵了有便宜的吗" → 结合对话历史定位之前推荐的产品
6. **序数指代**:用户说"第一个"、"第二个"、"第N个"、"最后一个"、"下单第三个" → 必须回顾上一轮对话中你推荐的 picks/商品列表,按顺序定位对应商品。例如上一轮你推荐了产品A、产品B、产品C,用户说"第二个"就是指产品B
澄清要求:
- 每次最多问2-3个关键问题,不要连珠炮式提问
...
...
@@ -90,7 +109,7 @@ ${catalogSection}${contextSection}
## 回复末尾必须包含JSON
\`\`\`json
{"picks":[
{"product_id":"从目录复制的真实UUID","note":"推荐理由"}],"intent":"SHOPPING|COMPARE|AFTERSALES|GENERAL|CLARIFY","highlights":["要点1","要点2"],"clarifies":["追问1","追问2"],"title":"简短标题"
}
{"picks":[
...],"intent":"SHOPPING|COMPARE|AFTERSALES|GENERAL|CLARIFY","highlights":[...],"clarifies":[...],"title":"...","extractedOrderNumber":"用户消息中提及的订单号,无则填null","action":"show_after_sales_form|...","actionPayload":{...},"components":[...]
}
\`\`\`
## 规则
...
...
@@ -99,8 +118,14 @@ ${catalogSection}${contextSection}
- clarifies: 当 intent=CLARIFY 时必填,列出2-3个具体追问问题
- highlights: 2-4个回复要点(各20-80字);澄清模式下为对话摘要
- title: 简短对话标题(≤30字)
- extractedOrderNumber: ★ 从用户消息(结合对话历史)中理解并提取订单号。即使用户说"上次那个订单"、"帮我查下订单进度"、"我之前买的那个"等模糊表达,只要能推断出订单号就填写;确实没有则填 null。LLM的理解能力可以补正则盲区
- 对比类询问使用Markdown表格
- 售后问题友好引导联系人工客服`
;
- action: 触发前端行为,可选值:show_after_sales_form(弹出售后申请表单)、show_order_detail(展示订单详情)、show_faq(展示FAQ)、none(无需操作)
- actionPayload: 当 action="show_after_sales_form" 时必填,包含订单UUID、订单号和商品列表
- components: 前端展示的UI组件数组,每项含 type(组件类型) 和 props(组件属性)
· order_status_card: 订单状态卡片,props含 orderNumber, status
· after_sales_progress: 售后进度组件,props含 afterSalesId, type, status, createdAt
- **售后处理规则**:如果系统提供了订单数据且用户明确要退货/换货/维修,必须设置 action="show_after_sales_form" 并填写 actionPayload;仅当确实需要人工介入(如特殊退款审批)时才引导联系人工客服`
;
}
// ─── 对话历史加载 ───
...
...
@@ -246,6 +271,361 @@ function detectIntent(message: string, answer: string): string {
return 'GENERAL';
}
// ─── 订单号提取 ───
/** 从用户消息中提取订单号(支持多种格式) */
function extractOrderNumber(message: string): string | null {
// 格式1: ORD + 日期 + 序号,如 ORD20240618001
const ordMatch = message.match(/ORD\d{8,}/i);
if (ordMatch) return ordMatch[0].toUpperCase();
// 格式2: # 开头 + 数字/字母,如 #20240618001
const hashMatch = message.match(/#([A-Z0-9]{6,})/i);
if (hashMatch) return hashMatch[1].toUpperCase();
// 格式3: 纯数字订单号(8位以上),如 20240618001
const numMatch = message.match(/\b(\d{8,20})\b/);
if (numMatch) {
const num = numMatch[1];
// 排除明显是手机号或金额的数字
if (/^1[3-9]\d{9}$/.test(num)) return null; // 手机号
if (/^\d{1,7}$/.test(num)) return null; // 太短
return num;
}
// 格式4: 关键词 "订单号" "订单编号" "订单" 后跟的编号
const keywordMatch = message.match(/订单(?:号|编号)?\s*[::]\s*([A-Z0-9-]{6,30})/i);
if (keywordMatch) return keywordMatch[1].toUpperCase();
// 格式5: 中英文混合 UUID 风格的订单引用
const uuidLike = message.match(/\b([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/i);
if (uuidLike) return uuidLike[1];
return null;
}
// ─── 数据查询函数 ───
/** 根据订单号查询订单详情(含商品) */
async function queryOrderByNumber(
supabase: any,
orderNumber: string
): Promise<any | null> {
try {
console.log(`
🔍
[
ai
-
chat
-
stream
]
查询订单
:
order_number
=
$
{
orderNumber
}
`);
const { data, error } = await supabase
.from('orders')
.select(`
id
,
order_number
,
status
,
total_amount
,
shipping_address
,
tracking_number
,
notes
,
created_at
,
updated_at
,
order_items
(
id
,
quantity
,
unit_price
,
product_id
,
products
(
id
,
name
,
price
,
image_url
,
brand
,
category
)
)
`)
.eq('order_number', orderNumber)
.single();
if (error) {
console.error('❌ [ai-chat-stream] 订单查询失败:', JSON.stringify(error));
return null;
}
console.log('✅ [ai-chat-stream] 订单查询成功:', {
order_number: data.order_number,
status: data.status,
items_count: data.order_items?.length || 0,
});
return data;
} catch (err) {
console.error('[ai-chat-stream] 订单查询异常:', err);
return null;
}
}
/** 根据用户ID查询最近订单列表 */
async function queryUserOrders(
supabase: any,
userId: string,
limit = 5
): Promise<any[]> {
try {
console.log(`
🔍
[
ai
-
chat
-
stream
]
查询用户订单
:
user_id
=
$
{
userId
}
`);
const { data, error } = await supabase
.from('orders')
.select(`
id
,
order_number
,
status
,
total_amount
,
created_at
,
order_items
(
id
,
quantity
,
unit_price
,
product_id
,
products
(
id
,
name
,
price
,
image_url
)
)
`)
.eq('user_id', userId)
.order('created_at', { ascending: false })
.limit(limit);
if (error) {
console.error('❌ [ai-chat-stream] 用户订单查询失败:', JSON.stringify(error));
return [];
}
console.log('✅ [ai-chat-stream] 用户订单查询成功:', { count: data?.length || 0 });
return data || [];
} catch (err) {
console.error('[ai-chat-stream] 用户订单查询异常:', err);
return [];
}
}
/** 根据订单ID查询售后记录 */
async function queryAfterSalesByOrder(
supabase: any,
orderId: string
): Promise<any[]> {
try {
console.log(`
🔍
[
ai
-
chat
-
stream
]
查询售后
:
order_id
=
$
{
orderId
}
`);
const { data, error } = await supabase
.from('after_sales_requests')
.select(`
id
,
type
,
status
,
reason
,
description
,
tracking_number
,
refund_amount
,
admin_notes
,
processed_at
,
created_at
,
updated_at
,
order_id
`)
.eq('order_id', orderId)
.order('created_at', { ascending: false });
if (error) {
console.error('❌ [ai-chat-stream] 售后查询失败:', JSON.stringify(error));
return [];
}
console.log('✅ [ai-chat-stream] 售后查询成功:', { count: data?.length || 0 });
return data || [];
} catch (err) {
console.error('[ai-chat-stream] 售后查询异常:', err);
return [];
}
}
/** 根据用户ID查询售后记录 */
async function queryAfterSalesByUser(
supabase: any,
userId: string,
limit = 5
): Promise<any[]> {
try {
console.log(`
🔍
[
ai
-
chat
-
stream
]
查询用户售后
:
user_id
=
$
{
userId
}
`
);
const
{
data
,
error
}
=
await
supabase
.
from
(
'after_sales_requests'
)
.
select
(
`
id, type, status, reason, description,
tracking_number, refund_amount, admin_notes,
processed_at, created_at, updated_at, order_id
`
)
.
eq
(
'user_id'
,
userId
)
.
order
(
'created_at'
,
{
ascending
:
false
})
.
limit
(
limit
);
if
(
error
)
{
console
.
error
(
'❌ [ai-chat-stream] 用户售后查询失败:'
,
JSON
.
stringify
(
error
));
return
[];
}
console
.
log
(
'✅ [ai-chat-stream] 用户售后查询成功:'
,
{
count
:
data
?.
length
||
0
});
return
data
||
[];
}
catch
(
err
)
{
console
.
error
(
'[ai-chat-stream] 用户售后查询异常:'
,
err
);
return
[];
}
}
/** 从知识库搜索FAQ(ilike全文匹配) */
async
function
searchFAQ
(
supabase
:
any
,
query
:
string
,
limit
=
5
):
Promise
<
Array
<
{
title
:
string
;
content
:
string
;
category
:
string
}
>>
{
try
{
console
.
log
(
`🔍 [ai-chat-stream] 搜索FAQ: query="
${
query
.
substring
(
0
,
60
)}
"`
);
// 提取关键词用于 ilike 搜索
const
keywords
=
query
.
replace
(
/
[
??!!,,。.、
\s]
+/g
,
' '
)
.
trim
()
.
split
(
/
\s
+/
)
.
filter
(
w
=>
w
.
length
>=
2
)
.
slice
(
0
,
3
);
// 最多3个关键词
if
(
keywords
.
length
===
0
)
return
[];
// 对每个关键词进行 ilike 搜索(OR 拼接)
let
dbQuery
=
supabase
.
from
(
'knowledge_base'
)
.
select
(
'title, content, category'
)
.
eq
(
'is_active'
,
true
);
// 构建 OR 条件
const
conditions
=
keywords
.
map
(
kw
=>
`title.ilike.%
${
kw
}
%,content.ilike.%
${
kw
}
%`
).
join
(
','
);
dbQuery
=
dbQuery
.
or
(
conditions
);
const
{
data
,
error
}
=
await
dbQuery
.
limit
(
limit
);
if
(
error
)
{
console
.
error
(
'❌ [ai-chat-stream] FAQ搜索失败:'
,
JSON
.
stringify
(
error
));
return
[];
}
console
.
log
(
'✅ [ai-chat-stream] FAQ搜索成功:'
,
{
keywords
,
results
:
data
?.
length
||
0
});
return
(
data
||
[]).
map
((
row
:
any
)
=>
({
title
:
row
.
title
,
content
:
row
.
content
?.
substring
(
0
,
500
)
||
''
,
// 截断长文本
category
:
row
.
category
||
''
,
}));
}
catch
(
err
)
{
console
.
error
(
'[ai-chat-stream] FAQ搜索异常:'
,
err
);
return
[];
}
}
// ─── 数据上下文构建 ───
/** 将查询到的数据格式化为注入 system prompt 的 Markdown 文本 */
function
buildDataContext
(
params
:
{
order
:
any
|
null
;
userOrders
:
any
[];
afterSales
:
any
[];
faqResults
:
Array
<
{
title
:
string
;
content
:
string
;
category
:
string
}
>
;
userId
:
string
|
null
;
}):
string
{
const
sections
:
string
[]
=
[];
// ── 订单数据 ──
if
(
params
.
order
)
{
const
o
=
params
.
order
;
const
statusMap
:
Record
<
string
,
string
>
=
{
pending
:
'待处理'
,
confirmed
:
'已确认'
,
shipped
:
'已发货'
,
delivered
:
'已签收'
,
cancelled
:
'已取消'
,
};
const
statusCN
=
statusMap
[
o
.
status
]
||
o
.
status
;
let
orderSection
=
`## 用户查询的订单(真实数据,禁止编造)
| 项目 | 详情 |
|------|------|
| 订单号 |
${
o
.
order_number
}
|
| 状态 |
${
statusCN
}
(
${
o
.
status
}
) |
| 金额 | ¥
${
Number
(
o
.
total_amount
).
toFixed
(
2
)}
|
| 下单时间 |
${
o
.
created_at
||
'未知'
}
|`
;
if
(
o
.
tracking_number
)
{
orderSection
+=
`\n| 物流单号 |
${
o
.
tracking_number
}
|`
;
}
if
(
o
.
shipping_address
)
{
orderSection
+=
`\n| 收货地址 |
${
o
.
shipping_address
}
|`
;
}
// 订单商品
if
(
o
.
order_items
?.
length
>
0
)
{
orderSection
+=
`\n\n**商品明细:**\n`
;
orderSection
+=
`| 商品 | 单价 | 数量 | 小计 |\n|------|------|------|------|\n`
;
for
(
const
item
of
o
.
order_items
)
{
const
name
=
item
.
products
?.
name
||
'未知商品'
;
const
price
=
Number
(
item
.
unit_price
).
toFixed
(
2
);
const
qty
=
item
.
quantity
;
const
subtotal
=
(
Number
(
item
.
unit_price
)
*
item
.
quantity
).
toFixed
(
2
);
orderSection
+=
`|
${
name
}
| ¥
${
price
}
| ×
${
qty
}
| ¥
${
subtotal
}
|\n`
;
}
}
sections
.
push
(
orderSection
);
}
// ── 用户最近订单列表 ──
if
(
params
.
userOrders
.
length
>
0
&&
!
params
.
order
)
{
let
userOrdersSection
=
`## 用户最近订单(真实数据,禁止编造)
| 订单号 | 状态 | 金额 | 时间 |
|------|------|------|------|
`
;
const
statusMap
:
Record
<
string
,
string
>
=
{
pending
:
'待处理'
,
confirmed
:
'已确认'
,
shipped
:
'已发货'
,
delivered
:
'已签收'
,
cancelled
:
'已取消'
,
};
for
(
const
o
of
params
.
userOrders
)
{
const
statusCN
=
statusMap
[
o
.
status
]
||
o
.
status
;
const
date
=
o
.
created_at
?
new
Date
(
o
.
created_at
).
toLocaleDateString
(
'zh-CN'
)
:
'-'
;
userOrdersSection
+=
`|
${
o
.
order_number
}
|
${
statusCN
}
| ¥
${
Number
(
o
.
total_amount
).
toFixed
(
2
)}
|
${
date
}
|\n`
;
}
sections
.
push
(
userOrdersSection
);
}
// ── 售后数据 ──
if
(
params
.
afterSales
.
length
>
0
)
{
const
typeMap
:
Record
<
string
,
string
>
=
{
return
:
'退货'
,
exchange
:
'换货'
,
repair
:
'维修'
,
};
const
statusMap
:
Record
<
string
,
string
>
=
{
pending
:
'待审核'
,
approved
:
'已通过'
,
processing
:
'处理中'
,
completed
:
'已完成'
,
rejected
:
'已拒绝'
,
};
let
asSection
=
`## 关联售后记录(真实数据,禁止编造)
| 类型 | 状态 | 原因 | 申请时间 |
|------|------|------|------|
`
;
for
(
const
as
of
params
.
afterSales
)
{
const
typeCN
=
typeMap
[
as
.
type
]
||
as
.
type
;
const
statusCN
=
statusMap
[
as
.
status
]
||
as
.
status
;
const
date
=
as
.
created_at
?
new
Date
(
as
.
created_at
).
toLocaleDateString
(
'zh-CN'
)
:
'-'
;
asSection
+=
`|
${
typeCN
}
|
${
statusCN
}
|
${
as
.
reason
?.
substring
(
0
,
30
)
||
'-'
}
|
$
{
date
}
|
\
n
`;
}
// 附上第一条售后的详细信息
const first = params.afterSales[0];
if (first) {
asSection += `
\
n
**
最近售后详情:
**
\
n
`;
asSection += `
-
类型:
$
{
typeMap
[
first
.
type
]
||
first
.
type
}
\
n
`;
asSection += `
-
状态:
$
{
statusMap
[
first
.
status
]
||
first
.
status
}
\
n
`;
asSection += `
-
原因:
$
{
first
.
reason
||
'-'
}
\
n
`;
if (first.description) asSection += `
-
描述:
$
{
first
.
description
.
substring
(
0
,
200
)}
\
n
`;
if (first.tracking_number) asSection += `
-
退回物流单号:
$
{
first
.
tracking_number
}
\
n
`;
if (first.refund_amount) asSection += `
-
退款金额:¥
$
{
Number
(
first
.
refund_amount
).
toFixed
(
2
)}
\
n
`;
if (first.admin_notes) asSection += `
-
客服备注:
$
{
first
.
admin_notes
.
substring
(
0
,
200
)}
\
n
`;
}
sections.push(asSection);
}
// ── FAQ / 知识库结果 ──
if (params.faqResults.length > 0) {
let faqSection = `
##
知识库
FAQ
(可参考,但需结合上下文回复)
`;
for (const faq of params.faqResults) {
faqSection += `
###
$
{
faq
.
title
}
\
n$
{
faq
.
content
}
\
n
\
n
`;
}
sections.push(faqSection);
}
// ── 无数据提示 ──
if (sections.length === 0) {
sections.push(`
##
数据查询结果
(本次查询未获取到订单
/
售后
/
FAQ
数据。如果用户询问订单或售后问题,请友好引导用户提供正确的订单号。)
`);
}
return sections.join('\n\n---\n\n');
}
// ─── 主处理 ───
Deno.serve(async (req) => {
...
...
@@ -328,7 +708,7 @@ Deno.serve(async (req) => {
if (localConvId && userId) {
historyMessages = await loadConversationHistory(supabase, localConvId, userId, 20);
historyMessages = truncateHistory(historyMessages,
6
000);
historyMessages = truncateHistory(historyMessages,
12
000);
console.log('📜 [ai-chat-stream] 历史消息:', {
loaded: historyMessages.length,
totalChars: historyMessages.reduce((s, m) => s + m.content.length, 0),
...
...
@@ -337,8 +717,67 @@ Deno.serve(async (req) => {
});
}
// ─── 数据查询:订单/售后/FAQ ───
const orderNumber = extractOrderNumber(message);
console.log('🔎 [ai-chat-stream] 订单号提取:', { orderNumber, messageLength: message.length });
let order: any = null;
let afterSales: any[] = [];
let userOrders: any[] = [];
let faqResults: Array<{ title: string; content: string; category: string }> = [];
// 并行查询:订单详情 + 售后记录 + FAQ + 用户最近订单
const [orderResult, asUserResult, faqResult, userOrdersResult] = await Promise.allSettled([
// 1. 如果提取到订单号,查询订单详情
orderNumber
? queryOrderByNumber(supabase, orderNumber)
: Promise.resolve(null),
// 2. 如果用户已登录,查询该用户的售后记录
userIdPassed
? queryAfterSalesByUser(supabase, userId, 5)
: Promise.resolve([]),
// 3. FAQ 搜索(总是执行,帮助回答常见问题)
searchFAQ(supabase, message, 5),
// 4. 用户最近订单(如果没有具体订单号但有 userId)
(userIdPassed && !orderNumber)
? queryUserOrders(supabase, userId, 5)
: Promise.resolve([]),
]);
if (orderResult.status === 'fulfilled') order = orderResult.value;
if (asUserResult.status === 'fulfilled') afterSales = asUserResult.value;
if (faqResult.status === 'fulfilled') faqResults = faqResult.value;
if (userOrdersResult.status === 'fulfilled') userOrders = userOrdersResult.value;
// 如果查到订单,再查该订单的售后记录(优先于用户级别的售后)
if (order?.id) {
try {
const orderAS = await queryAfterSalesByOrder(supabase, order.id);
if (orderAS.length > 0) afterSales = orderAS; // 订单级别售后记录覆盖用户级别
} catch {
// 保持已有售后数据
}
}
// 构建数据上下文
const dataContext = buildDataContext({
order,
userOrders,
afterSales,
faqResults,
userId: userIdPassed ? userId : null,
});
console.log('📊 [ai-chat-stream] 数据上下文构建完成:', {
hasOrder: !!order,
afterSalesCount: afterSales.length,
userOrdersCount: userOrders.length,
faqResultsCount: faqResults.length,
contextLength: dataContext.length,
});
// ─── 构建系统提示词 ───
const systemPrompt = buildSystemPrompt(catalog, inputs.user_context || '');
const systemPrompt = buildSystemPrompt(catalog, inputs.user_context || ''
, dataContext
);
// ─── 调用大模型流式 API ───
console.log('🚀 [ai-chat-stream] Calling LLM API...');
...
...
@@ -410,8 +849,20 @@ Deno.serve(async (req) => {
const
delta
=
parsed
?.
choices
?.[
0
]?.
delta
;
if
(
delta
?.
content
)
{
fullAnswer += delta.content;
const sseData = JSON.stringify({ type: 'text', content: delta.content });
// ★ 逐 chunk 拦截清洗——防止幻觉 URL 通过 SSE 流到达前端
let
cleanContent
=
delta
.
content
;
const
streamUrlPatterns
=
[
/
\[([^\]]
*
)\]\(
https
?
:
\/\/[^\)]
+
\)
/gi
,
/https
?
:
\/\/[^\s]
+/gi
,
];
for
(
const
p
of
streamUrlPatterns
)
{
if
(
p
.
test
(
cleanContent
))
{
console
.
log
(
'🧹 [ai-chat-stream] Stripped hallucinated URL from stream chunk'
);
cleanContent
=
cleanContent
.
replace
(
p
,
''
);
}
}
fullAnswer
+=
cleanContent
;
const
sseData
=
JSON
.
stringify
({
type
:
'text'
,
content
:
cleanContent
});
controller
.
enqueue
(
encoder
.
encode
(
`data:
${
sseData
}
\n\n`
));
}
}
catch
{
...
...
@@ -428,6 +879,15 @@ Deno.serve(async (req) => {
controller
.
enqueue
(
encoder
.
encode
(
`data:
${
sseData
}
\n\n`
));
}
// ★ 第二道防线:全文本再次清洗幻觉 URL(保护 DB 写入副本)
const
postUrlPatterns
=
[
/
\[([^\]]
*
)\]\(
https
?
:
\/\/[^\)]
+
\)
/gi
,
/https
?
:
\/\/[^\s]
+/gi
,
];
for
(
const
p
of
postUrlPatterns
)
{
fullAnswer
=
fullAnswer
.
replace
(
p
,
''
);
}
console
.
log
(
'📝 [ai-chat-stream] Full answer length:'
,
fullAnswer
.
length
);
// 优先尝试解析 LLM 直接返回的结构化 JSON
...
...
@@ -440,6 +900,9 @@ Deno.serve(async (req) => {
let
next
:
string
[]
|
undefined
;
let
title
:
string
|
undefined
;
let
clarifies
:
string
[]
|
undefined
;
let
action
:
string
|
undefined
;
let
actionPayload
:
any
|
undefined
;
let
components
:
any
[]
|
undefined
;
if
(
structured
)
{
console
.
log
(
'✅ [ai-chat-stream] Using structured payload from LLM'
);
...
...
@@ -475,12 +938,44 @@ Deno.serve(async (req) => {
}
picks
=
accepted
.
map
(
a
=>
({
product_id
:
a
.
product_id
as
string
,
note
:
a
.
note
||
''
}));
// ★ 回退:结构化 JSON 存在但所有 picks 被审计拒绝 → 尝试文本解析兜底
if
(
picks
.
length
===
0
&&
rawPicks
.
length
>
0
&&
catalog
.
length
>
0
)
{
console
.
log
(
'⚠️ [ai-chat-stream] All structured picks rejected by audit, falling back to text parsing'
);
const
textPicks
=
parsePicksFromText
(
fullAnswer
,
catalog
);
if
(
textPicks
.
length
>
0
)
{
picks
=
textPicks
;
console
.
log
(
'✅ [ai-chat-stream] Text fallback recovered picks:'
,
picks
.
length
);
}
else
{
console
.
log
(
'❌ [ai-chat-stream] Text fallback also found no valid picks'
);
}
}
intent
=
structured
.
intent
||
detectIntent
(
message
,
fullAnswer
);
highlights
=
Array
.
isArray
(
structured
.
highlights
)
?
structured
.
highlights
:
[];
comparison
=
typeof
structured
.
comparison
===
'string'
?
structured
.
comparison
:
undefined
;
next
=
Array
.
isArray
(
structured
.
next
)
?
structured
.
next
:
undefined
;
title
=
typeof
structured
.
title
===
'string'
?
structured
.
title
:
undefined
;
clarifies
=
Array
.
isArray
(
structured
.
clarifies
)
?
structured
.
clarifies
:
undefined
;
action
=
(
typeof
structured
.
action
===
'string'
&&
structured
.
action
!==
'none'
)
?
structured
.
action
:
undefined
;
actionPayload
=
(
action
&&
structured
.
actionPayload
&&
typeof
structured
.
actionPayload
===
'object'
)
?
structured
.
actionPayload
:
undefined
;
components
=
(
Array
.
isArray
(
structured
.
components
)
&&
structured
.
components
.
length
>
0
)
?
structured
.
components
:
undefined
;
// LLM 提取的订单号(补正则盲区:如"上次那个订单"、"帮我查下进度")
const
llmOrderNumber
=
(
typeof
structured
.
extractedOrderNumber
===
'string'
&&
structured
.
extractedOrderNumber
.
trim
())
?
structured
.
extractedOrderNumber
.
trim
()
:
null
;
if
(
llmOrderNumber
&&
!
order
)
{
console
.
log
(
'🧠 [ai-chat-stream] LLM 提取到正则漏掉的订单号:'
,
llmOrderNumber
);
try
{
order
=
await
queryOrderByNumber
(
supabase
,
llmOrderNumber
);
if
(
order
?.
id
)
{
const
orderAS
=
await
queryAfterSalesByOrder
(
supabase
,
order
.
id
);
if
(
orderAS
.
length
>
0
)
afterSales
=
orderAS
;
console
.
log
(
'✅ [ai-chat-stream] LLM订单号查询成功,补充数据注入'
);
}
}
catch
{
/* 查询失败不影响主流程 */
}
}
}
else
{
console
.
log
(
'⚠️ [ai-chat-stream] No structured payload, falling back to text parsing'
);
picks
=
parsePicksFromText
(
fullAnswer
,
catalog
);
...
...
@@ -562,6 +1057,9 @@ Deno.serve(async (req) => {
...(
next
?
{
next
}
:
{}),
...(
title
?
{
title
}
:
{}),
...(
clarifies
?
{
clarifies
}
:
{}),
...(
action
?
{
action
}
:
{}),
...(
actionPayload
?
{
actionPayload
}
:
{}),
...(
components
?
{
components
}
:
{}),
meta
:
{
platform
:
'ai-hub'
,
user
:
userId
,
...
...
@@ -570,6 +1068,9 @@ Deno.serve(async (req) => {
source
:
structured
?
'llm-structured'
:
'text-fallback'
,
hasHistory
:
historyMessages
.
length
>
0
,
historyMessages
:
historyMessages
.
length
,
hasOrderData
:
!!
order
,
hasAfterSalesData
:
afterSales
.
length
>
0
,
hasFAQData
:
faqResults
.
length
>
0
,
},
conversation_id
:
localConvId
,
});
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment