enh: analytics model modal

This commit is contained in:
Timothy Jaeryang Baek
2026-02-04 23:42:46 -06:00
parent e8a36f033b
commit 68a1e87b66
8 changed files with 874 additions and 126 deletions
+44 -6
View File
@@ -111,7 +111,7 @@ class ChatMessageModel(BaseModel):
embeds: Optional[list] = None embeds: Optional[list] = None
done: bool = True done: bool = True
status_history: Optional[list] = None status_history: Optional[list] = None
error: Optional[dict] = None error: Optional[dict | str] = None
usage: Optional[dict] = None usage: Optional[dict] = None
created_at: int created_at: int
updated_at: int updated_at: int
@@ -269,6 +269,36 @@ class ChatMessageTable:
) )
return [ChatMessageModel.model_validate(message) for message in messages] return [ChatMessageModel.model_validate(message) for message in messages]
def get_chat_ids_by_model_id(
self,
model_id: str,
start_date: Optional[int] = None,
end_date: Optional[int] = None,
skip: int = 0,
limit: int = 50,
db: Optional[Session] = None,
) -> list[str]:
"""Get distinct chat_ids that used a specific model."""
from sqlalchemy import distinct
with get_db_context(db) as db:
query = db.query(distinct(ChatMessage.chat_id)).filter(
ChatMessage.model_id == model_id
)
if start_date:
query = query.filter(ChatMessage.created_at >= start_date)
if end_date:
query = query.filter(ChatMessage.created_at <= end_date)
# Order by most recent message in each chat
chat_ids = (
query.order_by(ChatMessage.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
return [chat_id for (chat_id,) in chat_ids]
def delete_messages_by_chat_id( def delete_messages_by_chat_id(
self, chat_id: str, db: Optional[Session] = None self, chat_id: str, db: Optional[Session] = None
) -> bool: ) -> bool:
@@ -289,7 +319,11 @@ class ChatMessageTable:
query = db.query( query = db.query(
ChatMessage.model_id, func.count(ChatMessage.id).label("count") ChatMessage.model_id, func.count(ChatMessage.id).label("count")
).filter(ChatMessage.role == "assistant", ChatMessage.model_id.isnot(None)) ).filter(
ChatMessage.role == "assistant",
ChatMessage.model_id.isnot(None),
~ChatMessage.user_id.like("shared-%"),
)
if start_date: if start_date:
query = query.filter(ChatMessage.created_at >= start_date) query = query.filter(ChatMessage.created_at >= start_date)
@@ -338,6 +372,7 @@ class ChatMessageTable:
ChatMessage.role == "assistant", ChatMessage.role == "assistant",
ChatMessage.model_id.isnot(None), ChatMessage.model_id.isnot(None),
ChatMessage.usage.isnot(None), ChatMessage.usage.isnot(None),
~ChatMessage.user_id.like("shared-%"),
) )
if start_date: if start_date:
@@ -396,6 +431,7 @@ class ChatMessageTable:
ChatMessage.role == "assistant", ChatMessage.role == "assistant",
ChatMessage.user_id.isnot(None), ChatMessage.user_id.isnot(None),
ChatMessage.usage.isnot(None), ChatMessage.usage.isnot(None),
~ChatMessage.user_id.like("shared-%"),
) )
if start_date: if start_date:
@@ -426,7 +462,7 @@ class ChatMessageTable:
query = db.query( query = db.query(
ChatMessage.user_id, func.count(ChatMessage.id).label("count") ChatMessage.user_id, func.count(ChatMessage.id).label("count")
) ).filter(~ChatMessage.user_id.like("shared-%"))
if start_date: if start_date:
query = query.filter(ChatMessage.created_at >= start_date) query = query.filter(ChatMessage.created_at >= start_date)
@@ -447,7 +483,7 @@ class ChatMessageTable:
query = db.query( query = db.query(
ChatMessage.chat_id, func.count(ChatMessage.id).label("count") ChatMessage.chat_id, func.count(ChatMessage.id).label("count")
) ).filter(~ChatMessage.user_id.like("shared-%"))
if start_date: if start_date:
query = query.filter(ChatMessage.created_at >= start_date) query = query.filter(ChatMessage.created_at >= start_date)
@@ -469,7 +505,8 @@ class ChatMessageTable:
query = db.query(ChatMessage.created_at, ChatMessage.model_id).filter( query = db.query(ChatMessage.created_at, ChatMessage.model_id).filter(
ChatMessage.role == "assistant", ChatMessage.role == "assistant",
ChatMessage.model_id.isnot(None) ChatMessage.model_id.isnot(None),
~ChatMessage.user_id.like("shared-%"),
) )
if start_date: if start_date:
@@ -511,7 +548,8 @@ class ChatMessageTable:
query = db.query(ChatMessage.created_at, ChatMessage.model_id).filter( query = db.query(ChatMessage.created_at, ChatMessage.model_id).filter(
ChatMessage.role == "assistant", ChatMessage.role == "assistant",
ChatMessage.model_id.isnot(None) ChatMessage.model_id.isnot(None),
~ChatMessage.user_id.like("shared-%"),
) )
if start_date: if start_date:
+17
View File
@@ -191,6 +191,23 @@ class FeedbackTable:
except Exception: except Exception:
return None return None
def get_feedbacks_by_chat_id(
self, chat_id: str, db: Optional[Session] = None
) -> list[FeedbackModel]:
"""Get all feedbacks for a specific chat."""
try:
with get_db_context(db) as db:
# meta.chat_id stores the chat reference
feedbacks = (
db.query(Feedback)
.filter(Feedback.meta["chat_id"].as_string() == chat_id)
.order_by(Feedback.created_at.desc())
.all()
)
return [FeedbackModel.model_validate(fb) for fb in feedbacks]
except Exception:
return []
def get_feedback_items( def get_feedback_items(
self, self,
filter: dict = {}, filter: dict = {},
+200
View File
@@ -1,9 +1,14 @@
from typing import Optional from typing import Optional
from datetime import datetime, timedelta
from collections import defaultdict
import logging import logging
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel from pydantic import BaseModel
from open_webui.models.chat_messages import ChatMessages, ChatMessageModel from open_webui.models.chat_messages import ChatMessages, ChatMessageModel
from open_webui.models.chats import Chats
from open_webui.models.users import Users
from open_webui.models.feedbacks import Feedbacks
from open_webui.utils.auth import get_admin_user from open_webui.utils.auth import get_admin_user
from open_webui.internal.db import get_session from open_webui.internal.db import get_session
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -245,3 +250,198 @@ async def get_token_usage(
total_output_tokens=total_output, total_output_tokens=total_output,
total_tokens=total_input + total_output, total_tokens=total_input + total_output,
) )
####################
# Model Chats Browser
####################
class ModelChatEntry(BaseModel):
chat_id: str
user_id: Optional[str] = None
user_name: Optional[str] = None
first_message: Optional[str] = None
updated_at: int
class ModelChatsResponse(BaseModel):
chats: list[ModelChatEntry]
total: int
@router.get("/models/{model_id}/chats", response_model=ModelChatsResponse)
async def get_model_chats(
model_id: str,
start_date: Optional[int] = Query(None),
end_date: Optional[int] = Query(None),
skip: int = Query(0),
limit: int = Query(50, le=100),
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
"""Get chats that used a specific model, with preview and feedback info."""
# Get chat IDs that used this model
chat_ids = ChatMessages.get_chat_ids_by_model_id(
model_id=model_id,
start_date=start_date,
end_date=end_date,
skip=skip,
limit=limit,
db=db,
)
if not chat_ids:
return ModelChatsResponse(chats=[], total=0)
# Get chat details from messages only
chats_data = []
for chat_id in chat_ids:
messages = ChatMessages.get_messages_by_chat_id(chat_id, db=db)
if not messages:
continue
# Get user_id from first user message
first_user_msg = next((m for m in messages if m.role == "user"), None)
user_id = first_user_msg.user_id if first_user_msg else None
# Extract first message content as preview
first_message = None
if first_user_msg and first_user_msg.content:
content = first_user_msg.content
if isinstance(content, str):
first_message = content[:200]
elif isinstance(content, list):
text_parts = [
b.get("text", "") for b in content if isinstance(b, dict)
]
first_message = " ".join(text_parts)[:200]
# Get user info
user_name = None
if user_id:
user_info = Users.get_user_by_id(user_id, db=db)
user_name = user_info.name if user_info else None
# Timestamps from messages
updated_at = max(m.created_at for m in messages) if messages else 0
chats_data.append(
ModelChatEntry(
chat_id=chat_id,
user_id=user_id,
user_name=user_name,
first_message=first_message,
updated_at=updated_at,
)
)
return ModelChatsResponse(chats=chats_data, total=len(chats_data))
####################
# Model Overview
####################
class HistoryEntry(BaseModel):
date: str
won: int = 0
lost: int = 0
class TagEntry(BaseModel):
tag: str
count: int
class ModelOverviewResponse(BaseModel):
history: list[HistoryEntry]
tags: list[TagEntry]
@router.get("/models/{model_id}/overview", response_model=ModelOverviewResponse)
async def get_model_overview(
model_id: str,
days: int = Query(30, description="Number of days of history (0 for all)"),
user=Depends(get_admin_user),
db: Session = Depends(get_session),
):
"""Get model overview with feedback history and chat tags."""
# Get chat IDs that used this model
chat_ids = ChatMessages.get_chat_ids_by_model_id(
model_id=model_id,
start_date=None,
end_date=None,
skip=0,
limit=10000, # Get all chats
db=db,
)
# Get feedback history per day
history_counts: dict[str, dict] = defaultdict(lambda: {"won": 0, "lost": 0})
# Calculate start date for history
now = datetime.now()
start_dt = None
if days > 0:
start_dt = now - timedelta(days=days)
for chat_id in chat_ids:
feedbacks = Feedbacks.get_feedbacks_by_chat_id(chat_id, db=db)
for fb in feedbacks:
if fb.data and "rating" in fb.data:
rating = fb.data["rating"]
fb_date = datetime.fromtimestamp(fb.created_at)
# Filter by date range
if start_dt and fb_date < start_dt:
continue
date_str = fb_date.strftime("%Y-%m-%d")
if rating == 1:
history_counts[date_str]["won"] += 1
elif rating == -1:
history_counts[date_str]["lost"] += 1
# Fill in missing days
history = []
if history_counts or days > 0:
end_dt = now
if days > 0:
current = start_dt
elif history_counts:
# Find earliest date
min_date = min(history_counts.keys())
current = datetime.strptime(min_date, "%Y-%m-%d")
else:
current = now
while current <= end_dt:
date_str = current.strftime("%Y-%m-%d")
counts = history_counts.get(date_str, {"won": 0, "lost": 0})
history.append(HistoryEntry(
date=date_str,
won=counts["won"],
lost=counts["lost"],
))
current += timedelta(days=1)
# Get chat tags
tag_counts: dict[str, int] = defaultdict(int)
for chat_id in chat_ids:
chat = Chats.get_chat_by_id(chat_id, db=db)
if chat and chat.meta:
for tag in chat.meta.get("tags", []):
tag_counts[tag] += 1
# Sort by count and take top 10
tags = [
TagEntry(tag=tag, count=count)
for tag, count in sorted(tag_counts.items(), key=lambda x: -x[1])[:10]
]
return ModelOverviewResponse(history=history, tags=tags)
+76
View File
@@ -229,3 +229,79 @@ export const getTokenUsage = async (
return res; return res;
}; };
export const getModelChats = async (
token: string = '',
modelId: string,
startDate: number | null = null,
endDate: number | null = null,
skip: number = 0,
limit: number = 50
) => {
let error = null;
const searchParams = new URLSearchParams();
if (startDate) searchParams.append('start_date', startDate.toString());
if (endDate) searchParams.append('end_date', endDate.toString());
if (skip) searchParams.append('skip', skip.toString());
if (limit) searchParams.append('limit', limit.toString());
const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/models/${encodeURIComponent(modelId)}/chats?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getModelOverview = async (
token: string = '',
modelId: string,
days: number = 30
) => {
let error = null;
const searchParams = new URLSearchParams();
searchParams.append('days', days.toString());
const res = await fetch(`${WEBUI_API_BASE_URL}/analytics/models/${encodeURIComponent(modelId)}/overview?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
@@ -0,0 +1,248 @@
<script lang="ts">
import Modal from '$lib/components/common/Modal.svelte';
import { getContext } from 'svelte';
import { getModelChats, getModelOverview } from '$lib/apis/analytics';
import ModelActivityChart from '$lib/components/admin/Evaluations/ModelActivityChart.svelte';
import ChatList from '$lib/components/common/ChatList.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import { config } from '$lib/stores';
export let show = false;
export let model: { id: string; name: string } | null = null;
export let startDate: number | null = null;
export let endDate: number | null = null;
export let onClose: () => void = () => {};
const i18n = getContext('i18n');
type Tab = 'overview' | 'chats';
let selectedTab: Tab = 'overview';
// Overview tab state
type TimeRange = '30d' | '1y' | 'all';
const TIME_RANGES: { key: TimeRange; label: string; days: number }[] = [
{ key: '30d', label: '30D', days: 30 },
{ key: '1y', label: '1Y', days: 365 },
{ key: 'all', label: 'All', days: 0 }
];
let selectedRange: TimeRange = '30d';
let history: Array<{ date: string; won: number; lost: number }> = [];
let tags: Array<{ tag: string; count: number }> = [];
let loadingOverview = false;
// Chats tab state
let chatList: Array<{ id: string; title: string; updated_at: number; user_id?: string; user_name?: string }> = [];
let chatListLoading = false;
let allChatsLoaded = false;
const PAGE_SIZE = 50;
const close = () => {
show = false;
selectedTab = 'overview';
chatList = [];
allChatsLoaded = false;
history = [];
tags = [];
onClose();
};
const loadOverview = async (days: number) => {
if (!model?.id) return;
loadingOverview = true;
try {
const result = await getModelOverview(localStorage.token, model.id, days);
history = result?.history ?? [];
tags = result?.tags ?? [];
} catch (err) {
console.error('Failed to load overview:', err);
history = [];
tags = [];
}
loadingOverview = false;
};
const selectRange = (range: TimeRange) => {
selectedRange = range;
const config = TIME_RANGES.find((r) => r.key === range);
if (config) {
loadOverview(config.days);
}
};
const loadChats = async () => {
if (!model?.id) return;
chatListLoading = true;
chatList = [];
allChatsLoaded = false;
try {
const res = await getModelChats(localStorage.token, model.id, startDate, endDate, 0, PAGE_SIZE);
const chats = res?.chats ?? [];
chatList = chats.map((c: any) => ({
id: c.chat_id,
title: c.first_message || 'No preview',
updated_at: c.updated_at,
user_id: c.user_id,
user_name: c.user_name
}));
allChatsLoaded = chats.length < PAGE_SIZE;
} catch (err) {
console.error('Failed to load chats:', err);
chatList = [];
allChatsLoaded = true;
}
chatListLoading = false;
};
const loadMoreChats = async () => {
if (!model?.id || chatListLoading || allChatsLoaded) return;
chatListLoading = true;
try {
const skip = chatList.length;
const res = await getModelChats(localStorage.token, model.id, startDate, endDate, skip, PAGE_SIZE);
const chats = res?.chats ?? [];
const newChats = chats.map((c: any) => ({
id: c.chat_id,
title: c.first_message || 'No preview',
updated_at: c.updated_at,
user_id: c.user_id,
user_name: c.user_name
}));
chatList = [...chatList, ...newChats];
allChatsLoaded = chats.length < PAGE_SIZE;
} catch (err) {
console.error('Failed to load more chats:', err);
}
chatListLoading = false;
};
const selectTab = (tab: Tab) => {
selectedTab = tab;
if (tab === 'chats' && chatList.length === 0) {
loadChats();
}
};
// Load overview when modal opens
$: if (show && model?.id) {
selectedTab = 'overview';
chatList = [];
allChatsLoaded = false;
selectRange(selectedRange);
}
</script>
<Modal size="md" bind:show>
{#if model}
<div class="flex justify-between dark:text-gray-300 px-5 pt-4 pb-2">
<Tooltip content={`${model.name} (${model.id})`} placement="top-start">
<div class="text-lg font-medium self-center line-clamp-1">
{model.name}
</div>
</Tooltip>
<button class="self-center" on:click={close} aria-label="Close">
<XMark className={'size-5'} />
</button>
</div>
<!-- Tabs -->
<div class="px-5 border-b border-gray-100 dark:border-gray-850">
<div class="flex gap-4">
<button
class="py-2 text-sm font-medium border-b-2 transition-colors {selectedTab === 'overview'
? 'border-black dark:border-white text-gray-900 dark:text-white'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'}"
on:click={() => selectTab('overview')}
>
{$i18n.t('Overview')}
</button>
{#if $config?.features?.enable_admin_chat_access}
<button
class="py-2 text-sm font-medium border-b-2 transition-colors {selectedTab === 'chats'
? 'border-black dark:border-white text-gray-900 dark:text-white'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'}"
on:click={() => selectTab('chats')}
>
{$i18n.t('Chats')}
</button>
{/if}
</div>
</div>
<div class="px-5 pb-4 dark:text-gray-200">
{#if selectedTab === 'overview'}
<!-- Activity Chart -->
<div class="mb-4 mt-3">
<div class="flex items-center justify-between mb-2">
<Tooltip content={$i18n.t('Thumbs up/down ratings from users on model responses')}>
<div class="text-xs text-gray-500 font-medium uppercase tracking-wide cursor-help">
{$i18n.t('Feedback Activity')}
</div>
</Tooltip>
<div
class="inline-flex rounded-full bg-gray-100/80 p-0.5 dark:bg-gray-800/80 backdrop-blur-sm"
>
{#each TIME_RANGES as range}
<button
type="button"
class="rounded-full transition-all duration-200 px-2.5 py-0.5 text-xs font-medium {selectedRange ===
range.key
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'}"
on:click={() => selectRange(range.key)}
>
{range.label}
</button>
{/each}
</div>
</div>
<ModelActivityChart
{history}
loading={loadingOverview}
aggregateWeekly={selectedRange === '1y' || selectedRange === 'all'}
/>
</div>
<!-- Tags -->
<div class="mb-4">
<div class="text-xs text-gray-500 mb-2 font-medium uppercase tracking-wide">
{$i18n.t('Tags')}
</div>
{#if tags.length}
<div class="flex flex-wrap gap-1 -mx-1">
{#each tags as tagInfo}
<span class="px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-850 text-xs">
{tagInfo.tag} <span class="text-gray-500 font-medium">{tagInfo.count}</span>
</span>
{/each}
</div>
{:else}
<span class="text-gray-500 text-sm">-</span>
{/if}
</div>
{:else if selectedTab === 'chats'}
<div class="mt-3">
<ChatList
{chatList}
loading={chatListLoading}
allLoaded={allChatsLoaded}
showUserInfo={true}
shareUrl={true}
onLoadMore={loadMoreChats}
onChatClick={() => (show = false)}
/>
</div>
{/if}
<div class="flex justify-end pt-4">
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
type="button"
on:click={close}
>
{$i18n.t('Close')}
</button>
</div>
</div>
{/if}
</Modal>
@@ -6,8 +6,10 @@
import ChevronUp from '$lib/components/icons/ChevronUp.svelte'; import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte'; import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
import ChartLine from './ChartLine.svelte'; import ChartLine from './ChartLine.svelte';
import AnalyticsModelModal from './AnalyticsModelModal.svelte';
import { WEBUI_API_BASE_URL } from '$lib/constants'; import { WEBUI_API_BASE_URL } from '$lib/constants';
import { formatNumber } from '$lib/utils'; import { formatNumber } from '$lib/utils';
import { goto } from '$app/navigation';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@@ -43,6 +45,10 @@
let loading = true; let loading = true;
// Selected model for drill-down
let selectedModel: { id: string; name: string } | null = null;
let showModelModal = false;
// Sorting // Sorting
let modelOrderBy = 'count'; let modelOrderBy = 'count';
let modelDirection: 'asc' | 'desc' = 'desc'; let modelDirection: 'asc' | 'desc' = 'desc';
@@ -157,6 +163,14 @@
</select> </select>
</div> </div>
<!-- Model Details Modal -->
<AnalyticsModelModal
bind:show={showModelModal}
model={selectedModel}
startDate={getDateRange(selectedPeriod).start}
endDate={getDateRange(selectedPeriod).end}
/>
<!-- Summary stats --> <!-- Summary stats -->
{#if !loading} {#if !loading}
<div class="flex gap-3 text-xs text-gray-500 dark:text-gray-400 px-0.5 pb-2"> <div class="flex gap-3 text-xs text-gray-500 dark:text-gray-400 px-0.5 pb-2">
@@ -241,7 +255,10 @@
</thead> </thead>
<tbody> <tbody>
{#each sortedModels as model, idx (model.model_id)} {#each sortedModels as model, idx (model.model_id)}
<tr class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs"> <tr
class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
on:click={() => { selectedModel = { id: model.model_id, name: model.name }; showModelModal = true; }}
>
<td class="px-3 py-1 text-gray-400">{idx + 1}</td> <td class="px-3 py-1 text-gray-400">{idx + 1}</td>
<td class="px-3 py-1 font-medium text-gray-900 dark:text-white"> <td class="px-3 py-1 font-medium text-gray-900 dark:text-white">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
+121
View File
@@ -0,0 +1,121 @@
<script lang="ts">
import { getContext } from 'svelte';
import dayjs from 'dayjs';
import calendar from 'dayjs/plugin/calendar';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import Spinner from '$lib/components/common/Spinner.svelte';
import Loader from '$lib/components/common/Loader.svelte';
dayjs.extend(calendar);
const i18n = getContext('i18n');
export let chatList: Array<{
id: string;
title: string;
updated_at: number;
user_id?: string;
user_name?: string;
time_range?: string;
}> | null = null;
export let loading = false;
export let allLoaded = false;
export let showUserInfo = false;
export let shareUrl = false;
export let emptyMessage = 'No chats found';
export let onLoadMore: (() => void) | null = null;
export let onChatClick: ((chatId: string) => void) | null = null;
</script>
<div>
{#if chatList && chatList.length > 0}
<div class="flex text-xs font-medium mb-1.5">
{#if showUserInfo}
<div class="px-1.5 py-1 w-32">
{$i18n.t('User')}
</div>
{/if}
<div class="px-1.5 py-1 {showUserInfo ? 'flex-1' : 'basis-3/5'}">
{$i18n.t('Title')}
</div>
<div class="px-1.5 py-1 hidden sm:flex {showUserInfo ? 'w-28' : 'basis-2/5'} justify-end">
{$i18n.t('Updated at')}
</div>
</div>
{/if}
<div class="max-h-[22rem] overflow-y-scroll">
{#if loading && (!chatList || chatList.length === 0)}
<div class="flex justify-center py-8">
<Spinner />
</div>
{:else if !chatList || chatList.length === 0}
<div class="text-center text-gray-500 text-sm py-8">
{$i18n.t(emptyMessage)}
</div>
{:else}
{#each chatList as chat, idx (chat.id)}
{#if chat.time_range && (idx === 0 || chat.time_range !== chatList[idx - 1]?.time_range)}
<div
class="w-full text-xs text-gray-500 dark:text-gray-500 font-medium {idx === 0
? ''
: 'pt-5'} pb-2 px-2"
>
{$i18n.t(chat.time_range)}
</div>
{/if}
<div
class="w-full flex items-center rounded-lg text-sm py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-850"
>
{#if showUserInfo && chat.user_id}
<div class="w-32 shrink-0 flex items-center gap-2">
<img
src="{WEBUI_API_BASE_URL}/users/{chat.user_id}/profile/image"
alt={chat.user_name || 'User'}
class="size-5 rounded-full object-cover shrink-0"
/>
<span class="text-xs text-gray-600 dark:text-gray-400 truncate"
>{chat.user_name || 'Unknown'}</span
>
</div>
{/if}
<a
class={showUserInfo ? 'flex-1' : 'basis-3/5'}
href={shareUrl ? `/s/${chat.id}` : `/c/${chat.id}`}
on:click={() => onChatClick?.(chat.id)}
>
<div class="text-ellipsis line-clamp-1 w-full">
{chat.title}
</div>
</a>
<div class="{showUserInfo ? 'w-28' : 'basis-2/5'} flex items-center justify-end">
<div class="hidden sm:flex text-gray-500 dark:text-gray-400 text-xs">
{dayjs(chat.updated_at * 1000).calendar(null, {
sameDay: '[Today] h:mm A',
lastDay: '[Yesterday] h:mm A',
lastWeek: 'MMM D',
sameElse: 'MMM D, YYYY'
})}
</div>
</div>
</div>
{/each}
{#if !allLoaded && onLoadMore}
<Loader
on:visible={() => {
if (!loading) {
onLoadMore();
}
}}
>
<div class="w-full flex justify-center py-1 text-xs animate-pulse items-center gap-2">
<Spinner className="size-4" />
<div>{$i18n.t('Loading...')}</div>
</div>
</Loader>
{/if}
{/if}
</div>
</div>
+150 -119
View File
@@ -10,6 +10,7 @@
dayjs.extend(calendar); dayjs.extend(calendar);
import { deleteChatById } from '$lib/apis/chats'; import { deleteChatById } from '$lib/apis/chats';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import Modal from '$lib/components/common/Modal.svelte'; import Modal from '$lib/components/common/Modal.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
@@ -31,6 +32,9 @@
export let title = 'Chats'; export let title = 'Chats';
export let emptyPlaceholder = ''; export let emptyPlaceholder = '';
export let shareUrl = false; export let shareUrl = false;
export let showUserInfo = false;
export let showSearch = true;
export let readOnly = false;
export let query = ''; export let query = '';
@@ -105,52 +109,61 @@
</div> </div>
<div class="flex flex-col w-full px-5 pb-4 dark:text-gray-200"> <div class="flex flex-col w-full px-5 pb-4 dark:text-gray-200">
<div class=" flex w-full space-x-2 mb-0.5"> {#if showSearch}
<div class="flex flex-1"> <div class=" flex w-full space-x-2 mt-0.5 mb-1.5">
<div class=" self-center ml-1 mr-3"> <div class="flex flex-1">
<svg <div class=" self-center ml-1 mr-3">
xmlns="http://www.w3.org/2000/svg" <svg
viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"
fill="currentColor" viewBox="0 0 20 20"
class="w-4 h-4" fill="currentColor"
> class="w-4 h-4"
<path
fill-rule="evenodd"
d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
clip-rule="evenodd"
/>
</svg>
</div>
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
placeholder={$i18n.t('Search Chats')}
maxlength="500"
/>
{#if query}
<div class="self-center pl-1.5 pr-1 translate-y-[0.5px] rounded-l-xl bg-transparent">
<button
class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
on:click={() => {
query = '';
selectedIdx = 0;
}}
> >
<XMark className="size-3" strokeWidth="2" /> <path
</button> fill-rule="evenodd"
d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
clip-rule="evenodd"
/>
</svg>
</div> </div>
{/if} <input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
placeholder={$i18n.t('Search Chats')}
maxlength="500"
/>
{#if query}
<div class="self-center pl-1.5 pr-1 translate-y-[0.5px] rounded-l-xl bg-transparent">
<button
class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
on:click={() => {
query = '';
selectedIdx = 0;
}}
>
<XMark className="size-3" strokeWidth="2" />
</button>
</div>
{/if}
</div>
</div> </div>
</div> {/if}
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6"> <div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
{#if chatList} {#if chatList}
<div class="w-full"> <div class="w-full">
{#if chatList.length > 0} {#if chatList.length > 0}
<div class="flex text-xs font-medium mb-1.5"> <div class="flex text-xs font-medium mb-1.5">
{#if showUserInfo}
<div class="px-1.5 py-1 w-32">
{$i18n.t('User')}
</div>
{/if}
<button <button
class="px-1.5 py-1 cursor-pointer select-none basis-3/5" class="px-1.5 py-1 cursor-pointer select-none {showUserInfo
? 'flex-1'
: 'basis-3/5'}"
on:click={() => setSortKey('title')} on:click={() => setSortKey('title')}
> >
<div class="flex gap-1.5 items-center"> <div class="flex gap-1.5 items-center">
@@ -172,7 +185,9 @@
</div> </div>
</button> </button>
<button <button
class="px-1.5 py-1 cursor-pointer select-none hidden sm:flex sm:basis-2/5 justify-end" class="px-1.5 py-1 cursor-pointer select-none hidden sm:flex {showUserInfo
? 'w-28'
: 'sm:basis-2/5'} justify-end"
on:click={() => setSortKey('updated_at')} on:click={() => setSortKey('updated_at')}
> >
<div class="flex gap-1.5 items-center"> <div class="flex gap-1.5 items-center">
@@ -234,11 +249,23 @@
{/if} {/if}
<div <div
class=" w-full flex justify-between items-center rounded-lg text-sm py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-850" class=" w-full flex items-center rounded-lg text-sm py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-850"
draggable="false" draggable="false"
> >
{#if showUserInfo && chat.user_id}
<div class="w-32 shrink-0 flex items-center gap-2">
<img
src="{WEBUI_API_BASE_URL}/users/{chat.user_id}/profile/image"
alt={chat.user_name || 'User'}
class="size-5 rounded-full object-cover shrink-0"
/>
<span class="text-xs text-gray-600 dark:text-gray-400 truncate"
>{chat.user_name || 'Unknown'}</span
>
</div>
{/if}
<a <a
class=" basis-3/5" class={showUserInfo ? 'flex-1' : 'basis-3/5'}
href={shareUrl ? `/s/${chat.id}` : `/c/${chat.id}`} href={shareUrl ? `/s/${chat.id}` : `/c/${chat.id}`}
on:click={() => (show = false)} on:click={() => (show = false)}
> >
@@ -247,7 +274,7 @@
</div> </div>
</a> </a>
<div class="basis-2/5 flex items-center justify-end"> <div class="{showUserInfo ? 'w-28' : 'basis-2/5'} flex items-center justify-end">
<div class="hidden sm:flex text-gray-500 dark:text-gray-400 text-xs"> <div class="hidden sm:flex text-gray-500 dark:text-gray-400 text-xs">
{$i18n.t( {$i18n.t(
dayjs(chat?.updated_at * 1000).calendar(null, { dayjs(chat?.updated_at * 1000).calendar(null, {
@@ -261,89 +288,93 @@
)} )}
</div> </div>
<div class="flex justify-end pl-2.5 text-gray-600 dark:text-gray-300"> {#if !readOnly}
{#if unarchiveHandler} <div class="flex justify-end pl-2.5 text-gray-600 dark:text-gray-300">
<Tooltip content={$i18n.t('Unarchive Chat')}> {#if unarchiveHandler}
<button <Tooltip content={$i18n.t('Unarchive Chat')}>
class="self-center w-fit px-1 text-sm rounded-xl" <button
on:click={async (e) => { class="self-center w-fit px-1 text-sm rounded-xl"
e.stopImmediatePropagation(); on:click={async (e) => {
e.stopPropagation(); e.stopImmediatePropagation();
unarchiveHandler(chat.id); e.stopPropagation();
}} unarchiveHandler(chat.id);
> }}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-4"
> >
<path <svg
stroke-linecap="round" xmlns="http://www.w3.org/2000/svg"
stroke-linejoin="round" fill="none"
d="M9 8.25H7.5a2.25 2.25 0 0 0-2.25 2.25v9a2.25 2.25 0 0 0 2.25 2.25h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25H15m0-3-3-3m0 0-3 3m3-3V15" viewBox="0 0 24 24"
/> stroke-width="1.5"
</svg> stroke="currentColor"
</button> class="size-4"
</Tooltip> >
{/if} <path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 8.25H7.5a2.25 2.25 0 0 0-2.25 2.25v9a2.25 2.25 0 0 0 2.25 2.25h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25H15m0-3-3-3m0 0-3 3m3-3V15"
/>
</svg>
</button>
</Tooltip>
{/if}
{#if unshareHandler && chat.share_id} {#if unshareHandler && chat.share_id}
<Tooltip content={$i18n.t('Copy Share Link')}> <Tooltip content={$i18n.t('Copy Share Link')}>
<button <button
class="self-center w-fit px-1 text-sm rounded-xl" class="self-center w-fit px-1 text-sm rounded-xl"
on:click={async (e) => { on:click={async (e) => {
e.stopImmediatePropagation(); e.stopImmediatePropagation();
e.stopPropagation(); e.stopPropagation();
const shareUrl = `${window.location.origin}/s/${chat.share_id}`; const shareUrl = `${window.location.origin}/s/${chat.share_id}`;
await navigator.clipboard.writeText(shareUrl); await navigator.clipboard.writeText(shareUrl);
toast.success($i18n.t('Share link copied to clipboard.')); toast.success($i18n.t('Share link copied to clipboard.'));
}} }}
> >
<Clipboard class="size-4" strokeWidth="1.5" /> <Clipboard class="size-4" strokeWidth="1.5" />
</button> </button>
</Tooltip> </Tooltip>
{/if} {/if}
<Tooltip <Tooltip
content={unshareHandler ? $i18n.t('Unshare Chat') : $i18n.t('Delete Chat')} content={unshareHandler
> ? $i18n.t('Unshare Chat')
<button : $i18n.t('Delete Chat')}
class="self-center w-fit px-1 text-sm rounded-xl"
on:click={async (e) => {
e.stopImmediatePropagation();
e.stopPropagation();
if (unshareHandler) {
unshareHandler(chat.id);
} else {
selectedChatId = chat.id;
showDeleteConfirmDialog = true;
}
}}
> >
{#if unshareHandler} <button
<LinkSlash /> class="self-center w-fit px-1 text-sm rounded-xl"
{:else} on:click={async (e) => {
<svg e.stopImmediatePropagation();
xmlns="http://www.w3.org/2000/svg" e.stopPropagation();
fill="none" if (unshareHandler) {
viewBox="0 0 24 24" unshareHandler(chat.id);
stroke-width="1.5" } else {
stroke="currentColor" selectedChatId = chat.id;
class="w-4 h-4" showDeleteConfirmDialog = true;
> }
<path }}
stroke-linecap="round" >
stroke-linejoin="round" {#if unshareHandler}
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" <LinkSlash />
/> {:else}
</svg> <svg
{/if} xmlns="http://www.w3.org/2000/svg"
</button> fill="none"
</Tooltip> viewBox="0 0 24 24"
</div> stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
{/if}
</button>
</Tooltip>
</div>
{/if}
</div> </div>
</div> </div>
{/each} {/each}