mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-06-04 13:30:32 +08:00
fix(sessions): review fixes for keyword search / pinning / IM titles
- Apply the session-list additions to the SQLite init migration (Lite build) and make QueryPaged dialect-aware: LOWER(..) LIKE on SQLite, ILIKE on Postgres, drop NULLS LAST on SQLite. Escape LIKE wildcards in the keyword via the existing escapeLikeKeyword helper. - SetPinned now returns rowsAffected so the handler can respond 404 on unknown / unauthorized session IDs instead of a misleading 200. - GET /sessions always returns the enriched shape (pin state + IM origin fields) so the frontend never needs a second roundtrip; the dual-path legacy branch in the handler is gone. - Register pin routes with the wildcard name that matches each verb's existing radix tree (POST :session_id, DELETE :id) and accept either param name in the handler; avoids gin's "wildcard conflicts" panic. - Drop the redundant [platform] prefix from IM session titles now that the list renders a platform icon alongside the title; add unit tests for shortID / buildUserSessionTitle / buildThreadSessionTitle. - Frontend: remove the submenu search input and its i18n/keyword wiring (search lives elsewhere in the app); pin icon uses the TDesign `pin` glyph and inherits color so the active session turns green; optimistic pin moves the item to the top of the list so it shows up at the top of the Pinned group; IM text badge replaced by the platform SVG icons from assets/img/im, desaturated by default and full-color on hover or when the session is active.
This commit is contained in:
@@ -6,18 +6,8 @@ export async function createSessions(data = {}) {
|
||||
return post("/api/v1/sessions", data);
|
||||
}
|
||||
|
||||
export async function getSessionsList(
|
||||
page: number,
|
||||
page_size: number,
|
||||
filters: { keyword?: string; source?: string; agent_id?: string } = {}
|
||||
) {
|
||||
const params = new URLSearchParams();
|
||||
params.set("page", String(page));
|
||||
params.set("page_size", String(page_size));
|
||||
if (filters.keyword) params.set("keyword", filters.keyword);
|
||||
if (filters.source) params.set("source", filters.source);
|
||||
if (filters.agent_id) params.set("agent_id", filters.agent_id);
|
||||
return get(`/api/v1/sessions?${params.toString()}`);
|
||||
export async function getSessionsList(page: number, page_size: number) {
|
||||
return get(`/api/v1/sessions?page=${page}&page_size=${page_size}`);
|
||||
}
|
||||
|
||||
export async function pinSession(session_id: string) {
|
||||
|
||||
@@ -61,18 +61,6 @@
|
||||
</div>
|
||||
</t-tooltip>
|
||||
<div ref="submenuscrollContainer" @scroll="handleScroll" class="submenu" v-if="item.children && !uiStore.sidebarCollapsed">
|
||||
<!-- 搜索输入 -->
|
||||
<div class="submenu_search" v-if="!batchMode">
|
||||
<t-input
|
||||
v-model="searchKeyword"
|
||||
:placeholder="t('menu.searchPlaceholder')"
|
||||
size="small"
|
||||
clearable
|
||||
@input="onSearchKeywordChange"
|
||||
@clear="onSearchKeywordChange">
|
||||
<template #prefix-icon><t-icon name="search" /></template>
|
||||
</t-input>
|
||||
</div>
|
||||
<!-- 骨架屏占位 -->
|
||||
<template v-if="loading && groupedSessions.length === 0">
|
||||
<div v-for="n in 5" :key="'skel-'+n" class="submenu_item_p">
|
||||
@@ -95,8 +83,12 @@
|
||||
/>
|
||||
<span class="submenu_title"
|
||||
:style="batchMode ? 'margin-left:4px;max-width:170px;' : (currentSecondpath == subitem.path ? 'margin-left:18px;max-width:160px;' : 'margin-left:18px;max-width:185px;')">
|
||||
<t-icon v-if="subitem.is_pinned" name="push-pin" class="submenu_pin_icon" :title="t('menu.pinned')" />
|
||||
<span v-if="subitem.source_label" class="submenu_source_badge">{{ subitem.source_label }}</span>
|
||||
<t-icon v-if="subitem.is_pinned" name="pin" class="submenu_pin_icon" :title="t('menu.pinned')" />
|
||||
<img v-if="subitem.im_platform && platformLogo(subitem.im_platform)"
|
||||
:src="platformLogo(subitem.im_platform)"
|
||||
:alt="subitem.im_platform"
|
||||
:title="subitem.im_platform"
|
||||
class="submenu_source_icon" />
|
||||
{{ subitem.title }}
|
||||
</span>
|
||||
<t-dropdown v-if="!batchMode"
|
||||
@@ -161,6 +153,27 @@ import UserMenu from '@/components/UserMenu.vue';
|
||||
import TenantSelector from '@/components/TenantSelector.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { getSystemInfo } from '@/api/system';
|
||||
// Platform logos reused from IMChannelsOverviewPanel — keeps the session list
|
||||
// visually consistent with the channels admin view.
|
||||
import wecomLogo from '@/assets/img/im/wecom.svg';
|
||||
import feishuLogo from '@/assets/img/im/feishu.svg';
|
||||
import slackLogo from '@/assets/img/im/slack.svg';
|
||||
import telegramLogo from '@/assets/img/im/telegram.svg';
|
||||
import dingtalkLogo from '@/assets/img/im/dingtalk.svg';
|
||||
import mattermostLogo from '@/assets/img/im/mattermost.svg';
|
||||
import wechatLogo from '@/assets/img/im/wechat.svg';
|
||||
|
||||
const PLATFORM_LOGO: Record<string, string> = {
|
||||
wecom: wecomLogo,
|
||||
feishu: feishuLogo,
|
||||
slack: slackLogo,
|
||||
telegram: telegramLogo,
|
||||
dingtalk: dingtalkLogo,
|
||||
mattermost: mattermostLogo,
|
||||
wechat: wechatLogo,
|
||||
};
|
||||
|
||||
const platformLogo = (p: string): string => (p ? PLATFORM_LOGO[p] || '' : '');
|
||||
|
||||
const { t } = useI18n();
|
||||
const usemenuStore = useMenuStore();
|
||||
@@ -295,8 +308,6 @@ const bottomMenuItems = computed<MenuItem[]>(() => {
|
||||
const currentKbName = ref<string>('')
|
||||
const currentKbInfo = ref<any>(null)
|
||||
|
||||
// 搜索关键字(节流后触发后端 keyword 过滤)
|
||||
const searchKeyword = ref<string>('')
|
||||
// 进行中的置顶/取消置顶请求,避免重复点击
|
||||
const pinningIds = ref<Set<string>>(new Set())
|
||||
|
||||
@@ -465,14 +476,7 @@ const handleSessionMenuClick = (data: { value: string }, index: number, item: an
|
||||
}
|
||||
};
|
||||
|
||||
// 基于会话来源推导展示用的短标签。IM 会话的 title 已经用 "[platform] ..." 前缀表达来源,
|
||||
// 这里只在列表里加一个可过滤/可见的简短标签,Web 会话保持无标签。
|
||||
const deriveSourceLabel = (item: any): string => {
|
||||
if (item?.im_platform) {
|
||||
return `[${item.im_platform}]`;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
// 基于会话来源推导展示用的短标签已经被 platformLogo(<img>) 取代,Web 会话没有图标。
|
||||
|
||||
const buildSessionMenuOptions = (item: any) => {
|
||||
const options: any[] = [];
|
||||
@@ -480,13 +484,13 @@ const buildSessionMenuOptions = (item: any) => {
|
||||
options.push({
|
||||
content: t('menu.unpin'),
|
||||
value: 'unpin',
|
||||
prefixIcon: () => h(TIcon, { name: 'push-pin', size: '16px' }),
|
||||
prefixIcon: () => h(TIcon, { name: 'pin', size: '16px' }),
|
||||
});
|
||||
} else {
|
||||
options.push({
|
||||
content: t('menu.pin'),
|
||||
value: 'pin',
|
||||
prefixIcon: () => h(TIcon, { name: 'push-pin', size: '16px' }),
|
||||
prefixIcon: () => h(TIcon, { name: 'pin', size: '16px' }),
|
||||
});
|
||||
}
|
||||
options.push(
|
||||
@@ -506,10 +510,18 @@ const togglePin = (item: any, pin: boolean) => {
|
||||
if (res && res.success) {
|
||||
// 乐观更新本地列表项,避免整表重拉引起抖动。
|
||||
const chatMenu = (menuArr.value as any[]).find((m: any) => m.path === 'creatChat');
|
||||
const target = chatMenu?.children?.find((s: any) => s.id === item.id);
|
||||
if (target) {
|
||||
const idx = chatMenu?.children?.findIndex((s: any) => s.id === item.id) ?? -1;
|
||||
if (idx >= 0) {
|
||||
const target = chatMenu.children[idx];
|
||||
target.is_pinned = pin;
|
||||
target.pinned_at = pin ? new Date().toISOString() : null;
|
||||
// 置顶时把元素挪到数组最前,确保在置顶分组中出现在最上方
|
||||
// (groupedSessions 按 children 顺序分组)。取消置顶时无需移动,
|
||||
// 元素会自然回到它在时间分组内的原位。
|
||||
if (pin && idx > 0) {
|
||||
chatMenu.children.splice(idx, 1);
|
||||
chatMenu.children.unshift(target);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
MessagePlugin.error(pin ? t('menu.pinFailed') : t('menu.unpinFailed'));
|
||||
@@ -521,15 +533,6 @@ const togglePin = (item: any, pin: boolean) => {
|
||||
});
|
||||
};
|
||||
|
||||
// 搜索框输入节流:延迟 300ms 触发一次后端查询。
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const onSearchKeywordChange = () => {
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
getMessageList();
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const clearMessages = (item: any) => {
|
||||
clearSessionMessages(item.id).then((res: any) => {
|
||||
if (res && res.success) {
|
||||
@@ -607,11 +610,7 @@ const getMessageList = async (isLoadMore = false) => {
|
||||
usemenuStore.clearMenuArr();
|
||||
}
|
||||
|
||||
const filters: { keyword?: string } = {};
|
||||
const kw = searchKeyword.value.trim();
|
||||
if (kw) filters.keyword = kw;
|
||||
|
||||
return getSessionsList(currentPage.value, page_size.value, filters).then((res: any) => {
|
||||
return getSessionsList(currentPage.value, page_size.value).then((res: any) => {
|
||||
if (res.data && res.data.length) {
|
||||
// Display all sessions globally without filtering
|
||||
res.data.forEach((item: any) => {
|
||||
@@ -626,7 +625,6 @@ const getMessageList = async (isLoadMore = false) => {
|
||||
is_pinned: !!item.is_pinned,
|
||||
pinned_at: item.pinned_at || null,
|
||||
im_platform: item.im_platform || '',
|
||||
source_label: deriveSourceLabel(item),
|
||||
}
|
||||
usemenuStore.updatemenuArr(obj)
|
||||
});
|
||||
@@ -1130,27 +1128,31 @@ const onDragHandleMouseDown = (e: MouseEvent) => {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.submenu_search {
|
||||
padding: 8px 12px 4px 12px;
|
||||
}
|
||||
|
||||
.submenu_pin_icon {
|
||||
color: var(--td-text-color-secondary);
|
||||
color: inherit;
|
||||
font-size: 12px;
|
||||
margin-right: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.submenu_source_badge {
|
||||
display: inline-block;
|
||||
padding: 0 6px;
|
||||
margin-right: 6px;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
color: var(--td-text-color-secondary);
|
||||
background: var(--td-bg-color-secondarycontainer);
|
||||
border-radius: 4px;
|
||||
.submenu_source_icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-right: 0px;
|
||||
vertical-align: middle;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
// 默认淡化处理,避免未选中状态下彩色图标与灰色标题不协调;
|
||||
// 悬浮或选中时恢复彩色,交互时才引人注意。
|
||||
filter: grayscale(1);
|
||||
opacity: 0.55;
|
||||
transition: filter 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.submenu_item:hover .submenu_source_icon,
|
||||
.submenu_item_active .submenu_source_icon {
|
||||
filter: none;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes menuItemFadeIn {
|
||||
|
||||
@@ -20,9 +20,6 @@ export default {
|
||||
pinned: 'Pinned',
|
||||
pinFailed: 'Failed to pin, please try again later',
|
||||
unpinFailed: 'Failed to unpin, please try again later',
|
||||
searchPlaceholder: 'Search chats',
|
||||
sourceWeb: 'Web',
|
||||
sourceIM: 'IM',
|
||||
confirmLogout: 'Are you sure you want to logout?',
|
||||
systemInfo: 'System Information',
|
||||
knowledgeSearch: 'Search',
|
||||
|
||||
@@ -20,9 +20,6 @@ export default {
|
||||
pinned: "고정됨",
|
||||
pinFailed: "고정 실패, 나중에 다시 시도해 주세요",
|
||||
unpinFailed: "고정 해제 실패, 나중에 다시 시도해 주세요",
|
||||
searchPlaceholder: "대화 검색",
|
||||
sourceWeb: "웹",
|
||||
sourceIM: "IM",
|
||||
confirmLogout: "정말 로그아웃 하시겠습니까?",
|
||||
systemInfo: "시스템 정보",
|
||||
knowledgeSearch: "검색",
|
||||
|
||||
@@ -18,9 +18,6 @@ export default {
|
||||
pinned: 'Закреплено',
|
||||
pinFailed: 'Не удалось закрепить, попробуйте позже',
|
||||
unpinFailed: 'Не удалось открепить, попробуйте позже',
|
||||
searchPlaceholder: 'Поиск диалогов',
|
||||
sourceWeb: 'Веб',
|
||||
sourceIM: 'IM',
|
||||
confirmLogout: 'Вы уверены, что хотите выйти?',
|
||||
systemInfo: 'Информация о системе',
|
||||
knowledgeSearch: 'Поиск',
|
||||
|
||||
@@ -20,9 +20,6 @@ export default {
|
||||
pinned: "已置顶",
|
||||
pinFailed: "置顶失败,请稍后再试",
|
||||
unpinFailed: "取消置顶失败,请稍后再试",
|
||||
searchPlaceholder: "搜索会话",
|
||||
sourceWeb: "网页",
|
||||
sourceIM: "IM",
|
||||
confirmLogout: "确定要退出登录吗?",
|
||||
systemInfo: "系统信息",
|
||||
knowledgeSearch: "搜索",
|
||||
|
||||
@@ -83,6 +83,21 @@ func (r *sessionRepository) GetPagedByTenantID(
|
||||
func (r *sessionRepository) QueryPaged(
|
||||
ctx context.Context, q *types.SessionListQuery,
|
||||
) ([]*types.SessionListItem, int64, error) {
|
||||
// Dialect-aware bits so the same query works on Postgres and SQLite (Lite build).
|
||||
isPostgres := r.db.Dialector.Name() == "postgres"
|
||||
titleLikeExpr := "LOWER(s.title) LIKE LOWER(?)"
|
||||
if isPostgres {
|
||||
titleLikeExpr = "s.title ILIKE ?"
|
||||
}
|
||||
// SQLite (the driver used by Lite) does not support NULLS LAST; its default
|
||||
// nulls ordering puts NULLs first for DESC, which is actually what we want
|
||||
// for pinned_at (rows with pinned_at=NULL are never pinned, so they get
|
||||
// filtered to the tail by the preceding is_pinned DESC anyway).
|
||||
orderClause := "s.is_pinned DESC, s.pinned_at DESC NULLS LAST, s.updated_at DESC"
|
||||
if !isPostgres {
|
||||
orderClause = "s.is_pinned DESC, s.pinned_at DESC, s.updated_at DESC"
|
||||
}
|
||||
|
||||
// Base filter shared by count and list queries.
|
||||
applyBase := func(db *gorm.DB) *gorm.DB {
|
||||
db = db.Where("s.tenant_id = ? AND s.deleted_at IS NULL", q.TenantID)
|
||||
@@ -90,7 +105,7 @@ func (r *sessionRepository) QueryPaged(
|
||||
db = db.Where("(s.user_id = ? OR s.user_id IS NULL OR s.user_id = '')", q.UserID)
|
||||
}
|
||||
if kw := strings.TrimSpace(q.Keyword); kw != "" {
|
||||
db = db.Where("s.title ILIKE ?", "%"+kw+"%")
|
||||
db = db.Where(titleLikeExpr, "%"+escapeLikeKeyword(kw)+"%")
|
||||
}
|
||||
return db
|
||||
}
|
||||
@@ -144,7 +159,7 @@ func (r *sessionRepository) QueryPaged(
|
||||
ics.user_id AS im_user_id,
|
||||
ics.agent_id AS im_agent_id,
|
||||
ics.im_channel_id AS im_channel_id`).
|
||||
Order("s.is_pinned DESC, s.pinned_at DESC NULLS LAST, s.updated_at DESC").
|
||||
Order(orderClause).
|
||||
Offset((page - 1) * size).
|
||||
Limit(size)
|
||||
if err := rowsQ.Find(&items).Error; err != nil {
|
||||
@@ -156,11 +171,14 @@ func (r *sessionRepository) QueryPaged(
|
||||
|
||||
// SetPinned toggles is_pinned/pinned_at for a single session.
|
||||
// Scope: must match tenant, and user_id (when provided) to prevent pinning
|
||||
// other users' sessions. Legacy rows with user_id NULL/'' remain mutable
|
||||
// other users' sessions. Legacy rows with user_id NULL/” remain mutable
|
||||
// at the tenant level (same visibility rule as QueryPaged).
|
||||
//
|
||||
// Returns the number of rows affected so callers can distinguish "session
|
||||
// doesn't exist / not visible to this user" (0) from a real DB error.
|
||||
func (r *sessionRepository) SetPinned(
|
||||
ctx context.Context, tenantID uint64, userID string, id string, pinned bool,
|
||||
) error {
|
||||
) (int64, error) {
|
||||
now := time.Now()
|
||||
updates := map[string]interface{}{
|
||||
"is_pinned": pinned,
|
||||
@@ -178,7 +196,8 @@ func (r *sessionRepository) SetPinned(
|
||||
if userID != "" {
|
||||
q = q.Where("(user_id = ? OR user_id IS NULL OR user_id = '')", userID)
|
||||
}
|
||||
return q.Updates(updates).Error
|
||||
res := q.Updates(updates)
|
||||
return res.RowsAffected, res.Error
|
||||
}
|
||||
|
||||
// Update updates a session
|
||||
|
||||
@@ -199,11 +199,13 @@ func (s *sessionService) ListSessions(
|
||||
}
|
||||
|
||||
// SetSessionPinned pins or unpins a session for the current user scope.
|
||||
// Returns the number of rows affected; 0 means the session doesn't exist
|
||||
// or is not owned by the caller so the handler can respond 404.
|
||||
func (s *sessionService) SetSessionPinned(
|
||||
ctx context.Context, sessionID string, pinned bool,
|
||||
) error {
|
||||
) (int64, error) {
|
||||
if sessionID == "" {
|
||||
return errors.New("session id is required")
|
||||
return 0, errors.New("session id is required")
|
||||
}
|
||||
tenantID := types.MustTenantIDFromContext(ctx)
|
||||
userID, _ := types.UserIDFromContext(ctx)
|
||||
|
||||
@@ -205,45 +205,22 @@ func (h *Handler) GetSessionsByTenant(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
keyword := c.Query("keyword")
|
||||
source := c.Query("source")
|
||||
agentID := c.Query("agent_id")
|
||||
|
||||
// When the caller uses any of the new filter knobs, return enriched items
|
||||
// (with IM origin fields). Otherwise keep the legacy response so existing
|
||||
// clients are unaffected.
|
||||
if keyword != "" || source != "" || agentID != "" {
|
||||
result, err := h.sessionService.ListSessions(ctx, &types.SessionListQuery{
|
||||
Keyword: keyword,
|
||||
Source: source,
|
||||
AgentID: agentID,
|
||||
Page: pagination.Page,
|
||||
PageSize: pagination.PageSize,
|
||||
})
|
||||
if err != nil {
|
||||
logger.ErrorWithFields(ctx, err, nil)
|
||||
c.Error(errors.NewInternalServerError(err.Error()))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": result.Data,
|
||||
"total": result.Total,
|
||||
"page": result.Page,
|
||||
"page_size": result.PageSize,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Use paginated query to get sessions
|
||||
result, err := h.sessionService.GetPagedSessionsByTenant(ctx, &pagination)
|
||||
// Response items always include pin state and (when available) IM origin
|
||||
// fields so the frontend can render pin icons / source badges without a
|
||||
// second roundtrip. Unset filter params behave like "no filter".
|
||||
result, err := h.sessionService.ListSessions(ctx, &types.SessionListQuery{
|
||||
Keyword: c.Query("keyword"),
|
||||
Source: c.Query("source"),
|
||||
AgentID: c.Query("agent_id"),
|
||||
Page: pagination.Page,
|
||||
PageSize: pagination.PageSize,
|
||||
})
|
||||
if err != nil {
|
||||
logger.ErrorWithFields(ctx, err, nil)
|
||||
c.Error(errors.NewInternalServerError(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Return sessions with pagination data
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": result.Data,
|
||||
@@ -485,12 +462,12 @@ func (h *Handler) BatchDeleteSessions(c *gin.Context) {
|
||||
// @Description 将指定会话置顶(用户维度)
|
||||
// @Tags 会话
|
||||
// @Produce json
|
||||
// @Param id path string true "会话ID"
|
||||
// @Param session_id path string true "会话ID"
|
||||
// @Success 200 {object} map[string]interface{} "置顶成功"
|
||||
// @Failure 404 {object} errors.AppError "会话不存在"
|
||||
// @Security Bearer
|
||||
// @Security ApiKeyAuth
|
||||
// @Router /sessions/{id}/pin [post]
|
||||
// @Router /sessions/{session_id}/pin [post]
|
||||
func (h *Handler) PinSession(c *gin.Context) {
|
||||
h.setSessionPinned(c, true)
|
||||
}
|
||||
@@ -513,14 +490,21 @@ func (h *Handler) UnpinSession(c *gin.Context) {
|
||||
func (h *Handler) setSessionPinned(c *gin.Context, pinned bool) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
id := secutils.SanitizeForLog(c.Param("id"))
|
||||
// POST and DELETE for /sessions/.../pin register under different wildcards
|
||||
// (POST :session_id, DELETE :id — see router.go). Accept whichever is set.
|
||||
rawID := c.Param("session_id")
|
||||
if rawID == "" {
|
||||
rawID = c.Param("id")
|
||||
}
|
||||
id := secutils.SanitizeForLog(rawID)
|
||||
if id == "" {
|
||||
logger.Error(ctx, "Session ID is empty")
|
||||
c.Error(errors.NewBadRequestError(errors.ErrInvalidSessionID.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.sessionService.SetSessionPinned(ctx, id, pinned); err != nil {
|
||||
rows, err := h.sessionService.SetSessionPinned(ctx, id, pinned)
|
||||
if err != nil {
|
||||
logger.ErrorWithFields(ctx, err, map[string]interface{}{
|
||||
"session_id": id,
|
||||
"pinned": pinned,
|
||||
@@ -528,6 +512,12 @@ func (h *Handler) setSessionPinned(c *gin.Context, pinned bool) {
|
||||
c.Error(errors.NewInternalServerError(err.Error()))
|
||||
return
|
||||
}
|
||||
// Zero rows means the session doesn't exist or isn't visible to this user;
|
||||
// tell the client rather than reporting success.
|
||||
if rows == 0 {
|
||||
c.Error(errors.NewNotFoundError(errors.ErrSessionNotFound.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
|
||||
@@ -1339,9 +1339,10 @@ func (s *Service) resolveSession(ctx context.Context, msg *IncomingMessage, tena
|
||||
// buildUserSessionTitle produces a human-distinguishable title for a user-mode
|
||||
// IM session. Platform adapters only surface ChatID, not a readable chat name,
|
||||
// so we fall back to short ID suffixes to keep group/DM sessions visually distinct.
|
||||
// Platform prefix is intentionally omitted — the UI renders a platform icon badge
|
||||
// alongside the title, so the `[feishu]` prefix would be redundant clutter.
|
||||
func buildUserSessionTitle(msg *IncomingMessage) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "[%s] ", msg.Platform)
|
||||
if msg.UserName != "" {
|
||||
b.WriteString(msg.UserName)
|
||||
} else if msg.UserID != "" {
|
||||
@@ -1361,9 +1362,9 @@ func buildUserSessionTitle(msg *IncomingMessage) string {
|
||||
// buildThreadSessionTitle produces a title for a thread-mode IM session.
|
||||
// In thread mode different users can share one session, so the user name is
|
||||
// omitted and chat/thread IDs carry the distinguishing information.
|
||||
// Platform prefix is omitted for the same reason as buildUserSessionTitle.
|
||||
func buildThreadSessionTitle(msg *IncomingMessage) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "[%s] ", msg.Platform)
|
||||
if msg.ChatID != "" {
|
||||
fmt.Fprintf(&b, "chat %s · ", shortID(msg.ChatID))
|
||||
}
|
||||
|
||||
109
internal/im/session_title_test.go
Normal file
109
internal/im/session_title_test.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package im
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestShortID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"empty", "", ""},
|
||||
{"shorter than 8", "abc", "abc"},
|
||||
{"exactly 8", "12345678", "12345678"},
|
||||
{"longer than 8 keeps suffix", "aaaaaaaaXXXXXXXX", "XXXXXXXX"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := shortID(tt.in); got != tt.want {
|
||||
t.Errorf("shortID(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUserSessionTitle(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
msg *IncomingMessage
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "group with username",
|
||||
msg: &IncomingMessage{
|
||||
Platform: "feishu",
|
||||
UserName: "李四",
|
||||
ChatType: ChatTypeGroup,
|
||||
ChatID: "oc_aaaaaaaaaaaaaaaa",
|
||||
},
|
||||
want: "李四 · group aaaaaaaa",
|
||||
},
|
||||
{
|
||||
name: "direct message with username",
|
||||
msg: &IncomingMessage{
|
||||
Platform: "feishu",
|
||||
UserName: "李四",
|
||||
ChatType: ChatTypeDirect,
|
||||
},
|
||||
want: "李四 · dm",
|
||||
},
|
||||
{
|
||||
name: "group without username falls back to user id",
|
||||
msg: &IncomingMessage{
|
||||
Platform: "wecom",
|
||||
UserID: "WeCom_ZhangSan",
|
||||
ChatType: ChatTypeGroup,
|
||||
ChatID: "wc_group_1234",
|
||||
},
|
||||
want: "user ZhangSan · group oup_1234",
|
||||
},
|
||||
{
|
||||
name: "no user identity at all",
|
||||
msg: &IncomingMessage{
|
||||
Platform: "slack",
|
||||
ChatType: ChatTypeDirect,
|
||||
},
|
||||
want: "user · dm",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := buildUserSessionTitle(tt.msg); got != tt.want {
|
||||
t.Errorf("buildUserSessionTitle() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildThreadSessionTitle(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
msg *IncomingMessage
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "chat + thread",
|
||||
msg: &IncomingMessage{
|
||||
Platform: "slack",
|
||||
ChatID: "C0123456789",
|
||||
ThreadID: "1700000000.111000",
|
||||
},
|
||||
want: "chat 23456789 · thread 0.111000",
|
||||
},
|
||||
{
|
||||
name: "thread without chat id",
|
||||
msg: &IncomingMessage{
|
||||
Platform: "feishu",
|
||||
ThreadID: "om_thread_abcdefgh",
|
||||
},
|
||||
want: "thread abcdefgh",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := buildThreadSessionTitle(tt.msg); got != tt.want {
|
||||
t.Errorf("buildThreadSessionTitle() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -336,7 +336,11 @@ func RegisterSessionRoutes(r *gin.RouterGroup, handler *session.Handler) {
|
||||
sessions.DELETE("/:id/messages", handler.ClearSessionMessages)
|
||||
sessions.POST("/:session_id/generate_title", handler.GenerateTitle)
|
||||
sessions.POST("/:session_id/stop", handler.StopSession)
|
||||
sessions.POST("/:id/pin", handler.PinSession)
|
||||
// POST and DELETE share this path but gin maintains a separate radix tree
|
||||
// per HTTP verb, and the existing trees use different wildcard names
|
||||
// (POST uses :session_id, DELETE uses :id). Use whatever matches each
|
||||
// tree to avoid "wildcard conflicts" panic at route registration.
|
||||
sessions.POST("/:session_id/pin", handler.PinSession)
|
||||
sessions.DELETE("/:id/pin", handler.UnpinSession)
|
||||
// 继续接收活跃流
|
||||
sessions.GET("/continue-stream/:session_id", handler.ContinueStream)
|
||||
|
||||
@@ -29,7 +29,8 @@ type SessionService interface {
|
||||
// search/source filters and pin-aware ordering. User scope is pulled from ctx.
|
||||
ListSessions(ctx context.Context, query *types.SessionListQuery) (*types.PageResult, error)
|
||||
// SetSessionPinned pins or unpins the session for the current user scope.
|
||||
SetSessionPinned(ctx context.Context, sessionID string, pinned bool) error
|
||||
// Returns the number of rows affected; 0 signals "not found" to the handler.
|
||||
SetSessionPinned(ctx context.Context, sessionID string, pinned bool) (int64, error)
|
||||
// GenerateTitle generates a title for the current conversation
|
||||
// modelID: optional model ID to use for title generation (if empty, uses first available KnowledgeQA model)
|
||||
GenerateTitle(ctx context.Context, session *types.Session, messages []types.Message, modelID string) (string, error)
|
||||
@@ -68,7 +69,9 @@ type SessionRepository interface {
|
||||
Update(ctx context.Context, session *types.Session) error
|
||||
// SetPinned pins or unpins a session row scoped by tenant.
|
||||
// userID, when non-empty, is enforced so users cannot pin sessions they don't own.
|
||||
SetPinned(ctx context.Context, tenantID uint64, userID string, id string, pinned bool) error
|
||||
// Returns the number of rows affected; 0 means the session doesn't exist or is
|
||||
// not visible to this caller.
|
||||
SetPinned(ctx context.Context, tenantID uint64, userID string, id string, pinned bool) (int64, error)
|
||||
// Delete deletes a session
|
||||
Delete(ctx context.Context, tenantID uint64, id string) error
|
||||
// BatchDelete deletes multiple sessions by IDs
|
||||
|
||||
@@ -134,6 +134,9 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
agent_config TEXT DEFAULT NULL,
|
||||
context_config TEXT DEFAULT NULL,
|
||||
agent_id VARCHAR(36),
|
||||
user_id VARCHAR(36),
|
||||
is_pinned BOOLEAN NOT NULL DEFAULT 0,
|
||||
pinned_at DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at DATETIME
|
||||
@@ -141,6 +144,9 @@ CREATE TABLE IF NOT EXISTS sessions (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_tenant_id ON sessions(tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_agent_id ON sessions(agent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_tenant_user_pin
|
||||
ON sessions (tenant_id, user_id, is_pinned, pinned_at, updated_at)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
|
||||
Reference in New Issue
Block a user