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
2240e5ca
Commit
2240e5ca
authored
Jun 18, 2026
by
AI-甘富林
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
多轮对话提交
parent
6c66cc8d
Changes
1
Show whitespace changes
Inline
Side-by-side
Showing
1 changed file
with
154 additions
and
14 deletions
+154
-14
index.ts
supabase/functions/dify-chat-stream/index.ts
+154
-14
No files found.
supabase/functions/dify-chat-stream/index.ts
View file @
2240e5ca
/**
/**
* ai-chat-stream Edge Function
* ai-chat-stream Edge Function
* C端消费者聊天 - 直接大模型流式输出
* C端消费者聊天 - 直接大模型流式输出
(支持多轮对话记忆 + 主动澄清)
*
*
* 通过 AI Hub(OpenAI 兼容 API)直接调用大模型,不再经过 Dify/Coze 中间层。
* 通过 AI Hub(OpenAI 兼容 API)直接调用大模型,不再经过 Dify/Coze 中间层。
* 无 AI Hub 时回退到 Lovable Gateway。
* 无 AI Hub 时回退到 Lovable Gateway。
*
* v2 更新:
* - 从 messages 表加载对话历史,注入 LLM 上下文(多轮记忆)
* - 系统提示词增强:意图模糊时主动追问澄清(CLARIFY intent)
* - 支持 localConversationId 定位本地会话
*/
*/
import
{
createClient
}
from
'https://esm.sh/@supabase/supabase-js@2.57.4'
;
import
{
createClient
}
from
'https://esm.sh/@supabase/supabase-js@2.57.4'
;
...
@@ -16,7 +21,8 @@ const corsHeaders = {
...
@@ -16,7 +21,8 @@ const corsHeaders = {
interface
ChatRequest
{
interface
ChatRequest
{
message
:
string
;
message
:
string
;
conversationId
?:
string
;
conversationId
?:
string
;
// AI 服务商的远程会话 ID
localConversationId
?:
string
;
// ★ 本地 conversations 表的主键 UUID
inputs
:
{
inputs
:
{
user_id
?:
string
;
user_id
?:
string
;
trace_id
:
string
;
trace_id
:
string
;
...
@@ -39,6 +45,9 @@ const AI_HUB_MODEL = Deno.env.get('AI_HUB_DEFAULT_CHAT_MODEL') || 'agent-default
...
@@ -39,6 +45,9 @@ const AI_HUB_MODEL = Deno.env.get('AI_HUB_DEFAULT_CHAT_MODEL') || 'agent-default
const
LOVABLE_KEY
=
Deno
.
env
.
get
(
'LOVABLE_API_KEY'
)
||
''
;
const
LOVABLE_KEY
=
Deno
.
env
.
get
(
'LOVABLE_API_KEY'
)
||
''
;
const
USE_AI_HUB
=
!!
(
AI_HUB_KEY
&&
AI_HUB_BASE
);
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
):
string
{
const
catalogSection
=
catalog
.
length
>
0
const
catalogSection
=
catalog
.
length
>
0
...
@@ -60,23 +69,90 @@ function buildSystemPrompt(catalog: any[], userContext: string): string {
...
@@ -60,23 +69,90 @@ function buildSystemPrompt(catalog: any[], userContext: string): string {
2. 所有价格、库存以目录为准,不可臆造
2. 所有价格、库存以目录为准,不可臆造
3. 回复使用友好、专业、简洁的中文
3. 回复使用友好、专业、简洁的中文
4. 不透露你是AI
4. 不透露你是AI
5. 理解对话历史,记住用户之前说过的话——结合上下文做推荐和回复
${
catalogSection
}${
contextSection
}
${
catalogSection
}${
contextSection
}
## 回复末尾必须包含JSON(当有推荐时)
## 澄清规则(重要)
当用户意图模糊、信息不足时,你必须**主动追问澄清**,而不是盲目推荐。需要澄清的典型场景:
1. **购物意图模糊**:用户说"想买个手机"但没提预算、品牌偏好、用途 → 追问预算范围、偏好品牌/功能、主要用途
2. **对比请求缺标准**:用户说"哪个更好"但没说看重什么 → 追问用户最在意的因素(价格/性能/外观/售后)
3. **售后请求缺信息**:用户提退货/维修但没说订单号 → 友好索要订单号
4. **预算不明确**:用户只说"便宜的"/"好一点的" → 追问具体预算区间
5. **模糊指代**:用户说"刚才那个"、"太贵了有便宜的吗" → 结合对话历史定位之前推荐的产品
澄清要求:
- 每次最多问2-3个关键问题,不要连珠炮式提问
- 语气友好,先共情再追问(示例:"我理解您想找一款手机,为了给您更精准的推荐,想了解一下您的预算和品牌偏好?")
- 澄清后 JSON 中标记 intent: "CLARIFY"
- clarifies 字段列出具体追问点(数组,每个一条追问)
## 回复末尾必须包含JSON
\`\`\`json
\`\`\`json
{"picks":[{"product_id":"从目录复制的真实UUID","note":"推荐理由"}],"intent":"SHOPPING|COMPARE|AFTERSALES|GENERAL
","highlights":["要点1","要点
2"],"title":"简短标题"}
{"picks":[{"product_id":"从目录复制的真实UUID","note":"推荐理由"}],"intent":"SHOPPING|COMPARE|AFTERSALES|GENERAL
|CLARIFY","highlights":["要点1","要点2"],"clarifies":["追问1","追问
2"],"title":"简短标题"}
\`\`\`
\`\`\`
## 规则
## 规则
- picks 最多5个,product_id 必须从目录逐字符复制
- picks 最多5个,product_id 必须从目录逐字符复制;澄清模式下 picks 为空数组
- intent: SHOPPING(推荐购物)、COMPARE(对比)、AFTERSALES(售后)、GENERAL(一般)
- intent: SHOPPING(推荐购物)、COMPARE(对比)、AFTERSALES(售后)、GENERAL(一般)、CLARIFY(主动澄清追问)
- highlights: 2-4个回复要点(各20-80字)
- clarifies: 当 intent=CLARIFY 时必填,列出2-3个具体追问问题
- highlights: 2-4个回复要点(各20-80字);澄清模式下为对话摘要
- title: 简短对话标题(≤30字)
- title: 简短对话标题(≤30字)
- 对比类询问使用Markdown表格
- 对比类询问使用Markdown表格
- 售后问题友好引导联系人工客服`
;
- 售后问题友好引导联系人工客服`
;
}
}
// ─── 对话历史加载 ───
/** 从 messages 表加载指定会话的历史消息 */
async
function
loadConversationHistory
(
supabase
:
any
,
conversationId
:
string
,
userId
:
string
,
maxMessages
=
20
):
Promise
<
HistoryMessage
[]
>
{
try
{
const
{
data
,
error
}
=
await
supabase
.
from
(
'messages'
)
.
select
(
'sender_type, content'
)
.
eq
(
'conversation_id'
,
conversationId
)
.
eq
(
'user_id'
,
userId
)
.
order
(
'created_at'
,
{
ascending
:
true
})
.
limit
(
maxMessages
);
if
(
error
)
{
console
.
error
(
'[ai-chat-stream] 加载历史消息失败:'
,
error
);
return
[];
}
console
.
log
(
`📜 [ai-chat-stream] Loaded
${
data
?.
length
||
0
}
history
messages
for
conversation
$
{
conversationId
}
`);
return (data || []).map((m: any) => ({
role: m.sender_type === 'user' ? 'user' : 'assistant',
content: m.content,
}));
} catch (err) {
console.error('[ai-chat-stream] 加载历史消息异常:', err);
return [];
}
}
/** 按总字符数截断历史消息,保留最近的消息 */
function truncateHistory(history: HistoryMessage[], maxChars: number): HistoryMessage[] {
let total = 0;
const result: HistoryMessage[] = [];
// 从最新往最早遍历,保留最近的消息直到达到上限
for (let i = history.length - 1; i >= 0; i--) {
if (total + history[i].content.length > maxChars) {
if (result.length === 0) result.unshift(history[i]); // 至少保留最新一条
break;
}
total += history[i].content.length;
result.unshift(history[i]); // 保持时间顺序
}
return result;
}
// ─── 结构化输出解析 ───
// ─── 结构化输出解析 ───
// 尝试从文本中提取完整的结构化 JSON(包含 picks/intent/highlights/comparison/next 等字段)
// 尝试从文本中提取完整的结构化 JSON(包含 picks/intent/highlights/comparison/next 等字段)
...
@@ -95,7 +171,7 @@ function parseStructuredPayload(text: string): any | null {
...
@@ -95,7 +171,7 @@ function parseStructuredPayload(text: string): any | null {
for (const candidate of candidates) {
for (const candidate of candidates) {
try {
try {
const parsed = JSON.parse(candidate);
const parsed = JSON.parse(candidate);
if
(
parsed
&&
typeof
parsed
===
'object'
&&
(
parsed
.
picks
||
parsed
.
intent
||
parsed
.
highlights
))
{
if (parsed && typeof parsed === 'object' && (parsed.picks || parsed.intent || parsed.highlights
|| parsed.clarifies
)) {
return parsed;
return parsed;
}
}
} catch {
} catch {
...
@@ -155,14 +231,17 @@ function parsePicksFromText(text: string, catalog: any[]): Array<{ product_id: s
...
@@ -155,14 +231,17 @@ function parsePicksFromText(text: string, catalog: any[]): Array<{ product_id: s
return picks;
return picks;
}
}
// 检测意图
// 检测意图
(兜底方案,LLM 返回结构化 JSON 时优先使用 LLM 的判断)
function detectIntent(message: string, answer: string): string {
function detectIntent(message: string, answer: string): string {
const shoppingKeywords = ['买', '购', '推荐', '价格', '多少钱', '手机', '电脑'];
const shoppingKeywords = ['买', '购', '推荐', '价格', '多少钱', '手机', '电脑'];
const afterSalesKeywords = ['退货', '换货', '售后', '维修'];
const afterSalesKeywords = ['退货', '换货', '售后', '维修'];
const compareKeywords = ['对比', '比较', '哪个更好'];
const compareKeywords = ['对比', '比较', '哪个更好'];
const clarifyKeywords = ['是吗', '什么', '哪个', '哪款', '多少', '怎样', '如何', '怎么', '能不能', '可以吗'];
if (compareKeywords.some(kw => message.includes(kw))) return 'COMPARE';
if (compareKeywords.some(kw => message.includes(kw))) return 'COMPARE';
if (shoppingKeywords.some(kw => message.includes(kw)) || answer.includes('picks')) return 'SHOPPING';
if (shoppingKeywords.some(kw => message.includes(kw)) || answer.includes('picks')) return 'SHOPPING';
if (afterSalesKeywords.some(kw => message.includes(kw))) return 'AFTERSALES';
if (afterSalesKeywords.some(kw => message.includes(kw))) return 'AFTERSALES';
// 如果消息很短且以问号结尾,可能是模糊询问,倾向 CLARIFY
if (message.length < 20 && clarifyKeywords.some(kw => message.includes(kw))) return 'CLARIFY';
return 'GENERAL';
return 'GENERAL';
}
}
...
@@ -177,13 +256,14 @@ Deno.serve(async (req) => {
...
@@ -177,13 +256,14 @@ Deno.serve(async (req) => {
try {
try {
const body: ChatRequest = await req.json();
const body: ChatRequest = await req.json();
const
{
message
,
conversationId
,
inputs
}
=
body
;
const { message, conversationId,
localConversationId,
inputs } = body;
console.log('📥 [ai-chat-stream] request:', {
console.log('📥 [ai-chat-stream] request:', {
trace_id: traceId,
trace_id: traceId,
message: message?.substring(0, 80),
message: message?.substring(0, 80),
catalogCount: inputs?.catalog?.length || 0,
catalogCount: inputs?.catalog?.length || 0,
conversationId: conversationId || '(new)',
conversationId: conversationId || '(new)',
localConversationId: localConversationId || '(not provided)',
});
});
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
...
@@ -224,6 +304,23 @@ Deno.serve(async (req) => {
...
@@ -224,6 +304,23 @@ Deno.serve(async (req) => {
throw new Error('AI_HUB_API_KEY/AI_HUB_BASE_URL 和 LOVABLE_API_KEY 均未配置,无法调用大模型');
throw new Error('AI_HUB_API_KEY/AI_HUB_BASE_URL 和 LOVABLE_API_KEY 均未配置,无法调用大模型');
}
}
// ─── 加载对话历史 ───
let historyMessages: HistoryMessage[] = [];
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const userId = (inputs.user_id && uuidRegex.test(inputs.user_id)) ? inputs.user_id : crypto.randomUUID();
const localConvId = localConversationId || crypto.randomUUID(); // 前端没传则后端生成,保障多轮记忆自给自足
if (localConvId && userId) {
historyMessages = await loadConversationHistory(supabase, localConvId, userId, 20);
historyMessages = truncateHistory(historyMessages, 6000);
console.log('📜 [ai-chat-stream] 历史消息:', {
loaded: historyMessages.length,
totalChars: historyMessages.reduce((s, m) => s + m.content.length, 0),
oldestRole: historyMessages[0]?.role || 'n/a',
newestRole: historyMessages[historyMessages.length - 1]?.role || 'n/a',
});
}
// ─── 构建系统提示词 ───
// ─── 构建系统提示词 ───
const systemPrompt = buildSystemPrompt(catalog, inputs.user_context || '');
const systemPrompt = buildSystemPrompt(catalog, inputs.user_context || '');
...
@@ -240,6 +337,7 @@ Deno.serve(async (req) => {
...
@@ -240,6 +337,7 @@ Deno.serve(async (req) => {
model,
model,
messages: [
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'system', content: systemPrompt },
...historyMessages,
{ role: 'user', content: message },
{ role: 'user', content: message },
],
],
stream: true,
stream: true,
...
@@ -258,7 +356,6 @@ Deno.serve(async (req) => {
...
@@ -258,7 +356,6 @@ Deno.serve(async (req) => {
// ─── 流式转换:OpenAI SSE → 自定义 SSE ───
// ─── 流式转换:OpenAI SSE → 自定义 SSE ───
const encoder = new TextEncoder();
const encoder = new TextEncoder();
let fullAnswer = '';
let fullAnswer = '';
const
chatConversationId
=
conversationId
||
crypto
.
randomUUID
();
const stream = new ReadableStream({
const stream = new ReadableStream({
async start(controller) {
async start(controller) {
...
@@ -326,6 +423,7 @@ Deno.serve(async (req) => {
...
@@ -326,6 +423,7 @@ Deno.serve(async (req) => {
let comparison: string | undefined;
let comparison: string | undefined;
let next: string[] | undefined;
let next: string[] | undefined;
let title: string | undefined;
let title: string | undefined;
let clarifies: string[] | undefined;
if (structured) {
if (structured) {
console.log('✅ [ai-chat-stream] Using structured payload from LLM');
console.log('✅ [ai-chat-stream] Using structured payload from LLM');
...
@@ -366,6 +464,7 @@ Deno.serve(async (req) => {
...
@@ -366,6 +464,7 @@ Deno.serve(async (req) => {
comparison
=
typeof
structured
.
comparison
===
'string'
?
structured
.
comparison
:
undefined
;
comparison
=
typeof
structured
.
comparison
===
'string'
?
structured
.
comparison
:
undefined
;
next
=
Array
.
isArray
(
structured
.
next
)
?
structured
.
next
:
undefined
;
next
=
Array
.
isArray
(
structured
.
next
)
?
structured
.
next
:
undefined
;
title
=
typeof
structured
.
title
===
'string'
?
structured
.
title
:
undefined
;
title
=
typeof
structured
.
title
===
'string'
?
structured
.
title
:
undefined
;
clarifies
=
Array
.
isArray
(
structured
.
clarifies
)
?
structured
.
clarifies
:
undefined
;
}
else
{
}
else
{
console
.
log
(
'⚠️ [ai-chat-stream] No structured payload, falling back to text parsing'
);
console
.
log
(
'⚠️ [ai-chat-stream] No structured payload, falling back to text parsing'
);
picks
=
parsePicksFromText
(
fullAnswer
,
catalog
);
picks
=
parsePicksFromText
(
fullAnswer
,
catalog
);
...
@@ -379,6 +478,44 @@ Deno.serve(async (req) => {
...
@@ -379,6 +478,44 @@ Deno.serve(async (req) => {
.
map
(
c
=>
({
id
:
c
.
id
,
name
:
c
.
name
}));
.
map
(
c
=>
({
id
:
c
.
id
,
name
:
c
.
name
}));
console
.
log
(
'🔎 [ai-chat-stream] Product IDs mentioned in text:'
,
JSON
.
stringify
(
mentionedIds
));
console
.
log
(
'🔎 [ai-chat-stream] Product IDs mentioned in text:'
,
JSON
.
stringify
(
mentionedIds
));
// ─── 保存消息到数据库1(服务端写入,保障多轮记忆)───
if
(
localConvId
&&
userId
)
{
const
baseTime
=
new
Date
();
const
userTs
=
baseTime
.
toISOString
();
const
aiTs
=
new
Date
(
baseTime
.
getTime
()
+
1
).
toISOString
();
// +1ms 保证顺序
// 确保 conversations 表有对应行(访客场景下前端只写 localStorage,FK 约束会拒绝 messages 写入)
const
{
error
:
upsertConvError
}
=
await
supabase
.
from
(
'conversations'
).
upsert
({
id
:
localConvId
,
user_id
:
userId
,
},
{
onConflict
:
'id'
});
if
(
upsertConvError
)
console
.
error
(
'[ai-chat-stream] upsert conversations 失败:'
,
upsertConvError
);
// 保存用户消息
const
{
error
:
saveUserError
}
=
await
supabase
.
from
(
'messages'
).
insert
({
conversation_id
:
localConvId
,
user_id
:
userId
,
content
:
message
,
sender_type
:
'user'
,
message_type
:
'text'
,
created_at
:
userTs
,
});
if
(
saveUserError
)
console
.
error
(
'[ai-chat-stream] 保存用户消息失败:'
,
saveUserError
,
{
code
:
saveUserError
?.
code
});
// 保存 AI 回复
const
{
error
:
saveAiError
}
=
await
supabase
.
from
(
'messages'
).
insert
({
conversation_id
:
localConvId
,
user_id
:
userId
,
content
:
fullAnswer
,
sender_type
:
'ai'
,
message_type
:
'text'
,
metadata
:
{
picks
,
intent
,
highlights
,
title
},
created_at
:
aiTs
,
});
if
(
saveAiError
)
console
.
error
(
'[ai-chat-stream] 保存 AI 回复失败:'
,
saveAiError
,
{
code
:
saveAiError
?.
code
});
else
console
.
log
(
'💾 [ai-chat-stream] 消息已保存到 DB'
);
}
// ─── 发送最终元数据 ───
// ─── 发送最终元数据 ───
const
finalData
=
JSON
.
stringify
({
const
finalData
=
JSON
.
stringify
({
type
:
'done'
,
type
:
'done'
,
...
@@ -388,14 +525,17 @@ Deno.serve(async (req) => {
...
@@ -388,14 +525,17 @@ Deno.serve(async (req) => {
...(
comparison
?
{
comparison
}
:
{}),
...(
comparison
?
{
comparison
}
:
{}),
...(
next
?
{
next
}
:
{}),
...(
next
?
{
next
}
:
{}),
...(
title
?
{
title
}
:
{}),
...(
title
?
{
title
}
:
{}),
...(
clarifies
?
{
clarifies
}
:
{}),
meta
:
{
meta
:
{
platform
:
'ai-hub'
,
platform
:
'ai-hub'
,
user
:
inputs
.
user_id
||
'anonymous'
,
user
:
userId
,
catalog_count
:
catalog
.
length
,
catalog_count
:
catalog
.
length
,
probe_id
:
traceId
,
probe_id
:
traceId
,
source
:
structured
?
'llm-structured'
:
'text-fallback'
,
source
:
structured
?
'llm-structured'
:
'text-fallback'
,
hasHistory
:
historyMessages
.
length
>
0
,
historyMessages
:
historyMessages
.
length
,
},
},
conversation_id
:
chatConversation
Id
,
conversation_id
:
localConv
Id
,
});
});
controller
.
enqueue
(
encoder
.
encode
(
`data:
${
finalData
}
\n\n`
));
controller
.
enqueue
(
encoder
.
encode
(
`data:
${
finalData
}
\n\n`
));
...
@@ -407,7 +547,7 @@ Deno.serve(async (req) => {
...
@@ -407,7 +547,7 @@ Deno.serve(async (req) => {
picksCount
:
picks
.
length
,
picksCount
:
picks
.
length
,
intent
,
intent
,
hasComparison
:
!!
comparison
,
hasComparison
:
!!
comparison
,
conversation_id
:
chatConversation
Id
,
conversation_id
:
localConv
Id
,
});
});
}
catch
(
error
)
{
}
catch
(
error
)
{
console
.
error
(
'❌ [ai-chat-stream] Stream error:'
,
error
);
console
.
error
(
'❌ [ai-chat-stream] Stream error:'
,
error
);
...
...
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