Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/develop-guides/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,15 @@
### 0.6.2 开发记录

<!-- 0.6.2 的内容请放在这里 -->
- 重构输入框与历史消息中的 `@` 提及药丸(Mention Pills)视觉系统与交互体验:移除强硬色块底色与粗边框,采用高雅通透的 Borderless Card(无界/轻量卡片)极简皮肤。新增对深色(暗色)模式的极致自适应适配,通过将药丸背景和边框切换为多态 CSS 变量(`var(--dark-5)`、`var(--dark-10)`)并微调 MCP 实体的前景色彩为高对比度极客粉紫(`#b37feb`),彻底修复了深色模式下药丸因硬编码颜色完全不可见的视觉缺陷;通过字重(550)与各实体高饱和度专属主题色提供卓越的视觉锚点;文件类型左侧自动解析后缀名并渲染三色微发光的 CSS 迷你语法高亮代码行,其他类型统一升级为 `1.8` 极细描边精致 SVG 线框;引入 `height: 22px` 物理高度锁定与 line-height 控制,对图标及删除键做像素级(Pixel-perfect)垂直对齐纠偏,彻底解决浏览器亚像素舍入错位;删除按钮采用绝对定位并配套 `padding-right` 呼吸动效,实现 Hover 动态向右撑开交互;引入 CSS 3.0 选择器 `:has(.pill-close)`,使共享 Less 样式自动兼容“输入框 Hover 展开”与“消息历史只读静美”两种业务态,并将原本重复两份近 300 行的 Less 代码优雅地抽取至通用 `mention-pill.less` 文件,清理了输入框中多余的重复触发注释。全面加固了安全性(P0 XSS 防御),在 `renderUserMessage` 解析流中对提取出的 `type`、`value`、`label` 做强制 `escapeHtml` 转义,物理杜绝了通过 `v-html` 属性注入进行 XSS 攻击的安全隐患;提取了 `createPillElement` 通用 DOM 组装工厂函数,彻底去除了原本在 3 处冗余且高度重复的药丸节点构建逻辑;在文本层 `.pill-text` 增加 `padding-right: 4px` 极窄弹性渲染缓冲槽,彻底解决因 `overflow: hidden` 做长文本省略截断时导致英文斜向或非平衡字形字母(如 `.py` 的尾字母 `y`)最右边缘像素被物理裁剪缺角的微小视觉缺陷。
- 建立全新「无界全局文件预览系统」:彻底消除了点击药丸和交付件(Artifacts)必须强制弹出左侧工作台面板的繁重交互,解耦并设计了自包含的高内聚全局单例弹窗组件 `AgentFilePreviewModal.vue`;通过 Pinia Store 建立响应式的全局预览触发信号源,实现了多触发端(药丸点击、交付件卡片预览、窄屏文件树节点选中)的极致 DRY 重构,彻底清洗并删除了 `AgentChatComponent.vue`、`AgentArtifactsCard.vue` 与 `AgentPanel.vue` 中总计几百行重复的私有 Modal 节点、API 调用逻辑与 Blob 内存销毁代码;将 `parseDownloadFilename` 文件名解析函数抽取至公共工具包 `file_utils.js`,达成极致的前端代码重构去重。
- 封堵双 Watcher 冲突 Bug 与性能守护:为 `AgentPanel.vue` 中的全局预览 Watcher 新增了 `useInlinePreview.value` 内联拦截守卫,彻底解决了宽屏模式下点击药丸产生双倍 API 重复调用的严重缺陷,完美隔离了宽窄屏下各自的预览高亮界限。
- 下放扩展管理权限:普通管理员现在可进入扩展管理并完整管理 Tools、MCP、SubAgent、Skills;同步放开 Skill 管理接口权限并补充权限测试。
- 调整 Agent 知识库默认选择:未显式配置知识库时默认启用当前用户可访问的全部知识库,显式保存空列表仍表示不启用知识库。
- 移除知识库沙盒文件系统映射:不再通过 `/home/gem/kbs` 暴露知识库文件树,Agent 继续使用 `query_kb` 与 `open_kb_document` 访问知识库内容。
- 优化评估基准自动生成:仅支持 commonrag/Milvus 知识库,默认参考 chunks 数量改为 1;多 chunk 场景复用知识库向量检索选择相似 chunks,不再对全量 chunks 重新计算 embedding,并移除前端 Embedding 模型选择。
- 修复知识库文档入库状态回退:当已解析文件缺失 `markdown_file` 解析产物时,索引流程会将文件状态恢复为未解析,便于重新解析而不是停留在索引失败。
- 优化 `@` 文件 mention 候选搜索:前端废弃全量递归遍历,改为后端 `/api/mention/search` 接口 + Redis `ormsgpack` 二进制缓存(TTL 60s,上限 10 万条);扫描加宽度/深度/黑名单三重剪枝防卡死;搜索结果按文件名/前缀/路径加权排序,最多返回 50 条;前端加防抖(250ms)+ `AbortController` 防竞态,高亮渲染使用 DOMPurify 防 XSS。
- 优化 `@` 文件 mention 候选搜索与药丸插入并打通跨生态复制粘贴闭环:前端废弃全量递归遍历,改为后端 `/api/mention/search` 接口 + Redis `ormsgpack` 二进制缓存(TTL 60s,上限 10 万条);扫描加宽度/深度/黑名单三重剪枝防卡死;搜索结果按文件名/前缀/路径加权排序,最多返回 50 条;前端加防抖(250ms)+ `AbortController` 防竞态,高亮渲染使用 DOMPurify 防 XSS;修复了当焦点聚焦在文本框内 `@` 字符或光标发生位移时,误触发重复搜索的性能开销,在 `insertMention` 首部加入物理熔断,并建立 `mentionPopupVisible` 的统一重置 watch 机制以精准控制生命周期和缓存熔断;在 `handleKeyUp` 中增加方向键及 Home/End 键在文本中移动光标时的自适应检测,实现与鼠标点击完全相同的交互一致性。将原本堆叠在 `MessageInputComponent.vue` 中的选区明文翻译引擎、HTML/Text 双重粘贴解析转换引擎等非 UI 业务逻辑,优雅且物理抽离至通用工具库 `mention.js`,瘦身组件近 220 行冗余计算,大幅提升了核心解包与去重算法的独立可测试性。在 `parseMentionText` 算法末尾处,深度追加了针对剩余文本的首空格智能剥离逻辑,彻底修复了气泡历史消息点击复制后粘贴至输入框产生双空格排版的历史缺陷,确保渲染出呼吸般完美的单空格
- 调整知识库思维导图后端结构:将思维导图路由文件重命名为知识库语义更明确的 router,并把文件列表整理、提示词构建、AI JSON 解析等纯逻辑下沉到知识库 utils。
- 收敛知识库评估后端结构:将评估指标、单题评估、答案生成提示词和自动基准生成算法下沉到 `knowledge/eval`,`EvaluationService` 保留任务、文件和持久化编排职责。
- 新增个人工作区预览与管理:提供独立于对话 thread 的用户级 workspace API,并增加“工作区”页面,用于浏览个人 workspace 文件、预览 Markdown/文本/代码/图片/PDF;支持新建文件夹、上传文件、下载文件、删除文件/文件夹和多选删除;工作区预览支持 Markdown/TXT 在右侧预览框内切换编辑并保存,其他格式和非工作区预览默认只读;知识库与团队空间入口先展示到占位层级;默认创建 `agents/AGENTS.md`,并在 Agent 执行时将其内容追加到系统提示词。
Expand Down
203 changes: 203 additions & 0 deletions web/src/assets/css/mention-pill.less
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
/* @/assets/css/mention-pill.less */

