feat(chat): unify resource chips and browse recent files in @ mention

Add recent=true to knowledge search so the mention picker can list recent
files without a keyword, and consolidate KB/FAQ/file chip styling across
chat input and message history.
This commit is contained in:
wizardchen
2026-06-24 20:30:56 +08:00
committed by lyingbug
parent babe3f2315
commit 122408b283
8 changed files with 194 additions and 224 deletions
+5 -2
View File
@@ -458,16 +458,19 @@ export function searchKnowledge(
offset = 0,
limit = 20,
fileTypes?: string[],
options?: { agent_id?: string }
options?: { agent_id?: string; recent?: boolean }
) {
const query = new URLSearchParams();
query.set('keyword', keyword);
if (keyword) {
query.set('keyword', keyword);
}
query.set('offset', String(offset));
query.set('limit', String(limit));
if (fileTypes && fileTypes.length > 0) {
query.set('file_types', fileTypes.join(','));
}
if (options?.agent_id) query.set('agent_id', options.agent_id);
if (options?.recent) query.set('recent', 'true');
return get(`/api/v1/knowledge/search?${query.toString()}`);
}
+48 -66
View File
@@ -1099,16 +1099,19 @@ const loadMentionItems = async (q: string, resetIndex = true, append = false) =>
const toolsAllowFiles = !hasAgentConfig.value || toolsConsumeFiles(agentAllowedTools.value);
const shouldLoadFiles = kbModeAllowsFiles && toolsAllowFiles;
// 后端 /knowledge/search 要求非空 keyword;打开 @ 面板时仅展示本地 KB 列表,
// 用户输入搜索词后再拉取文件结果。
// 空关键词时显式请求最近文件;有关键词时返回匹配文件。
// `recent=true` 只用于浏览态,避免其他搜索调用漏传关键词时静默退化为最近列表。
const fileSearchKeyword = q.trim();
if (shouldLoadFiles && fileSearchKeyword) {
if (shouldLoadFiles) {
mentionLoading.value = true;
try {
const fileTypesParam = agentSupportedFileTypes.value.length > 0 ? agentSupportedFileTypes.value : undefined;
const sourceTenantId = settingsStore.selectedAgentSourceTenantId;
const agentId = selectedAgentId.value;
const searchOptions = sourceTenantId && agentId ? { agent_id: agentId } : undefined;
const searchOptions = {
...(sourceTenantId && agentId ? { agent_id: agentId } : {}),
recent: !fileSearchKeyword,
};
const res: any = await searchKnowledge(
fileSearchKeyword,
mentionOffset.value,
@@ -2366,6 +2369,8 @@ const getImgSrc = (url: string) => {
}
</script>
<style scoped lang="less">
@import './css/chat-resource-chips.less';
.answers-input {
position: absolute;
z-index: 99;
@@ -2418,18 +2423,24 @@ const getImgSrc = (url: string) => {
}
.mention-chip {
.chat-resource-chip-surface();
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 6px 3px 5px;
border-radius: 6px;
gap: 5px;
min-height: 26px;
padding: 3px 7px 3px 6px;
border-radius: var(--td-radius-medium, 6px);
box-sizing: border-box;
font-size: 12px;
font-weight: 500;
cursor: default;
transition: background 0.2s, border-color 0.2s, box-shadow 0.2s;
border: .5px solid transparent;
color: var(--td-text-color-primary, #1f2937);
line-height: 1.3;
transition: background 0.15s, border-color 0.15s;
line-height: 18px;
&:hover {
.chat-resource-chip-hover();
}
}
.mention-chip__icon-wrap {
@@ -2441,7 +2452,6 @@ const getImgSrc = (url: string) => {
min-width: 0;
align-items: center;
justify-content: center;
border-radius: 3px;
}
.mention-chip__icon {
@@ -2493,7 +2503,7 @@ const getImgSrc = (url: string) => {
line-height: 1;
font-weight: 400;
cursor: pointer;
opacity: 0.45;
opacity: 0.5;
transition: opacity 0.15s, background 0.15s, color 0.15s;
color: currentColor;
flex-shrink: 0;
@@ -2505,72 +2515,39 @@ const getImgSrc = (url: string) => {
.mention-chip__remove:hover {
opacity: 1;
background: rgba(0, 0, 0, 0.08);
background: var(--td-bg-color-component);
color: var(--td-text-color-primary, #1f2937);
}
/* 知识库:浅绿/青色调 */
/* 标签表面保持中性,仅用图标颜色表达资源类型。 */
.mention-chip--kb {
background: rgba(5, 192, 95, 0.08);
border-color: rgba(5, 192, 95, 0.25);
color: var(--td-text-color-primary, #1f2937);
color: var(--td-text-color-primary);
}
.mention-chip--kb .mention-chip__icon-wrap {
background: rgba(5, 192, 95, 0.12);
color: var(--td-brand-color, #07c05f);
}
.mention-chip--kb:hover {
background: rgba(5, 192, 95, 0.12);
border-color: rgba(5, 192, 95, 0.35);
}
/* FAQ:浅紫/靛色调 */
.mention-chip--faq {
background: rgba(107, 114, 228, 0.08);
border-color: rgba(107, 114, 228, 0.25);
color: var(--td-text-color-primary, #1f2937);
color: var(--td-text-color-primary);
}
.mention-chip--faq .mention-chip__icon-wrap {
background: rgba(107, 114, 228, 0.12);
color: var(--td-brand-color);
color: var(--weknora-faq-color, #0052d9);
}
.mention-chip--faq:hover {
background: rgba(107, 114, 228, 0.12);
border-color: rgba(107, 114, 228, 0.35);
}
/* 文件:浅灰/中性色 */
.mention-chip--file {
background: var(--td-bg-color-secondarycontainer, #f3f4f6);
border-color: var(--td-component-stroke, #e5e7eb);
color: var(--td-text-color-primary, #1f2937);
color: var(--td-text-color-primary);
}
.mention-chip--file .mention-chip__icon-wrap {
background: rgba(107, 114, 128, 0.12);
color: var(--td-text-color-secondary, #6b7280);
}
.mention-chip--file:hover {
background: var(--td-bg-color-component, #e5e7eb);
border-color: var(--td-component-stroke, #d1d5db);
}
/* 智能体预配置:虚线边框区分 */
.mention-chip--agent {
border-style: dashed;
}
.mention-chip--agent.mention-chip--kb {
border-color: rgba(5, 192, 95, 0.4);
}
.mention-chip--agent.mention-chip--faq {
border-color: rgba(107, 114, 228, 0.4);
border-color: var(--td-component-border);
}
:deep(.t-textarea__inner) {
@@ -2709,16 +2686,18 @@ const getImgSrc = (url: string) => {
.kb-btn {
height: 28px;
padding: 0 10px;
min-width: auto;
width: 30px;
padding: 0;
min-width: 30px;
position: relative;
&.active {
background: rgba(16, 185, 129, 0.1);
background: var(--td-bg-color-secondarycontainer);
color: var(--td-brand-color);
box-shadow: inset 0 0 0 1px var(--td-component-stroke);
&:hover {
background: rgba(16, 185, 129, 0.15);
background: var(--td-bg-color-secondarycontainer-hover);
}
}
@@ -2731,23 +2710,26 @@ const getImgSrc = (url: string) => {
}
&.active:hover {
background: rgba(16, 185, 129, 0.1);
background: var(--td-bg-color-secondarycontainer);
}
}
}
.kb-count {
position: absolute;
top: -4px;
right: -4px;
min-width: 16px;
height: 16px;
padding: 0 4px;
top: -5px;
right: -5px;
min-width: 15px;
height: 15px;
padding: 0 3px;
background: var(--td-brand-color);
color: white;
font-size: 10px;
color: var(--td-text-color-anti, #fff);
font-size: 9px;
font-weight: 600;
border-radius: 8px;
line-height: 15px;
border: 2px solid var(--td-bg-color-container);
border-radius: var(--td-radius-round, 999px);
box-sizing: content-box;
display: flex;
align-items: center;
justify-content: center;
+61 -50
View File
@@ -8,8 +8,8 @@
:key="item.id"
placement="right-start"
trigger="hover"
:show-arrow="true"
:delay="[200, 0]"
:show-arrow="false"
:delay="[320, 80]"
:disabled="isScrolling"
:overlay-class-name="'mention-detail-popup'"
:overlay-inner-class-name="'mention-detail-popup-wrap'"
@@ -28,7 +28,7 @@
</div>
<div class="item-main">
<span class="name">{{ item.name }}</span>
<span class="count">({{ item.count || 0 }})</span>
<span class="count">{{ item.count || 0 }}</span>
</div>
</div>
<template #content>
@@ -82,8 +82,8 @@
:key="item.id"
placement="right-start"
trigger="hover"
:show-arrow="true"
:delay="[200, 0]"
:show-arrow="false"
:delay="[320, 80]"
:disabled="isScrolling"
:overlay-class-name="'mention-detail-popup'"
:overlay-inner-class-name="'mention-detail-popup-wrap'"
@@ -301,59 +301,62 @@ const scrollToItem = (index: number) => {
position: fixed;
z-index: 10000;
background: var(--td-bg-color-container, #fff);
border: 1px solid var(--td-component-border, #e7e9eb);
border-radius: var(--td-radius-medium, 6px);
box-shadow: var(--td-shadow-2, 0 3px 14px 2px rgba(0, 0, 0, 0.05));
width: 300px;
max-height: 360px;
border: 1px solid var(--td-component-stroke, #e7e9eb);
border-radius: var(--td-radius-extraLarge, 12px);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1), 0 2px 8px rgba(0, 0, 0, 0.04);
width: 288px;
max-height: 344px;
overflow-y: auto;
display: flex;
flex-direction: column;
padding: 4px 0;
padding: 6px 0;
}
.mention-group {
padding: 4px 0;
padding: 2px 0 5px;
}
.mention-group:not(:last-child) {
border-bottom: 1px solid var(--td-component-border, #f0f0f0);
border-bottom: 1px solid var(--td-component-stroke, #f0f0f0);
}
.mention-group-header {
padding: 8px 12px 4px;
padding: 7px 14px 5px;
font-size: var(--td-font-size-mark-small, 12px);
font-weight: 600;
color: var(--td-text-color-secondary, #999);
line-height: 18px;
color: var(--td-text-color-placeholder, #999);
}
.mention-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
margin: 0 4px;
min-height: 40px;
padding: 7px 10px;
margin: 1px 6px;
box-sizing: border-box;
cursor: pointer;
border-radius: var(--td-radius-default, 3px);
border-radius: var(--td-radius-medium, 6px);
color: var(--td-text-color-primary, #333);
font-size: var(--td-font-size-body-medium, 14px);
font-family: var(--app-font-family);
transition: background 0.2s cubic-bezier(0.38, 0, 0.24, 1);
transition: background 0.15s ease;
}
.mention-item:hover {
background: var(--td-bg-color-container-hover, #f3f3f3);
background: var(--td-bg-color-secondarycontainer, #f3f3f3);
}
.mention-item.active {
background: var(--td-brand-color-light, #e9f8ec);
color: var(--td-brand-color, #07c05f);
background: var(--td-bg-color-secondarycontainer, #f3f3f3);
color: var(--td-text-color-primary, #333);
}
.icon-wrap {
position: relative;
width: 20px;
height: 20px;
width: 18px;
height: 18px;
flex-shrink: 0;
}
@@ -361,11 +364,10 @@ const scrollToItem = (index: number) => {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: var(--td-radius-small, 2px);
width: 18px;
height: 18px;
flex-shrink: 0;
/* background: var(--td-bg-color-secondarycontainer, #f3f3f3); */
font-size: 16px;
}
/* 右下角组织角标:柔和小圆 + 绿色/灰色 icon,不刺眼 */
@@ -399,9 +401,11 @@ const scrollToItem = (index: number) => {
}
.mention-item.active .icon {
/* Active state keeps the colored icon but maybe adjusts background or just inherits */
background: transparent;
color: inherit;
color: var(--td-brand-color);
}
.mention-item.active .faq-icon {
color: var(--weknora-faq-color, #0052d9);
}
.item-main {
@@ -413,6 +417,7 @@ const scrollToItem = (index: number) => {
}
.name {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -425,9 +430,11 @@ const scrollToItem = (index: number) => {
}
.count {
margin-left: auto;
flex-shrink: 0;
font-size: var(--td-font-size-mark-small, 12px);
color: var(--td-text-color-secondary, #999);
font-variant-numeric: tabular-nums;
color: var(--td-text-color-placeholder, #999);
}
.org-name {
@@ -451,7 +458,7 @@ const scrollToItem = (index: number) => {
}
.empty {
padding: 24px 12px;
padding: 28px 16px;
text-align: center;
color: var(--td-text-color-placeholder, #999);
font-size: var(--td-font-size-body-medium, 14px);
@@ -467,16 +474,16 @@ const scrollToItem = (index: number) => {
<style>
/* 详情浮层在 Teleport 中,需全局样式 */
.mention-detail-popup-wrap.t-popup__content {
padding: 12px;
max-width: 320px;
min-width: 240px;
}
/* 箭头对齐到触发条目的垂直中心(条目高约36px,箭头应在距顶部约18px处) */
.mention-detail-popup.t-popup[data-popper-placement^="right"] > .t-popup__arrow {
top: 14px !important;
min-width: 220px;
max-width: 280px;
padding: 13px 14px;
border: 1px solid var(--td-component-stroke);
border-radius: var(--td-radius-large, 9px);
background: var(--td-bg-color-container);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.1), 0 2px 8px rgba(0, 0, 0, 0.04);
}
.mention-detail-content {
font-size: var(--td-font-size-body-medium, 14px);
font-size: var(--td-font-size-body-small, 12px);
color: var(--td-text-color-primary, #333);
line-height: 1.5;
}
@@ -494,26 +501,30 @@ const scrollToItem = (index: number) => {
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 6px;
margin-bottom: 8px;
}
.mention-detail-content .detail-name {
font-weight: 600;
font-size: var(--td-font-size-body-large, 14px);
font-size: var(--td-font-size-body-medium, 14px);
line-height: 20px;
word-break: break-word;
}
.mention-detail-content .detail-type-badge {
flex-shrink: 0;
padding: 2px 6px;
border-radius: var(--td-radius-small, 2px);
padding: 1px 6px;
border: 1px solid var(--td-component-stroke);
border-radius: var(--td-radius-medium, 6px);
font-size: var(--td-font-size-mark-small, 12px);
line-height: 18px;
}
.mention-detail-content .detail-type-badge.doc {
background: rgba(16, 185, 129, 0.1);
color: var(--td-success-color);
background: var(--td-bg-color-secondarycontainer);
color: var(--td-brand-color);
}
.mention-detail-content .detail-type-badge.faq {
background: rgba(0, 82, 217, 0.1);
color: var(--td-brand-color);
border-color: rgba(0, 82, 217, 0.16);
background: rgba(0, 82, 217, 0.08);
color: var(--weknora-faq-color, #0052d9);
}
.mention-detail-content .detail-desc {
margin: 0 0 8px;
@@ -532,7 +543,7 @@ const scrollToItem = (index: number) => {
color: var(--td-text-color-placeholder, #999);
display: flex;
flex-direction: column;
gap: 6px;
gap: 5px;
align-items: flex-start;
}
.mention-detail-content .detail-readonly-hint {
@@ -1,3 +1,5 @@
@import './chat-resource-chips.less';
.answer-toolbar {
display: flex;
align-items: center;
@@ -0,0 +1,64 @@
.chat-resource-chip-surface() {
background: var(--td-bg-color-secondarycontainer);
border: 1px solid var(--td-component-stroke);
color: var(--td-text-color-primary);
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--td-bg-color-container) 72%, transparent);
}
.chat-resource-chip-hover() {
background: var(--td-bg-color-secondarycontainer-hover);
border-color: var(--td-component-border);
}
.chat-mentioned-items(@justify: flex-start) {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: @justify;
max-width: 100%;
margin-bottom: 2px;
}
.chat-mentioned-tag() {
.chat-resource-chip-surface();
display: inline-flex;
align-items: center;
gap: 5px;
min-height: 26px;
max-width: 200px;
padding: 3px 8px;
border-radius: var(--td-radius-medium, 6px);
box-sizing: border-box;
font-size: 12px;
font-weight: 500;
line-height: 18px;
cursor: default;
&.kb-tag .tag_icon {
color: var(--td-brand-color);
}
&.faq-tag .tag_icon {
color: var(--weknora-faq-color, #0052d9);
}
&.file-tag .tag_icon {
color: var(--td-text-color-secondary);
}
.tag_icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 14px;
}
.tag_name {
overflow: hidden;
color: currentColor;
text-overflow: ellipsis;
white-space: nowrap;
}
}
+2 -50
View File
@@ -328,59 +328,11 @@ onBeforeUnmount(() => {
}
.mentioned_items {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-start;
max-width: 100%;
margin-bottom: 2px;
.chat-mentioned-items();
}
.mentioned_tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
max-width: 200px;
cursor: default;
transition: all 0.15s;
background: rgba(7, 192, 95, 0.06);
border: 1px solid rgba(7, 192, 95, 0.2);
color: var(--td-text-color-primary);
&.kb-tag {
.tag_icon {
color: var(--td-brand-color);
}
}
&.faq-tag {
.tag_icon {
color: var(--td-warning-color);
}
}
&.file-tag {
.tag_icon {
color: var(--td-text-color-secondary);
}
}
.tag_icon {
font-size: 13px;
display: flex;
align-items: center;
}
.tag_name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: currentColor;
}
.chat-mentioned-tag();
}
.fallback-icon-btn {
+4 -50
View File
@@ -147,6 +147,8 @@ const closePreImg = () => {
};
</script>
<style scoped lang="less">
@import '../../../components/css/chat-resource-chips.less';
.user_msg_container {
display: flex;
flex-direction: column;
@@ -156,59 +158,11 @@ const closePreImg = () => {
}
.mentioned_items {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-end;
max-width: 100%;
margin-bottom: 2px;
.chat-mentioned-items(flex-end);
}
.mentioned_tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 5px;
font-size: 12px;
font-weight: 500;
max-width: 200px;
cursor: default;
transition: all 0.15s;
background: rgba(7, 192, 95, 0.06);
border: 1px solid rgba(7, 192, 95, 0.2);
color: var(--td-text-color-primary);
&.kb-tag {
.tag_icon {
color: var(--td-brand-color);
}
}
&.faq-tag {
.tag_icon {
color: var(--td-warning-color);
}
}
&.file-tag {
.tag_icon {
color: var(--td-text-color-secondary);
}
}
.tag_icon {
font-size: 13px;
display: flex;
align-items: center;
}
.tag_name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: currentColor;
}
.chat-mentioned-tag();
}
.user_msg_container {
+8 -6
View File
@@ -1811,7 +1811,7 @@ func (h *KnowledgeHandler) UpdateImageInfo(c *gin.Context) {
// SearchKnowledge godoc
// @Summary Search knowledge
// @Description Search knowledge files by keyword. When agent_id is set (shared agent), scope is the agent's configured knowledge bases.
// @Description Search knowledge files by keyword. Pass recent=true without a keyword to browse recent files. When agent_id is set (shared agent), scope is the agent's configured knowledge bases.
// @Tags Knowledge
// @Accept json
// @Produce json
@@ -1820,6 +1820,7 @@ func (h *KnowledgeHandler) UpdateImageInfo(c *gin.Context) {
// @Param limit query int false "Limit for pagination (default 20)"
// @Param file_types query string false "Comma-separated file extensions to filter (e.g., csv,xlsx)"
// @Param agent_id query string false "Shared agent ID (search within agent's KB scope)"
// @Param recent query bool false "Return recent files when keyword is empty"
// @Success 200 {object} map[string]interface{} "Search results"
// @Failure 400 {object} errors.AppError "Invalid request"
// @Security Bearer
@@ -1831,18 +1832,19 @@ func (h *KnowledgeHandler) SearchKnowledge(c *gin.Context) {
ctx = context.WithValue(ctx, types.UserIDContextKey, userID)
}
// Accept both ?keyword= (legacy / upstream name) and ?query= (what most
// MCP / agent integrations send). Falling silently back to an unsorted
// listing when both are empty caused the "all queries return the same 5
// newest cards" footgun — return 400 instead so the caller gets a clear
// signal that the search wasn't query-driven.
// MCP / agent integrations send). Empty input is only valid for an explicit
// recent-file browse request; ordinary callers still get a clear 400 instead
// of silently receiving the same newest cards for every missing query.
keyword := c.Query("keyword")
if keyword == "" {
keyword = c.Query("query")
}
if strings.TrimSpace(keyword) == "" {
recent, _ := strconv.ParseBool(c.DefaultQuery("recent", "false"))
if strings.TrimSpace(keyword) == "" && !recent {
c.Error(errors.NewBadRequestError("missing search keyword: pass ?keyword=... or ?query=..."))
return
}
keyword = strings.TrimSpace(keyword)
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))