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
77a77a8f
Commit
77a77a8f
authored
Jun 17, 2026
by
AI-甘富林
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
客户端ai助手对话恢复
parent
489a5b3c
Changes
1
Hide whitespace changes
Inline
Side-by-side
Showing
1 changed file
with
137 additions
and
107 deletions
+137
-107
index.ts
supabase/functions/dify-chat-stream/index.ts
+137
-107
No files found.
supabase/functions/dify-chat-stream/index.ts
View file @
77a77a8f
/**
* dify-chat-stream Edge Function
* C端消费者聊天 - Dify 流式输出版本
*
* 优先使用 Dify,失败时回退到 Coze
* ai-chat-stream Edge Function
* C端消费者聊天 - 直接大模型流式输出
*
* 通过 AI Hub(OpenAI 兼容 API)直接调用大模型,不再经过 Dify/Coze 中间层。
* 无 AI Hub 时回退到 Lovable Gateway。
*/
import
{
createClient
}
from
'https://esm.sh/@supabase/supabase-js@2.57.4'
;
...
...
@@ -31,6 +32,53 @@ interface ChatRequest {
};
}
// ─── AI Hub / LLM 配置 ───
const
AI_HUB_KEY
=
Deno
.
env
.
get
(
'AI_HUB_API_KEY'
)
||
''
;
const
AI_HUB_BASE
=
(
Deno
.
env
.
get
(
'AI_HUB_BASE_URL'
)
||
''
).
replace
(
/
\/
+$/
,
''
);
const
AI_HUB_MODEL
=
Deno
.
env
.
get
(
'AI_HUB_DEFAULT_CHAT_MODEL'
)
||
'agent-default'
;
const
LOVABLE_KEY
=
Deno
.
env
.
get
(
'LOVABLE_API_KEY'
)
||
''
;
const
USE_AI_HUB
=
!!
(
AI_HUB_KEY
&&
AI_HUB_BASE
);
// ─── 系统提示词构建 ───
function
buildSystemPrompt
(
catalog
:
any
[],
userContext
:
string
):
string
{
const
catalogSection
=
catalog
.
length
>
0
?
`\n\n## 可用产品目录(共
${
catalog
.
length
}
个,展示前30个)\n
${
catalog
.
slice
(
0
,
30
).
map
(
p
=>
`• ID="
${
p
.
id
}
" |
${
p
.
name
}
| ¥
${
p
.
price
}
| 库存:
${
p
.
stock_quantity
??
'未知'
}
|
$
{
p
.
category
||
''
}
|
$
{(
p
.
description
||
''
).
substring
(
0
,
60
)}
`
).join('\n')
}\n\n严格规则:product_id 必须从上面目录中逐字符精确复制,禁止编造、简化或截断UUID!`
:
'
\
n
\
n(当前无可用产品目录)'
;
const
contextSection
=
userContext
?
`\n\n## 用户上下文\n
${
userContext
}
`
:
''
;
return
`你是"小智",千江商城的智能购物顾问。
## 核心规则
1. 产品ID必须从产品目录中精确复制,严禁编造或生成
2. 所有价格、库存以目录为准,不可臆造
3. 回复使用友好、专业、简洁的中文
4. 不透露你是AI
${
catalogSection
}${
contextSection
}
## 回复末尾必须包含JSON(当有推荐时)
\`\`\`json
{"picks":[{"product_id":"从目录复制的真实UUID","note":"推荐理由"}],"intent":"SHOPPING|COMPARE|AFTERSALES|GENERAL","highlights":["要点1","要点2"],"title":"简短标题"}
\`\`\`
## 规则
- picks 最多5个,product_id 必须从目录逐字符复制
- intent: SHOPPING(推荐购物)、COMPARE(对比)、AFTERSALES(售后)、GENERAL(一般)
- highlights: 2-4个回复要点(各20-80字)
- title: 简短对话标题(≤30字)
- 对比类询问使用Markdown表格
- 售后问题友好引导联系人工客服`
;
}
// ─── 结构化输出解析 ───
// 尝试从文本中提取完整的结构化 JSON(包含 picks/intent/highlights/comparison/next 等字段)
function
parseStructuredPayload
(
text
:
string
):
any
|
null
{
const
candidates
:
string
[]
=
[];
...
...
@@ -118,13 +166,7 @@ function detectIntent(message: string, answer: string): string {
return
'GENERAL'
;
}
// 构建产品目录 prompt
function
buildCatalogPrompt
(
catalog
:
any
[]):
string
{
if
(
catalog
.
length
===
0
)
return
''
;
return
`\n\n## 可用产品目录(共
${
catalog
.
length
}
个,请从这里选择推荐):\n
${
catalog
.
slice
(
0
,
30
).
map
(
p
=>
`• ID="
${
p
.
id
}
" |
${
p
.
name
}
| ¥
${
p
.
price
}
| 库存:
${
p
.
stock_quantity
??
'未知'
}
`).join('\n')
}\n\n当推荐产品时,必须在回复末尾包含:\n\`\`\`json\n{"picks": [{"product_id": "从上面目录复制的真实ID", "note": "推荐理由"}]}\n\`\`\`\n严格规则:product_id 必须从产品目录中复制,禁止编造!`
;
}
// ─── 主处理 ───
Deno
.
serve
(
async
(
req
)
=>
{
if
(
req
.
method
===
'OPTIONS'
)
{
...
...
@@ -137,9 +179,9 @@ Deno.serve(async (req) => {
const
body
:
ChatRequest
=
await
req
.
json
();
const
{
message
,
conversationId
,
inputs
}
=
body
;
console
.
log
(
'📥 [
dify
-chat-stream] request:'
,
{
console
.
log
(
'📥 [
ai
-chat-stream] request:'
,
{
trace_id
:
traceId
,
message
:
message
?.
substring
(
0
,
5
0
),
message
:
message
?.
substring
(
0
,
8
0
),
catalogCount
:
inputs
?.
catalog
?.
length
||
0
,
conversationId
:
conversationId
||
'(new)'
,
});
...
...
@@ -148,7 +190,7 @@ Deno.serve(async (req) => {
const
supabaseKey
=
Deno
.
env
.
get
(
'SUPABASE_SERVICE_ROLE_KEY'
)
!
;
const
supabase
=
createClient
(
supabaseUrl
,
supabaseKey
);
//
获取权威产品目录并与前端传入目录合并,避免前端截断导致真实 ID 被误过滤
//
─── 构建产品目录(合并前端传入 + 数据库查询)───
const
inputCatalog
=
Array
.
isArray
(
inputs
.
catalog
)
?
inputs
.
catalog
:
[];
const
{
data
:
products
}
=
await
supabase
.
from
(
'products'
)
...
...
@@ -163,60 +205,72 @@ Deno.serve(async (req) => {
});
const
catalog
=
Array
.
from
(
catalogById
.
values
());
const
DIFY_API_KEY
=
Deno
.
env
.
get
(
'DIFY_API_KEY'
);
const
DIFY_BASE_URL
=
Deno
.
env
.
get
(
'DIFY_BASE_URL'
)
||
'https://api.dify.ai/v1'
;
if
(
!
DIFY_API_KEY
)
{
throw
new
Error
(
'DIFY_API_KEY not configured'
);
// ─── 确定 LLM API 端点 ───
let
apiUrl
:
string
;
let
apiKey
:
string
;
let
model
:
string
;
if
(
USE_AI_HUB
)
{
apiUrl
=
`
${
AI_HUB_BASE
}
/chat/completions`
;
apiKey
=
AI_HUB_KEY
;
model
=
AI_HUB_MODEL
;
console
.
log
(
'🤖 [ai-chat-stream] Using AI Hub:'
,
{
base
:
AI_HUB_BASE
,
model
});
}
else
if
(
LOVABLE_KEY
)
{
apiUrl
=
'https://ai.gateway.lovable.dev/v1/chat/completions'
;
apiKey
=
LOVABLE_KEY
;
model
=
'google/gemini-2.5-flash'
;
console
.
log
(
'🤖 [ai-chat-stream] Using Lovable Gateway'
);
}
else
{
throw
new
Error
(
'AI_HUB_API_KEY/AI_HUB_BASE_URL 和 LOVABLE_API_KEY 均未配置,无法调用大模型'
);
}
// 构建 Dify 请求 - 只发送用户原始消息,不注入目录(让 Dify Agent 自行处理)
const
difyBody
:
any
=
{
inputs
:
{
user_context
:
inputs
.
user_context
||
''
,
},
query
:
message
,
response_mode
:
'streaming'
,
user
:
inputs
.
user_id
||
'anonymous'
,
};
// 如果有对话ID,传入以维持多轮对话
if
(
conversationId
)
{
difyBody
.
conversation_id
=
conversationId
;
}
// ─── 构建系统提示词 ───
const
systemPrompt
=
buildSystemPrompt
(
catalog
,
inputs
.
user_context
||
''
);
console
.
log
(
'🚀 [dify-chat-stream] Calling Dify API...'
);
// ─── 调用大模型流式 API ───
console
.
log
(
'🚀 [ai-chat-stream] Calling LLM API...'
);
const
difyResponse
=
await
fetch
(
`
${
DIFY_BASE_URL
}
/chat-messages`
,
{
const
llmResponse
=
await
fetch
(
apiUrl
,
{
method
:
'POST'
,
headers
:
{
'Authorization'
:
`Bearer
${
DIFY_API_KEY
}
`
,
'Authorization'
:
`Bearer
${
apiKey
}
`
,
'Content-Type'
:
'application/json'
,
},
body
:
JSON
.
stringify
(
difyBody
),
body
:
JSON
.
stringify
({
model
,
messages
:
[
{
role
:
'system'
,
content
:
systemPrompt
},
{
role
:
'user'
,
content
:
message
},
],
stream
:
true
,
stream_options
:
{
include_usage
:
true
},
temperature
:
0.7
,
max_tokens
:
4096
,
}),
});
if
(
!
dify
Response
.
ok
)
{
const
errorText
=
await
dify
Response
.
text
();
console
.
error
(
'❌ [
dify-chat-stream] Dify API error:'
,
dify
Response
.
status
,
errorText
);
throw
new
Error
(
`
Dify API error:
${
difyResponse
.
status
}
-
${
errorText
}
`
);
if
(
!
llm
Response
.
ok
)
{
const
errorText
=
await
llm
Response
.
text
();
console
.
error
(
'❌ [
ai-chat-stream] LLM API error:'
,
llm
Response
.
status
,
errorText
);
throw
new
Error
(
`
LLM API error:
${
llmResponse
.
status
}
-
${
errorText
.
substring
(
0
,
300
)
}
`
);
}
//
创建 SSE 流
//
─── 流式转换:OpenAI SSE → 自定义 SSE ───
const
encoder
=
new
TextEncoder
();
let
fullAnswer
=
''
;
let
difyConversationId
=
conversationId
||
''
;
const
chatConversationId
=
conversationId
||
crypto
.
randomUUID
()
;
const
stream
=
new
ReadableStream
({
async
start
(
controller
)
{
try
{
const
reader
=
dify
Response
.
body
?.
getReader
();
if
(
!
reader
)
throw
new
Error
(
'
No response bod
y'
);
const
reader
=
llm
Response
.
body
?.
getReader
();
if
(
!
reader
)
throw
new
Error
(
'
LLM response body is empt
y'
);
const
decoder
=
new
TextDecoder
();
let
buffer
=
''
;
let
openaiDone
=
false
;
while
(
tru
e
)
{
while
(
!
openaiDon
e
)
{
const
{
done
,
value
}
=
await
reader
.
read
();
if
(
done
)
break
;
...
...
@@ -225,60 +279,45 @@ Deno.serve(async (req) => {
buffer
=
lines
.
pop
()
||
''
;
for
(
const
line
of
lines
)
{
if
(
!
line
.
trim
()
||
line
.
startsWith
(
':'
))
continue
;
const
trimmed
=
line
.
trim
();
if
(
!
trimmed
)
continue
;
// OpenAI SSE 格式: "data: {...}" 或 "data: [DONE]"
if
(
!
trimmed
.
startsWith
(
'data:'
))
continue
;
let
dataContent
=
line
;
if
(
line
.
startsWith
(
'data:'
))
{
dataContent
=
line
.
slice
(
5
).
trim
();
const
dataContent
=
trimmed
.
slice
(
5
).
trim
();
// 去掉 "data:" 前缀
if
(
dataContent
===
'[DONE]'
)
{
openaiDone
=
true
;
break
;
}
if
(
!
dataContent
||
dataContent
===
'[DONE]'
)
continue
;
if
(
!
dataContent
)
continue
;
// 跳过空的 keepalive 行
try
{
const
parsed
=
JSON
.
parse
(
dataContent
);
const
event
=
parsed
.
event
;
// Dify SSE 事件类型
if
(
event
===
'message'
||
event
===
'agent_message'
)
{
const
content
=
parsed
.
answer
||
''
;
if
(
content
)
{
fullAnswer
+=
content
;
const
sseData
=
JSON
.
stringify
({
type
:
'text'
,
content
});
controller
.
enqueue
(
encoder
.
encode
(
`data:
${
sseData
}
\n\n`
));
}
// 捕获 conversation_id
if
(
parsed
.
conversation_id
)
{
difyConversationId
=
parsed
.
conversation_id
;
}
}
else
if
(
event
===
'message_end'
)
{
if
(
parsed
.
conversation_id
)
{
difyConversationId
=
parsed
.
conversation_id
;
}
console
.
log
(
'✅ [dify-chat-stream] Dify message_end, conversation_id:'
,
difyConversationId
);
}
else
if
(
event
===
'error'
)
{
console
.
error
(
'❌ [dify-chat-stream] Dify error event:'
,
parsed
);
throw
new
Error
(
parsed
.
message
||
'Dify streaming error'
);
}
}
catch
(
e
)
{
if
(
e
instanceof
SyntaxError
)
{
// 非 JSON,忽略
}
else
{
throw
e
;
const
delta
=
parsed
?.
choices
?.[
0
]?.
delta
;
if
(
delta
?.
content
)
{
fullAnswer
+=
delta
.
content
;
const
sseData
=
JSON
.
stringify
({
type
:
'text'
,
content
:
delta
.
content
});
controller
.
enqueue
(
encoder
.
encode
(
`data:
${
sseData
}
\n\n`
));
}
}
catch
{
// 非 JSON 行,跳过(如注释行、空 data 等)
}
}
}
// 如果 Dify 没有返回内容,发送友好错误
// ─── 流结束,后处理 ───
// 如果 LLM 没有返回内容,发送友好提示
if
(
!
fullAnswer
)
{
fullAnswer
=
'抱歉,AI助手暂时无法回复,请稍后再试。'
;
const
sseData
=
JSON
.
stringify
({
type
:
'text'
,
content
:
fullAnswer
});
controller
.
enqueue
(
encoder
.
encode
(
`data:
${
sseData
}
\n\n`
));
}
// 打印 Dify 完整回复,便于调试是否包含商品ID
console
.
log
(
'📝 [dify-chat-stream] Full answer from Dify:
\
n'
+
fullAnswer
);
console
.
log
(
'📝 [ai-chat-stream] Full answer length:'
,
fullAnswer
.
length
);
// 优先尝试解析
Dify 直接返回的结构化 JSON(包含 picks/intent/highlights/comparison/next)
// 优先尝试解析
LLM 直接返回的结构化 JSON
const
structured
=
parseStructuredPayload
(
fullAnswer
);
let
picks
:
Array
<
{
product_id
:
string
;
note
:
string
}
>
=
[];
...
...
@@ -289,7 +328,7 @@ Deno.serve(async (req) => {
let
title
:
string
|
undefined
;
if
(
structured
)
{
console
.
log
(
'✅ [
dify-chat-stream] Using structured payload from Dify
'
);
console
.
log
(
'✅ [
ai-chat-stream] Using structured payload from LLM
'
);
// 校验 picks 中的 product_id 必须存在于 catalog(防幻觉)
const
rawPicks
=
Array
.
isArray
(
structured
.
picks
)
?
structured
.
picks
:
[];
const
catalogIdSet
=
new
Set
(
catalog
.
map
((
c
:
any
)
=>
c
?.
id
).
filter
(
Boolean
));
...
...
@@ -307,10 +346,9 @@ Deno.serve(async (req) => {
const
accepted
=
pickAudit
.
filter
(
a
=>
a
.
accepted
);
const
rejected
=
pickAudit
.
filter
(
a
=>
!
a
.
accepted
);
console
.
log
(
`📊 [
dify
-chat-stream] Pick audit — total:
${
pickAudit
.
length
}
accepted:
${
accepted
.
length
}
rejected:
${
rejected
.
length
}
catalog_size:
${
catalogIdSet
.
size
}
`
);
console
.
log
(
`📊 [
ai
-chat-stream] Pick audit — total:
${
pickAudit
.
length
}
accepted:
${
accepted
.
length
}
rejected:
${
rejected
.
length
}
catalog_size:
${
catalogIdSet
.
size
}
`
);
if
(
rejected
.
length
>
0
)
{
console
.
log
(
'🚫 [dify-chat-stream] Rejected picks:'
,
JSON
.
stringify
(
rejected
,
null
,
2
));
// 对每个被拒的 ID,再做一次精确比对说明(区分「不在 catalog」vs「字段问题」)
console
.
log
(
'🚫 [ai-chat-stream] Rejected picks:'
,
JSON
.
stringify
(
rejected
,
null
,
2
));
rejected
.
forEach
(
r
=>
{
if
(
r
.
product_id
&&
r
.
reasons
.
includes
(
'not_in_catalog'
))
{
const
prefix
=
r
.
product_id
.
slice
(
0
,
8
);
...
...
@@ -321,14 +359,6 @@ Deno.serve(async (req) => {
}
});
}
if
(
accepted
.
length
>
0
)
{
console
.
log
(
'✅ [dify-chat-stream] Accepted picks:'
,
JSON
.
stringify
(
accepted
.
map
(
a
=>
{
const
product
=
catalog
.
find
((
c
:
any
)
=>
c
.
id
===
a
.
product_id
);
return
{
id
:
a
.
product_id
,
name
:
product
?.
name
};
})
));
}
picks
=
accepted
.
map
(
a
=>
({
product_id
:
a
.
product_id
as
string
,
note
:
a
.
note
||
''
}));
intent
=
structured
.
intent
||
detectIntent
(
message
,
fullAnswer
);
...
...
@@ -337,19 +367,19 @@ Deno.serve(async (req) => {
next
=
Array
.
isArray
(
structured
.
next
)
?
structured
.
next
:
undefined
;
title
=
typeof
structured
.
title
===
'string'
?
structured
.
title
:
undefined
;
}
else
{
console
.
log
(
'⚠️ [
dify
-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
);
intent
=
detectIntent
(
message
,
fullAnswer
);
highlights
=
fullAnswer
.
split
(
/
[
。!?
\n]
/
).
filter
((
s
:
string
)
=>
s
.
length
>
10
&&
s
.
length
<
100
).
slice
(
0
,
3
);
}
console
.
log
(
'🔍 [
dify
-chat-stream] Parsed picks:'
,
JSON
.
stringify
(
picks
));
console
.
log
(
'🔍 [
ai
-chat-stream] Parsed picks:'
,
JSON
.
stringify
(
picks
));
const
mentionedIds
=
catalog
.
filter
(
c
=>
fullAnswer
.
includes
(
c
.
id
))
.
map
(
c
=>
({
id
:
c
.
id
,
name
:
c
.
name
}));
console
.
log
(
'🔎 [
dify
-chat-stream] Product IDs mentioned in text:'
,
JSON
.
stringify
(
mentionedIds
));
console
.
log
(
'🔎 [
ai
-chat-stream] Product IDs mentioned in text:'
,
JSON
.
stringify
(
mentionedIds
));
//
发送最终元数据
//
─── 发送最终元数据 ───
const
finalData
=
JSON
.
stringify
({
type
:
'done'
,
intent
,
...
...
@@ -359,28 +389,28 @@ Deno.serve(async (req) => {
...(
next
?
{
next
}
:
{}),
...(
title
?
{
title
}
:
{}),
meta
:
{
platform
:
'
dify-stream
'
,
platform
:
'
ai-hub
'
,
user
:
inputs
.
user_id
||
'anonymous'
,
catalog_count
:
catalog
.
length
,
probe_id
:
traceId
,
source
:
structured
?
'
dify
-structured'
:
'text-fallback'
,
source
:
structured
?
'
llm
-structured'
:
'text-fallback'
,
},
conversation_id
:
dify
ConversationId
,
conversation_id
:
chat
ConversationId
,
});
controller
.
enqueue
(
encoder
.
encode
(
`data:
${
finalData
}
\n\n`
));
controller
.
enqueue
(
encoder
.
encode
(
'data: [DONE]
\
n
\
n'
));
controller
.
close
();
console
.
log
(
'✅ [
dify
-chat-stream] Stream completed:'
,
{
console
.
log
(
'✅ [
ai
-chat-stream] Stream completed:'
,
{
answerLength
:
fullAnswer
.
length
,
picksCount
:
picks
.
length
,
intent
,
hasComparison
:
!!
comparison
,
conversation_id
:
dify
ConversationId
,
conversation_id
:
chat
ConversationId
,
});
}
catch
(
error
)
{
console
.
error
(
'❌ [
dify
-chat-stream] Stream error:'
,
error
);
console
.
error
(
'❌ [
ai
-chat-stream] Stream error:'
,
error
);
const
errorData
=
JSON
.
stringify
({
type
:
'error'
,
message
:
(
error
instanceof
Error
?
error
.
message
:
'流式响应失败'
),
...
...
@@ -401,9 +431,9 @@ Deno.serve(async (req) => {
},
});
}
catch
(
error
)
{
console
.
error
(
'❌ [
dify
-chat-stream] Fatal error:'
,
error
);
console
.
error
(
'❌ [
ai
-chat-stream] Fatal error:'
,
error
);
return
new
Response
(
JSON
.
stringify
({
error
:
(
error
instanceof
Error
?
error
.
message
:
'Unknown error'
)}),
JSON
.
stringify
({
error
:
(
error
instanceof
Error
?
error
.
message
:
'Unknown error'
)
}),
{
status
:
500
,
headers
:
{
...
corsHeaders
,
'Content-Type'
:
'application/json'
},
...
...
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