:deep(.mention-pill) {
--mention-mcp-color: var(--color-primary-700); /* 极客莫兰迪主色 (一等公民,自动适配亮暗) */
--mention-mcp-hover-bg: var(--color-primary-50);
--mention-mcp-hover-border: var(--color-primary-100);

display: inline-flex;
align-items: center;
gap: 5px;
border-radius: 6px;
height: 22px; /* 绝对锁定药丸物理高度为 22px */
box-sizing: border-box; /* 启用 border-box 确保总高度严丝合缝 */
padding: 0 6px; /* 物理高度已锁定,上下 padding 归零,左右保持对称的 6px */
margin: 0 3px;
font-size: 13px;
font-weight: 550; /* 调重字重以在无界/轻量卡片下提供卓越的实体聚焦锚点 */
line-height: 1; /* 极关键!缩减行高溢出,让 flex align-items 获得绝对垂直控制权 */
vertical-align: middle;
cursor: default;
user-select: none;
background-color: var(--dark-5); /* 极度清透的微弱半透明底色,自动适配明暗 */
border: 1px solid var(--dark-10); /* 极其隐约的超细淡灰色边框,自动适配明暗 */
position: relative; /* 注入相对定位,为删除按钮绝对定位铺垫 */
// 注入 padding-right 过渡,实现呼吸般的横向展开动画
transition:
background-color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
border-color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
padding-right 0.2s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.1s ease;

&:hover {
background-color: var(--dark-10);
border-color: var(--dark-25);
}

// 极客 CSS 迷你高亮代码行样式矩阵
.mini-code-icon {
display: inline-flex;
flex-direction: column;
gap: 1.5px;
width: 12px;
height: 12px;
justify-content: center;
align-items: flex-start;
flex-shrink: 0;
position: relative;
top: 0.5px; /* 调低图标 1px,对齐中文视觉重心 */

.mini-code-line {
height: 2px;
border-radius: 1px;
display: block;

&.mini-code-line-1 {
width: 11px;
background-color: #569cd6; /* VS Code 经典的优雅蓝色 (Keyword 属性) */
box-shadow: 0 0 3px rgba(86, 156, 214, 0.45); /* 微弱发光光晕以增强 12px 极小空间的色彩立体感 */
}
&.mini-code-line-2 {
width: 7px;
background-color: #4ec9b0; /* 薄荷绿 (Type/Methods 属性) */
box-shadow: 0 0 3px rgba(78, 201, 176, 0.45);
}
&.mini-code-line-3 {
width: 9px;
background-color: #ce9178; /* 橙红色 (Strings/Constants 属性) */
box-shadow: 0 0 3px rgba(206, 145, 120, 0.45);
}
}
}

.pill-icon {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
flex-shrink: 0;
height: 12px; /* 显式物理高度,同 svg 保持绝对一致 */
position: relative;
top: 0.5px; /* 调低图标 1px,对齐中文视觉重心 */

svg {
width: 12px;
height: 12px;
color: inherit;
display: block;
transition: transform 0.2s ease;
}
}

.pill-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 200px;
padding-right: 4px; /* 极其关键的 4px 充足缓冲,物理容纳英文字体 y, f 等侧斜字母的右溢出笔画,彻底规避 overflow:hidden 强行斩断字体的缺陷,更显大方 */
letter-spacing: -0.01em;
display: inline-block; /* 保证不受行高溢出干扰 */
line-height: 1; /* 强制单行行高为 1,消除多语言字体包围盒计算偏差 */
position: relative;
top: -0.5px; /* 文字稍微往上提,抵消中文 baseline 偏下问题 */
}

