refac
This commit is contained in:
@@ -176,6 +176,7 @@ class ChatResponse(BaseModel):
|
||||
|
||||
tasks: list | None = None
|
||||
summary: str | None = None
|
||||
context_usage: dict | None = None
|
||||
|
||||
|
||||
class ChatTitleIdResponse(BaseModel):
|
||||
|
||||
@@ -37,7 +37,7 @@ from open_webui.tasks import has_active_tasks, stop_item_tasks
|
||||
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
|
||||
from open_webui.utils.access_control.folders import has_folder_access
|
||||
from open_webui.utils.auth import get_admin_user, get_verified_user
|
||||
from open_webui.utils.context_compaction import compact_chat_branch
|
||||
from open_webui.utils.context_compaction import compact_chat_branch, get_chat_context_usage
|
||||
from open_webui.utils.misc import get_message_list
|
||||
from open_webui.utils.models import get_all_models
|
||||
from pydantic import BaseModel
|
||||
@@ -1157,6 +1157,7 @@ async def compact_chat_by_id(
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='No model found for context compaction.')
|
||||
|
||||
result = await compact_chat_branch(request, user, chat, model_id, request.app.state.MODELS)
|
||||
result['context_usage'] = await get_chat_context_usage(chat, model_id)
|
||||
if result.get('compacted'):
|
||||
await publish_event(
|
||||
request,
|
||||
@@ -1203,7 +1204,9 @@ async def get_chat_by_id(id: str, user=Depends(get_verified_user), db: AsyncSess
|
||||
chat = candidate
|
||||
|
||||
if chat:
|
||||
return ChatResponse(**chat.model_dump())
|
||||
data = ChatResponse(**chat.model_dump()).model_dump()
|
||||
data['context_usage'] = await get_chat_context_usage(chat)
|
||||
return data
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.NOT_FOUND)
|
||||
|
||||
|
||||
@@ -145,8 +145,6 @@ async def compact_messages_for_request(
|
||||
|
||||
async def compact_chat_branch(request, user, chat: Any, model_id: str, models: dict) -> dict:
|
||||
config = await _load_config()
|
||||
if not config['enable']:
|
||||
return {'ok': True, 'compacted': False, 'reason': 'disabled'}
|
||||
|
||||
history = (chat.chat or {}).get('history') or {}
|
||||
current_id = history.get('currentId')
|
||||
@@ -158,11 +156,11 @@ async def compact_chat_branch(request, user, chat: Any, model_id: str, models: d
|
||||
messages_map = history.get('messages') or {}
|
||||
|
||||
messages, previous_summary = _apply_latest_summary_checkpoint(get_message_list(messages_map, current_id))
|
||||
if len(messages) <= 2:
|
||||
return {'ok': True, 'compacted': False, 'reason': 'too_short'}
|
||||
|
||||
compacted_messages = messages[:-1]
|
||||
recent_messages = messages[-1:]
|
||||
if not compacted_messages or not recent_messages:
|
||||
return {'ok': True, 'compacted': False, 'reason': 'too_short'}
|
||||
|
||||
summary = await _generate_summary(
|
||||
request,
|
||||
user,
|
||||
@@ -215,6 +213,48 @@ def _resolve_token_threshold(global_threshold: int, global_cap: int, metadata: d
|
||||
return min(configured_threshold or global_threshold, global_cap)
|
||||
|
||||
|
||||
async def get_chat_context_usage(chat: Any, model_id: str | None = None) -> dict | None:
|
||||
history = (chat.chat or {}).get('history') or {}
|
||||
current_id = history.get('currentId')
|
||||
if not current_id:
|
||||
return None
|
||||
|
||||
messages_map = await Chats.get_messages_map_by_chat_id(chat.id)
|
||||
messages = get_message_list(messages_map or history.get('messages') or {}, current_id)
|
||||
if not messages:
|
||||
return None
|
||||
|
||||
config = await _load_config()
|
||||
params = ((chat.chat or {}).get('params') or {}).copy()
|
||||
if model_id:
|
||||
params['model'] = model_id
|
||||
threshold = _resolve_token_threshold(config['token_threshold'], config['token_cap'], {'params': params})
|
||||
messages, previous_summary = _apply_latest_summary_checkpoint(messages)
|
||||
|
||||
for idx in range(len(messages) - 1, -1, -1):
|
||||
usage = messages[idx].get('usage') or (messages[idx].get('info') or {}).get('usage')
|
||||
input_tokens = (usage or {}).get('input_tokens') or (usage or {}).get('prompt_tokens')
|
||||
if isinstance(usage, dict) and input_tokens:
|
||||
tokens = int(input_tokens or 0) + int(
|
||||
usage.get('output_tokens') or usage.get('completion_tokens') or 0
|
||||
)
|
||||
tokens += _estimate_messages_tokens(messages[idx + 1 :])
|
||||
return _build_context_usage(tokens, threshold)
|
||||
|
||||
tokens = _estimate_tokens(previous_summary or '') + _estimate_messages_tokens(messages)
|
||||
return _build_context_usage(tokens, threshold)
|
||||
|
||||
|
||||
def _build_context_usage(tokens: int, threshold: int) -> dict:
|
||||
return {
|
||||
'tokens': tokens,
|
||||
'estimated_tokens': tokens,
|
||||
'threshold': threshold,
|
||||
'percent': round((tokens / threshold) * 100) if threshold > 0 else 0,
|
||||
'source': 'estimated',
|
||||
}
|
||||
|
||||
|
||||
def _apply_latest_summary_checkpoint(messages: list[dict]) -> tuple[list[dict], str | None]:
|
||||
summary = None
|
||||
summary_idx = None
|
||||
|
||||
+12
@@ -68,6 +68,18 @@ code {
|
||||
font-family: 'InstrumentSerif', sans-serif;
|
||||
}
|
||||
|
||||
.app-muted {
|
||||
@apply text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.app-icon-muted {
|
||||
@apply text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
|
||||
.app-interactive-active {
|
||||
@apply bg-gray-50/40 dark:bg-gray-800/40;
|
||||
}
|
||||
|
||||
.marked a {
|
||||
@apply underline;
|
||||
}
|
||||
|
||||
@@ -1191,6 +1191,39 @@ export const updateChatById = async (token: string, id: string, chat: object) =>
|
||||
return res;
|
||||
};
|
||||
|
||||
export const compactChatById = async (token: string, id: string, model?: string | null) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/compact`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { authorization: `Bearer ${token}` })
|
||||
},
|
||||
body: JSON.stringify({ model })
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.then((json) => {
|
||||
return json;
|
||||
})
|
||||
.catch((err) => {
|
||||
error = err;
|
||||
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const deleteChatMessageById = async (token: string, id: string, messageId: string) => {
|
||||
let error = null;
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
|
||||
import {
|
||||
archiveChatById,
|
||||
compactChatById,
|
||||
createNewChat,
|
||||
deleteChatById,
|
||||
getAllTags,
|
||||
@@ -150,6 +151,94 @@
|
||||
} else {
|
||||
selectedModelIds = selectedModels;
|
||||
}
|
||||
let serverContextUsage = null;
|
||||
let contextUsage = null;
|
||||
|
||||
const estimateTokens = (value) => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return 0;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
try {
|
||||
value = JSON.stringify(value);
|
||||
} catch {
|
||||
value = String(value);
|
||||
}
|
||||
}
|
||||
return Math.max(1, Math.floor(value.length / 4));
|
||||
};
|
||||
|
||||
const estimateMessagesTokens = (messages) =>
|
||||
messages.reduce((total, message) => {
|
||||
let next = total + 4 + estimateTokens(message.content);
|
||||
next += estimateTokens(message.output);
|
||||
next += estimateTokens(message.tool_calls);
|
||||
next += estimateTokens(message.files);
|
||||
return next;
|
||||
}, 0);
|
||||
|
||||
const getContextThreshold = () => {
|
||||
const chatThreshold = Number(params?.compact_token_threshold);
|
||||
if (Number.isFinite(chatThreshold) && chatThreshold > 0) {
|
||||
return chatThreshold;
|
||||
}
|
||||
|
||||
const modelId = atSelectedModel?.id ?? selectedModels.find((id) => id);
|
||||
const model = $models.find((item) => item.id === modelId);
|
||||
const threshold = Number(model?.info?.params?.compact_token_threshold);
|
||||
return Number.isFinite(threshold) && threshold > 0 ? threshold : 80000;
|
||||
};
|
||||
|
||||
const getContextUsage = () => {
|
||||
if (!history?.currentId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const messages = createMessagesList(history, history.currentId);
|
||||
const threshold = getContextThreshold();
|
||||
const systemTokens = estimateTokens($settings?.system ?? '');
|
||||
let estimatedTokens = systemTokens;
|
||||
let hasUsageCheckpoint = false;
|
||||
let summary = '';
|
||||
let startIdx = 0;
|
||||
|
||||
for (let idx = 0; idx < messages.length; idx += 1) {
|
||||
const value = messages[idx]?.contextSummary ?? messages[idx]?.context_summary;
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
summary = value;
|
||||
startIdx = idx;
|
||||
}
|
||||
}
|
||||
|
||||
const activeMessages = messages.slice(startIdx);
|
||||
|
||||
for (let idx = activeMessages.length - 1; idx >= 0; idx -= 1) {
|
||||
const usage = activeMessages[idx]?.usage ?? activeMessages[idx]?.info?.usage;
|
||||
const inputTokens = usage?.input_tokens ?? usage?.prompt_tokens;
|
||||
if (inputTokens) {
|
||||
hasUsageCheckpoint = true;
|
||||
estimatedTokens =
|
||||
Number(inputTokens || 0) +
|
||||
Number(usage.output_tokens ?? usage.completion_tokens ?? 0) +
|
||||
estimateMessagesTokens(activeMessages.slice(idx + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasUsageCheckpoint) {
|
||||
estimatedTokens += estimateTokens(summary) + estimateMessagesTokens(activeMessages);
|
||||
}
|
||||
|
||||
return {
|
||||
tokens: estimatedTokens,
|
||||
estimated_tokens: estimatedTokens,
|
||||
threshold,
|
||||
percent: threshold > 0 ? Math.max(0, Math.round((estimatedTokens / threshold) * 100)) : 0,
|
||||
source: 'estimated'
|
||||
};
|
||||
};
|
||||
|
||||
$: contextUsage = getContextUsage() ?? serverContextUsage;
|
||||
|
||||
let selectedToolIds = [];
|
||||
let selectedSkillIds = [];
|
||||
@@ -1635,6 +1724,7 @@
|
||||
|
||||
// Load tasks from chat-level DB field
|
||||
chatTasks = chat?.tasks ?? [];
|
||||
serverContextUsage = chat?.context_usage ?? null;
|
||||
|
||||
autoScroll = true;
|
||||
await tick();
|
||||
@@ -2122,6 +2212,59 @@
|
||||
await sendMessage(history, userMessageId);
|
||||
};
|
||||
|
||||
const handleManualCompact = async () => {
|
||||
if (!$chatId || !history?.currentId) {
|
||||
toast.message($i18n.t('No chat to compact'));
|
||||
return;
|
||||
}
|
||||
|
||||
const currentMessage = history.messages?.[history.currentId];
|
||||
if (
|
||||
generating ||
|
||||
taskIds?.length ||
|
||||
(currentMessage?.role === 'assistant' && !currentMessage.done)
|
||||
) {
|
||||
toast.warning($i18n.t('Wait for the current response to finish before compacting.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const model = atSelectedModel?.id ?? selectedModels.find((modelId) => modelId);
|
||||
const toastId = toast.loading($i18n.t('Compacting context...'));
|
||||
|
||||
try {
|
||||
const result = await compactChatById(localStorage.token, $chatId, model);
|
||||
serverContextUsage = result?.context_usage ?? serverContextUsage;
|
||||
|
||||
if (result?.compacted) {
|
||||
toast.success($i18n.t('Context compacted'), { id: toastId });
|
||||
} else {
|
||||
const skippedReason =
|
||||
result?.reason === 'too_short'
|
||||
? $i18n.t('Chat is too short to compact')
|
||||
: result?.reason === 'empty'
|
||||
? $i18n.t('No chat to compact')
|
||||
: $i18n.t('Nothing to compact');
|
||||
toast.message(skippedReason, { id: toastId });
|
||||
}
|
||||
|
||||
await loadChat();
|
||||
} catch (error) {
|
||||
const message = error?.detail ?? error?.message ?? $i18n.t('Context compaction failed');
|
||||
toast.error(message, { id: toastId });
|
||||
} finally {
|
||||
messageInput?.setText('');
|
||||
prompt = '';
|
||||
document.getElementById('chat-input')?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusCommand = () => {
|
||||
messageInput?.showStatus();
|
||||
messageInput?.setText('');
|
||||
prompt = '';
|
||||
document.getElementById('chat-input')?.focus();
|
||||
};
|
||||
|
||||
const submitHandler = async (userPrompt, { _raw = false } = {}) => {
|
||||
console.log('submitHandler', userPrompt, $chatId);
|
||||
|
||||
@@ -2133,6 +2276,15 @@
|
||||
selectedModels = _selectedModels;
|
||||
}
|
||||
|
||||
if (String(userPrompt).trim() === '/compact') {
|
||||
await handleManualCompact();
|
||||
return;
|
||||
}
|
||||
if (String(userPrompt).trim() === '/status') {
|
||||
handleStatusCommand();
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingOAuthTools.length > 0) {
|
||||
toast.warning($i18n.t('Please connect all required integrations before sending a message'));
|
||||
return;
|
||||
@@ -3295,6 +3447,10 @@
|
||||
bind:atSelectedModel
|
||||
bind:showCommands
|
||||
bind:dragged
|
||||
chatId={$chatId}
|
||||
{contextUsage}
|
||||
compactHandler={handleManualCompact}
|
||||
statusHandler={handleStatusCommand}
|
||||
toolServers={$toolServers}
|
||||
{generating}
|
||||
{stopResponse}
|
||||
|
||||
@@ -114,6 +114,10 @@
|
||||
|
||||
export let createMessagePair: Function;
|
||||
export let stopResponse: Function;
|
||||
export let compactHandler: Function = () => {};
|
||||
export let statusHandler: Function = () => {};
|
||||
export let chatId = '';
|
||||
export let contextUsage = null;
|
||||
|
||||
export let autoScroll = false;
|
||||
export let generating = false;
|
||||
@@ -132,6 +136,7 @@
|
||||
(taskIds && taskIds.length > 0) ||
|
||||
(history.currentId && history.messages[history.currentId]?.done != true) ||
|
||||
generating;
|
||||
$: canCompact = !!history?.currentId;
|
||||
|
||||
export let prompt = '';
|
||||
export let files = [];
|
||||
@@ -163,6 +168,8 @@
|
||||
let inputVariableValues = {};
|
||||
|
||||
let showValvesModal = false;
|
||||
let showStatusPanel = false;
|
||||
let copiedStatusChatId = false;
|
||||
let selectedValvesType = 'tool'; // 'tool' or 'function'
|
||||
let selectedValvesItemId = null;
|
||||
let integrationsMenuCloseOnOutsideClick = true;
|
||||
@@ -364,6 +371,36 @@
|
||||
}
|
||||
};
|
||||
|
||||
export const showStatus = async () => {
|
||||
showStatusPanel = true;
|
||||
await tick();
|
||||
document.getElementById('chat-input')?.focus();
|
||||
};
|
||||
|
||||
const formatTokenCount = (value: number) => {
|
||||
if (value >= 1_000_000) return `${trimNumber(value / 1_000_000)}m`;
|
||||
if (value >= 1_000) return `${trimNumber(value / 1_000)}k`;
|
||||
return String(value ?? 0);
|
||||
};
|
||||
|
||||
const trimNumber = (value: number) =>
|
||||
value >= 10 ? String(Math.round(value)) : value.toFixed(1).replace(/\.0$/, '');
|
||||
|
||||
const copyStatusChatId = async () => {
|
||||
if (!chatId) return;
|
||||
await navigator.clipboard.writeText(chatId);
|
||||
copiedStatusChatId = true;
|
||||
setTimeout(() => {
|
||||
copiedStatusChatId = false;
|
||||
}, 1600);
|
||||
};
|
||||
|
||||
$: contextPercent = Math.max(0, Math.round(contextUsage?.percent ?? 0));
|
||||
$: contextValue = contextUsage
|
||||
? `${contextPercent}% ${formatTokenCount(contextUsage.estimated_tokens || contextUsage.tokens)}/${formatTokenCount(contextUsage.threshold)}`
|
||||
: $i18n.t('unknown');
|
||||
$: contextBarPercent = Math.min(contextPercent, 100);
|
||||
|
||||
const getCommand = () => {
|
||||
const chatInput = document.getElementById('chat-input');
|
||||
let word = '';
|
||||
@@ -1046,6 +1083,12 @@
|
||||
char: '/',
|
||||
render: getSuggestionRenderer(CommandSuggestionList, {
|
||||
i18n,
|
||||
canCompact: () => !!history?.currentId,
|
||||
compactDisabled: () => isActive,
|
||||
canStatus: () => !!history?.currentId,
|
||||
contextUsage: () => contextUsage,
|
||||
onCompact: compactHandler,
|
||||
onStatus: statusHandler,
|
||||
onSelect: (e) => {
|
||||
const { type, data } = e;
|
||||
|
||||
@@ -1359,6 +1402,86 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showStatusPanel}
|
||||
<div class="relative mx-2">
|
||||
<button
|
||||
type="button"
|
||||
class="fixed inset-0 z-40 cursor-default bg-transparent"
|
||||
aria-label={$i18n.t('Close')}
|
||||
on:click={() => {
|
||||
showStatusPanel = false;
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
class="absolute bottom-full right-0 z-50 mb-1 w-80 max-w-[calc(100vw-1.5rem)] rounded-xl border border-gray-100 bg-white p-0.5 text-xs shadow-lg dark:border-gray-800 dark:bg-gray-850 dark:text-white"
|
||||
>
|
||||
<div class="max-h-[70dvh] overflow-y-auto px-1 py-0.5 text-xs">
|
||||
<section>
|
||||
<div class="rounded-xl px-1.5 py-1.5 text-gray-600 dark:text-gray-400">
|
||||
<div class="flex h-5 items-center gap-3">
|
||||
<span class="min-w-0 flex-1 truncate">Context usage</span>
|
||||
<span
|
||||
class="shrink-0 font-mono text-[0.625rem] text-gray-400 dark:text-gray-600"
|
||||
>
|
||||
{contextValue}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="mt-2 h-px overflow-hidden rounded-full bg-gray-100 dark:bg-white/8"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full bg-gray-300 dark:bg-white/20"
|
||||
style={`width: ${contextBarPercent}%`}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if messageQueue.length}
|
||||
<div
|
||||
class="flex h-7 items-center gap-3 rounded-xl px-1.5 text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">Queued messages</span>
|
||||
<span class="font-mono text-[0.625rem] text-gray-400 dark:text-gray-600">
|
||||
{messageQueue.length}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if chatTasks.length}
|
||||
<div
|
||||
class="flex h-7 items-center gap-3 rounded-xl px-1.5 text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">Tasks</span>
|
||||
<span class="font-mono text-[0.625rem] text-gray-400 dark:text-gray-600">
|
||||
{chatTasks.length}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="flex h-7 items-center gap-3 rounded-xl px-1.5 text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
<span class="min-w-0 flex-1 truncate">Chat ID</span>
|
||||
{#if chatId}
|
||||
<button
|
||||
type="button"
|
||||
class="min-w-0 max-w-[11rem] truncate font-mono text-[0.625rem] text-gray-400 underline-offset-2 transition-colors duration-75 hover:text-gray-700 hover:underline dark:text-gray-600 dark:hover:text-gray-200"
|
||||
on:click={copyStatusChatId}
|
||||
>
|
||||
{copiedStatusChatId ? $i18n.t('Copied') : chatId}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="font-mono text-[0.625rem] text-gray-400 dark:text-gray-600">
|
||||
none
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
id="message-input-container"
|
||||
class="flex-1 flex flex-col relative w-full shadow-lg rounded-3xl border {$temporaryChatEnabled
|
||||
|
||||
@@ -12,7 +12,20 @@
|
||||
|
||||
export let onSelect: (e: any) => void = () => {};
|
||||
export let onUpload: (e: any) => void = () => {};
|
||||
export let onCompact: () => void = () => {};
|
||||
export let onStatus: () => void = () => {};
|
||||
export let insertTextHandler: (text: string) => void = () => {};
|
||||
export let canCompact: boolean | (() => boolean) = false;
|
||||
export let compactDisabled: boolean | (() => boolean) = false;
|
||||
export let canStatus: boolean | (() => boolean) = false;
|
||||
export let contextUsage = null;
|
||||
|
||||
$: compactAvailable = typeof canCompact === 'function' ? canCompact() : canCompact;
|
||||
$: isCompactDisabled =
|
||||
typeof compactDisabled === 'function' ? compactDisabled() : compactDisabled;
|
||||
$: statusAvailable = typeof canStatus === 'function' ? canStatus() : canStatus;
|
||||
$: resolvedContextUsage = typeof contextUsage === 'function' ? contextUsage() : contextUsage;
|
||||
$: contextPercent = Math.max(0, Math.round(resolvedContextUsage?.percent ?? 0));
|
||||
|
||||
let suggestionElement: any = null;
|
||||
let filteredItems: any[] = [];
|
||||
@@ -61,11 +74,21 @@
|
||||
bind:this={suggestionElement}
|
||||
{query}
|
||||
bind:filteredItems
|
||||
canCompact={compactAvailable}
|
||||
compactDisabled={isCompactDisabled}
|
||||
canStatus={statusAvailable}
|
||||
{contextPercent}
|
||||
onSelect={(e) => {
|
||||
const { type, data } = e;
|
||||
|
||||
if (type === 'prompt') {
|
||||
insertTextHandler(data.content);
|
||||
} else if (type === 'command' && data.id === 'compact') {
|
||||
insertTextHandler('');
|
||||
onCompact();
|
||||
} else if (type === 'command' && data.id === 'status') {
|
||||
insertTextHandler('');
|
||||
onStatus();
|
||||
} else if (type === 'skill') {
|
||||
command({
|
||||
id: `${data.id}|${data.name}`,
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
|
||||
export let query = '';
|
||||
export let onSelect = (e) => {};
|
||||
export let canCompact = false;
|
||||
export let compactDisabled = false;
|
||||
export let canStatus = false;
|
||||
export let contextPercent = 0;
|
||||
|
||||
let selectedIdx = 0;
|
||||
export let filteredItems = [];
|
||||
@@ -17,11 +21,24 @@
|
||||
let skills = [];
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
$: contextCirclePercent = Math.min(Math.max(0, Math.round(contextPercent)), 100);
|
||||
$: contextCircleOffset = 50.27 * (1 - contextCirclePercent / 100);
|
||||
|
||||
$: commandItems = [
|
||||
...(canCompact && 'compact'.startsWith(query.toLowerCase())
|
||||
? [{ type: 'command', data: { id: 'compact' } }]
|
||||
: []),
|
||||
...(canStatus && 'status'.startsWith(query.toLowerCase())
|
||||
? [{ type: 'command', data: { id: 'status' } }]
|
||||
: [])
|
||||
];
|
||||
|
||||
$: filteredPrompts = prompts
|
||||
.filter((p) => p.command.toLowerCase().includes(query.toLowerCase()))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
$: filteredItems = [
|
||||
...commandItems,
|
||||
...filteredPrompts.map((data) => ({ type: 'prompt', data })),
|
||||
...skills.map((data) => ({ type: 'skill', data }))
|
||||
];
|
||||
@@ -66,6 +83,9 @@
|
||||
|
||||
export const select = async () => {
|
||||
const item = filteredItems[selectedIdx];
|
||||
if (item?.type === 'command' && item.data?.id === 'compact' && compactDisabled) {
|
||||
return;
|
||||
}
|
||||
if (item) {
|
||||
onSelect(item);
|
||||
}
|
||||
@@ -89,15 +109,118 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if commandItems.length > 0}
|
||||
<div class="app-muted mb-0.5 px-2 pt-1 pb-0.5 text-[0.625rem] leading-none">
|
||||
{$i18n.t('Commands')}
|
||||
</div>
|
||||
|
||||
{#each commandItems as item, commandIdx}
|
||||
{#if item.data.id === 'compact'}
|
||||
<Tooltip content="Shorten older messages so this chat can keep going." placement="top">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Compact: shorten older messages so this chat can keep going."
|
||||
class="slash-command-row flex items-center gap-2 w-full h-6 px-2 rounded-xl text-xs text-left transition-colors duration-75
|
||||
{commandIdx === selectedIdx ? 'app-interactive-active' : ''} disabled:opacity-50"
|
||||
disabled={compactDisabled}
|
||||
on:mousedown={(e) => e.preventDefault()}
|
||||
on:click={() => {
|
||||
if (!compactDisabled) {
|
||||
onSelect(item);
|
||||
}
|
||||
}}
|
||||
on:mouseenter={() => {
|
||||
selectedIdx = commandIdx;
|
||||
}}
|
||||
on:focus={() => {}}
|
||||
data-selected={commandIdx === selectedIdx}
|
||||
>
|
||||
<span class="app-icon-muted flex items-center justify-center w-4 shrink-0">
|
||||
<svg class="size-3.5 -rotate-90" viewBox="0 0 20 20" aria-hidden="true">
|
||||
<circle
|
||||
cx="10"
|
||||
cy="10"
|
||||
r="8"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
class="opacity-20"
|
||||
/>
|
||||
<circle
|
||||
cx="10"
|
||||
cy="10"
|
||||
r="8"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="50.27"
|
||||
style={`stroke-dashoffset: ${contextCircleOffset};`}
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="flex-1 min-w-0 flex items-baseline gap-1.5 overflow-hidden">
|
||||
<span class="truncate">Compact</span>
|
||||
<span class="app-muted text-[0.625rem] truncate shrink-0">
|
||||
{contextCirclePercent}% full
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{:else if item.data.id === 'status'}
|
||||
<Tooltip content="Check what is running in this chat." placement="top">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Status: check what is running in this chat."
|
||||
class="slash-command-row flex items-center gap-2 w-full h-6 px-2 rounded-xl text-xs text-left transition-colors duration-75
|
||||
{commandIdx === selectedIdx ? 'app-interactive-active' : ''}"
|
||||
on:mousedown={(e) => e.preventDefault()}
|
||||
on:click={() => {
|
||||
onSelect(item);
|
||||
}}
|
||||
on:mouseenter={() => {
|
||||
selectedIdx = commandIdx;
|
||||
}}
|
||||
on:focus={() => {}}
|
||||
data-selected={commandIdx === selectedIdx}
|
||||
>
|
||||
<span class="app-icon-muted flex items-center justify-center w-4 shrink-0">
|
||||
<svg
|
||||
class="size-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 14l4-4" />
|
||||
<path d="M3.34 19a10 10 0 1 1 17.32 0" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="flex-1 min-w-0 flex items-baseline gap-1.5 overflow-hidden">
|
||||
<span class="truncate">Status</span>
|
||||
<span class="app-muted text-[0.625rem] truncate shrink-0">
|
||||
Check what is running in this chat.
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if filteredPrompts.length > 0}
|
||||
<div class="px-2 py-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||||
{$i18n.t('Prompts')}
|
||||
</div>
|
||||
|
||||
{#each filteredPrompts as promptItem, promptIdx}
|
||||
{@const itemIdx = commandItems.length + promptIdx}
|
||||
<Tooltip content={promptItem.name} placement="top-start">
|
||||
<button
|
||||
class="flex h-[1.6875rem] w-full items-center gap-1.5 rounded-xl px-2 text-left text-[13px] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 {promptIdx ===
|
||||
class="flex h-[1.6875rem] w-full items-center gap-1.5 rounded-xl px-2 text-left text-[13px] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 {itemIdx ===
|
||||
selectedIdx
|
||||
? 'bg-gray-50/40 dark:bg-gray-800/40 selected-command-option-button'
|
||||
: ''}"
|
||||
@@ -106,10 +229,10 @@
|
||||
onSelect({ type: 'prompt', data: promptItem });
|
||||
}}
|
||||
on:mousemove={() => {
|
||||
selectedIdx = promptIdx;
|
||||
selectedIdx = itemIdx;
|
||||
}}
|
||||
on:focus={() => {}}
|
||||
data-selected={promptIdx === selectedIdx}
|
||||
data-selected={itemIdx === selectedIdx}
|
||||
>
|
||||
<span class="shrink-0 font-normal text-black dark:text-gray-100">
|
||||
{promptItem.command}
|
||||
@@ -129,7 +252,7 @@
|
||||
</div>
|
||||
|
||||
{#each skills as skill, skillIdx}
|
||||
{@const itemIdx = filteredPrompts.length + skillIdx}
|
||||
{@const itemIdx = commandItems.length + filteredPrompts.length + skillIdx}
|
||||
<Tooltip
|
||||
content={getSkillTooltipContent(skill)}
|
||||
placement="top-start"
|
||||
|
||||
@@ -257,24 +257,23 @@
|
||||
const generateTitleHandler = async () => {
|
||||
const content = note.data.content.md;
|
||||
const DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE = `### Task:
|
||||
Generate a concise, 3-5 word title with an emoji summarizing the content in the content's primary language.
|
||||
Generate a concise title summarizing the content in the content's primary language.
|
||||
### Guidelines:
|
||||
- The title should clearly represent the main theme or subject of the content.
|
||||
- Use emojis that enhance understanding of the topic, but avoid quotation marks or special formatting.
|
||||
- Keep it short: 2-4 words is best.
|
||||
- Do not use emojis, quotation marks, or special formatting.
|
||||
- Write the title in the content's primary language.
|
||||
- Prioritize accuracy over excessive creativity; keep it clear and simple.
|
||||
- Prioritize accuracy over creativity.
|
||||
- Your entire response must consist solely of the JSON object, without any introductory or concluding text.
|
||||
- The output must be a single, raw JSON object, without any markdown code fences or other encapsulating text.
|
||||
- Ensure no conversational text, affirmations, or explanations precede or follow the raw JSON output, as this will cause direct parsing failure.
|
||||
### Output:
|
||||
JSON format: { "title": "your concise title here" }
|
||||
### Examples:
|
||||
- { "title": "📉 Stock Market Trends" },
|
||||
- { "title": "🍪 Perfect Chocolate Chip Recipe" },
|
||||
- { "title": "Evolution of Music Streaming" },
|
||||
- { "title": "Remote Work Productivity Tips" },
|
||||
- { "title": "Artificial Intelligence in Healthcare" },
|
||||
- { "title": "🎮 Video Game Development Insights" }
|
||||
- { "title": "Stock Trends" },
|
||||
- { "title": "Chocolate Chip Cookies" },
|
||||
- { "title": "Music Streaming" },
|
||||
- { "title": "Remote Work" }
|
||||
### Content:
|
||||
<content>
|
||||
${content}
|
||||
|
||||
Reference in New Issue
Block a user