mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-09-01 14:53:07 +08:00
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:
@@ -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) {
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: "문서 업로드",
|
||||
|
||||
@@ -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: 'Загрузить документ',
|
||||
|
||||
@@ -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: "上传文档",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 $$;
|
||||
Reference in New Issue
Block a user