// 绝对定位的精致删除按钮 (Notion 级隐藏与唤醒)
.pill-close {
position: absolute;
right: 4px;
top: 3px; /* 22px 容器减去 14px 按钮高度除以 2,得到 3px 的完美物理垂直居中,杜绝 translateY 亚像素偏移 */
transform: scale(0.7); /* 默认微型缩小,与透明度配合营造优雅浮现感 */
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border-radius: 50%;
color: inherit;
opacity: 0;
pointer-events: none; /* 默认未激活时穿透鼠标,彻底防误触 */
cursor: pointer;
transition:
opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
background-color 0.2s ease;
margin-left: 0;

&:hover {
opacity: 1 !important;
background-color: var(--dark-10);
}

&:active {
transform: scale(0.85) !important; /* 点击时物理弹性微按压缩放反馈 */
}
}

// hover 展开删除按钮(仅当有 hover 时且药丸内含 pill-close 按钮)
&:hover {
&:has(.pill-close) {
padding-right: 22px; /* Hover 时右侧内边距优雅撑开,供删除按钮浮现而绝不挤压文字 */

.pill-close {
opacity: 0.6;
transform: scale(1); /* 优雅淡入并弹性膨胀归位 */
pointer-events: auto; /* 仅在显示时允许鼠标事件 */
}
}
}

// 极客通透色相高亮专属主题配比 (只高亮纯净的前景色,不依赖沉重背景色)
&.file-pill {
color: var(--color-info-700); /* 科技深邃蓝 */
cursor: pointer;

&:hover {
background-color: var(--color-info-50);
border-color: var(--color-info-100);
}

&:active {
transform: scale(0.96);
}
}

&.knowledge-pill {
color: var(--color-success-700); /* 薄荷森林绿 */

&:hover {
background-color: var(--color-success-50);
border-color: var(--color-success-100);
}
}

&.mcp-pill {
color: var(--mention-mcp-color);

&:hover {
background-color: var(--mention-mcp-hover-bg);
border-color: var(--mention-mcp-hover-border);
}
}

&.skill-pill {
color: var(--color-warning-700); /* 温暖金橘橙 */

&:hover {
background-color: var(--color-warning-50);
border-color: var(--color-warning-100);
}
}

&.subagent-pill {
color: var(--color-accent-700); /* 高透青色 */

&:hover {
background-color: var(--color-accent-50);
border-color: var(--color-accent-100);
}
}
}

// 针对暗色模式的微调
// 提及药丸已实现 100% 纯正 Token 化,明暗模式下均由底层 base.css 和 base.dark.css 的系统级 CSS 变量全自动无缝响应,无需任何本地冗余重写。
125 changes: 10 additions & 115 deletions web/src/components/AgentArtifactsCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
<div class="artifacts-panel-inner">
<div class="output-list">
<div v-for="file in normalizedArtifacts" :key="file.path" class="output-item">
<div class="item-main" @click="openPreview(file)">
<div class="item-main" @click="openGlobalPreview(file)">
<component
:is="getFileIcon(file.path)"
class="item-icon"
Expand Down Expand Up @@ -44,39 +44,18 @@
</div>
</div>
</div>

