diff --git a/app/Http/Controllers/Api/AssistantController.php b/app/Http/Controllers/Api/AssistantController.php index 6ead884c8..b464c719e 100644 --- a/app/Http/Controllers/Api/AssistantController.php +++ b/app/Http/Controllers/Api/AssistantController.php @@ -244,7 +244,7 @@ class AssistantController extends AbstractController /** * @api {post} api/assistant/feedback/save 保存回复反馈 * - * @apiDescription 需要token身份。保存用户对一条 AI 回复的 👍/👎 反馈,同一条回复可改票(覆盖更新) + * @apiDescription 需要token身份。保存用户对一条 AI 回复的 👍/👎 反馈,同一条回复可改票(覆盖更新);传空 feedback 表示取消反馈(删除记录) * @apiVersion 1.0.0 * @apiGroup assistant * @apiName feedback__save @@ -252,7 +252,7 @@ class AssistantController extends AbstractController * @apiParam {String} session_key 场景分类key * @apiParam {String} session_id 前端会话ID * @apiParam {Number} local_id 回复条目localId - * @apiParam {String} feedback like|dislike + * @apiParam {String} feedback like|dislike,空字符串表示取消反馈 * @apiParam {String} [prompt] 用户问题 * @apiParam {String} [answer] 回复摘录 * @apiParam {Array} [source_ids] 回复引用的kb source id列表 @@ -279,7 +279,7 @@ class AssistantController extends AbstractController if (empty($sessionId) || $localId <= 0) { return Base::retError('参数错误'); } - if (!in_array($feedback, ['like', 'dislike'])) { + if (!in_array($feedback, ['', 'like', 'dislike'])) { return Base::retError('反馈类型错误'); } if (!is_array($sourceIds)) { @@ -292,6 +292,14 @@ class AssistantController extends AbstractController ->where('local_id', $localId) ->first(); + // 空反馈表示取消:删除已有记录 + if ($feedback === '') { + $exist?->delete(); + return Base::retSuccess('success', [ + 'feedback' => '', + ]); + } + $row = AiAssistantFeedback::createInstance([ 'userid' => $user->userid, 'session_key' => $sessionKey, diff --git a/resources/ai-kb/zh/howto/ai-assistant/feedback.md b/resources/ai-kb/zh/howto/ai-assistant/feedback.md index 9bc23be32..d43c75668 100644 --- a/resources/ai-kb/zh/howto/ai-assistant/feedback.md +++ b/resources/ai-kb/zh/howto/ai-assistant/feedback.md @@ -11,6 +11,8 @@ aliases: - AI 回答不好怎么反馈 - 反馈 AI 回答 - 有帮助 没帮助 + - 复制 AI 回答 + - 取消反馈 related_tools: [] related_pages: [] prerequisites: @@ -18,18 +20,20 @@ prerequisites: negative: - 反馈只针对 AI 助手浮窗里的回复,聊天对话里 @AI 机器人的消息暂不支持 - 点踩不会让 AI 立即重新回答,需要自己追问或重新提问 -last_verified: v1.7.90 +last_verified: v1.7.91 --- # 给 AI 回答点赞或点踩 ## 这是什么 -AI 助手浮窗中,每条 AI 回复完成后下方会出现「有帮助 / 没帮助」两个图标按钮(👍/👎)。点击即提交反馈,用于帮助官方改进 AI 回答质量和帮助文档内容。 +AI 助手浮窗中,每条 AI 回复完成后下方右侧会出现「复制 / 有帮助 / 没帮助」三个图标按钮(📋/👍/👎)。复制用于把回复内容复制到剪贴板,👍/👎 用于提交反馈,帮助官方改进 AI 回答质量和帮助文档内容。 ## 怎么操作 1. 在 AI 助手浮窗中提问,等待回复完成(流式输出结束后按钮才出现) -2. 回复下方右侧点击 👍(有帮助)或 👎(没帮助) -3. 按钮高亮表示已提交;再点另一个按钮可以改票,同一条回复只记最新一次 +2. 回复下方右侧点击 📋(复制)可复制该条回复正文到剪贴板(不含推理过程) +3. 点击 👍(有帮助)或 👎(没帮助)提交反馈 +4. 按钮高亮表示已提交;再点另一个按钮可以改票,同一条回复只记最新一次 +5. 再次点击当前已高亮的按钮可取消反馈 ## 反馈会被怎么用 - 反馈与该回复引用的帮助文档关联,被频繁点踩的文档会被优先修订 @@ -37,7 +41,6 @@ AI 助手浮窗中,每条 AI 回复完成后下方会出现「有帮助 / 没 ## 不支持 - 不支持填写文字原因,只有 👍/👎 两档 -- 不支持取消反馈(可改票,不可清除) ## 相关 - AI 查帮助文档:[[ai-assistant.search-help-docs.howto]] diff --git a/resources/assets/js/components/AIAssistant/index.vue b/resources/assets/js/components/AIAssistant/index.vue index df93c6437..afc4d568f 100644 --- a/resources/assets/js/components/AIAssistant/index.vue +++ b/resources/assets/js/components/AIAssistant/index.vue @@ -18,7 +18,8 @@ trigger="click" placement="bottom-end" :transfer="true" - :z-index="topZIndex + 1"> + :z-index="topZIndex + 1" + @on-visible-change="onHistoryVisibleChange">
@@ -28,10 +29,21 @@ :key="session.id" :class="{'active': session.id === currentSessionId}" @click.native="loadSession(session.id)"> -
+
{{ session.title }}
-
+
+ +
+
@@ -142,18 +154,25 @@
+ + + - + - + + {{ formatResponseTime(response.createdAt) }}
@@ -273,6 +292,8 @@ export default { loadIng: 0, pendingAutoSubmit: false, autoSubmitTimer: null, + confirmingDeleteId: null, + confirmingDeleteTimer: null, modalTitle: null, applyButtonText: null, submitButtonText: null, @@ -368,6 +389,7 @@ export default { emitter.off('openAIAssistant', this.onOpenAIAssistant); this.clearActiveSSEClients(); this.clearAutoSubmitTimer(); + this.resetDeleteConfirm(); this.unmountFloatButton(); this.refreshWelcomePromptsDebounced?.cancel(); this.stopZIndexTimer(); @@ -1105,6 +1127,8 @@ export default { handleStreamDone(owner, sse, event) { const donePayload = this.parseStreamPayload(event); const target = this.locateStreamTarget(owner); + // 完成前先判断是否仍贴底(此时反馈行尚未渲染) + const stickToBottom = target.isCurrent && this.shouldStickToBottom(); if (target.entry) { if (donePayload && donePayload.error) { this.markResponseError(target.entry, donePayload.error); @@ -1114,6 +1138,10 @@ export default { } this.releaseSSEClient(sse); this.persistStreamTarget(target); + // 完成后反馈/时间等按钮新增了高度,贴底用户需要再滚到底部 + if (stickToBottom) { + this.scrollResponsesToBottom(); + } }, /** @@ -1325,6 +1353,7 @@ export default { error: '', applyLoading: false, feedback: '', + createdAt: Date.now(), }; this.responses.push(entry); if (this.responses.length > this.maxResponses) { @@ -1427,14 +1456,27 @@ export default { }, /** - * 提交 👍/👎 反馈(可改票:点另一个值覆盖更新) + * 复制回复内容(去除推理段落) */ - async submitFeedback(response, value) { - if (!response || response.feedbackLoading || response.feedback === value) { + copyResponse(response) { + const text = this.removeReasoningSections(response?.displayOutput || response?.rawOutput) || ''; + if (!text) { return; } + this.copyText(text); + }, + + /** + * 提交 👍/👎 反馈(可改票:点另一个值覆盖更新;再点当前已选项则取消) + */ + async submitFeedback(response, value) { + if (!response || response.feedbackLoading) { + return; + } + // 点击当前已选中的反馈表示取消 + const next = response.feedback === value ? '' : value; const prev = response.feedback; - this.$set(response, 'feedback', value); + this.$set(response, 'feedback', next); this.$set(response, 'feedbackLoading', true); try { await this.$store.dispatch("call", { @@ -1444,19 +1486,20 @@ export default { session_key: this.currentSessionKey, session_id: this.currentSessionId || '', local_id: response.localId, - feedback: value, + feedback: next, prompt: this.parsePromptContent(response.prompt).text.substring(0, 1000), answer: (this.removeReasoningSections(response.rawOutput) || '').substring(0, 2000), source_ids: this.extractSourceIds(response.rawOutput), model: response.model, }, }); + // 先复位 loading 再保存,避免把 feedbackLoading=true 持久化导致重载后无法再点 + this.$set(response, 'feedbackLoading', false); this.saveCurrentSession(); } catch (e) { this.$set(response, 'feedback', prev); - $A.messageError(e?.msg || '反馈失败,请重试'); - } finally { this.$set(response, 'feedbackLoading', false); + $A.messageError(e?.msg || '反馈失败,请重试'); } }, @@ -1554,6 +1597,8 @@ export default { r.status = 'error'; r.error = r.error || this.$L('会话中断'); } + // feedbackLoading 为瞬时态,恢复时强制复位,避免历史脏数据卡死反馈按钮 + r.feedbackLoading = false; }); } // 缓存服务端返回的图片URL映射 @@ -1577,10 +1622,12 @@ export default { */ sanitizeResponsesForPersist(responses) { return (responses || []).map(r => { - if (r.status === 'streaming' || r.status === 'waiting') { - return {...r, status: 'error', error: r.error || this.$L('会话中断')}; + // feedbackLoading 为瞬时 UI 态,不持久化,避免重载后卡死反馈按钮 + const {feedbackLoading, ...rest} = r; + if (rest.status === 'streaming' || rest.status === 'waiting') { + return {...rest, status: 'error', error: rest.error || this.$L('会话中断')}; } - return r; + return rest; }); }, @@ -1803,7 +1850,35 @@ export default { /** * 删除指定会话 */ + /** + * 第一次点击删除:进入「确认删除」态(不立即删除),3 秒后自动复位 + */ + askDeleteSession(sessionId) { + this.confirmingDeleteId = sessionId; + clearTimeout(this.confirmingDeleteTimer); + this.confirmingDeleteTimer = setTimeout(() => { + this.resetDeleteConfirm(); + }, 3000); + }, + + /** + * 复位「确认删除」态 + */ + resetDeleteConfirm() { + this.confirmingDeleteId = null; + clearTimeout(this.confirmingDeleteTimer); + this.confirmingDeleteTimer = null; + }, + + /** + * 历史下拉显隐变化时复位「确认删除」态 + */ + onHistoryVisibleChange() { + this.resetDeleteConfirm(); + }, + deleteSession(sessionId) { + this.resetDeleteConfirm(); const index = this.sessionStore.findIndex(s => s.id === sessionId); if (index > -1) { const session = this.sessionStore[index]; @@ -1850,6 +1925,40 @@ export default { /** * 格式化会话时间显示 */ + /** + * 格式化单条回复时间(规则参考 happy-next formatMessageTime) + * - 今天:HH:mm + * - 本周内(周一为起点):周几 HH:mm + * - 今年内:MM-DD HH:mm + * - 更早:YYYY-MM-DD + */ + formatResponseTime(timestamp) { + if (!timestamp) { + return ''; + } + const now = $A.daytz(); + const time = $A.dayjs(timestamp); + const hm = time.format('HH:mm'); + // 今天 + if (now.format('YYYY-MM-DD') === time.format('YYYY-MM-DD')) { + return hm; + } + // 本周内(周一 00:00 起点,且不晚于当前) + const weekStart = now.subtract((now.day() + 6) % 7, 'day').startOf('day'); + if (time.valueOf() >= weekStart.valueOf() && time.valueOf() <= now.valueOf()) { + const map = {zh: 'zh-CN', 'zh-CHT': 'zh-TW'}; + const lang = getLanguage(); + const locale = map[lang] || lang || 'en'; + const weekday = new Intl.DateTimeFormat(locale, {weekday: 'short'}).format(time.toDate()); + return `${weekday} ${hm}`; + } + // 今年内 + if (now.year() === time.year()) { + return time.format('MM-DD HH:mm'); + } + return time.format('YYYY-MM-DD'); + }, + formatSessionTime(timestamp) { const now = $A.daytz(); const time = $A.dayjs(timestamp); @@ -2698,19 +2807,21 @@ export default { } .ai-assistant-output-placeholder { + height: 34px; + line-height: 34px; margin-top: 12px; font-size: 13px; color: #999; - padding: 8px; + padding: 0 10px; border-radius: 6px; background: rgba(0, 0, 0, 0.02); } .ai-assistant-output-feedback { - margin-top: 6px; + margin-top: 8px; display: flex; justify-content: flex-end; - gap: 4px; + gap: 3px; .ai-assistant-feedback-btn { display: inline-flex; @@ -2724,8 +2835,8 @@ export default { transition: all 0.2s; svg { - width: 15px; - height: 15px; + width: 14px; + height: 14px; } &:hover { @@ -2734,26 +2845,32 @@ export default { } &.active { - color: var(--primary-color, #1677ff); - background: rgba(22, 119, 255, 0.08); + color: var(--primary-color, #8bcf70); + background: rgba(139, 207, 112, 0.08); } &.ai-assistant-feedback-btn-down { - svg { - transform: rotate(180deg); - } - &.active { color: #f56c6c; background: rgba(245, 108, 108, 0.08); } } } + + .ai-assistant-output-time { + display: inline-flex; + align-items: center; + margin-left: 4px; + font-size: 12px; + color: #bbb; + white-space: nowrap; + } } .ai-assistant-output-markdown { margin-top: 12px; font-size: 13px; + min-height: 34px; .apply-reasoning { margin: 0 0 12px 0; @@ -2966,6 +3083,22 @@ export default { font-size: 12px; color: #909399; } + + &.history-item-delete-confirm { + display: flex; + color: #fff; + background-color: #f56c6c; + opacity: 1; + + &:hover { + background-color: #e15a5a; + } + + > svg { + width: 13px; + height: 13px; + } + } } } @@ -2981,6 +3114,16 @@ export default { } } } + + // 触摸设备无 hover,删除按钮常显 + @media (pointer: coarse) { + .history-item-content { + .history-item-delete { + display: flex; + opacity: 0.6; + } + } + } } .history-clear { @@ -3317,6 +3460,21 @@ export default { } } +body.window-portrait { + .ai-assistant-content { + .ai-assistant-output-feedback { + gap: 6px; + + .ai-assistant-feedback-btn { + svg { + width: 15px; + height: 15px; + } + } + } + } +} + body.dark-mode-reverse { .ai-assistant-content { .ai-assistant-welcome,