feat(web): 添加消息复制功能并优化反馈组件图标

This commit is contained in:
Wenjie Zhang 2025-12-23 01:51:05 +08:00
parent abf4414a0f
commit dede6679db
7 changed files with 99 additions and 34 deletions

View File

@ -11,10 +11,14 @@
- 文档解析部分的 markdown 中的图片替换为内部可访问的链接 (2/4)
- chat_model 的 call 需要异步
- 优化chunk逻辑移除 QA 分割,集成到普通分块中
- 考虑修改附件的处理逻辑,考虑使用文件系统,将附件解析后放到文件系统中,智能体按需读取,用户可以使用 @ 来引用附件,例如 @file:reports.md 相较于现在的处理逻辑感觉会更加自然一点。在此之前可能还要考虑文件系统的后端支持问题
- skills 如何实现还需要继续调研
### Bugs
- 部分异常状态下,智能体的模型名称出现重叠[#279](https://github.com/xerrors/Yuxi-Know/issues/279)
- 部分 local 的 mcp server 无法正常加载,但是建议在项目外部启动 mcp 服务器,然后通过 sse 的方式使用。
- DeepSeek 官方接口适配会出现问题
- 部分推理后端与 langchain 的适配有问题:
- 目前的知识库的图片存在公开访问风险
- 工具传递给模型的时候使用英文但部分模型不支持中文函数名如gpt-4o-mini
- 首页加载的问题

View File

@ -12,8 +12,8 @@ from src.utils import logger
@tool
async def text_to_img_qwen(text: str) -> str:
"""(用来测试文件存储)使用模型生成图片, 会返回图片的URL"""
async def text_to_img_demo(text: str) -> str:
"""【测试用】使用模型生成图片, 会返回图片的URL"""
url = "https://api.siliconflow.cn/v1/images/generations"
@ -27,13 +27,13 @@ async def text_to_img_qwen(text: str) -> str:
response = requests.post(url, json=payload, headers=headers)
response_json = response.json()
except Exception as e:
logger.error(f"Failed to generate image with Kolors: {e}")
logger.error(f"Failed to generate image with: {e}")
raise ValueError(f"Image generation failed: {e}")
try:
image_url = response_json["images"][0]["url"]
except (KeyError, IndexError, TypeError) as e:
logger.error(f"Failed to parse image URL from Kolors response: {e}, {response_json=}")
logger.error(f"Failed to parse image URL from response: {e}, {response_json=}")
raise ValueError(f"Image URL extraction failed: {e}")
# 2. Upload to MinIO (Simplified)
@ -51,6 +51,6 @@ async def text_to_img_qwen(text: str) -> str:
def get_tools() -> list[Any]:
"""获取所有可运行的工具(给大模型使用)"""
tools = get_buildin_tools()
tools.append(text_to_img_qwen)
tools.append(text_to_img_demo)
tools.extend(get_mysql_tools())
return tools

View File

@ -107,12 +107,12 @@
--color-text-tertiary: rgba(255, 255, 255, 0.45);
/* Shadow System - 深色模式阴影系统 */
--shadow-0: rgba(0, 0, 0, 0.1);
--shadow-1: rgba(0, 0, 0, 0.2);
--shadow-2: rgba(0, 0, 0, 0.3);
--shadow-3: rgba(0, 0, 0, 0.4);
--shadow-4: rgba(0, 0, 0, 0.5);
--shadow-5: rgba(0, 0, 0, 0.6);
--shadow-0: rgba(255, 255, 255, 0.02);
--shadow-1: rgba(255, 255, 255, 0.05);
--shadow-2: rgba(255, 255, 255, 0.08);
--shadow-3: rgba(255, 255, 255, 0.12);
--shadow-4: rgba(255, 255, 255, 0.16);
--shadow-5: rgba(255, 255, 255, 0.20);
--color-trans-light: rgba(0, 0, 0, 0.85);

View File

@ -684,7 +684,8 @@ const resetConfig = async () => {
background-color: var(--gray-25);
padding: 12px;
border-radius: 8px;
box-shadow: 0px 1px 1px var(--shadow-2);
border: 1px solid var(--gray-100);
// box-shadow: 0px 0px 2px var(--shadow-3);
:deep(label.form_item_model) {
font-weight: 600;

View File

@ -4,6 +4,10 @@
</div>
<div class="message-box" :class="[message.type, customClasses]">
<!-- 用户消息 -->
<div v-if="message.type === 'human'" class="message-copy-btn human-copy" @click="copyToClipboard(message.content)" :class="{ 'is-copied': isCopied }">
<Check v-if="isCopied" size="14" />
<Copy v-else size="14" />
</div>
<p v-if="message.type === 'human'" class="message-text">{{ message.content }}</p>
<p v-else-if="message.type === 'system'" class="message-text-system">{{ message.content }}</p>
@ -102,7 +106,7 @@
import { computed, ref } from 'vue';
import { CaretRightOutlined, ThunderboltOutlined, LoadingOutlined } from '@ant-design/icons-vue';
import RefsComponent from '@/components/RefsComponent.vue'
import { Loader, CircleCheckBig } from 'lucide-vue-next';
import { Loader, CircleCheckBig, Copy, Check } from 'lucide-vue-next';
import { ToolResultRenderer } from '@/components/ToolCallingResult'
import { useAgentStore } from '@/stores/agent'
import { useInfoStore } from '@/stores/info'
@ -150,6 +154,21 @@ const editorRef = ref()
const emit = defineEmits(['retry', 'retryStoppedMessage', 'openRefs']);
//
const isCopied = ref(false);
const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text);
isCopied.value = true;
setTimeout(() => {
isCopied.value = false;
}, 2000);
} catch (err) {
console.error('Failed to copy: ', err);
}
};
//
const reasoningActiveKey = ref(['hide']);
const expandedToolCalls = ref(new Set()); //
@ -321,6 +340,38 @@ const toggleToolCall = (toolCallId) => {
white-space: pre-line;
}
.message-copy-btn {
cursor: pointer;
color: var(--gray-400);
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
flex-shrink: 0;
&:hover {
color: var(--main-color);
}
&.is-copied {
color: var(--color-success-500);
opacity: 1;
}
&.human-copy {
position: absolute;
left: -28px;
bottom: 8px;
}
}
&:hover {
.message-copy-btn {
opacity: 1;
}
}
.message-text-system {
max-width: 100%;
margin-bottom: 0;

View File

@ -264,7 +264,7 @@ onUnmounted(() => {
backdrop-filter: blur(4px);
padding: 2px;
border-radius: 8px;
box-shadow: 0 0px 4px var(--shadow-3);
box-shadow: 0 0 4px 0px var(--shadow-2);
}
:deep(.ant-input-affix-wrapper) {
@ -272,10 +272,10 @@ onUnmounted(() => {
border-radius: 6px;
border-color: transparent;
box-shadow: none;
background: rgba(255, 255, 255, 0.6);
background: var(--color-trans-light);
&:hover, &:focus, &-focused {
background: #fff;
background: var(--main-0);
border-color: var(--primary-color);
}

View File

@ -8,8 +8,7 @@
@click="likeThisResponse(msg)"
:title="feedbackState.hasSubmitted && feedbackState.rating === 'like' ? '已点赞' : '点赞'"
>
<LikeFilled v-if="feedbackState.rating === 'like'" />
<LikeOutlined v-else />
<ThumbsUp size="12" :fill="feedbackState.rating === 'like' ? 'currentColor' : 'none'" />
</span>
<span
class="item btn"
@ -17,23 +16,24 @@
@click="dislikeThisResponse(msg)"
:title="feedbackState.hasSubmitted && feedbackState.rating === 'dislike' ? '已点踩' : '点踩'"
>
<DislikeFilled v-if="feedbackState.rating === 'dislike'" />
<DislikeOutlined v-else />
<ThumbsDown size="12" :fill="feedbackState.rating === 'dislike' ? 'currentColor' : 'none'" />
</span>
<!-- 模型名称 -->
<span v-if="showKey('model') && getModelName(msg)" class="item" @click="console.log(msg)">
<BulbOutlined /> {{ getModelName(msg) }}
<Bot size="12" /> {{ getModelName(msg) }}
</span>
<!-- 复制 -->
<span
v-if="showKey('copy')"
class="item btn" @click="copyText(msg.content)" title="复制"><CopyOutlined />
class="item btn" @click="copyText(msg.content)" title="复制">
<Check v-if="isCopied" size="12" />
<Copy v-else size="12" />
</span>
<!-- 重试 -->
<span
v-if="showKey('regenerate')"
class="item btn" @click="regenerateMessage()" title="重新生成"><ReloadOutlined />
class="item btn" @click="regenerateMessage()" title="重新生成"><RotateCcw size="12" />
</span>
</div>
</div>
@ -63,14 +63,13 @@ import { ref, computed, reactive, onMounted } from 'vue'
import { useClipboard } from '@vueuse/core'
import { message } from 'ant-design-vue'
import {
CopyOutlined,
BulbOutlined,
ReloadOutlined,
LikeOutlined,
LikeFilled,
DislikeOutlined,
DislikeFilled,
} from '@ant-design/icons-vue'
ThumbsUp,
ThumbsDown,
Bot,
Copy,
Check,
RotateCcw,
} from 'lucide-vue-next'
import { agentApi } from '@/apis'
const emit = defineEmits(['retry', 'openRefs']);
@ -110,12 +109,19 @@ const showKey = (key) => {
return props.showRefs.includes(key)
}
//
const isCopied = ref(false)
// copy
const copyText = async (text) => {
if (isSupported) {
try {
await copy(text)
message.success('文本已复制到剪贴板')
isCopied.value = true
setTimeout(() => {
isCopied.value = false
}, 2000)
} catch (error) {
console.error('复制失败:', error)
message.error('复制失败,请手动复制')
@ -255,11 +261,16 @@ const cancelDislike = () => {
.item {
background: var(--gray-50);
color: var(--gray-700);
padding: 2px 8px;
padding: 6px 8px;
border-radius: 8px;
font-size: 13px;
user-select: none;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
line-height: 1;
&.btn {
cursor: pointer;
@ -272,8 +283,6 @@ const cancelDislike = () => {
// Disabled state - when feedback has been submitted
&.disabled {
cursor: not-allowed;
opacity: 0.7;
&:hover {
background: var(--gray-50);