<a-modal
v-model:open="modalVisible"
width="800px"
:style="{ maxWidth: '90vw', top: '5vh' }"
:bodyStyle="{ maxHeight: '90vh', overflow: 'auto' }"
:footer="null"
:closable="false"
wrapClassName="agent-file-preview-modal"
@cancel="closePreview"
>
<AgentFilePreview
:file="currentFile"
:filePath="currentFilePath"
:showClose="true"
:showDownload="true"
:showFullscreen="true"
@download="downloadFile"
@close="closePreview"
/>
</a-modal>
</section>
</template>

<script setup>
import { computed, onUnmounted, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { message } from 'ant-design-vue'
import { ChevronDown, Download, FolderOutput, LoaderCircle, Save } from 'lucide-vue-next'
import { threadApi } from '@/apis/agent_api'
import AgentFilePreview from '@/components/AgentFilePreview.vue'
import { getFileIcon, getFileIconColor } from '@/utils/file_utils'
import { getFileIcon, getFileIconColor, parseDownloadFilename } from '@/utils/file_utils'
import { getPreviewTypeByPath } from '@/utils/file_preview'
import { downloadViewerFile, getViewerFileContent } from '@/apis/viewer_filesystem'
import { downloadViewerFile } from '@/apis/viewer_filesystem'
import { useChatUIStore } from '@/stores/chatUI'

const props = defineProps({
artifacts: {
Expand All @@ -98,6 +77,8 @@ const props = defineProps({
})
const emit = defineEmits(['saved'])

const chatUIStore = useChatUIStore()

const normalizedArtifacts = computed(() =>
(props.artifacts || [])
.filter((path) => typeof path === 'string' && path.trim())
Expand All @@ -114,95 +95,13 @@ const normalizedArtifacts = computed(() =>
)
const artifactsCountLabel = computed(() => `${normalizedArtifacts.value.length} 个文件`)
const expanded = ref(false)

const modalVisible = ref(false)
const currentFile = ref(null)
const currentFilePath = ref('')
const savingState = ref({})

const parseDownloadFilename = (contentDisposition) => {
if (!contentDisposition) return ''

const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i)
if (utf8Match && utf8Match[1]) {
try {
return decodeURIComponent(utf8Match[1])
} catch (error) {
console.warn('解析 UTF-8 文件名失败:', error)
}
}

const asciiMatch = contentDisposition.match(/filename="?([^";]+)"?/i)
return asciiMatch?.[1] || ''
}

const revokeCurrentPreviewUrl = () => {
const previewUrl = currentFile.value?.previewUrl
if (previewUrl) {
window.URL.revokeObjectURL(previewUrl)
}
}

const closePreview = () => {
revokeCurrentPreviewUrl()
modalVisible.value = false
currentFile.value = null
currentFilePath.value = ''
}

const openPreview = async (file) => {
if (!props.threadId || !file?.path) return

revokeCurrentPreviewUrl()
currentFilePath.value = file.path
currentFile.value = {
...file,
content: 'Loading...',
supported: true,
previewType: 'text',
message: '',
previewUrl: ''
}
modalVisible.value = true

try {
const res = await getViewerFileContent(
props.threadId,
file.path,
props.agentId,
props.agentConfigId
)
const previewType = res?.preview_type || 'text'
let previewUrl = ''

if ((previewType === 'image' || previewType === 'pdf') && res?.supported) {
const response = await downloadViewerFile(
props.threadId,
file.path,
props.agentId,
props.agentConfigId
)
const blob = await response.blob()
previewUrl = window.URL.createObjectURL(blob)
}

currentFile.value = {
...file,
content: res?.content ?? '',
supported: res?.supported !== false,
previewType,
message: res?.message || '',
previewUrl
}
} catch (error) {
currentFile.value = {
...file,
content: `Error loading file: ${error?.message || 'unknown error'}`,
supported: false,
previewType: 'unsupported',
message: error?.message || '文件预览失败',
previewUrl: ''
}
const openGlobalPreview = (file) => {
if (file?.path) {
chatUIStore.triggerFilePreview(file.path)
}
}

Expand Down Expand Up @@ -253,10 +152,6 @@ const saveToWorkspace = async (file) => {
}
}

onUnmounted(() => {
revokeCurrentPreviewUrl()
})

watch(
() => [props.threadId, normalizedArtifacts.value.map((file) => file.path).join('|')],
() => {
Expand Down
Loading