This commit is contained in:
Timothy Jaeryang Baek 2026-09-04 18:31:59 -04:00
parent 7b6562e335
commit 858ab727d2
39 changed files with 1033 additions and 345 deletions

View file

@ -2163,6 +2163,7 @@ else:
class BannerModel(BaseModel):
i18n: dict[str, dict[str, str]] | None = None
id: str
type: str
title: str | None = None

View file

@ -33,6 +33,7 @@ class Skill(Base):
class SkillMeta(BaseModel):
i18n: dict[str, dict[str, str]] | None = None
tags: Optional[list[str]] = []

View file

@ -34,6 +34,7 @@ class Tool(Base): # database table definition
class ToolMeta(BaseModel):
i18n: dict[str, dict[str, str]] | None = None
description: str | None = None
manifest: dict | None = {}
has_user_valves: bool = False

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { resolveLocalizedResource } from '$lib/utils/localizedContent';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { toast } from 'svelte-sonner';
@ -116,6 +117,9 @@
(selectedType !== '' ? f.type === selectedType : true) &&
(query === '' ||
f.name.toLowerCase().includes(query.toLowerCase()) ||
resolveLocalizedResource(f, $i18n.language)
.toLowerCase()
.includes(query.toLowerCase()) ||
f.id.toLowerCase().includes(query.toLowerCase()) ||
(f.user?.name || '').toLowerCase().includes(query.toLowerCase()) ||
(f.user?.email || '').toLowerCase().includes(query.toLowerCase()) ||
@ -522,7 +526,7 @@
<div
class="truncate text-[0.8125rem] leading-5 text-gray-800 group-hover:underline dark:text-gray-200"
>
{func.name}
{resolveLocalizedResource(func, $i18n.language)}
</div>
</Tooltip>
@ -544,16 +548,16 @@
</div>
</div>
{#if func?.meta?.description}
{#if resolveLocalizedResource(func, $i18n.language, 'description')}
<Tooltip
content={func?.meta?.description}
content={resolveLocalizedResource(func, $i18n.language, 'description')}
className="min-w-0"
placement="top-start"
>
<div
class="mt-0.5 truncate text-[0.6875rem] leading-4 text-gray-400 dark:text-gray-600"
>
{func?.meta?.description}
{resolveLocalizedResource(func, $i18n.language, 'description')}
</div>
</Tooltip>
{/if}
@ -754,7 +758,9 @@
}}
>
<div class=" text-sm text-gray-500 truncate">
{$i18n.t('This will delete')} <span class=" font-normal">{selectedFunction.name}</span>.
{$i18n.t('This will delete')}
<span class=" font-normal">{resolveLocalizedResource(selectedFunction, $i18n.language)}</span
>.
</div>
</DeleteConfirmDialog>
@ -763,6 +769,7 @@
bind:show={showValvesModal}
type="function"
id={selectedFunction?.id ?? null}
meta={selectedFunction?.meta}
on:save={async () => {
await tick();
models.set(

View file

@ -10,6 +10,11 @@
import Spinner from '$lib/components/common/Spinner.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
import LanguageModeSelect from '$lib/components/common/LanguageModeSelect.svelte';
import LocalizedField from '$lib/components/common/LocalizedField.svelte';
import PluginTranslations from '$lib/components/workspace/common/PluginTranslations.svelte';
import { pruneEmptyLocaleEntries } from '$lib/utils/localizedContent';
let locale = '';
let formElement = null;
let loading = false;
@ -22,6 +27,7 @@
export let id = '';
export let name = '';
/** @type {{description: string, i18n?: Record<string, Record<string, string>>, manifest?: {translations?: Record<string, Record<string, string>>}}} */
export let meta = {
description: ''
};
@ -320,12 +326,17 @@ class Pipe:
`;
const saveHandler = async () => {
if (!name.trim() || !meta.description?.trim()) {
locale = '';
toast.error($i18n.t('Name and description are required'));
return;
}
loading = true;
try {
await onSave({
id,
name,
meta,
meta: { ...meta, i18n: pruneEmptyLocaleEntries(meta.i18n) },
content
});
} finally {
@ -376,15 +387,14 @@ class Pipe:
<span>{$i18n.t('Back')}</span>
</button>
<div class="flex shrink-0 items-start gap-2 pb-2 px-1">
<div class="min-w-0 flex-1">
<div class="flex shrink-0 flex-col gap-2 pb-2 px-1 sm:flex-row sm:items-start">
<div class="min-w-0 w-full flex-1">
<Tooltip content={$i18n.t('e.g. My Filter')} placement="top-start">
<input
class="w-full bg-transparent text-sm outline-hidden"
type="text"
<LocalizedField
placeholder={$i18n.t('Function Name')}
aria-label={$i18n.t('Function Name')}
bind:value={name}
bind:translations={meta.i18n}
{locale}
required
/>
</Tooltip>
@ -417,12 +427,12 @@ class Pipe:
content={$i18n.t('e.g. A filter to remove profanity from text')}
placement="top-start"
>
<input
class="w-full bg-transparent outline-hidden"
type="text"
<LocalizedField
placeholder={$i18n.t('Function Description')}
aria-label={$i18n.t('Function Description')}
bind:value={meta.description}
bind:translations={meta.i18n}
{locale}
field="description"
required
/>
</Tooltip>
@ -430,6 +440,10 @@ class Pipe:
</div>
<div class="flex shrink-0 items-center gap-1">
<LanguageModeSelect
bind:value={locale}
translatedLocales={Object.keys(pruneEmptyLocaleEntries(meta.i18n))}
/>
{#if !edit}
<select
class="h-7 rounded-lg border border-gray-100 bg-transparent px-2 text-xs outline-hidden dark:border-gray-800"
@ -444,32 +458,42 @@ class Pipe:
</div>
</div>
<div class="min-h-0 flex-1 overflow-hidden rounded-lg">
<CodeEditor
bind:this={codeEditor}
value={content}
lang="python"
{boilerplate}
className="text-[0.6875rem]"
onChange={(e) => {
_content = e;
if (!edit) {
const fm = extractFrontmatter(e);
if (fm.title && !name) {
name = formatSkillName(fm.title);
id = nameToId(fm.title);
<div class="min-h-0 flex-1 overflow-hidden rounded-lg flex flex-col">
{#if locale}
<PluginTranslations
id={edit ? id : ''}
kind="function"
{locale}
bind:translations={meta.i18n}
/>
{/if}
<div class={locale ? 'hidden' : 'h-full'}>
<CodeEditor
bind:this={codeEditor}
value={content}
lang="python"
{boilerplate}
className="text-[0.6875rem]"
onChange={(e) => {
_content = e;
if (!edit) {
const fm = extractFrontmatter(e);
if (fm.title && !name) {
name = formatSkillName(fm.title);
id = nameToId(fm.title);
}
if (fm.description && !meta.description) {
meta = { ...meta, description: fm.description };
}
}
if (fm.description && !meta.description) {
meta = { ...meta, description: fm.description };
}}
onSave={async () => {
if (formElement) {
formElement.requestSubmit();
}
}
}}
onSave={async () => {
if (formElement) {
formElement.requestSubmit();
}
}}
/>
}}
/>
</div>
</div>
<div class="shrink-0 py-2 text-xs text-gray-500">

View file

@ -21,13 +21,14 @@
let evaluationConfig = null;
let showAddModel = false;
const submitHandler = async () => {
evaluationConfig = await updateConfig(localStorage.token, evaluationConfig).catch((err) => {
const submitHandler = async (nextConfig = evaluationConfig) => {
const result = await updateConfig(localStorage.token, nextConfig).catch((err) => {
toast.error(err);
return null;
});
if (evaluationConfig) {
if (result) {
evaluationConfig = result;
toast.success($i18n.t('Settings saved successfully!'));
models.set(
await getModels(
@ -38,32 +39,23 @@
)
);
}
return !!result;
};
const addModelHandler = async (model) => {
evaluationConfig.EVALUATION_ARENA_MODELS.push(model);
evaluationConfig.EVALUATION_ARENA_MODELS = [...evaluationConfig.EVALUATION_ARENA_MODELS];
await submitHandler();
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections ? ($settings?.directConnections ?? null) : null
)
);
return submitHandler({
...evaluationConfig,
EVALUATION_ARENA_MODELS: [...evaluationConfig.EVALUATION_ARENA_MODELS, model]
});
};
const editModelHandler = async (model, modelIdx) => {
evaluationConfig.EVALUATION_ARENA_MODELS[modelIdx] = model;
evaluationConfig.EVALUATION_ARENA_MODELS = [...evaluationConfig.EVALUATION_ARENA_MODELS];
await submitHandler();
models.set(
await getModels(
localStorage.token,
$config?.features?.enable_direct_connections ? ($settings?.directConnections ?? null) : null
return submitHandler({
...evaluationConfig,
EVALUATION_ARENA_MODELS: evaluationConfig.EVALUATION_ARENA_MODELS.map((item, index) =>
index === modelIdx ? model : item
)
);
});
};
const deleteModelHandler = async (modelIdx) => {
@ -90,12 +82,7 @@
});
</script>
<ArenaModelModal
bind:show={showAddModel}
on:submit={async (e) => {
addModelHandler(e.detail);
}}
/>
<ArenaModelModal bind:show={showAddModel} onSubmit={addModelHandler} />
<form
class="flex flex-col h-full justify-between text-sm"
@ -146,9 +133,7 @@
{#each evaluationConfig.EVALUATION_ARENA_MODELS as model, index}
<Model
{model}
on:edit={(e) => {
editModelHandler(e.detail, index);
}}
onEdit={(model) => editModelHandler(model, index)}
on:delete={(e) => {
deleteModelHandler(index);
}}

View file

@ -14,13 +14,19 @@
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import { WEBUI_BASE_URL } from '$lib/constants';
import LanguageModeSelect from '$lib/components/common/LanguageModeSelect.svelte';
import LocalizedField from '$lib/components/common/LocalizedField.svelte';
export let show = false;
export let edit = false;
export let model = null;
export let onSubmit = async (_model) => true;
let name = '';
let locale = '';
let translations = {};
let originalMeta = {};
let id = '';
$: if (name) {
@ -60,7 +66,7 @@
selectedModelId = '';
};
const submitHandler = () => {
const submitHandler = async () => {
loading = true;
if (!name || !id) {
@ -72,7 +78,6 @@
if (!edit) {
if ($models.find((model) => model.name === name)) {
loading = false;
name = '';
toast.error($i18n.t('Model name already exists, please choose a different one'));
return;
}
@ -82,6 +87,8 @@
id: id,
name: name,
meta: {
...originalMeta,
i18n: translations,
profile_image_url: profileImageUrl,
description: description || null,
model_ids: modelIds.length > 0 ? modelIds : null,
@ -90,8 +97,14 @@
}
};
dispatch('submit', model);
loading = false;
try {
if ((await onSubmit(model)) === false) return;
} catch (error) {
toast.error(String(error));
return;
} finally {
loading = false;
}
show = false;
name = '';
@ -106,6 +119,16 @@
};
const initModel = () => {
locale = '';
originalMeta = structuredClone(model?.meta ?? {});
translations = structuredClone(model?.meta?.i18n ?? {});
if (!model) {
name = '';
id = '';
description = '';
modelIds = [];
accessGrants = [];
}
if (model) {
name = model.name;
id = model.id;
@ -163,6 +186,12 @@
}}
>
<div class="px-1">
<div class="flex justify-end mb-3">
<LanguageModeSelect
bind:value={locale}
translatedLocales={Object.keys(translations)}
/>
</div>
<div class="flex justify-center pb-3">
<input
bind:this={imageInputElement}
@ -254,12 +283,12 @@
<div class=" mb-0.5 text-xs text-gray-500">{$i18n.t('Name')}</div>
<div class="flex-1">
<input
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
type="text"
<LocalizedField
bind:value={name}
bind:translations
{locale}
field="name"
placeholder={$i18n.t('Model Name')}
autocomplete="off"
required
/>
</div>
@ -286,12 +315,12 @@
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Description')}</div>
<div class="flex-1">
<input
class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
type="text"
<LocalizedField
bind:value={description}
bind:translations
{locale}
field="description"
placeholder={$i18n.t('Enter description')}
autocomplete="off"
/>
</div>
</div>

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { resolveLocalizedResource } from '$lib/utils/localizedContent';
import { getContext, createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
const i18n = getContext('i18n');
@ -7,6 +8,7 @@
import ArenaModelModal from './ArenaModelModal.svelte';
import { WEBUI_API_BASE_URL } from '$lib/constants';
export let model;
export let onEdit = async (_model) => true;
let showModel = false;
</script>
@ -15,9 +17,7 @@
bind:show={showModel}
edit={true}
{model}
on:submit={async (e) => {
dispatch('edit', e.detail);
}}
onSubmit={onEdit}
on:delete={async () => {
dispatch('delete');
}}
@ -29,14 +29,14 @@
<div class="flex gap-2.5 items-center">
<img
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${model.id}`}
alt={model.name}
alt={resolveLocalizedResource(model, $i18n.language)}
class="size-8 rounded-full object-cover shrink-0"
/>
<div class="w-full flex flex-col">
<div class="flex items-center gap-1">
<div class=" line-clamp-1">
{model.name}
{resolveLocalizedResource(model, $i18n.language)}
</div>
</div>

View file

@ -7,6 +7,7 @@
import { getBanners, setBanners } from '$lib/apis/configs';
import InterfaceSettings from '$lib/components/common/InterfaceSettings.svelte';
import SettingsSelect from '$lib/components/common/SettingsSelect.svelte';
import LanguageModeSelect from '$lib/components/common/LanguageModeSelect.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import { WEBUI_BUILD_HASH, WEBUI_VERSION } from '$lib/constants';
@ -38,6 +39,7 @@
let showUserUiDefaults = false;
let banners: Banner[] = [];
let bannerLocale = '';
const inputClass =
'w-full h-7 rounded-lg border border-gray-100/50 bg-gray-50/40 px-2 text-xs text-gray-700 outline-hidden transition-colors placeholder:text-gray-300 focus:border-blue-400 dark:border-white/[0.04] dark:bg-white/[0.03] dark:text-gray-300 dark:placeholder:text-gray-700 dark:focus:border-blue-500';
const textareaClass =
@ -454,39 +456,40 @@
</div>
<div>
<div class="mb-2 flex w-full items-start justify-between gap-4">
<div class="min-w-0">
<div class="text-xs text-gray-600 dark:text-gray-400">{$i18n.t('Banners')}</div>
<div class="mt-1.5 text-[0.6875rem] text-gray-400 dark:text-gray-600">
{$i18n.t('Create announcements shown to users in the app.')}
</div>
<div class="mb-1 flex min-h-7 w-full items-center justify-between gap-2">
<div class="min-w-0 text-xs text-gray-600 dark:text-gray-400">{$i18n.t('Banners')}</div>
<div class="flex shrink-0 items-center gap-1">
{#if banners.length > 0}
<LanguageModeSelect bind:value={bannerLocale} className="w-fit" />
{/if}
<button
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-black/5 hover:text-gray-900 dark:text-gray-600 dark:hover:bg-white/5 dark:hover:text-white"
type="button"
aria-label={$i18n.t('Add banner')}
on:click={() => {
if (banners.length === 0 || banners[banners.length - 1]?.content !== '') {
banners = [
...banners,
{
id: uuidv4(),
type: '',
title: '',
content: '',
dismissible: true,
timestamp: Math.floor(Date.now() / 1000)
}
];
}
}}
>
<Plus />
</button>
</div>
<button
class="flex size-6 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-black/5 hover:text-gray-900 dark:text-gray-600 dark:hover:bg-white/5 dark:hover:text-white"
type="button"
aria-label={$i18n.t('Add banner')}
on:click={() => {
if (banners.length === 0 || banners[banners.length - 1]?.content !== '') {
banners = [
...banners,
{
id: uuidv4(),
type: '',
title: '',
content: '',
dismissible: true,
timestamp: Math.floor(Date.now() / 1000)
}
];
}
}}
>
<Plus />
</button>
</div>
<Banners bind:banners />
<div class="mb-2 text-[0.6875rem] text-gray-400 dark:text-gray-600">
{$i18n.t('Create announcements shown to users in the app.')}
</div>
<Banners bind:banners locale={bannerLocale} />
</div>
</AdminSettingSection>
{/if}

View file

@ -1,6 +1,6 @@
<script lang="ts">
import Switch from '$lib/components/common/Switch.svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import LocalizedField from '$lib/components/common/LocalizedField.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import EllipsisVertical from '$lib/components/icons/EllipsisVertical.svelte';
import XMark from '$lib/components/icons/XMark.svelte';
@ -10,11 +10,9 @@
const i18n = getContext('i18n');
export let banners = [];
export let locale = '';
let sortable = null;
let bannerListElement = null;
const positionChangeHandler = () => {
const positionChangeHandler = (bannerListElement: HTMLElement) => {
const bannerIdOrder = Array.from(bannerListElement.children).map((child) =>
child.id.replace('banner-item-', '')
);
@ -26,72 +24,76 @@
});
};
const classNames: Record<string, string> = {
info: 'bg-blue-500/20 text-blue-700 dark:text-blue-200 ',
success: 'bg-green-500/20 text-green-700 dark:text-green-200',
warning: 'bg-yellow-500/20 text-yellow-700 dark:text-yellow-200',
error: 'bg-red-500/20 text-red-700 dark:text-red-200'
};
$: if (banners) {
init();
}
const init = () => {
if (sortable) {
sortable.destroy();
}
if (bannerListElement) {
sortable = new Sortable(bannerListElement, {
animation: 150,
handle: '.item-handle',
onUpdate: async (event) => {
positionChangeHandler();
}
});
}
const init = (node: HTMLElement) => {
const sortable = new Sortable(node, {
animation: 150,
handle: '.item-handle',
onUpdate: () => positionChangeHandler(node)
});
return { destroy: () => sortable.destroy() };
};
</script>
<div class=" flex flex-col gap-3 {banners?.length > 0 ? 'mt-2' : ''}" bind:this={bannerListElement}>
<div class="flex flex-col gap-1.5" use:init>
{#each banners as banner, bannerIdx (banner.id)}
<div class=" flex justify-between items-start -ml-1" id="banner-item-{banner.id}">
<EllipsisVertical className="size-4 cursor-move item-handle" />
<div class="flex flex-row flex-1 gap-2 items-start">
<SettingsSelect bind:value={banner.type} required selectClassName="capitalize">
<div
class="flex items-start gap-1 rounded-lg border border-gray-100/40 bg-transparent px-2 py-1 transition focus-within:border-gray-300 dark:border-gray-850/50 dark:focus-within:border-gray-600"
id="banner-item-{banner.id}"
>
<Tooltip content={$i18n.t('Reorder')}>
<div
class="item-handle flex h-6 w-3 shrink-0 cursor-move items-center justify-center text-gray-400 dark:text-gray-600"
>
<EllipsisVertical className="size-3.5" />
</div>
</Tooltip>
<div class="flex min-w-0 flex-1 flex-col gap-0.5 sm:flex-row sm:items-start sm:gap-2">
<SettingsSelect
bind:value={banner.type}
required
ariaLabel={$i18n.t('Type')}
className="w-24 max-w-full shrink-0"
selectClassName="!h-6 !border-transparent !bg-transparent !ps-1 capitalize dark:!bg-transparent"
>
<option value="" disabled hidden class="text-gray-900">{$i18n.t('Type')}</option>
<option value="info" class="text-gray-900">{$i18n.t('Info')}</option>
<option value="warning" class="text-gray-900">{$i18n.t('Warning')}</option>
<option value="error" class="text-gray-900">{$i18n.t('Error')}</option>
<option value="success" class="text-gray-900">{$i18n.t('Success')}</option>
</SettingsSelect>
<Textarea
className="mr-2 text-xs w-full border border-gray-100/50 !bg-gray-50/40 outline-hidden dark:border-white/[0.04] dark:!bg-white/[0.03] dark:text-gray-300 resize-none"
placeholder={$i18n.t('Content')}
bind:value={banner.content}
maxSize={100}
/>
<div class="relative -left-2">
<Tooltip content={$i18n.t('Remember Dismissal')} className="flex h-fit items-center">
<Switch bind:state={banner.dismissible} />
</Tooltip>
<div class="w-full min-w-0 flex-1 sm:pt-0.5">
<LocalizedField
className="block min-h-5 max-h-40 w-full resize-none bg-transparent text-[0.8125rem] leading-5 text-gray-700 outline-hidden placeholder:text-gray-300 dark:text-gray-200 dark:placeholder:text-gray-700 [field-sizing:content]"
placeholder={$i18n.t('Content')}
bind:value={banner.content}
bind:translations={banner.i18n}
{locale}
field="content"
multiline
rows={1}
/>
</div>
</div>
<button
class="pr-3"
type="button"
on:click={() => {
banners.splice(bannerIdx, 1);
banners = banners;
}}
>
<XMark className={'size-4'} />
</button>
<div class="flex h-6 shrink-0 items-center px-1">
<Switch
bind:state={banner.dismissible}
ariaLabel={$i18n.t('Remember Dismissal')}
tooltip={$i18n.t('Remember Dismissal')}
/>
</div>
<Tooltip content={$i18n.t('Delete')}>
<button
class="flex size-6 shrink-0 items-center justify-center text-gray-400 opacity-70 transition hover:text-gray-700 hover:opacity-100 dark:text-gray-600 dark:hover:text-gray-300"
type="button"
aria-label={$i18n.t('Delete')}
on:click={() => {
banners.splice(bannerIdx, 1);
banners = banners;
}}
>
<XMark className="size-3.5" />
</button>
</Tooltip>
</div>
{/each}
</div>

View file

@ -14,7 +14,7 @@
import Capabilities from '$lib/components/workspace/Models/Capabilities.svelte';
import DefaultFeatures from '$lib/components/workspace/Models/DefaultFeatures.svelte';
import BuiltinTools from '$lib/components/workspace/Models/BuiltinTools.svelte';
import LanguageModeSelect from '$lib/components/workspace/Models/LanguageModeSelect.svelte';
import LanguageModeSelect from '$lib/components/common/LanguageModeSelect.svelte';
import LocalizedPromptSuggestions from '$lib/components/workspace/Models/LocalizedPromptSuggestions.svelte';
export let initHandler = () => {};

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { resolveLocalizedResource } from '$lib/utils/localizedContent';
import { toast } from 'svelte-sonner';
import { config, functions, models, settings, tools, user } from '$lib/stores';
@ -181,7 +182,9 @@
{#each $tools
.filter((tool) => !tool?.id?.startsWith('server:'))
.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')) as tool, toolIdx}
<option value={tool.id} class="bg-gray-100 dark:bg-gray-800">{tool.name}</option>
<option value={tool.id} class="bg-gray-100 dark:bg-gray-800"
>{resolveLocalizedResource(tool, $i18n.language)}</option
>
{/each}
{:else if tab === 'functions'}
<option value="" selected disabled class="bg-gray-100 dark:bg-gray-800"
@ -189,7 +192,9 @@
>
{#each $functions.sort( (a, b) => (a.name ?? '').localeCompare(b.name ?? '') ) as func, funcIdx}
<option value={func.id} class="bg-gray-100 dark:bg-gray-800">{func.name}</option>
<option value={func.id} class="bg-gray-100 dark:bg-gray-800"
>{resolveLocalizedResource(func, $i18n.language)}</option
>
{/each}
{/if}
</select>
@ -203,6 +208,10 @@
<div class="chat-control-valves">
<Valves
{valvesSpec}
meta={(tab === 'tools' ? $tools : $functions)?.find(
(item) => item.id === selectedId
)?.meta}
userValves
bind:valves
on:change={() => {
debounceSubmitHandler();

View file

@ -1,4 +1,6 @@
<script lang="ts">
import { resolveLocalizedFunction } from '$lib/utils/localizedContent';
import { functions as localizedFunctions } from '$lib/stores';
import DOMPurify from 'dompurify';
import { toast } from 'svelte-sonner';
@ -2373,7 +2375,14 @@
{#each selectedFilterIds as filterId (filterId)}
{@const filter = toggleFilters.find((f) => f.id === filterId)}
{#if filter}
<Tooltip content={filter?.name} placement="top">
<Tooltip
content={resolveLocalizedFunction(
filter,
$localizedFunctions,
$i18n.language
)}
placement="top"
>
<button
on:click|preventDefault={() => {
if (
@ -2405,7 +2414,11 @@
? 'dark:invert-[80%]'
: ''}"
style="fill: currentColor;"
alt={filter.name}
alt={resolveLocalizedFunction(
filter,
$localizedFunctions,
$i18n.language
)}
/>
</div>
{:else}

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { resolveLocalizedResource } from '$lib/utils/localizedContent';
import { getContext, onDestroy } from 'svelte';
import { getSkillItems } from '$lib/apis/skills';
import Tooltip from '$lib/components/common/Tooltip.svelte';
@ -60,8 +61,10 @@
.replaceAll("'", '&#39;');
const getTooltipContent = (skill) => {
const name = escapeTooltipText(skill.name);
const description = escapeTooltipText(skill.description);
const name = escapeTooltipText(resolveLocalizedResource(skill, $i18n.language));
const description = escapeTooltipText(
resolveLocalizedResource(skill, $i18n.language, 'description')
);
return `<div class="max-w-80 whitespace-normal text-left leading-snug">
<span class="break-words font-normal">${name}</span>${description ? `: <span class="break-words opacity-80">${description}</span>` : ''}
@ -100,7 +103,7 @@
<Cube className="size-3.5" />
</div>
<div class="truncate min-w-0 flex-1">
{skill.name}
{resolveLocalizedResource(skill, $i18n.language)}
</div>
<div class="ml-2 max-w-24 shrink-0 truncate text-xs text-gray-500 dark:text-gray-400">
{skill.id}

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { resolveLocalizedResource } from '$lib/utils/localizedContent';
import { getContext, onDestroy } from 'svelte';
import { getPrompts } from '$lib/apis/prompts';
import { getSkillItems } from '$lib/apis/skills';
@ -126,8 +127,10 @@
.replaceAll("'", '&#39;');
const getSkillTooltipContent = (skill) => {
const name = escapeTooltipText(skill.name);
const description = escapeTooltipText(skill.description);
const name = escapeTooltipText(resolveLocalizedResource(skill, $i18n.language));
const description = escapeTooltipText(
resolveLocalizedResource(skill, $i18n.language, 'description')
);
return `<div class="max-w-80 whitespace-normal text-left leading-snug">
<span class="break-words font-normal">${name}</span>${description ? `: <span class="break-words opacity-80">${description}</span>` : ''}
@ -441,7 +444,7 @@
<Cube className="size-3.5" />
</div>
<div class="truncate min-w-0 flex-1">
{skill.name}
{resolveLocalizedResource(skill, $i18n.language)}
</div>
<div class="ml-2 max-w-24 shrink-0 truncate text-xs text-gray-500 dark:text-gray-400">
{skill.id}

View file

@ -1,4 +1,6 @@
<script lang="ts">
import { resolveLocalizedResource, resolveLocalizedFunction } from '$lib/utils/localizedContent';
import { functions as localizedFunctions } from '$lib/stores';
import { getContext, onDestroy, tick } from 'svelte';
import { fly } from 'svelte/transition';
@ -108,14 +110,22 @@
const setTools = (toolItems: IntegrationItem[] | null, query = '') => {
const q = query.trim().toLowerCase();
const items = (toolItems ?? []).reduce<Record<string, IntegrationItem>>((a, tool) => {
a[tool.id] = {
...tool,
name: tool.name,
description: tool.meta?.description
};
return a;
}, {});
const items = (toolItems ?? [])
.filter(
(tool) =>
!q ||
`${tool.name} ${resolveLocalizedResource(tool, $i18n.language)} ${resolveLocalizedResource(tool, $i18n.language, 'description')}`
.toLowerCase()
.includes(q)
)
.reduce<Record<string, IntegrationItem>>((a, tool) => {
a[tool.id] = {
...tool,
name: tool.name,
description: tool.meta?.description
};
return a;
}, {});
for (const serverIdx in ($toolServers ?? []) as any[]) {
const server = (($toolServers ?? []) as any[])[serverIdx];
@ -142,7 +152,14 @@
const setSkills = (skillItems: IntegrationItem[] | null, query = '') => {
skills = (skillItems ?? [])
.filter((skill) => skill.is_active)
.filter(
(skill) =>
skill.is_active &&
(!query.trim() ||
`${skill.name} ${resolveLocalizedResource(skill, $i18n.language)} ${resolveLocalizedResource(skill, $i18n.language, 'description')}`
.toLowerCase()
.includes(query.trim().toLowerCase()))
)
.reduce<Record<string, IntegrationItem>>((a, skill) => {
a[skill.id] = {
...skill,
@ -162,13 +179,6 @@
const q = query.trim();
searchedToolQuery = query;
if (q) {
const toolItems = await getTools(localStorage.token, q).catch(() => []);
if (requestId !== toolRequestId) return;
setTools(toolItems, q);
return;
}
if ($_tools === null) {
await _tools.set(await getTools(localStorage.token));
}
@ -181,13 +191,6 @@
const q = query.trim();
searchedSkillQuery = query;
if (q) {
const skillItems = await getSkills(localStorage.token, q).catch(() => []);
if (requestId !== skillRequestId) return;
setSkills(skillItems, q);
return;
}
if ($_skills === null) {
await _skills.set(await getSkills(localStorage.token));
}
@ -328,7 +331,15 @@
{#if toggleFilters && toggleFilters.length > 0}
{#each toggleFilters.sort( (a, b) => a.name.localeCompare( b.name, undefined, { sensitivity: 'base' } ) ) as filter, filterIdx (filter.id)}
<Tooltip content={filter?.description} placement="top-start">
<Tooltip
content={resolveLocalizedFunction(
filter,
$localizedFunctions,
$i18n.language,
'description'
)}
placement="top-start"
>
<button
class="flex w-full justify-between gap-2 items-center h-[1.6875rem] px-2 text-[0.8125rem] font-normal cursor-pointer rounded-xl hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
aria-pressed={selectedFilterIds.includes(filter.id)}
@ -351,7 +362,11 @@
? 'dark:invert-[80%]'
: ''}"
style="fill: currentColor;"
alt={filter.name}
alt={resolveLocalizedFunction(
filter,
$localizedFunctions,
$i18n.language
)}
/>
</div>
{:else}
@ -359,7 +374,9 @@
{/if}
</div>
<div class=" truncate">{filter?.name}</div>
<div class=" truncate">
{resolveLocalizedFunction(filter, $localizedFunctions, $i18n.language)}
</div>
</div>
</div>
@ -513,13 +530,29 @@
{/if}
<div class="flex-1 truncate">
<div class="flex flex-1 gap-2 items-center">
<Tooltip content={tools?.[toolId]?.name ?? ''} placement="top">
<Tooltip
content={resolveLocalizedResource(
tools?.[toolId],
$i18n.language,
'name'
)}
placement="top"
>
<div class="shrink-0">
<Wrench />
</div>
</Tooltip>
<Tooltip content={tools?.[toolId]?.description ?? ''} placement="top-start">
<div class=" truncate">{tools?.[toolId]?.name}</div>
<Tooltip
content={resolveLocalizedResource(
tools?.[toolId],
$i18n.language,
'description'
)}
placement="top-start"
>
<div class=" truncate">
{resolveLocalizedResource(tools?.[toolId], $i18n.language, 'name')}
</div>
</Tooltip>
</div>
</div>
@ -623,16 +656,29 @@
>
<div class="flex-1 truncate">
<div class="flex flex-1 gap-2 items-center">
<Tooltip content={skills?.[skillId]?.name ?? ''} placement="top">
<Tooltip
content={resolveLocalizedResource(
skills?.[skillId],
$i18n.language,
'name'
)}
placement="top"
>
<div class="shrink-0">
<Cube className="size-3.5" strokeWidth="1.75" />
</div>
</Tooltip>
<Tooltip
content={skills?.[skillId]?.description ?? ''}
content={resolveLocalizedResource(
skills?.[skillId],
$i18n.language,
'description'
)}
placement="top-start"
>
<div class=" truncate">{skills?.[skillId]?.name}</div>
<div class=" truncate">
{resolveLocalizedResource(skills?.[skillId], $i18n.language, 'name')}
</div>
</Tooltip>
</div>
</div>

View file

@ -1,4 +1,6 @@
<script lang="ts">
import { resolveLocalizedFunction } from '$lib/utils/localizedContent';
import { functions as localizedFunctions } from '$lib/stores';
import { toast } from 'svelte-sonner';
import { createEventDispatcher, onDestroy } from 'svelte';
@ -1469,10 +1471,21 @@
{/if}
{#each model?.actions ?? [] as action}
<Tooltip content={action.name} placement="bottom">
<Tooltip
content={resolveLocalizedFunction(
action,
$localizedFunctions,
$i18n.language
)}
placement="bottom"
>
<button
type="button"
aria-label={action.name}
aria-label={resolveLocalizedFunction(
action,
$localizedFunctions,
$i18n.language
)}
class="{isLastMessage || ($settings?.highContrastMode ?? false)
? 'visible'
: 'hover-reveal'} p-1.5 hover:bg-black/5 dark:hover:bg-white/5 rounded-lg dark:hover:text-white hover:text-black transition"
@ -1488,7 +1501,11 @@
? 'dark:invert-[80%]'
: ''}"
style="fill: currentColor;"
alt={action.name}
alt={resolveLocalizedFunction(
action,
$localizedFunctions,
$i18n.language
)}
draggable="false"
/>
</div>

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { resolveLocalizedResource } from '$lib/utils/localizedContent';
import { getContext } from 'svelte';
import { skills } from '$lib/stores';
@ -42,12 +43,12 @@
<Collapsible buttonClassName="w-full mb-0.5">
<div class="truncate">
<div class="text-sm font-normal dark:text-gray-100 text-gray-800 truncate">
{skill?.name}
{resolveLocalizedResource(skill, $i18n.language)}
</div>
{#if skill?.description}
{#if resolveLocalizedResource(skill, $i18n.language, 'description')}
<div class="text-xs text-gray-500">
{skill?.description}
{resolveLocalizedResource(skill, $i18n.language, 'description')}
</div>
{/if}
</div>

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { resolveLocalizedResource } from '$lib/utils/localizedContent';
import { getContext } from 'svelte';
import { toolServers, tools } from '$lib/stores';
@ -66,7 +67,7 @@
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1 min-w-0">
<div class="text-sm font-normal dark:text-gray-100 text-gray-800 truncate">
{tool?.name}
{resolveLocalizedResource(tool, $i18n.language)}
</div>
{#if status}
<span class="text-[0.6875rem] {status.pill} shrink-0">{status.label}</span>
@ -83,9 +84,9 @@
{/if}
</div>
{#if tool?.meta?.description}
{#if resolveLocalizedResource(tool, $i18n.language, 'description')}
<div class="text-xs text-gray-500 truncate">
{tool?.meta?.description}
{resolveLocalizedResource(tool, $i18n.language, 'description')}
</div>
{/if}
</div>

View file

@ -1,5 +1,6 @@
<script lang="ts">
import type { Banner } from '$lib/types';
import { resolveLocalizedString } from '$lib/utils/localizedContent';
import { onMount, createEventDispatcher, getContext } from 'svelte';
import { fade } from 'svelte/transition';
import DOMPurify from 'dompurify';
@ -100,7 +101,16 @@
{/if}
</div>
<div class="flex-1 text-xs text-gray-700 dark:text-white max-h-60 overflow-y-auto">
{@html DOMPurify.sanitize(marked.parse((banner?.content ?? '').replace(/\n/g, '<br>')))}
{@html DOMPurify.sanitize(
marked.parse(
resolveLocalizedString(
banner?.content,
banner?.i18n,
$i18n.language,
'content'
).replace(/\n/g, '<br>')
)
)}
</div>
</div>

View file

@ -3,15 +3,17 @@
import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import { getContext } from 'svelte';
import availableLanguages from '$lib/i18n/locales/languages.json';
const i18n = getContext<any>('i18n');
export let value = '';
export let languages: { code: string; title: string }[] = [];
export let languages: { code: string; title: string }[] = availableLanguages;
export let translatedLocales: string[] = [];
export let className = '';
export let includeDefault = true;
$: options = languages.filter((language) => language.code !== 'en-US');
$: options = languages;
const statusFor = (code: string) => (translatedLocales.includes(code) ? ' *' : '');
</script>
@ -29,7 +31,7 @@
selectClassName="min-w-[8.5rem] !ps-8"
ariaLabel={$i18n.t('Editing language')}
>
<option value="">{$i18n.t('Default')}</option>
{#if includeDefault}<option value="">{$i18n.t('Default')}</option>{/if}
{#each options as language}
<option value={language.code}>{language.title}{statusFor(language.code)}</option>
{/each}

View file

@ -0,0 +1,74 @@
<script lang="ts">
import { getContext } from 'svelte';
import { resolveLocalizedString } from '$lib/utils/localizedContent';
const i18n = getContext<any>('i18n');
export let value = '';
export let translations: Record<string, Record<string, string>> = {};
export let locale = '';
export let field = 'name';
export let placeholder = '';
export let className = 'w-full bg-transparent text-sm outline-hidden';
export let multiline = false;
export let rows = 3;
export let required = false;
export let disabled = false;
$: translated = translations?.[locale]?.[field] ?? '';
$: fallback = resolveLocalizedString(value, translations, locale, field);
const update = (next: string) => {
if (!locale) {
value = next;
return;
}
const entry = { ...translations?.[locale] };
if (next.trim()) entry[field] = next;
else delete entry[field];
const nextTranslations = { ...translations };
if (Object.keys(entry).length) nextTranslations[locale] = entry;
else delete nextTranslations[locale];
translations = nextTranslations;
};
</script>
<div class="min-w-0 flex-1">
{#if multiline}
<textarea
class={className}
{rows}
value={locale ? translated : value}
placeholder={locale ? fallback || placeholder : placeholder}
aria-label={placeholder || field}
required={required && !locale}
{disabled}
on:input={(e) => update(e.currentTarget.value)}
></textarea>
{:else}
<input
class={className}
value={locale ? translated : value}
placeholder={locale ? fallback || placeholder : placeholder}
aria-label={placeholder || field}
required={required && !locale}
{disabled}
on:input={(e) => update(e.currentTarget.value)}
/>
{/if}
{#if locale}
<div
class="mt-1 flex flex-wrap items-center gap-3 text-[0.6875rem] text-gray-400 dark:text-gray-600"
>
<span>{translated ? $i18n.t('Translated') : $i18n.t('Using default')}</span>
{#if translated}<button
type="button"
class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
{disabled}
on:click={() => update('')}>{$i18n.t('Use default')}</button
>
{:else if fallback}<button
type="button"
class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
{disabled}
on:click={() => update(fallback)}>{$i18n.t('Copy default')}</button
>{/if}
</div>
{/if}
</div>

View file

@ -0,0 +1,188 @@
<script lang="ts">
import { getContext } from 'svelte';
import { toast } from 'svelte-sonner';
import { validateDictionary } from '$lib/utils/translationDictionary';
import Tooltip from './Tooltip.svelte';
import ArrowUturnLeft from '$lib/components/icons/ArrowUturnLeft.svelte';
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
const i18n = getContext<any>('i18n');
export let value: Record<string, string> = {};
export let source: Record<string, string> = {};
export let filename = 'translations.json';
export let onChange = (_value: Record<string, string>) => {};
export let allowNewKeys = false;
let adding = false;
let newKey = '';
const addKey = () => {
if (newKey.trim()) {
update(newKey.trim(), value?.[newKey.trim()] ?? '');
query = newKey.trim();
modified = false;
adding = false;
newKey = '';
}
};
let query = '';
let modified = false;
let page = 0;
let fileInput: HTMLInputElement;
const exportFile = () => {
const data = Object.fromEntries(Object.entries(value ?? {}).filter(([, text]) => text.trim()));
const url = URL.createObjectURL(
new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
};
$: keys = [...new Set([...Object.keys(source), ...Object.keys(value ?? {})])]
.filter(
(key) =>
(!modified || value?.[key]?.trim()) &&
`${key} ${source[key] ?? ''} ${value?.[key] ?? ''}`
.toLocaleLowerCase()
.includes(query.toLocaleLowerCase())
)
.sort();
$: (query, modified, (page = 0));
$: pages = Math.max(1, Math.ceil(keys.length / 50));
$: if (page >= pages) page = pages - 1;
const update = (key: string, text: string) => {
value = { ...value, [key]: text };
onChange(value);
};
const reset = (key: string) => {
const next = { ...value };
delete next[key];
value = next;
onChange(value);
};
const importFile = async (event: Event) => {
const input = event.currentTarget as HTMLInputElement;
try {
if (input.files?.[0]) {
value = {
...value,
...validateDictionary(JSON.parse(await input.files[0].text()), source)
};
onChange(value);
}
} catch (error) {
toast.error(String(error));
}
input.value = '';
};
</script>
<div class="flex min-h-0 flex-1 flex-col gap-2">
<div class="flex flex-wrap items-center gap-3 text-xs">
<slot name="language" />
<input
bind:this={fileInput}
type="file"
accept="application/json,.json"
hidden
on:change={importFile}
/>
<button type="button" on:click={() => fileInput.click()}>{$i18n.t('Import')}</button>
<button type="button" on:click={exportFile}>{$i18n.t('Export')}</button>
{#if allowNewKeys}<Tooltip content={$i18n.t('Add translation')}
><button
class="flex size-6 items-center justify-center"
type="button"
aria-label={$i18n.t('Add translation')}
on:click={() => (adding = !adding)}><Plus className="size-3.5" /></button
></Tooltip
>{/if}
</div>
{#if adding}<div class="flex items-center gap-2 text-xs">
<input
class="min-w-0 flex-1 rounded-lg border border-gray-200 bg-transparent px-2 py-1.5 dark:border-gray-800"
aria-label={$i18n.t('Translation key')}
placeholder={$i18n.t('Translation key')}
bind:value={newKey}
on:keydown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
addKey();
}
}}
/><button
type="button"
class="flex size-7 items-center justify-center"
aria-label={$i18n.t('Add')}
on:click={addKey}><Plus className="size-4" /></button
>
</div>{/if}
<div class="flex items-center gap-3 text-xs">
<input
class="min-w-0 flex-1 rounded-lg border border-gray-200 bg-transparent px-2 py-1.5 outline-hidden dark:border-gray-800"
bind:value={query}
placeholder={$i18n.t('Search')}
aria-label={$i18n.t('Search translations')}
/>
<label class="flex shrink-0 items-center gap-1.5"
><input type="checkbox" bind:checked={modified} />{$i18n.t('Modified')}</label
>
</div>
<div class="min-h-0 flex-1 overflow-y-auto">
<div
class="sticky top-0 z-10 hidden grid-cols-[minmax(0,1fr)_minmax(0,1fr)_1.5rem] gap-3 border-b border-gray-100 bg-white py-2 text-xs text-gray-500 dark:border-gray-800 dark:bg-gray-900 sm:grid"
>
<span>{$i18n.t('Original')}</span><span>{$i18n.t('Translation')}</span>
</div>
{#each keys.slice(page * 50, (page + 1) * 50) as key (key)}
<div
class="grid grid-cols-[minmax(0,1fr)_1.5rem] items-start gap-2 border-b border-gray-100 py-2 dark:border-gray-800 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_1.5rem] sm:gap-3"
>
<div class="col-span-2 min-w-0 text-xs sm:col-span-1">
<div class="break-words">{key}</div>
{#if source[key] && source[key] !== key}<div class="mt-1 break-words text-gray-500">
{source[key]}
</div>{/if}
</div>
<textarea
rows="2"
class="w-full min-w-0 resize-y rounded-md border border-gray-100 bg-transparent px-2 py-1 text-xs outline-hidden focus:border-gray-400 dark:border-gray-800"
aria-label={key}
placeholder={source[key] || key}
value={value?.[key] ?? ''}
on:input={(event) => update(key, event.currentTarget.value)}
></textarea>
<Tooltip content={$i18n.t('Use default')}
><button
type="button"
class="flex size-6 items-center justify-center disabled:opacity-30"
aria-label={`${$i18n.t('Use default')}: ${key}`}
disabled={!Object.hasOwn(value ?? {}, key)}
on:click={() => reset(key)}><ArrowUturnLeft className="size-3.5" /></button
></Tooltip
>
</div>
{/each}
{#if !keys.length}<div class="py-6 text-center text-xs text-gray-500">
{$i18n.t('No results found')}
</div>{/if}
</div>
<div class="flex items-center justify-end gap-3 text-xs text-gray-500">
<span>{page + 1} / {pages}</span>
<button
type="button"
class="flex size-7 items-center justify-center disabled:opacity-30"
aria-label={$i18n.t('Previous')}
disabled={page === 0}
on:click={() => page--}><ChevronLeft className="size-4" /></button
>
<button
type="button"
class="flex size-7 items-center justify-center disabled:opacity-30"
aria-label={$i18n.t('Next')}
disabled={page + 1 >= pages}
on:click={() => page++}><ChevronRight className="size-4" /></button
>
</div>
</div>

View file

@ -2,6 +2,7 @@
import { getContext, createEventDispatcher } from 'svelte';
import DOMPurify from 'dompurify';
import { marked } from 'marked';
import { localizeValvesSchema, resolveLocalizedString } from '$lib/utils/localizedContent';
const dispatch = createEventDispatcher();
const i18n = getContext('i18n');
@ -14,16 +15,20 @@
export let valvesSpec = null;
export let valves = {};
export let meta = {};
export let userValves = false;
$: prefix = userValves ? 'user_valves' : 'valves';
$: displaySpec = localizeValvesSchema(valvesSpec, $i18n.language, meta, prefix);
</script>
{#if valvesSpec && Object.keys(valvesSpec?.properties ?? {}).length}
{#each Object.keys(valvesSpec.properties) as property}
{#if displaySpec && Object.keys(displaySpec?.properties ?? {}).length}
{#each Object.keys(displaySpec.properties) as property}
<div class=" py-0.5 w-full justify-between">
<div class="flex w-full justify-between">
<div class=" self-center text-xs font-normal">
{valvesSpec.properties[property].title}
{displaySpec.properties[property].title}
{#if (valvesSpec?.required ?? []).includes(property)}
{#if (displaySpec?.required ?? []).includes(property)}
<span class=" text-gray-500">{$i18n.t('*required')}</span>
{/if}
</div>
@ -32,7 +37,7 @@
class="px-2 py-1 text-xs flex rounded-lg transition hover:bg-gray-50/70 dark:hover:bg-gray-850/50"
type="button"
on:click={() => {
const propertySpec = valvesSpec.properties[property] ?? {};
const propertySpec = displaySpec.properties[property] ?? {};
if ((valves[property] ?? null) === null) {
// Initialize to custom value
@ -55,7 +60,7 @@
>
{#if (valves[property] ?? null) === null}
<span class="ml-2 self-center">
{#if (valvesSpec?.required ?? []).includes(property)}
{#if (displaySpec?.required ?? []).includes(property)}
{$i18n.t('None')}
{:else}
{$i18n.t('Default')}
@ -71,7 +76,7 @@
<!-- {valves[property]} -->
<div class="flex mt-0.5 mb-0.5 space-x-2">
<div class=" flex-1 min-w-0">
{#if valvesSpec.properties[property]?.enum ?? null}
{#if displaySpec.properties[property]?.enum ?? null}
<select
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100/30 dark:border-gray-850/30"
bind:value={valves[property]}
@ -79,13 +84,18 @@
dispatch('change');
}}
>
{#each valvesSpec.properties[property].enum as option}
{#each displaySpec.properties[property].enum as option}
<option value={option} selected={option === valves[property]}>
{option}
{resolveLocalizedString(
String(option),
meta?.i18n,
$i18n.language,
`${prefix}.${property}.enum.${option}`
)}
</option>
{/each}
</select>
{:else if (valvesSpec.properties[property]?.type ?? null) === 'boolean'}
{:else if (displaySpec.properties[property]?.type ?? null) === 'boolean'}
<div class="flex justify-between items-center">
<div class="text-xs text-gray-500">
{valves[property] ? $i18n.t('Enabled') : $i18n.t('Disabled')}
@ -100,21 +110,21 @@
/>
</div>
</div>
{:else if valvesSpec.properties[property]?.input?.type === 'multiselect' && valvesSpec.properties[property]?.input?.options}
{:else if displaySpec.properties[property]?.input?.type === 'multiselect' && displaySpec.properties[property]?.input?.options}
<MultiSelect
className="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100/30 dark:border-gray-850/30"
bind:value={valves[property]}
options={valvesSpec.properties[property].input.options}
options={displaySpec.properties[property].input.options}
placeholder={$i18n.t('Select options')}
on:change={() => {
dispatch('change');
}}
/>
{:else if (valvesSpec.properties[property]?.type ?? null) !== 'string'}
{:else if (displaySpec.properties[property]?.type ?? null) !== 'string'}
<input
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100/30 dark:border-gray-850/30"
type="text"
placeholder={valvesSpec.properties[property].title}
placeholder={displaySpec.properties[property].title}
bind:value={valves[property]}
autocomplete="off"
required
@ -122,33 +132,33 @@
dispatch('change');
}}
/>
{:else if valvesSpec.properties[property]?.input ?? null}
{#if valvesSpec.properties[property]?.input?.type === 'password'}
{:else if displaySpec.properties[property]?.input ?? null}
{#if displaySpec.properties[property]?.input?.type === 'password'}
<div
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 border border-gray-100/30 dark:border-gray-850/30"
>
<SensitiveInput
id="valve-{property}"
placeholder={valvesSpec.properties[property]?.description ?? ''}
placeholder={displaySpec.properties[property]?.description ?? ''}
bind:value={valves[property]}
required={(valvesSpec?.required ?? []).includes(property)}
required={(displaySpec?.required ?? []).includes(property)}
on:change={() => {
dispatch('change');
}}
/>
</div>
{:else if valvesSpec.properties[property]?.input?.type === 'select' && valvesSpec.properties[property]?.input?.options}
{:else if displaySpec.properties[property]?.input?.type === 'select' && displaySpec.properties[property]?.input?.options}
<NativeSelect
className="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100/30 dark:border-gray-850/30"
bind:value={valves[property]}
options={valvesSpec.properties[property].input.options}
placeholder={valvesSpec.properties[property]?.description ??
options={displaySpec.properties[property].input.options}
placeholder={displaySpec.properties[property]?.description ??
$i18n.t('Select an option')}
on:change={() => {
dispatch('change');
}}
/>
{:else if valvesSpec.properties[property]?.input?.type === 'color'}
{:else if displaySpec.properties[property]?.input?.type === 'color'}
<div class="flex items-center space-x-2">
<div class="relative size-6">
<input
@ -175,7 +185,7 @@
}}
/>
</div>
{:else if valvesSpec.properties[property]?.input?.type === 'map'}
{:else if displaySpec.properties[property]?.input?.type === 'map'}
<!-- EXPERIMENTAL INPUT TYPE, DO NOT USE IN PRODUCTION -->
<div class="flex flex-col items-center gap-1">
<MapSelector
@ -205,7 +215,7 @@
{:else}
<textarea
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100/30 dark:border-gray-850/30"
placeholder={valvesSpec.properties[property].title}
placeholder={displaySpec.properties[property].title}
bind:value={valves[property]}
autocomplete="off"
required
@ -218,11 +228,11 @@
</div>
{/if}
{#if (valvesSpec.properties[property]?.description ?? null) !== null}
{#if (displaySpec.properties[property]?.description ?? null) !== null}
<div class="markdown-prose-xs max-w-full text-gray-500 dark:text-gray-400">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html DOMPurify.sanitize(
marked.parse(valvesSpec.properties[property].description ?? '', { async: false })
marked.parse(displaySpec.properties[property].description ?? '', { async: false })
)}
</div>
{/if}

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { resolveLocalizedResource } from '$lib/utils/localizedContent';
import { getContext } from 'svelte';
import Checkbox from '$lib/components/common/Checkbox.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
@ -65,7 +66,7 @@
<div class=" flex items-center gap-2 mr-3">
<div class="self-center flex items-center">
<Checkbox
ariaLabel={action.name}
ariaLabel={resolveLocalizedResource(action, $i18n.language)}
state="checked"
disabled={action.is_global}
on:change={(e) => {
@ -77,8 +78,11 @@
</div>
<div class=" py-0.5 text-xs capitalize">
<Tooltip content={action.meta?.description ?? action.id}>
{action.name}
<Tooltip
content={resolveLocalizedResource(action, $i18n.language, 'description') ||
action.id}
>
{resolveLocalizedResource(action, $i18n.language)}
</Tooltip>
</div>
</div>

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { resolveLocalizedResource } from '$lib/utils/localizedContent';
import { getContext } from 'svelte';
import Checkbox from '$lib/components/common/Checkbox.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
@ -67,7 +68,7 @@
<div class=" flex items-center gap-2 mr-3">
<div class="self-center flex items-center">
<Checkbox
ariaLabel={filter.name}
ariaLabel={resolveLocalizedResource(filter, $i18n.language)}
state={isSelected ? 'checked' : 'unchecked'}
disabled={filter.is_global}
on:change={(e) => {
@ -85,8 +86,11 @@
</div>
<div class=" py-0.5 text-xs capitalize">
<Tooltip content={filter.meta?.description ?? filter.id}>
{filter.name}
<Tooltip
content={resolveLocalizedResource(filter, $i18n.language, 'description') ||
filter.id}
>
{resolveLocalizedResource(filter, $i18n.language)}
</Tooltip>
</div>
</div>

View file

@ -29,7 +29,7 @@
import DefaultFiltersSelector from './DefaultFiltersSelector.svelte';
import DefaultFeatures from './DefaultFeatures.svelte';
import BuiltinTools from './BuiltinTools.svelte';
import LanguageModeSelect from './LanguageModeSelect.svelte';
import LanguageModeSelect from '$lib/components/common/LanguageModeSelect.svelte';
import LocalizedPromptSuggestions from './LocalizedPromptSuggestions.svelte';
import PromptSuggestions from './PromptSuggestions.svelte';
import TerminalSelector from './TerminalSelector.svelte';

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { resolveLocalizedResource } from '$lib/utils/localizedContent';
import Checkbox from '$lib/components/common/Checkbox.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import TypeaheadSelector from './TypeaheadSelector.svelte';
@ -58,7 +59,7 @@
<div class=" flex items-center gap-2 mr-3">
<div class="self-center flex items-center">
<Checkbox
ariaLabel={skill.name}
ariaLabel={resolveLocalizedResource(skill, $i18n.language)}
state="checked"
on:change={(e) => {
if (e.detail === 'unchecked') {
@ -68,9 +69,11 @@
/>
</div>
<Tooltip content={skill.description ?? skill.id}>
<Tooltip
content={resolveLocalizedResource(skill, $i18n.language, 'description') || skill.id}
>
<div class=" py-0.5 text-xs capitalize">
{skill.name}
{resolveLocalizedResource(skill, $i18n.language)}
</div>
</Tooltip>
</div>

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { resolveLocalizedResource } from '$lib/utils/localizedContent';
import Checkbox from '$lib/components/common/Checkbox.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import TypeaheadSelector from './TypeaheadSelector.svelte';
@ -56,7 +57,7 @@
<div class=" flex items-center gap-2 mr-3">
<div class="self-center flex items-center">
<Checkbox
ariaLabel={tool.name}
ariaLabel={resolveLocalizedResource(tool, $i18n.language)}
state="checked"
on:change={(e) => {
if (e.detail === 'unchecked') {
@ -66,9 +67,11 @@
/>
</div>
<Tooltip content={tool.meta?.description ?? tool.id}>
<Tooltip
content={resolveLocalizedResource(tool, $i18n.language, 'description') || tool.id}
>
<div class=" py-0.5 text-xs capitalize">
{tool.name}
{resolveLocalizedResource(tool, $i18n.language)}
</div>
</Tooltip>
</div>

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { resolveLocalizedResource } from '$lib/utils/localizedContent';
import { createEventDispatcher, getContext, tick } from 'svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import Search from '$lib/components/icons/Search.svelte';
@ -39,7 +40,12 @@
const description = (item.description ?? item.meta?.description ?? '').toLowerCase();
return (
query === '' || id.includes(query) || name.includes(query) || description.includes(query)
query === '' ||
id.includes(query) ||
name.includes(query) ||
description.includes(query) ||
resolveLocalizedResource(item, $i18n.language).toLowerCase().includes(query) ||
resolveLocalizedResource(item, $i18n.language, 'description').toLowerCase().includes(query)
);
});
@ -123,7 +129,9 @@
selectItem(item);
}}
>
<span class="min-w-0 flex-1 truncate">{item.name || item.id}</span>
<span class="min-w-0 flex-1 truncate"
>{resolveLocalizedResource(item, $i18n.language)}</span
>
{#if selectedIds !== null && selectedIds.includes(item.id)}
<svg
class="size-3.5 shrink-0 text-gray-500 dark:text-gray-400"

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { resolveLocalizedResource } from '$lib/utils/localizedContent';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { toast } from 'svelte-sonner';
@ -453,7 +454,7 @@
<div
class="truncate text-[0.8125rem] leading-5 text-gray-800 group-hover:underline dark:text-gray-200"
>
{skill.name}
{resolveLocalizedResource(skill, $i18n.language)}
</div>
</Tooltip>
@ -485,12 +486,16 @@
</div>
</div>
{#if skill.description}
<Tooltip content={skill.description} className="min-w-0" placement="top-start">
{#if resolveLocalizedResource(skill, $i18n.language, 'description')}
<Tooltip
content={resolveLocalizedResource(skill, $i18n.language, 'description')}
className="min-w-0"
placement="top-start"
>
<div
class="mt-0.5 truncate text-[0.6875rem] leading-4 text-gray-400 dark:text-gray-600"
>
{skill.description}
{resolveLocalizedResource(skill, $i18n.language, 'description')}
</div>
</Tooltip>
{/if}
@ -618,7 +623,8 @@
}}
>
<div class=" text-sm text-gray-500 truncate">
{$i18n.t('This will delete')} <span class=" font-normal">{selectedSkill.name}</span>.
{$i18n.t('This will delete')}
<span class=" font-normal">{resolveLocalizedResource(selectedSkill, $i18n.language)}</span>.
</div>
</DeleteConfirmDialog>
{:else}

View file

@ -7,6 +7,10 @@
import AccessButton from '$lib/components/common/AccessButton.svelte';
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
import AccessControlModal from '../common/AccessControlModal.svelte';
import LanguageModeSelect from '$lib/components/common/LanguageModeSelect.svelte';
import LocalizedField from '$lib/components/common/LocalizedField.svelte';
let locale = '';
let meta: { i18n?: Record<string, Record<string, string>>; [key: string]: any } = {};
import { user } from '$lib/stores';
import { slugify, parseFrontmatter, formatSkillName } from '$lib/utils';
import Spinner from '$lib/components/common/Spinner.svelte';
@ -15,11 +19,11 @@
export let onSubmit: Function;
export let edit = false;
export let skill = null;
export let skill: any = null;
export let clone = false;
export let disabled = false;
const i18n = getContext('i18n');
const i18n = getContext<any>('i18n');
let loading = false;
@ -51,23 +55,31 @@
return;
}
loading = true;
if (!name.trim()) {
locale = '';
loading = false;
return;
}
if (!edit) id = slugify(id);
await onSubmit({
id,
name,
description,
content,
is_active: true,
meta: { tags: [] },
access_grants: accessGrants
});
loading = false;
try {
await onSubmit({
id,
name,
description,
content,
is_active: true,
meta,
access_grants: accessGrants
});
} finally {
loading = false;
}
};
onMount(async () => {
if (skill) {
meta = structuredClone(skill.meta ?? {});
name = skill.name || '';
await tick();
id = skill.id || '';
@ -110,15 +122,14 @@
<span>{$i18n.t('Back')}</span>
</button>
<div class="flex shrink-0 items-start gap-2 pb-2 px-1">
<div class="min-w-0 flex-1">
<div class="flex shrink-0 flex-col gap-2 pb-2 px-1 sm:flex-row sm:items-start">
<div class="min-w-0 w-full flex-1">
<Tooltip content={$i18n.t('e.g. Code Review Guidelines')} placement="top-start">
<input
class="w-full bg-transparent text-sm outline-hidden"
type="text"
<LocalizedField
placeholder={$i18n.t('Skill Name')}
aria-label={$i18n.t('Skill Name')}
bind:value={name}
bind:translations={meta.i18n}
{locale}
required
{disabled}
/>
@ -152,12 +163,12 @@
content={$i18n.t('e.g. Step-by-step instructions for code reviews')}
placement="top-start"
>
<input
class="w-full bg-transparent outline-hidden"
type="text"
<LocalizedField
placeholder={$i18n.t('Skill Description')}
aria-label={$i18n.t('Skill Description')}
bind:value={description}
bind:translations={meta.i18n}
{locale}
field="description"
{disabled}
/>
</Tooltip>
@ -165,6 +176,7 @@
</div>
<div class="flex shrink-0 items-center gap-1 pr-0.5">
<LanguageModeSelect bind:value={locale} translatedLocales={Object.keys(meta.i18n ?? {})} />
{#if !disabled}
<AccessButton on:click={() => (showAccessControlModal = true)} />
{:else}

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { resolveLocalizedResource } from '$lib/utils/localizedContent';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { toast } from 'svelte-sonner';
@ -141,6 +142,7 @@
const lowerQuery = query.toLowerCase();
return (
((t.name || '').toLowerCase().includes(lowerQuery) ||
resolveLocalizedResource(t, $i18n.language).toLowerCase().includes(lowerQuery) ||
(t.id || '').toLowerCase().includes(lowerQuery) ||
(t.user?.name || '').toLowerCase().includes(lowerQuery) || // Search by user name
(t.user?.email || '').toLowerCase().includes(lowerQuery)) && // Search by user email
@ -450,7 +452,7 @@
<div
class="truncate text-[0.8125rem] leading-5 text-gray-800 group-hover:underline dark:text-gray-200"
>
{tool.name}
{resolveLocalizedResource(tool, $i18n.language)}
</div>
</Tooltip>
@ -476,16 +478,16 @@
</div>
</div>
{#if tool?.meta?.description}
{#if resolveLocalizedResource(tool, $i18n.language, 'description')}
<Tooltip
content={tool?.meta?.description}
content={resolveLocalizedResource(tool, $i18n.language, 'description')}
className="min-w-0"
placement="top-start"
>
<div
class="mt-0.5 truncate text-[0.6875rem] leading-4 text-gray-400 dark:text-gray-600"
>
{tool?.meta?.description}
{resolveLocalizedResource(tool, $i18n.language, 'description')}
</div>
</Tooltip>
{/if}
@ -648,11 +650,17 @@
}}
>
<div class=" text-sm text-gray-500 truncate">
{$i18n.t('This will delete')} <span class=" font-normal">{selectedTool.name}</span>.
{$i18n.t('This will delete')}
<span class=" font-normal">{resolveLocalizedResource(selectedTool, $i18n.language)}</span>.
</div>
</DeleteConfirmDialog>
<ValvesModal bind:show={showValvesModal} type="tool" id={selectedTool?.id ?? null} />
<ValvesModal
bind:show={showValvesModal}
type="tool"
id={selectedTool?.id ?? null}
meta={selectedTool?.meta}
/>
<ManifestModal bind:show={showManifestModal} manifest={selectedTool?.meta?.manifest ?? {}} />
<ConfirmDialog

View file

@ -16,6 +16,11 @@
import AccessButton from '$lib/components/common/AccessButton.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import AccessControlModal from '../common/AccessControlModal.svelte';
import LanguageModeSelect from '$lib/components/common/LanguageModeSelect.svelte';
import LocalizedField from '$lib/components/common/LocalizedField.svelte';
import PluginTranslations from '$lib/components/workspace/common/PluginTranslations.svelte';
import { pruneEmptyLocaleEntries } from '$lib/utils/localizedContent';
let locale = '';
let formElement = null;
let loading = false;
@ -30,6 +35,7 @@
export let id = '';
export let name = '';
/** @type {{description: string, i18n?: Record<string, Record<string, string>>, manifest?: {translations?: Record<string, Record<string, string>>}}} */
export let meta = {
description: ''
};
@ -159,12 +165,17 @@ class Tools:
`;
const saveHandler = async () => {
if (!name.trim() || !meta.description?.trim()) {
locale = '';
toast.error($i18n.t('Name and description are required'));
return;
}
loading = true;
try {
await onSave({
id,
name,
meta,
meta: { ...meta, i18n: pruneEmptyLocaleEntries(meta.i18n) },
content,
access_grants: accessGrants
});
@ -235,15 +246,14 @@ class Tools:
<span>{$i18n.t('Back')}</span>
</button>
<div class="flex shrink-0 items-start gap-2 pb-2 px-1">
<div class="min-w-0 flex-1">
<div class="flex shrink-0 flex-col gap-2 pb-2 px-1 sm:flex-row sm:items-start">
<div class="min-w-0 w-full flex-1">
<Tooltip content={$i18n.t('e.g. My Tools')} placement="top-start">
<input
class="w-full bg-transparent text-sm outline-hidden"
type="text"
<LocalizedField
placeholder={$i18n.t('Tool Name')}
aria-label={$i18n.t('Tool Name')}
bind:value={name}
bind:translations={meta.i18n}
{locale}
required
/>
</Tooltip>
@ -276,12 +286,12 @@ class Tools:
content={$i18n.t('e.g. Tools for performing various operations')}
placement="top-start"
>
<input
class="w-full bg-transparent outline-hidden"
type="text"
<LocalizedField
placeholder={$i18n.t('Tool Description')}
aria-label={$i18n.t('Tool Description')}
bind:value={meta.description}
bind:translations={meta.i18n}
{locale}
field="description"
required
/>
</Tooltip>
@ -289,6 +299,10 @@ class Tools:
</div>
<div class="flex shrink-0 items-center gap-1 pr-0.5">
<LanguageModeSelect
bind:value={locale}
translatedLocales={Object.keys(pruneEmptyLocaleEntries(meta.i18n))}
/>
<AccessButton
on:click={() => {
showAccessControlModal = true;
@ -297,32 +311,42 @@ class Tools:
</div>
</div>
<div class="min-h-0 flex-1 overflow-hidden rounded-lg">
<CodeEditor
bind:this={codeEditor}
value={content}
lang="python"
{boilerplate}
className="text-[0.6875rem]"
onChange={(e) => {
_content = e;
if (!edit) {
const fm = extractFrontmatter(e);
if (fm.title && !name) {
name = formatSkillName(fm.title);
id = nameToId(fm.title);
<div class="min-h-0 flex-1 overflow-hidden rounded-lg flex flex-col">
{#if locale}
<PluginTranslations
id={edit ? id : ''}
kind="tool"
{locale}
bind:translations={meta.i18n}
/>
{/if}
<div class={locale ? 'hidden' : 'h-full'}>
<CodeEditor
bind:this={codeEditor}
value={content}
lang="python"
{boilerplate}
className="text-[0.6875rem]"
onChange={(e) => {
_content = e;
if (!edit) {
const fm = extractFrontmatter(e);
if (fm.title && !name) {
name = formatSkillName(fm.title);
id = nameToId(fm.title);
}
if (fm.description && !meta.description) {
meta = { ...meta, description: fm.description };
}
}
if (fm.description && !meta.description) {
meta = { ...meta, description: fm.description };
}}
onSave={async () => {
if (formElement) {
formElement.requestSubmit();
}
}
}}
onSave={async () => {
if (formElement) {
formElement.requestSubmit();
}
}}
/>
}}
/>
</div>
</div>
<div class="shrink-0 py-2 text-xs text-gray-500">

View file

@ -0,0 +1,71 @@
<script lang="ts">
import { onMount, getContext } from 'svelte';
import {
getToolValvesSpecById,
getUserValvesSpecById as getToolUserValvesSpec
} from '$lib/apis/tools';
import {
getFunctionValvesSpecById,
getUserValvesSpecById as getFunctionUserValvesSpec
} from '$lib/apis/functions';
import { valveTranslationSource } from '$lib/utils/localizedContent';
import TranslationTable from '$lib/components/common/TranslationTable.svelte';
const i18n = getContext<any>('i18n');
export let id = '';
export let kind: 'tool' | 'function';
export let locale: string;
export let translations: Record<string, Record<string, string>> = {};
let fields: Record<string, string> = {};
let loadError = false;
const headerFields = ['name', 'description'];
$: source = fields;
$: values = Object.fromEntries(
Object.entries(translations?.[locale] ?? {}).filter(([key]) => !headerFields.includes(key))
);
const load = async () => {
if (!id) return;
loadError = false;
const loaders =
kind === 'tool'
? [getToolValvesSpecById, getToolUserValvesSpec]
: [getFunctionValvesSpecById, getFunctionUserValvesSpec];
const results = await Promise.allSettled(
loaders.map((loader) => loader(localStorage.token, id))
);
fields = Object.assign(
{},
...results.map((result, index) => {
if (result.status === 'rejected') {
loadError = true;
return {};
}
return valveTranslationSource(result.value, index ? 'user_valves' : 'valves');
})
);
};
const changed = (next: Record<string, string>) => {
translations = {
...translations,
[locale]: {
...Object.fromEntries(
Object.entries(translations?.[locale] ?? {}).filter(([key]) => headerFields.includes(key))
),
...next
}
};
};
onMount(load);
</script>
{#if loadError}<div class="mb-2 text-xs text-red-500" role="alert">
{$i18n.t('Failed to load valves')}<button type="button" class="ms-2 underline" on:click={load}
>{$i18n.t('Retry')}</button
>
</div>{/if}
<TranslationTable
value={values}
{source}
filename={`${kind}-${id}-${locale}.json`}
onChange={changed}
allowNewKeys
/>

View file

@ -2,6 +2,7 @@
import { toast } from 'svelte-sonner';
import { createEventDispatcher } from 'svelte';
import { onMount, getContext } from 'svelte';
import { tools, functions } from '$lib/stores';
import { addUser } from '$lib/apis/auths';
import Modal from '../../common/Modal.svelte';
@ -38,6 +39,9 @@
export let type = 'tool';
export let id = null;
export let userValves = false;
export let meta = null;
$: resourceMeta =
meta ?? (type === 'tool' ? $tools : $functions)?.find((item) => item.id === id)?.meta ?? {};
let saving = false;
let loading = false;
@ -179,7 +183,7 @@
>
<div>
{#if !loading}
<Valves {valvesSpec} bind:valves />
<Valves {valvesSpec} bind:valves meta={resourceMeta} {userValves} />
{:else}
<Spinner className="size-5" />
{/if}

View file

@ -1,4 +1,5 @@
export type Banner = {
i18n?: Record<string, { content: string }>;
id: string;
type: string;
title?: string;

View file

@ -32,6 +32,91 @@ export const resolveLocalizedString = (
return fallback ?? '';
};
export const resolveLocalizedResource = (resource: any, locale: string, field = 'name') => {
const meta = resource?.meta;
const key = resource?.action_id ? `actions.${resource.action_id}.${field}` : field;
return resolveLocalizedString(
resource?.[field] ?? meta?.[field] ?? (field === 'name' ? resource?.id : ''),
meta?.i18n,
locale,
key
);
};
export const resolveLocalizedFunction = (
item: any,
functions: any[] | null,
locale: string,
field = 'name'
) => {
const owner = (functions ?? [])
.filter((fn) => item?.id === fn.id || item?.id?.startsWith(`${fn.id}.`))
.sort((a, b) => b.id.length - a.id.length)[0];
const key =
owner && item.id !== owner.id
? `actions.${item.id.slice(owner.id.length + 1)}.${field}`
: field;
return resolveLocalizedString(item?.[field], owner?.meta?.i18n ?? item?.meta?.i18n, locale, key);
};
// This is a display-only copy: identifiers, defaults and submitted values stay untouched.
export const localizeValvesSchema = (
schema: any,
locale: string,
meta: any = {},
prefix = 'valves'
) => {
if (!schema) return schema;
const translate = (value: string, key: string) =>
resolveLocalizedString(value, meta?.i18n, locale, `${prefix}.${key}`);
return {
...schema,
properties: Object.fromEntries(
Object.entries(schema.properties ?? {}).map(([key, value]: [string, any]) => [
key,
{
...value,
title: translate(value.title ?? key, `${key}.title`),
description: translate(value.description ?? '', `${key}.description`),
...(Array.isArray(value.input?.options)
? {
input: {
...value.input,
options: value.input.options.map((option: any) =>
typeof option === 'object'
? {
...option,
label: translate(
option.label ?? String(option.value),
`${key}.enum.${option.value}`
)
}
: { value: option, label: translate(String(option), `${key}.enum.${option}`) }
)
}
}
: {})
}
])
)
};
};
export const valveTranslationSource = (schema: any, prefix: string): Record<string, string> => {
const strings: Record<string, string> = {};
for (const [key, field] of Object.entries(schema?.properties ?? {}) as [string, any][]) {
strings[`${prefix}.${key}.title`] = field.title ?? key;
if (field.description) strings[`${prefix}.${key}.description`] = field.description;
for (const option of field.enum ?? field.input?.options ?? []) {
const value = typeof option === 'object' ? option.value : option;
strings[`${prefix}.${key}.enum.${value}`] = String(
typeof option === 'object' ? (option.label ?? value) : value
);
}
}
return strings;
};
export const resolveLocalizedModelName = (model: any, locale?: string | null) => {
const meta = model?.info?.meta ?? model?.meta;
const info = model?.info ?? model;

View file

@ -0,0 +1,25 @@
export const placeholders = (text: string) =>
[
...new Set(
Array.from(text.matchAll(/\{\{\s*-?\s*([^},]+)(?:,[^}]+)?\s*\}\}/g), (match) =>
match[1].trim()
)
)
].sort();
export const validateDictionary = (
value: unknown,
source: Record<string, string> = {}
): Record<string, string> => {
if (!value || typeof value !== 'object' || Array.isArray(value))
throw new Error('Expected a JSON object of translation keys and strings.');
for (const [key, text] of Object.entries(value)) {
if (typeof text !== 'string') throw new Error(`Translation must be a string: ${key}`);
if (
text.trim() &&
JSON.stringify(placeholders(source[key] || key)) !== JSON.stringify(placeholders(text))
)
throw new Error(`Interpolation placeholders do not match: ${key}`);
}
return value as Record<string, string>;
};