feat(sessions): add keyword search, user-scoped pinning, and IM source visibility

Sessions today are a flat list ordered by updated_at. Two gaps showed up in
practice:
- Users cannot find specific chats as the list grows beyond a screen.
- IM-created sessions (WeCom/Feishu/Slack/...) are indistinguishable:
  every title was "IM-<platform>" or "IM-<platform>-<username>" and the
  list API hid the underlying im_channel_sessions mapping, so admins had
  no way to tell which Feishu group a session came from.

Backend
- Migration 000039 adds sessions.user_id (owner), is_pinned, pinned_at
  plus a composite index for the list query. Existing rows keep user_id
  NULL and stay visible at the tenant level for backward compatibility.
- CreateSession now writes the caller's user_id from auth context.
- GET /sessions accepts keyword / source / agent_id. When any filter is
  set, the response switches to enriched items that LEFT JOIN
  im_channel_sessions and expose im_platform / im_chat_id / im_thread_id
  / im_user_id / im_agent_id / im_channel_id. No filters => legacy shape,
  existing clients unaffected.
- Ordering: is_pinned DESC, pinned_at DESC NULLS LAST, updated_at DESC.
- POST/DELETE /sessions/:id/pin for user-scoped pin/unpin.
- IM session titles: "[platform] <user|chat|thread>" with short ID
  suffixes so group/DM/thread sessions are visually distinct without
  needing a round-trip to fetch a display name from the IM adapter.

Frontend
- Search input debounced at 300ms drives the keyword filter.
- Pinned chats render in a dedicated group above the time-based groups,
  with a pin icon and a pin/unpin entry in the per-chat dropdown.
