fix: 移除损坏的 Claude gitlink 并同步业务与文档更新
- 从索引移除误记录的 .claude/worktrees gitlink(旧绝对路径会导致 git 命令失败) - 新增根目录 .gitignore 忽略 .claude/worktrees 与 .DS_Store - 后端:Coze/知识库、ResultAdvice、应用配置 - 前端 uniapp:AI 营养、食物百科等页面与 API - 更新 README、测试文档与 shop-msh.sql Made-with: Cursor
This commit is contained in:
@@ -353,6 +353,114 @@ function cozeChat(data) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Coze - 流式对话 (Chat Stream via SSE + enableChunked)
|
||||
* 使用微信小程序 enableChunked 能力消费 SSE 事件流
|
||||
* @param {object} data 请求参数(与 cozeChat 一致)
|
||||
* @returns {object} 控制器 { onMessage, onError, onComplete, abort, getTask }
|
||||
*/
|
||||
function cozeChatStream(data) {
|
||||
let _onMessage = () => {}
|
||||
let _onError = () => {}
|
||||
let _onComplete = () => {}
|
||||
let _buffer = ''
|
||||
let _task = null
|
||||
let _gotChunks = false
|
||||
|
||||
const controller = {
|
||||
onMessage(fn) { _onMessage = fn; return controller },
|
||||
onError(fn) { _onError = fn; return controller },
|
||||
onComplete(fn) { _onComplete = fn; return controller },
|
||||
abort() { if (_task) _task.abort() },
|
||||
getTask() { return _task }
|
||||
}
|
||||
|
||||
const parseSseLines = (text) => {
|
||||
_buffer += text
|
||||
const lines = _buffer.split('\n')
|
||||
_buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith(':')) continue
|
||||
if (trimmed.startsWith('data:')) {
|
||||
const jsonStr = trimmed.slice(5).trim()
|
||||
if (!jsonStr) continue
|
||||
try {
|
||||
const evt = JSON.parse(jsonStr)
|
||||
_onMessage(evt)
|
||||
} catch (e) {
|
||||
// skip malformed JSON fragments
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const parseSseResponseBody = (body) => {
|
||||
if (!body || typeof body !== 'string') return
|
||||
const lines = body.split('\n')
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith(':')) continue
|
||||
if (trimmed.startsWith('data:')) {
|
||||
const jsonStr = trimmed.slice(5).trim()
|
||||
if (!jsonStr) continue
|
||||
try {
|
||||
const evt = JSON.parse(jsonStr)
|
||||
_onMessage(evt)
|
||||
} catch (e) {
|
||||
// skip malformed JSON fragments
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const token = uni.getStorageSync('LOGIN_STATUS_TOKEN') || ''
|
||||
_task = uni.request({
|
||||
url: `${API_BASE_URL}/api/front/coze/chat/stream`,
|
||||
method: 'POST',
|
||||
data: data,
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { 'Authori-zation': token } : {})
|
||||
},
|
||||
enableChunked: true,
|
||||
responseType: 'text',
|
||||
success: (res) => {
|
||||
if (_buffer.trim()) {
|
||||
parseSseLines('\n')
|
||||
}
|
||||
if (!_gotChunks && res && res.data) {
|
||||
const body = typeof res.data === 'string' ? res.data : JSON.stringify(res.data)
|
||||
parseSseResponseBody(body)
|
||||
}
|
||||
_onComplete()
|
||||
},
|
||||
fail: (err) => {
|
||||
_onError(err)
|
||||
}
|
||||
})
|
||||
|
||||
if (_task && _task.onChunkReceived) {
|
||||
_task.onChunkReceived((res) => {
|
||||
_gotChunks = true
|
||||
try {
|
||||
const bytes = new Uint8Array(res.data)
|
||||
let text = ''
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
text += String.fromCharCode(bytes[i])
|
||||
}
|
||||
text = decodeURIComponent(escape(text))
|
||||
parseSseLines(text)
|
||||
} catch (e) {
|
||||
// chunk decode error, skip
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Coze - 检索对话详情 (Retrieve Chat)
|
||||
* @param {object} params 请求参数
|
||||
@@ -471,6 +579,7 @@ export default {
|
||||
kieaiGeminiChat,
|
||||
// Coze API
|
||||
cozeChat,
|
||||
cozeChatStream,
|
||||
cozeRetrieveChat,
|
||||
cozeMessageList,
|
||||
cozeWorkflowRun,
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
// |
|
||||
// +----------------------------------------------------------------------
|
||||
// 移动端商城API
|
||||
// let domain = 'http://127.0.0.1:20822'
|
||||
let domain = 'http://127.0.0.1:20822'
|
||||
// let domain = 'https://chenyin.uj345.cc'
|
||||
let domain = 'https://sophia-shop.uj345.cc'
|
||||
// let domain = 'https://sophia-shop.uj345.cc'
|
||||
|
||||
module.exports = {
|
||||
domain,
|
||||
|
||||
@@ -63,13 +63,13 @@
|
||||
|
||||
<!-- 消息气泡 -->
|
||||
<view :class="['message-bubble', msg.role === 'user' ? 'user-bubble' : 'ai-bubble']">
|
||||
<!-- AI 消息 loading 占位(等待回复时显示打字动画)-->
|
||||
<view v-if="msg.role === 'ai' && msg.loading" class="typing-indicator">
|
||||
<view class="typing-dot"></view>
|
||||
<view class="typing-dot"></view>
|
||||
<view class="typing-dot"></view>
|
||||
</view>
|
||||
<text v-else-if="msg.type !== 'image'" class="message-text">{{ msg.content }}</text>
|
||||
<!-- AI 消息 loading 占位(等待回复时显示打字动画)-->
|
||||
<view v-if="msg.role === 'ai' && msg.loading" class="typing-indicator">
|
||||
<view class="typing-dot"></view>
|
||||
<view class="typing-dot"></view>
|
||||
<view class="typing-dot"></view>
|
||||
</view>
|
||||
<text v-else-if="msg.type !== 'image'" class="message-text">{{ msg.content }}<text v-if="msg.streaming" class="streaming-cursor">|</text></text>
|
||||
<image
|
||||
v-else
|
||||
:src="msg.imageUrl"
|
||||
@@ -81,8 +81,8 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 加载中提示 -->
|
||||
<view v-if="isLoading" class="message-item ai-message">
|
||||
<!-- 加载中提示(仅在没有流式占位消息时显示) -->
|
||||
<view v-if="isLoading && !messageList.some(m => m.loading || m.streaming)" class="message-item ai-message">
|
||||
<view class="message-avatar">
|
||||
<text class="avatar-icon">🤖</text>
|
||||
</view>
|
||||
@@ -236,6 +236,10 @@ export default {
|
||||
if (this.isRecording && this.recorderManager) {
|
||||
this.recorderManager.stop();
|
||||
}
|
||||
if (this._streamCtrl) {
|
||||
this._streamCtrl.abort();
|
||||
this._streamCtrl = null;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 初始化录音管理器
|
||||
@@ -541,8 +545,13 @@ export default {
|
||||
content: '确定要清空对话吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
this.messageList = []
|
||||
this.conversationId = '' // 清空会话ID,开始新的对话
|
||||
if (this._streamCtrl) {
|
||||
this._streamCtrl.abort();
|
||||
this._streamCtrl = null;
|
||||
}
|
||||
this.isLoading = false;
|
||||
this.messageList = [];
|
||||
this.conversationId = '';
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -650,79 +659,164 @@ export default {
|
||||
return data;
|
||||
},
|
||||
|
||||
buildCozeMessages(content, type) {
|
||||
const messages = [];
|
||||
if (type === 'text') {
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: typeof content === 'string' ? content : JSON.stringify(content),
|
||||
content_type: 'text'
|
||||
});
|
||||
} else if (type === 'multimodal') {
|
||||
const parts = Array.isArray(content) ? content : [{ type: 'text', text: String(content) }];
|
||||
const textPart = parts.find(p => p && p.type === 'text');
|
||||
const imgPart = parts.find(p => p && (p.type === 'image_url' || p.type === 'image'));
|
||||
if (imgPart) {
|
||||
const fileId = imgPart.file_id || (imgPart.image_url && imgPart.image_url.url) || '';
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: JSON.stringify([
|
||||
...(textPart ? [{ type: 'text', text: textPart.text }] : []),
|
||||
{ type: 'image', file_id: fileId }
|
||||
]),
|
||||
content_type: 'object_string'
|
||||
});
|
||||
} else {
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: textPart ? textPart.text : JSON.stringify(content),
|
||||
content_type: 'text'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let fileInfo = content;
|
||||
if (typeof fileInfo === 'string') {
|
||||
try { fileInfo = JSON.parse(fileInfo); } catch (e) { /* ignore */ }
|
||||
}
|
||||
const fileId = (fileInfo && fileInfo.id) || (fileInfo && fileInfo.file_id) || '';
|
||||
if (fileId) {
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: JSON.stringify([{ type: 'image', file_id: fileId }]),
|
||||
content_type: 'object_string'
|
||||
});
|
||||
} else {
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: '我发送了一张图片,请帮我分析',
|
||||
content_type: 'text'
|
||||
});
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
},
|
||||
|
||||
supportsChunked() {
|
||||
try {
|
||||
const sysInfo = uni.getSystemInfoSync();
|
||||
if (sysInfo.SDKVersion) {
|
||||
const parts = sysInfo.SDKVersion.split('.').map(Number);
|
||||
return (parts[0] > 2) || (parts[0] === 2 && parts[1] > 20) ||
|
||||
(parts[0] === 2 && parts[1] === 20 && (parts[2] || 0) >= 1);
|
||||
}
|
||||
} catch (e) { /* fallback */ }
|
||||
return false;
|
||||
},
|
||||
|
||||
async sendToAI(content, type) {
|
||||
this.isLoading = true;
|
||||
|
||||
// 添加 AI 占位消息(loading 状态,等待 Coze 返回后填充内容)
|
||||
const aiMsg = { role: 'ai', content: '', loading: true };
|
||||
const aiMsg = { role: 'ai', content: '', loading: true, streaming: false };
|
||||
this.messageList.push(aiMsg);
|
||||
this.scrollToBottom();
|
||||
|
||||
// 统一走 Coze API(文本、多模态、图片均使用 Coze Bot)
|
||||
const userId = this.uid || (uni.getStorageSync('userInfo') || {}).id || 'default_user';
|
||||
const messages = this.buildCozeMessages(content, type);
|
||||
|
||||
const requestData = {
|
||||
botId: this.botId,
|
||||
userId: userId,
|
||||
additionalMessages: messages,
|
||||
stream: true,
|
||||
autoSaveHistory: true
|
||||
};
|
||||
if (this.conversationId) requestData.conversationId = this.conversationId;
|
||||
|
||||
if (this.supportsChunked()) {
|
||||
this.sendToAIStream(requestData, aiMsg);
|
||||
} else {
|
||||
this.sendToAIPoll(requestData, aiMsg);
|
||||
}
|
||||
},
|
||||
|
||||
sendToAIStream(requestData, aiMsg) {
|
||||
const ctrl = api.cozeChatStream(requestData)
|
||||
.onMessage((evt) => {
|
||||
const eventType = evt.event || '';
|
||||
|
||||
if (eventType === 'conversation.chat.created') {
|
||||
if (evt.conversation_id) {
|
||||
this.conversationId = evt.conversation_id;
|
||||
}
|
||||
} else if (eventType === 'conversation.message.delta') {
|
||||
const role = evt.role || 'assistant';
|
||||
const type = evt.type || 'answer';
|
||||
if (role === 'assistant' && type === 'answer') {
|
||||
if (aiMsg.loading) {
|
||||
aiMsg.loading = false;
|
||||
aiMsg.streaming = true;
|
||||
}
|
||||
aiMsg.content += (evt.content || '');
|
||||
this.messageList = [...this.messageList];
|
||||
this.scrollToBottom();
|
||||
}
|
||||
} else if (eventType === 'conversation.chat.completed') {
|
||||
aiMsg.streaming = false;
|
||||
this.isLoading = false;
|
||||
this.messageList = [...this.messageList];
|
||||
this.scrollToBottom();
|
||||
} else if (eventType === 'conversation.chat.failed') {
|
||||
aiMsg.loading = false;
|
||||
aiMsg.streaming = false;
|
||||
if (!aiMsg.content) {
|
||||
aiMsg.content = '抱歉,AI 对话失败,请稍后再试。';
|
||||
}
|
||||
this.isLoading = false;
|
||||
this.messageList = [...this.messageList];
|
||||
this.scrollToBottom();
|
||||
}
|
||||
})
|
||||
.onError((err) => {
|
||||
console.error('SSE 流式对话失败:', err);
|
||||
aiMsg.loading = false;
|
||||
aiMsg.streaming = false;
|
||||
if (!aiMsg.content) {
|
||||
aiMsg.content = '抱歉,处理您的请求时出现错误,请稍后再试。';
|
||||
}
|
||||
this.isLoading = false;
|
||||
this.messageList = [...this.messageList];
|
||||
this.scrollToBottom();
|
||||
})
|
||||
.onComplete(() => {
|
||||
aiMsg.loading = false;
|
||||
aiMsg.streaming = false;
|
||||
if (!aiMsg.content) {
|
||||
aiMsg.content = '未能获取到有效回复。';
|
||||
}
|
||||
this.isLoading = false;
|
||||
this.messageList = [...this.messageList];
|
||||
this.scrollToBottom();
|
||||
});
|
||||
|
||||
this._streamCtrl = ctrl;
|
||||
},
|
||||
|
||||
async sendToAIPoll(requestData, aiMsg) {
|
||||
requestData.stream = false;
|
||||
try {
|
||||
const messages = [];
|
||||
if (type === 'text') {
|
||||
// 纯文字消息
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: typeof content === 'string' ? content : JSON.stringify(content),
|
||||
content_type: 'text'
|
||||
});
|
||||
} else if (type === 'multimodal') {
|
||||
// 图文混合:content 为 parts 数组 [{ type: 'text', text }, { type: 'image_url', ... }]
|
||||
const parts = Array.isArray(content) ? content : [{ type: 'text', text: String(content) }];
|
||||
const textPart = parts.find(p => p && p.type === 'text');
|
||||
const imgPart = parts.find(p => p && (p.type === 'image_url' || p.type === 'image'));
|
||||
if (imgPart) {
|
||||
const fileId = imgPart.file_id || (imgPart.image_url && imgPart.image_url.url) || '';
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: JSON.stringify([
|
||||
...(textPart ? [{ type: 'text', text: textPart.text }] : []),
|
||||
{ type: 'image', file_id: fileId }
|
||||
]),
|
||||
content_type: 'object_string'
|
||||
});
|
||||
} else {
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: textPart ? textPart.text : JSON.stringify(content),
|
||||
content_type: 'text'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 图片路径(旧路径,content 为 fileInfo 对象)
|
||||
let fileInfo = content;
|
||||
if (typeof fileInfo === 'string') {
|
||||
try { fileInfo = JSON.parse(fileInfo); } catch (e) { /* 非JSON */ }
|
||||
}
|
||||
const fileId = (fileInfo && fileInfo.id) || (fileInfo && fileInfo.file_id) || '';
|
||||
if (fileId) {
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: JSON.stringify([{ type: 'image', file_id: fileId }]),
|
||||
content_type: 'object_string'
|
||||
});
|
||||
} else {
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: '我发送了一张图片,请帮我分析',
|
||||
content_type: 'text'
|
||||
});
|
||||
}
|
||||
}
|
||||
const requestData = {
|
||||
botId: this.botId,
|
||||
userId: userId,
|
||||
additionalMessages: messages,
|
||||
stream: false,
|
||||
autoSaveHistory: true
|
||||
};
|
||||
if (this.conversationId) requestData.conversationId = this.conversationId;
|
||||
const response = await api.cozeChat(requestData);
|
||||
const cozeData = this.unwrapCozeResponse(response);
|
||||
if (cozeData) {
|
||||
const response = await api.cozeChat(requestData);
|
||||
const cozeData = this.unwrapCozeResponse(response);
|
||||
if (cozeData) {
|
||||
const chat = cozeData.chat || cozeData;
|
||||
const conversationId = chat.conversation_id || chat.conversationID || chat.conversationId;
|
||||
const chatId = chat.id;
|
||||
@@ -741,13 +835,19 @@ export default {
|
||||
this.isLoading = false;
|
||||
aiMsg.content = '抱歉,处理您的请求时出现错误,请稍后再试。';
|
||||
aiMsg.loading = false;
|
||||
this.messageList = [...this.messageList]; // 触发响应式更新
|
||||
this.messageList = [...this.messageList];
|
||||
this.scrollToBottom();
|
||||
}
|
||||
},
|
||||
|
||||
getPollInterval(attempt) {
|
||||
if (attempt <= 10) return 500;
|
||||
if (attempt <= 30) return 1000;
|
||||
return 1500;
|
||||
},
|
||||
|
||||
async pollChatStatus(conversationId, chatId, aiMsg) {
|
||||
const maxAttempts = 60; // 最多轮询60次(每次1.5秒),即90秒
|
||||
const maxAttempts = 80;
|
||||
let attempts = 0;
|
||||
|
||||
const checkStatus = async () => {
|
||||
@@ -761,37 +861,33 @@ export default {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api.cozeRetrieveChat({
|
||||
conversationId,
|
||||
chatId
|
||||
});
|
||||
|
||||
const retrieveData = this.unwrapCozeResponse(res);
|
||||
if (retrieveData) {
|
||||
console.log("====api.cozeRetrieveChat response====", retrieveData);
|
||||
const chatObj = retrieveData.chat || retrieveData;
|
||||
const status = chatObj && chatObj.status;
|
||||
|
||||
if (status === 'completed') {
|
||||
// 对话完成,获取消息详情
|
||||
await this.getChatMessages(conversationId, chatId, aiMsg);
|
||||
} else if (status === 'failed' || status === 'canceled') {
|
||||
this.isLoading = false;
|
||||
const failMsg = `抱歉,对话${status === 'canceled' ? '已取消' : '失败'}。`;
|
||||
if (aiMsg) { aiMsg.content = failMsg; aiMsg.loading = false; this.messageList = [...this.messageList]; }
|
||||
else { this.messageList.push({ role: 'ai', content: failMsg }); }
|
||||
this.scrollToBottom();
|
||||
const res = await api.cozeRetrieveChat({
|
||||
conversationId,
|
||||
chatId
|
||||
});
|
||||
|
||||
const retrieveData = this.unwrapCozeResponse(res);
|
||||
if (retrieveData) {
|
||||
const chatObj = retrieveData.chat || retrieveData;
|
||||
const status = chatObj && chatObj.status;
|
||||
|
||||
if (status === 'completed') {
|
||||
await this.getChatMessages(conversationId, chatId, aiMsg);
|
||||
} else if (status === 'failed' || status === 'canceled') {
|
||||
this.isLoading = false;
|
||||
const failMsg = `抱歉,对话${status === 'canceled' ? '已取消' : '失败'}。`;
|
||||
if (aiMsg) { aiMsg.content = failMsg; aiMsg.loading = false; this.messageList = [...this.messageList]; }
|
||||
else { this.messageList.push({ role: 'ai', content: failMsg }); }
|
||||
this.scrollToBottom();
|
||||
} else {
|
||||
setTimeout(checkStatus, this.getPollInterval(attempts));
|
||||
}
|
||||
} else {
|
||||
// 继续轮询 (created, in_progress) 每 1.5 秒
|
||||
setTimeout(checkStatus, 1500);
|
||||
}
|
||||
} else {
|
||||
// 查询失败,重试
|
||||
setTimeout(checkStatus, 1000);
|
||||
setTimeout(checkStatus, this.getPollInterval(attempts));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('查询对话状态失败:', e);
|
||||
setTimeout(checkStatus, 1000);
|
||||
setTimeout(checkStatus, this.getPollInterval(attempts));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1088,6 +1184,18 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
/* 流式输出闪烁光标 */
|
||||
.streaming-cursor {
|
||||
animation: blink 0.8s step-end infinite;
|
||||
color: #4facfe;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* 打字指示器 */
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
|
||||
@@ -119,9 +119,9 @@
|
||||
<text>{{ item.safety || '—' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="item.category" class="category-badge">
|
||||
<!-- <view v-if="item.category" class="category-badge">
|
||||
<text>{{ item.category }}</text>
|
||||
</view>
|
||||
</view> -->
|
||||
</view>
|
||||
<view class="nutrition-list">
|
||||
<view
|
||||
|
||||
Reference in New Issue
Block a user