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:
wizardchen
2026-04-30 16:15:49 +08:00
committed by lyingbug
parent dbd804d6e3
commit fe0d24ae87
14 changed files with 244 additions and 130 deletions

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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',

View File

@@ -20,9 +20,6 @@ export default {
pinned: "고정됨",
pinFailed: "고정 실패, 나중에 다시 시도해 주세요",
unpinFailed: "고정 해제 실패, 나중에 다시 시도해 주세요",
searchPlaceholder: "대화 검색",
sourceWeb: "웹",
sourceIM: "IM",
confirmLogout: "정말 로그아웃 하시겠습니까?",
systemInfo: "시스템 정보",
knowledgeSearch: "검색",

View File

@@ -18,9 +18,6 @@ export default {
pinned: 'Закреплено',
pinFailed: 'Не удалось закрепить, попробуйте позже',
unpinFailed: 'Не удалось открепить, попробуйте позже',
searchPlaceholder: 'Поиск диалогов',
sourceWeb: 'Веб',
sourceIM: 'IM',
confirmLogout: 'Вы уверены, что хотите выйти?',
systemInfo: 'Информация о системе',
knowledgeSearch: 'Поиск',

View File

@@ -20,9 +20,6 @@ export default {
pinned: "已置顶",
pinFailed: "置顶失败,请稍后再试",
unpinFailed: "取消置顶失败,请稍后再试",
searchPlaceholder: "搜索会话",
sourceWeb: "网页",
sourceIM: "IM",
confirmLogout: "确定要退出登录吗?",
systemInfo: "系统信息",
knowledgeSearch: "搜索",

View File

@@ -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

View File

@@ -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)

View File

@@ -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,

View File

@@ -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))
}

View 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)
}
})
}
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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,