Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/lib/common/InPlaceEdit.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@
dispatch('submit', value);
}
}


/** @param {any} event */
function handleInput(event) {
dispatch('input', event);
}

/** @param {any} event */
function keydown(event) {
if (event.key == 'Escape') {
Expand All @@ -57,6 +62,7 @@
maxlength={maxLength}
use:focus
on:blur={() => submit()}
on:input={handleInput}
/>
</form>
{:else}
Expand Down
84 changes: 84 additions & 0 deletions src/lib/common/RemoteSearchInput.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<script>
import { Input, Dropdown, DropdownMenu, DropdownItem, Spinner, DropdownToggle } from '@sveltestrap/sveltestrap';
import { debounce } from 'lodash';

/** @type {{id: string, name: string} | null} */
export let selectedValue;
export let disabled = false;
export let placeholder = '';
/**

* @type {(arg0: any) => any[] | PromiseLike<any[]>}
*/
export let onSearch;
export let loading = false;

/** @type {any[]} */
let searchResults = [];
let isOpen = false;

// @ts-ignore
const debouncedSearch = debounce(async (query) => {
if (query.length) {
loading = true;
searchResults = await onSearch(query);
loading = false;
isOpen = true;
} else {
searchResults = [];
isOpen = false;
}
}, 500);

/**
* @param {any} e
*/
async function handleInput(e) {
const query = e.target.value;
selectedValue = { id: query, name: query };
await debouncedSearch(query);
}

/**
* @param {{ id: string; name: string; }} result
*/
function selectResult(result) {
selectedValue = result;
}

export function clearSearchResults() {
searchResults = [];
}

</script>

<div class="position-relative">
<Dropdown class="scrollable-dropdown" isOpen={isOpen && (searchResults.length > 0 || loading)} toggle={() => isOpen = !isOpen}>
<DropdownToggle tag="div">
<Input
type="text"
value={selectedValue?.name}
on:input={handleInput}
{disabled}
{placeholder}
/>
</DropdownToggle>
<DropdownMenu class="w-100">
{#if loading}
<DropdownItem>
<Spinner size="sm" />
</DropdownItem>
{:else}
{#each searchResults as result, index}
<DropdownItem
active={selectedValue?.id === result.id}
on:click={() => selectResult(result)}
title={result.name}
>
{result.name}
</DropdownItem>
{/each}
{/if}
</DropdownMenu>
</Dropdown>
</div>
16 changes: 15 additions & 1 deletion src/lib/common/nav-bar/NavItem.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@
/** @type {() => void} */
export let onDelete = () => {};

/** @type {() => void} */
export let onInput = () => {};

/** @param {any} e */
function handleTabClick(e) {
e.preventDefault();
Expand All @@ -79,6 +82,12 @@
e.preventDefault();
onDelete?.();
}

/** @param {any} e */
function handleTabInput(e) {
e.preventDefault();
onInput?.();
}
</script>

<li
Expand All @@ -102,7 +111,12 @@
on:click={(e) => handleTabClick(e)}
>
{#if allowEdit}
<InPlaceEdit bind:value={navBtnText} maxLength={maxEditLength} placeholder={editPlaceholder} />
<InPlaceEdit
bind:value={navBtnText}
maxLength={maxEditLength}
placeholder={editPlaceholder}
on:input={handleTabInput}
/>
{:else}
<div style="height: 100%" class="line-align-center">
<div>{navBtnText}</div>
Expand Down
1 change: 1 addition & 0 deletions src/lib/helpers/http.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ function skipLoader(config) {
new RegExp('http(s*)://(.*?)/role/(.*?)/details', 'g'),
new RegExp('http(s*)://(.*?)/user/(.*?)/details', 'g'),
new RegExp('http(s*)://(.*?)/agent/labels', 'g'),
new RegExp('http(s*)://(.*?)/conversation/state-search', 'g'),
];

if (config.method === 'post' && postRegexes.some(regex => regex.test(config.url || ''))) {
Expand Down
155 changes: 155 additions & 0 deletions src/lib/helpers/utils/storage-manager.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
class LocalStorageManager {
/**
* @param {{ maxSize?: number, overflowStrategy?: 'LRU' | 'EXPIRE_FIRST' }} options
*/
constructor(options = {}) {
this.maxSize = options.maxSize || 4 * 1024 * 1024;
this.overflowStrategy = options.overflowStrategy || 'EXPIRE_FIRST';
}

/**
* @param {string} key
* @param {any} value
* @param {number | null} ttl
*/
set(key, value, ttl = null) {
try {
const item = {
value,
meta: {
expire: ttl ? Date.now() + ttl : null,
lastAccess: Date.now()
}
};

const cost = this._calculateItemCost(key, JSON.stringify(item));

if (cost > this.maxSize) throw new Error('Item exceeds maximum storage size');

if (this._getTotalSize() + cost > this.maxSize) {
this._performCleanup(cost);
}

if (this._getTotalSize() + cost > this.maxSize) throw new Error('Item exceeds maximum storage size');

localStorage.setItem(key, JSON.stringify(item));
this._updateSizeCache(cost);
} catch (/** @type {any} */ error) {
console.error('Storage Error:', error);
}
}

/**
* @param {string} key
* @returns {any}
*/
get(key) {
const raw = localStorage.getItem(key);
if (!raw) return null;

const item = JSON.parse(raw);
if (this._isExpired(item)) {
this.remove(key);
return null;
}

item.meta.lastAccess = Date.now();
localStorage.setItem(key, JSON.stringify(item));
return item.value;
}

/**
* @param {string} key
*/
remove(key) {
const raw = localStorage.getItem(key);
localStorage.removeItem(key);
if (raw) this._updateSizeCache(-this._calculateItemCost(key, raw));
}

clear() {
localStorage.clear();
this._sizeCache = 0;
}

/**
* @param {any} item
* @returns {boolean}
*/
_isExpired(item) {
return item && item.meta && item.meta.expire && item.meta.expire < Date.now();
}

/**
* @param {string} key
* @param {string} valueString
* @returns {number}
*/
_calculateItemCost(key, valueString) {
const encoder = new TextEncoder();
return encoder.encode(key).length + encoder.encode(valueString).length;
}

_getTotalSize() {
if (!this._sizeCache) this._rebuildSizeCache();
return this._sizeCache;
}

_rebuildSizeCache() {
this._sizeCache = Array.from({ length: localStorage.length })
.reduce((total, _, i) => {
const key = localStorage.key(i);
const item = key ? localStorage.getItem(key) : null;
return total + (key && item ? this._calculateItemCost(key, item) : 0);
}, 0);
}

/**
* @param {number} delta
*/
_updateSizeCache(delta) {
this._sizeCache = (this._sizeCache || 0) + delta;
}

/**
* @param {number} requiredSpace
*/
_performCleanup(requiredSpace) {
const /** @type {any[]} */ candidates = [];

Array.from({ length: localStorage.length }).forEach((_, i) => {
const key = localStorage.key(i);
const raw = key ? localStorage.getItem(key) : null;
if (!key || !raw) {
return;
}
const item = JSON.parse(raw);
if (item && item.meta) {
candidates.push({
key,
size: this._calculateItemCost(key, raw),
expire: item.meta.expire || Infinity,
lastAccess: item.meta.lastAccess
});
}
});

switch (this.overflowStrategy) {
case 'EXPIRE_FIRST':
candidates.sort((a, b) => a.expire - b.expire);
break;
case 'LRU':
candidates.sort((a, b) => a.lastAccess - b.lastAccess);
break;
}

let freedSpace = 0;
while (freedSpace < requiredSpace && candidates.length > 0) {
const target = candidates.shift();
this.remove(target.key);
freedSpace += target.size;
}
}
}

export default LocalStorageManager;
3 changes: 2 additions & 1 deletion src/lib/langs/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -300,5 +300,6 @@
"Account Origin":"Account Origin",
"Update Date":"Update Date",
"Create Date":"Create Date",
"Active now":"Active now"
"Active now":"Active now",
"Reset":"Reset"
}
3 changes: 2 additions & 1 deletion src/lib/langs/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -495,5 +495,6 @@
"Account Origin":"账户起源",
"Update Date":"更新日期",
"Create Date":"创建日期",
"Active now":"正在工作"
"Active now":"正在工作",
"Reset":"重置"
}
12 changes: 12 additions & 0 deletions src/lib/scss/custom/common/_common.scss
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,15 @@ button:focus {
.thin-scrollbar {
scrollbar-width: thin;
}

.scrollable-dropdown {
.dropdown-menu {
max-height: 200px;
overflow-y: auto;
}
.dropdown-item {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
2 changes: 2 additions & 0 deletions src/lib/services/api-endpoints.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ export const endpoints = {
conversationTagsUpdateUrl: `${host}/conversation/{conversationId}/update-tags`,
fileUploadUrl: `${host}/agent/{agentId}/conversation/{conversationId}/upload`,
pinConversationUrl: `${host}/agent/{agentId}/conversation/{conversationId}/dashboard`,
conversationStateValueUrl: `${host}/conversation/state-search`,
conversationStateKeyListUrl: `${host}/conversation/state-key`,

// LLM provider
llmProvidersUrl: `${host}/llm-providers`,
Expand Down
34 changes: 34 additions & 0 deletions src/lib/services/conversation-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -288,4 +288,38 @@ export async function getAddressOptions(text) {
}
});
return response.data;
}

/**
* get conversation state key list
* @returns {Promise<{id: string, name: string, description: string}[]>}
*/
export async function getConversationStateKey() {
let url = endpoints.conversationStateKeyListUrl;
const response = await axios.get(url);
return response.data;
}

/** @type {import('axios').CancelTokenSource | null} */
let getConversationStateValueCancelToken = null;
/**
* get conversation state value
* @param {string} key
* @param {string} query
* @returns {Promise<{id: string, name: string}[]>}
*/
export async function getConversationStateValue(key, query) {
let url = endpoints.conversationStateValueUrl;
if (getConversationStateValueCancelToken) {
getConversationStateValueCancelToken.cancel();
}
getConversationStateValueCancelToken = axios.CancelToken.source();
const response = await axios.get(url, {
params: {
conversatinFilterType: key,
searchKey: query
},
cancelToken: getConversationStateValueCancelToken.token
});
return response.data;
}
Loading