- IM chats get a short [platform] badge in the list.
- Pin toggle is optimistic and guards against double-clicks.
- zh/en/ko/ru i18n keys added for the new strings.
This commit is contained in:
wizardchen
2026-04-30 15:14:49 +08:00
committed by lyingbug
parent e029d31f86
commit dbd804d6e3
15 changed files with 564 additions and 38 deletions
+20 -2
View File
@@ -6,8 +6,26 @@ export async function createSessions(data = {}) {
return post("/api/v1/sessions", data);
}
export async function getSessionsList(page: number, page_size: number) {
return get(`/api/v1/sessions?page=${page}&page_size=${page_size}`);
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 pinSession(session_id: string) {
return post(`/api/v1/sessions/${session_id}/pin`, {});
}
export async function unpinSession(session_id: string) {
return del(`/api/v1/sessions/${session_id}/pin`);
}
export async function generateSessionsTitle(session_id: string, data: any) {
+141 -21
View File
@@ -61,6 +61,18 @@
</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">
@@ -83,10 +95,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>
{{ subitem.title }}
</span>
<t-dropdown v-if="!batchMode"
:options="[{ content: t('menu.clearMessages'), value: 'clearMessages', prefixIcon: () => h(TIcon, { name: 'clear', size: '16px' }) }, { content: t('menu.batchManage'), value: 'batchManage', prefixIcon: () => h(TIcon, { name: 'queue', size: '16px' }) }, { content: t('upload.deleteRecord'), value: 'delete', theme: 'error', prefixIcon: () => h(TIcon, { name: 'delete', size: '16px' }) }]"
:options="buildSessionMenuOptions(subitem)"
@click="handleSessionMenuClick($event, subitem.originalIndex, subitem)"
placement="bottom-right"
trigger="click">
@@ -135,7 +149,7 @@
import { storeToRefs } from 'pinia';
import { onMounted, watch, computed, ref, h } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { getSessionsList, delSession, batchDelSessions, deleteAllSessions, clearSessionMessages } from "@/api/chat/index";
import { getSessionsList, delSession, batchDelSessions, deleteAllSessions, clearSessionMessages, pinSession, unpinSession } from "@/api/chat/index";
import { getKnowledgeBaseById } from '@/api/knowledge-base';
import { logout as logoutApi } from '@/api/auth';
import { useMenuStore } from '@/stores/menu';
@@ -281,6 +295,11 @@ 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())
// 时间分组函数
const getTimeCategory = (dateStr: string): string => {
if (!dateStr) return t('time.earlier');
@@ -310,14 +329,16 @@ const getTimeCategory = (dateStr: string): string => {
}
};
// 按时间分组Session列表
// 按时间分组Session列表,置顶会话单独置于最上方
const groupedSessions = computed(() => {
const chatMenu = (menuArr.value as unknown as MenuItem[]).find((item: MenuItem) => item.path === 'creatChat');
if (!chatMenu || !chatMenu.children || chatMenu.children.length === 0) {
return [];
}
const pinnedLabel = t('time.pinned');
const groups: { [key: string]: any[] } = {
[pinnedLabel]: [],
[t('time.today')]: [],
[t('time.yesterday')]: [],
[t('time.last7Days')]: [],
@@ -325,18 +346,19 @@ const groupedSessions = computed(() => {
[t('time.lastYear')]: [],
[t('time.earlier')]: []
};
// 将sessions按时间分组
(chatMenu.children as any[]).forEach((session: any, index: number) => {
const withIndex = { ...session, originalIndex: index };
if (session.is_pinned) {
groups[pinnedLabel].push(withIndex);
return;
}
const category = getTimeCategory(session.updated_at || session.created_at);
groups[category].push({
...session,
originalIndex: index
});
groups[category].push(withIndex);
});
// 按顺序返回非空分组
const orderedLabels = [t('time.today'), t('time.yesterday'), t('time.last7Days'), t('time.last30Days'), t('time.lastYear'), t('time.earlier')];
// 按顺序返回非空分组(置顶组在最上方)
const orderedLabels = [pinnedLabel, t('time.today'), t('time.yesterday'), t('time.last7Days'), t('time.last30Days'), t('time.lastYear'), t('time.earlier')];
return orderedLabels
.filter(label => groups[label].length > 0)
.map(label => ({
@@ -438,9 +460,76 @@ const handleSessionMenuClick = (data: { value: string }, index: number, item: an
clearMessages(item);
} else if (data?.value === 'batchManage') {
enterBatchMode()
} else if (data?.value === 'pin' || data?.value === 'unpin') {
togglePin(item, data.value === 'pin');
}
};
// 基于会话来源推导展示用的短标签。IM 会话的 title 已经用 "[platform] ..." 前缀表达来源,
// 这里只在列表里加一个可过滤/可见的简短标签,Web 会话保持无标签。
const deriveSourceLabel = (item: any): string => {
if (item?.im_platform) {
return `[${item.im_platform}]`;
}
return '';
};
const buildSessionMenuOptions = (item: any) => {
const options: any[] = [];
if (item.is_pinned) {
options.push({
content: t('menu.unpin'),
value: 'unpin',
prefixIcon: () => h(TIcon, { name: 'push-pin', size: '16px' }),
});
} else {
options.push({
content: t('menu.pin'),
value: 'pin',
prefixIcon: () => h(TIcon, { name: 'push-pin', size: '16px' }),
});
}
options.push(
{ content: t('menu.clearMessages'), value: 'clearMessages', prefixIcon: () => h(TIcon, { name: 'clear', size: '16px' }) },
{ content: t('menu.batchManage'), value: 'batchManage', prefixIcon: () => h(TIcon, { name: 'queue', size: '16px' }) },
{ content: t('upload.deleteRecord'), value: 'delete', theme: 'error', prefixIcon: () => h(TIcon, { name: 'delete', size: '16px' }) },
);
return options;
};
const togglePin = (item: any, pin: boolean) => {
if (pinningIds.value.has(item.id)) return;
pinningIds.value.add(item.id);
const call = pin ? pinSession(item.id) : unpinSession(item.id);
call.then((res: any) => {
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) {
target.is_pinned = pin;
target.pinned_at = pin ? new Date().toISOString() : null;
}
} else {
MessagePlugin.error(pin ? t('menu.pinFailed') : t('menu.unpinFailed'));
}
}).catch(() => {
MessagePlugin.error(pin ? t('menu.pinFailed') : t('menu.unpinFailed'));
}).finally(() => {
pinningIds.value.delete(item.id);
});
};
// 搜索框输入节流:延迟 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) {
@@ -511,25 +600,33 @@ const handleScroll = debounce(checkScrollBottom, 200)
const getMessageList = async (isLoadMore = false) => {
if (loading.value) return Promise.resolve();
loading.value = true;
// 只有在首次加载或路由变化时才清空数组,滚动加载时不清空
if (!isLoadMore) {
currentPage.value = 1; // 重置页码
usemenuStore.clearMenuArr();
}
return getSessionsList(currentPage.value, page_size.value).then((res: any) => {
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) => {
if (res.data && res.data.length) {
// Display all sessions globally without filtering
res.data.forEach((item: any) => {
let obj = {
let obj = {
title: item.title ? item.title : t('menu.newSession'),
path: `chat/${item.id}`,
id: item.id,
isMore: false,
path: `chat/${item.id}`,
id: item.id,
isMore: false,
isNoTitle: item.title ? false : true,
created_at: item.created_at,
updated_at: item.updated_at
updated_at: item.updated_at,
is_pinned: !!item.is_pinned,
pinned_at: item.pinned_at || null,
im_platform: item.im_platform || '',
source_label: deriveSourceLabel(item),
}
usemenuStore.updatemenuArr(obj)
});
@@ -1032,6 +1129,29 @@ const onDragHandleMouseDown = (e: MouseEvent) => {
min-height: 0;
margin-left: 4px;
}
.submenu_search {
padding: 8px 12px 4px 12px;
}
.submenu_pin_icon {
color: var(--td-text-color-secondary);
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;
vertical-align: middle;
}
@keyframes menuItemFadeIn {
from { opacity: 0; transform: translateX(-4px); }
+10 -1
View File
@@ -15,6 +15,14 @@ export default {
clearMessagesFailed: 'Failed to clear messages, please try again later',
batchManage: 'Batch Manage',
newSession: 'New Chat',
pin: 'Pin',
unpin: 'Unpin',
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',
@@ -2576,7 +2584,8 @@ export default {
last7Days: 'Last 7 Days',
last30Days: 'Last 30 Days',
lastYear: 'Last Year',
earlier: 'Earlier'
earlier: 'Earlier',
pinned: 'Pinned',
},
upload: {
uploadDocument: 'Upload Document',
+9
View File
@@ -15,6 +15,14 @@ export default {
clearMessagesFailed: "메시지 지우기 실패, 나중에 다시 시도해 주세요",
batchManage: "일괄 관리",
newSession: "새 세션",
pin: "고정",
unpin: "고정 해제",
pinned: "고정됨",
pinFailed: "고정 실패, 나중에 다시 시도해 주세요",
unpinFailed: "고정 해제 실패, 나중에 다시 시도해 주세요",
searchPlaceholder: "대화 검색",
sourceWeb: "웹",
sourceIM: "IM",
confirmLogout: "정말 로그아웃 하시겠습니까?",
systemInfo: "시스템 정보",
knowledgeSearch: "검색",
@@ -1947,6 +1955,7 @@ export default {
last30Days: "최근 30일",
lastYear: "최근 1년",
earlier: "이전",
pinned: "고정됨",
},
upload: {
uploadDocument: "문서 업로드",
+10 -1
View File
@@ -13,6 +13,14 @@ export default {
clearMessagesFailed: 'Не удалось очистить сообщения, попробуйте позже',
batchManage: 'Пакетное управление',
newSession: 'Новый диалог',
pin: 'Закрепить',
unpin: 'Открепить',
pinned: 'Закреплено',
pinFailed: 'Не удалось закрепить, попробуйте позже',
unpinFailed: 'Не удалось открепить, попробуйте позже',
searchPlaceholder: 'Поиск диалогов',
sourceWeb: 'Веб',
sourceIM: 'IM',
confirmLogout: 'Вы уверены, что хотите выйти?',
systemInfo: 'Информация о системе',
knowledgeSearch: 'Поиск',
@@ -2383,7 +2391,8 @@ export default {
last7Days: 'Последние 7 дней',
last30Days: 'Последние 30 дней',
lastYear: 'Последний год',
earlier: 'Ранее'
earlier: 'Ранее',
pinned: 'Закреплено',
},
upload: {
uploadDocument: 'Загрузить документ',
+9
View File
@@ -15,6 +15,14 @@ export default {
clearMessagesFailed: "清空消息失败,请稍后再试",
batchManage: "批量管理",
newSession: "新会话",
pin: "置顶",
unpin: "取消置顶",
pinned: "已置顶",
pinFailed: "置顶失败,请稍后再试",
unpinFailed: "取消置顶失败,请稍后再试",
searchPlaceholder: "搜索会话",
sourceWeb: "网页",
sourceIM: "IM",
confirmLogout: "确定要退出登录吗?",
systemInfo: "系统信息",
knowledgeSearch: "搜索",
@@ -1925,6 +1933,7 @@ export default {
last30Days: "近30天",
lastYear: "近1年",
earlier: "更早",
pinned: "已置顶",
},
upload: {
uploadDocument: "上传文档",
+104
View File
@@ -2,6 +2,7 @@ package repository
import (
"context"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/types"
@@ -77,6 +78,109 @@ func (r *sessionRepository) GetPagedByTenantID(
return sessions, total, nil
}
// QueryPaged lists sessions for tenant/user with keyword/source/agent filters,
// pin-aware ordering, and IM origin fields from a LEFT JOIN.
func (r *sessionRepository) QueryPaged(
ctx context.Context, q *types.SessionListQuery,
) ([]*types.SessionListItem, int64, error) {
// 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)
if q.UserID != "" {
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+"%")
}
return db
}
// LEFT JOIN IM mappings to surface origin fields and support source/agent filters.
joinClause := "LEFT JOIN im_channel_sessions ics ON ics.session_id = s.id AND ics.deleted_at IS NULL"
applySource := func(db *gorm.DB) *gorm.DB {
switch strings.ToLower(strings.TrimSpace(q.Source)) {
case "":
return db
case "web":
return db.Where("ics.id IS NULL")
default:
return db.Where("ics.platform = ?", strings.ToLower(q.Source))
}
}
applyAgent := func(db *gorm.DB) *gorm.DB {
if q.AgentID != "" {
return db.Where("ics.agent_id = ?", q.AgentID)
}
return db
}
// Count distinct sessions to guard against fan-out from the join.
var total int64
countQ := applyAgent(applySource(applyBase(
r.db.WithContext(ctx).Table("sessions AS s").Joins(joinClause),
)))
if err := countQ.Distinct("s.id").Count(&total).Error; err != nil {
return nil, 0, err
}
page := q.Page
if page < 1 {
page = 1
}
size := q.PageSize
if size < 1 {
size = 20
}
items := make([]*types.SessionListItem, 0)
rowsQ := applyAgent(applySource(applyBase(
r.db.WithContext(ctx).Table("sessions AS s").Joins(joinClause),
))).
Select(`s.*,
ics.platform AS im_platform,
ics.chat_id AS im_chat_id,
ics.thread_id AS im_thread_id,
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").
Offset((page - 1) * size).
Limit(size)
if err := rowsQ.Find(&items).Error; err != nil {
return nil, 0, err
}
return items, total, nil
}
// 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
// at the tenant level (same visibility rule as QueryPaged).
func (r *sessionRepository) SetPinned(
ctx context.Context, tenantID uint64, userID string, id string, pinned bool,
) error {
now := time.Now()
updates := map[string]interface{}{
"is_pinned": pinned,
"updated_at": now,
}
if pinned {
updates["pinned_at"] = now
} else {
updates["pinned_at"] = nil
}
q := r.db.WithContext(ctx).
Model(&types.Session{}).
Where("tenant_id = ? AND id = ?", tenantID, id)
if userID != "" {
q = q.Where("(user_id = ? OR user_id IS NULL OR user_id = '')", userID)
}
return q.Updates(updates).Error
}
// Update updates a session
func (r *sessionRepository) Update(ctx context.Context, session *types.Session) error {
session.UpdatedAt = time.Now()
+41
View File
@@ -169,6 +169,47 @@ func (s *sessionService) GetPagedSessionsByTenant(ctx context.Context,
return types.NewPageResult(total, pagination, sessions), nil
}
// ListSessions returns a page of sessions with search/source filters, scoped to
// the current tenant (and user when the caller is an authenticated user).
func (s *sessionService) ListSessions(
ctx context.Context, query *types.SessionListQuery,
) (*types.PageResult, error) {
if query == nil {
query = &types.SessionListQuery{}
}
query.TenantID = types.MustTenantIDFromContext(ctx)
if uid, ok := types.UserIDFromContext(ctx); ok {
query.UserID = uid
}
items, total, err := s.sessionRepo.QueryPaged(ctx, query)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"tenant_id": query.TenantID,
"user_id": query.UserID,
"keyword": query.Keyword,
"source": query.Source,
"agent_id": query.AgentID,
})
return nil, err
}
pagination := &types.Pagination{Page: query.Page, PageSize: query.PageSize}
return types.NewPageResult(total, pagination, items), nil
}
// SetSessionPinned pins or unpins a session for the current user scope.
func (s *sessionService) SetSessionPinned(
ctx context.Context, sessionID string, pinned bool,
) error {
if sessionID == "" {
return errors.New("session id is required")
}
tenantID := types.MustTenantIDFromContext(ctx)
userID, _ := types.UserIDFromContext(ctx)
return s.sessionRepo.SetPinned(ctx, tenantID, userID, sessionID, pinned)
}
// UpdateSession updates an existing session's properties
func (s *sessionService) UpdateSession(ctx context.Context, session *types.Session) error {
// Validate session ID
+96 -3
View File
@@ -108,6 +108,11 @@ func (h *Handler) CreateSession(c *gin.Context) {
Title: request.Title,
Description: request.Description,
}
// Attach the calling user as the session owner when available.
// API-key / legacy callers without a user id fall back to tenant-level visibility.
if userID, ok := types.UserIDFromContext(ctx); ok {
createdSession.UserID = userID
}
// Call service to create session
logger.Infof(ctx, "Calling session service to create session")
@@ -175,12 +180,15 @@ func (h *Handler) GetSession(c *gin.Context) {
// GetSessionsByTenant godoc
// @Summary 获取会话列表
// @Description 获取当前租户的会话列表,支持分页
// @Description 获取当前租户的会话列表,支持分页、关键字搜索、按来源/Agent 筛选
// @Tags 会话
// @Accept json
// @Produce json
// @Param page query int false "页码"
// @Param page_size query int false "每页数量"
// @Param page query int false "页码"
// @Param page_size query int false "每页数量"
// @Param keyword query string false "标题模糊搜索"
// @Param source query string false "来源过滤:web / feishu / wechat / slack / ..."
// @Param agent_id query string false "按 Agent 过滤(仅对 IM 会话生效)"
// @Success 200 {object} map[string]interface{} "会话列表"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
@@ -197,6 +205,36 @@ 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)
if err != nil {
@@ -441,3 +479,58 @@ func (h *Handler) BatchDeleteSessions(c *gin.Context) {
"message": "Sessions deleted successfully",
})
}
// PinSession godoc
// @Summary 置顶会话
// @Description 将指定会话置顶(用户维度)
// @Tags 会话
// @Produce json
// @Param 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]
func (h *Handler) PinSession(c *gin.Context) {
h.setSessionPinned(c, true)
}
// UnpinSession godoc
// @Summary 取消置顶会话
// @Description 取消指定会话的置顶
// @Tags 会话
// @Produce json
// @Param id path string true "会话ID"
// @Success 200 {object} map[string]interface{} "取消置顶成功"
// @Failure 404 {object} errors.AppError "会话不存在"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /sessions/{id}/pin [delete]
func (h *Handler) UnpinSession(c *gin.Context) {
h.setSessionPinned(c, false)
}
func (h *Handler) setSessionPinned(c *gin.Context, pinned bool) {
ctx := c.Request.Context()
id := secutils.SanitizeForLog(c.Param("id"))
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 {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"session_id": id,
"pinned": pinned,
})
c.Error(errors.NewInternalServerError(err.Error()))
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"is_pinned": pinned,
})
}
+48 -10
View File
@@ -1336,6 +1336,51 @@ 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.
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 != "" {
b.WriteString("user ")
b.WriteString(shortID(msg.UserID))
} else {
b.WriteString("user")
}
if msg.ChatType == ChatTypeGroup && msg.ChatID != "" {
fmt.Fprintf(&b, " · group %s", shortID(msg.ChatID))
} else if msg.ChatType == ChatTypeDirect {
b.WriteString(" · dm")
}
return b.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.
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))
}
b.WriteString("thread ")
b.WriteString(shortID(msg.ThreadID))
return b.String()
}
// shortID returns the last 8 characters of id, or id itself when shorter.
// Used to keep long platform IDs readable inside titles without losing uniqueness.
func shortID(id string) string {
if len(id) > 8 {
return id[len(id)-8:]
}
return id
}
// resolveUserSession finds or creates a ChannelSession keyed by (platform, user_id, chat_id, tenant_id, agent_id).
// This is the original session resolution strategy.
func (s *Service) resolveUserSession(ctx context.Context, msg *IncomingMessage, tenantID uint64, agentID string, imChannelID string) (*ChannelSession, error) {
@@ -1353,10 +1398,7 @@ func (s *Service) resolveUserSession(ctx context.Context, msg *IncomingMessage,
}
// Create a new WeKnora session
title := fmt.Sprintf("IM-%s", msg.Platform)
if msg.UserName != "" {
title = fmt.Sprintf("IM-%s-%s", msg.Platform, msg.UserName)
}
title := buildUserSessionTitle(msg)
newSession := &types.Session{
TenantID: tenantID,
@@ -1427,12 +1469,8 @@ func (s *Service) resolveThreadSession(ctx context.Context, msg *IncomingMessage
return nil, fmt.Errorf("query thread session: %w", result.Error)
}
// Build a session title with thread ID suffix for traceability.
threadSuffix := threadID
if len(threadSuffix) > 8 {
threadSuffix = threadSuffix[len(threadSuffix)-8:]
}
title := fmt.Sprintf("IM-%s-thread-%s", msg.Platform, threadSuffix)
// Build a session title including chat + thread suffix for traceability.
title := buildThreadSessionTitle(msg)
newSession := &types.Session{
TenantID: tenantID,
+2
View File
@@ -336,6 +336,8 @@ 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)
sessions.DELETE("/:id/pin", handler.UnpinSession)
// 继续接收活跃流
sessions.GET("/continue-stream/:session_id", handler.ContinueStream)
}
+10
View File
@@ -25,6 +25,11 @@ type SessionService interface {
BatchDeleteSessions(ctx context.Context, ids []string) error
// DeleteAllSessions deletes all sessions for the current tenant
DeleteAllSessions(ctx context.Context) error
// ListSessions returns a page of sessions for the current tenant/user with
// 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
// 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)
@@ -57,8 +62,13 @@ type SessionRepository interface {
GetByTenantID(ctx context.Context, tenantID uint64) ([]*types.Session, error)
// GetPagedByTenantID gets paged sessions of a tenant
GetPagedByTenantID(ctx context.Context, tenantID uint64, page *types.Pagination) ([]*types.Session, int64, error)
// QueryPaged lists sessions with filters, user-scoped ownership and pin-aware ordering.
QueryPaged(ctx context.Context, q *types.SessionListQuery) ([]*types.SessionListItem, int64, error)
// Update updates a session
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
// Delete deletes a session
Delete(ctx context.Context, tenantID uint64, id string) error
// BatchDelete deletes multiple sessions by IDs
+35
View File
@@ -81,6 +81,13 @@ type Session struct {
Description string `json:"description"`
// Tenant ID
TenantID uint64 `json:"tenant_id" gorm:"index"`
// UserID is the owner of the session. Empty for legacy rows (visible at
// tenant level) and for IM-created sessions that do not map to a WeKnora user.
UserID string `json:"user_id,omitempty" gorm:"type:varchar(36);index"`
// IsPinned indicates whether the session is pinned in the list.
IsPinned bool `json:"is_pinned" gorm:"default:false"`
// PinnedAt records when the session was pinned; nil when not pinned.
PinnedAt *time.Time `json:"pinned_at,omitempty"`
// // Strategy configuration
// KnowledgeBaseID string `json:"knowledge_base_id"` // 关联的知识库ID
@@ -112,6 +119,34 @@ func (s *Session) BeforeCreate(tx *gorm.DB) (err error) {
return nil
}
// SessionListQuery bundles the parameters for listing sessions.
// UserID empty means "tenant-wide" (used by API-key callers / legacy rows).
// Keyword matches title ILIKE '%keyword%'.
// Source values: "web" (no IM mapping) or an IM platform name (e.g. "feishu", "wechat").
// AgentID currently only filters sessions that have an IM channel mapping.
type SessionListQuery struct {
TenantID uint64
UserID string
Keyword string
Source string
AgentID string
Page int
PageSize int
}
// SessionListItem is a session row enriched with its IM origin (when any).
// IM-related fields are populated from the im_channel_sessions table via LEFT JOIN
// and are empty for Web-created sessions.
type SessionListItem struct {
Session
IMPlatform string `json:"im_platform,omitempty" gorm:"column:im_platform"`
IMChatID string `json:"im_chat_id,omitempty" gorm:"column:im_chat_id"`
IMThreadID string `json:"im_thread_id,omitempty" gorm:"column:im_thread_id"`
IMUserID string `json:"im_user_id,omitempty" gorm:"column:im_user_id"`
IMAgentID string `json:"im_agent_id,omitempty" gorm:"column:im_agent_id"`
IMChannelID string `json:"im_channel_id,omitempty" gorm:"column:im_channel_id"`
}
// StringArray represents a list of strings
type StringArray []string
@@ -0,0 +1,8 @@
-- Rollback: 000039_session_user_id_and_pin
DROP INDEX IF EXISTS idx_sessions_tenant_user_pin;
ALTER TABLE sessions
DROP COLUMN IF EXISTS pinned_at,
DROP COLUMN IF EXISTS is_pinned,
DROP COLUMN IF EXISTS user_id;
@@ -0,0 +1,21 @@
-- Migration: 000039_session_user_id_and_pin
-- Description: Add user_id, is_pinned, pinned_at to sessions for per-user
-- session ownership and user-level pinning. Existing rows keep
-- user_id = NULL and stay visible at the tenant level for
-- backward compatibility.
DO $$ BEGIN RAISE NOTICE '[Migration 000039] Adding user_id/is_pinned/pinned_at to sessions'; END $$;
ALTER TABLE sessions
ADD COLUMN IF NOT EXISTS user_id VARCHAR(36),
ADD COLUMN IF NOT EXISTS is_pinned BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS pinned_at TIMESTAMP WITH TIME ZONE;
-- Index for the common list query:
-- WHERE tenant_id = ? AND (user_id = ? OR user_id IS NULL) AND deleted_at IS NULL
-- ORDER BY is_pinned DESC, pinned_at DESC NULLS LAST, updated_at DESC
CREATE INDEX IF NOT EXISTS idx_sessions_tenant_user_pin
ON sessions (tenant_id, user_id, is_pinned DESC, pinned_at DESC, updated_at DESC)
WHERE deleted_at IS NULL;
DO $$ BEGIN RAISE NOTICE '[Migration 000039] sessions user_id/pin columns added'; END $$;