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,