feat(ai-assistant): AI 回复增加复制/时间/反馈取消,历史删除二次确认

- 反馈行新增复制按钮(去推理段落)与回复时间(规则参考 formatMessageTime)
- 点赞/点踩支持再点取消;修复 feedbackLoading 持久化导致重载后无法再点
- 历史删除改为二次确认(垃圾桶→红勾),离开/3秒/下拉关闭自动复位,触摸端常显
- 回复完成后贴底用户自动滚动到底部,露出反馈按钮
- 后端 feedback__save 支持空 feedback 取消(删除记录),同步 ai-kb

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
kuaifan 2026-06-16 22:58:50 +00:00
parent 97bd58312e
commit b0a4fe6646
3 changed files with 203 additions and 34 deletions

View File

@ -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,

View File

@ -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]]

View File

@ -18,7 +18,8 @@
trigger="click"
placement="bottom-end"
:transfer="true"
:z-index="topZIndex + 1">
:z-index="topZIndex + 1"
@on-visible-change="onHistoryVisibleChange">
<div class="ai-assistant-header-btn" :title="$L('历史会话')">
<i class="taskfont">&#xe6e8;</i>
</div>
@ -28,10 +29,21 @@
:key="session.id"
:class="{'active': session.id === currentSessionId}"
@click.native="loadSession(session.id)">
<div class="history-item">
<div class="history-item" @mouseleave="resetDeleteConfirm">
<div class="history-item-content">
<div class="history-item-title">{{ session.title }}</div>
<div class="history-item-delete" @click.stop="deleteSession(session.id)">
<div
v-if="confirmingDeleteId === session.id"
class="history-item-delete history-item-delete-confirm"
:title="$L('确认删除')"
@click.stop="deleteSession(session.id)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
</div>
<div
v-else
class="history-item-delete"
:title="$L('删除')"
@click.stop="askDeleteSession(session.id)">
<i class="taskfont">&#xe6e5;</i>
</div>
</div>
@ -142,18 +154,25 @@
<div
v-if="response.rawOutput && response.status === 'completed'"
class="ai-assistant-output-feedback">
<span
class="ai-assistant-feedback-btn"
:title="$L('复制')"
@click="copyResponse(response)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.0"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
</span>
<span
:class="['ai-assistant-feedback-btn', {active: response.feedback === 'like'}]"
:title="$L('有帮助')"
@click="submitFeedback(response, 'like')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path stroke-linecap="round" stroke-linejoin="round" d="M6.633 10.25c.806 0 1.533-.446 2.031-1.08a9.041 9.041 0 0 1 2.861-2.4c.723-.384 1.35-.956 1.653-1.715a4.498 4.498 0 0 0 .322-1.672V3a.75.75 0 0 1 .75-.75 2.25 2.25 0 0 1 2.25 2.25c0 1.152-.26 2.243-.723 3.218-.266.558.107 1.282.725 1.282h3.126c1.026 0 1.945.694 2.054 1.715.045.422.068.85.068 1.285a11.95 11.95 0 0 1-2.649 7.521c-.388.482-.987.729-1.605.729H13.48c-.483 0-.964-.078-1.423-.23l-3.114-1.04a4.501 4.501 0 0 0-1.423-.23H5.904m1.729-7.5a8.97 8.97 0 0 0-.621 4.72c.063.504.123 1.012.182 1.52.04.35.05.703.05 1.06v.27c0 .415-.336.75-.75.75h-2.25a.75.75 0 0 1-.75-.75v-7.5a.75.75 0 0 1 .75-.75h2.25c.414 0 .75.335.75.75v.198Z"/></svg>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.0"><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z"/><path d="M7 10v12"/></svg>
</span>
<span
:class="['ai-assistant-feedback-btn ai-assistant-feedback-btn-down', {active: response.feedback === 'dislike'}]"
:title="$L('没帮助')"
@click="submitFeedback(response, 'dislike')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path stroke-linecap="round" stroke-linejoin="round" d="M6.633 10.25c.806 0 1.533-.446 2.031-1.08a9.041 9.041 0 0 1 2.861-2.4c.723-.384 1.35-.956 1.653-1.715a4.498 4.498 0 0 0 .322-1.672V3a.75.75 0 0 1 .75-.75 2.25 2.25 0 0 1 2.25 2.25c0 1.152-.26 2.243-.723 3.218-.266.558.107 1.282.725 1.282h3.126c1.026 0 1.945.694 2.054 1.715.045.422.068.85.068 1.285a11.95 11.95 0 0 1-2.649 7.521c-.388.482-.987.729-1.605.729H13.48c-.483 0-.964-.078-1.423-.23l-3.114-1.04a4.501 4.501 0 0 0-1.423-.23H5.904m1.729-7.5a8.97 8.97 0 0 0-.621 4.72c.063.504.123 1.012.182 1.52.04.35.05.703.05 1.06v.27c0 .415-.336.75-.75.75h-2.25a.75.75 0 0 1-.75-.75v-7.5a.75.75 0 0 1 .75-.75h2.25c.414 0 .75.335.75.75v.198Z"/></svg>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.0"><path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z"/><path d="M17 14V2"/></svg>
</span>
<span v-if="response.createdAt" class="ai-assistant-output-time" :title="$A.dayjs(response.createdAt).format('YYYY-MM-DD HH:mm:ss')">{{ formatResponseTime(response.createdAt) }}</span>
</div>
</div>
</div>
@ -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,