feat: implement hotel staff chat model configuration API and update localization files

This commit is contained in:
duanshuwen
2026-04-21 19:39:36 +08:00
parent 413e566430
commit 488d420e06
11 changed files with 153 additions and 672 deletions

View File

@@ -4,13 +4,13 @@ import request from './request';
import * as API from './types';
/** 获取模型配置 获取模型配置 GET /chat/modelConfig */
export function chatModelConfigUsingGet({
/** 获取模型配置 获取模型配置 GET /hotelStaff/chat/modelConfig */
export function hotelStaffChatModelConfigUsingGet({
options,
}: {
options?: { [key: string]: unknown };
}) {
return request<API.RModelConfigDTO>('/chat/modelConfig', {
return request<API.RModelConfigDTO>('/hotelStaff/chat/modelConfig', {
method: 'GET',
...(options || {}),
});

View File

@@ -16,4 +16,4 @@ export * from './recentConversation';
export * from './conversationMessageList';
export * from './recommendedQuestionList';
export * from './chatConfig';
export * from './modelConfig';
export * from './chat';

View File

@@ -41,10 +41,6 @@ export type ChatConversationMessageListUsingPostResponses = {
200: RPageConversationMessageDTO;
};
export type ChatModelConfigUsingGetResponses = {
200: RModelConfigDTO;
};
export type ChatRecentConversationUsingGetResponses = {
200: RConversationDTO;
};
@@ -298,6 +294,10 @@ export type EventListSearchForm = {
eventStatus?: number;
};
export type HotelStaffChatModelConfigUsingGetResponses = {
200: RModelConfigDTO;
};
export type HotelStaffConfigChannelBindingUsingPostResponses = {
200: RBoolean;
};

View File

@@ -14,6 +14,7 @@
"providers": {
"title": "AI Providers",
"subtitle": "Manage your AI models and API keys.",
"providerName": "Provider",
"add": "Add Provider",
"loading": "Loading provider settings...",
"defaultBadge": "Default",
@@ -22,7 +23,7 @@
"setDefault": "Set Default",
"edit": "Edit",
"delete": "Delete",
"empty": "No providers configured yet. Click \"Add Provider\" to start.",
"empty": "No model configuration is available yet.",
"confirmDelete": "Are you sure you want to delete this provider?",
"notices": {
"loadError": "Failed to load provider settings.",
@@ -68,7 +69,9 @@
"window7d": "Last 7 Days",
"window30d": "Last 30 Days",
"windowAll": "All Time",
"showingRecords": "Showing {count} records",
"showingRecords": "Showing {{count}} records",
"showingRecords_one": "Showing {{count}} record",
"showingRecords_other": "Showing {{count}} records",
"chart": {
"total": "Total",
"input": "Input",
@@ -78,17 +81,17 @@
"row": {
"noUsageInfo": "No usage info",
"errorParsingUsage": "Error parsing usage",
"input": "Input: {count}",
"output": "Output: {count}",
"cacheRead": "Cache Read: {count}",
"cacheWrite": "Cache Write: {count}",
"input": "Input: {{count}}",
"output": "Output: {{count}}",
"cacheRead": "Cache Read: {{count}}",
"cacheWrite": "Cache Write: {{count}}",
"noUsageReported": "No usage reported",
"parseError": "Parse error",
"viewContent": "View Content",
"error": "Error"
},
"pagination": {
"pageOf": "Page {page} of {total}",
"pageOf": "Page {{page}} of {{total}}",
"prev": "Prev",
"next": "Next"
},

View File

@@ -14,6 +14,7 @@
"providers": {
"title": "AI プロバイダー",
"subtitle": "AI モデルと API キーを管理します。",
"providerName": "Provider",
"add": "プロバイダーを追加",
"loading": "プロバイダー設定を読み込み中...",
"defaultBadge": "デフォルト",
@@ -22,7 +23,7 @@
"setDefault": "デフォルトに設定",
"edit": "編集",
"delete": "削除",
"empty": "プロバイダーはまだ設定されていません。「プロバイダーを追加」をクリックして開始してください。",
"empty": "モデル設定はまだ取得されていません。",
"confirmDelete": "このプロバイダーを削除してもよろしいですか?",
"notices": {
"loadError": "プロバイダー設定の読み込みに失敗しました。",
@@ -68,7 +69,7 @@
"window7d": "過去 7 日",
"window30d": "過去 30 日",
"windowAll": "全期間",
"showingRecords": "{count} 件を表示",
"showingRecords": "{{count}} 件を表示",
"chart": {
"total": "合計",
"input": "入力",
@@ -78,17 +79,17 @@
"row": {
"noUsageInfo": "使用量情報なし",
"errorParsingUsage": "使用量の解析エラー",
"input": "入力: {count}",
"output": "出力: {count}",
"cacheRead": "キャッシュ読込: {count}",
"cacheWrite": "キャッシュ書込: {count}",
"input": "入力: {{count}}",
"output": "出力: {{count}}",
"cacheRead": "キャッシュ読込: {{count}}",
"cacheWrite": "キャッシュ書込: {{count}}",
"noUsageReported": "使用量が報告されていません",
"parseError": "解析エラー",
"viewContent": "内容を見る",
"error": "エラー"
},
"pagination": {
"pageOf": "{page} / {total} ページ",
"pageOf": "{{page}} / {{total}} ページ",
"prev": "前へ",
"next": "次へ"
},

View File

@@ -14,6 +14,7 @@
"providers": {
"title": "AI 服务商",
"subtitle": "管理你的 AI 模型与 API Key。",
"providerName": "Provider",
"add": "添加服务商",
"loading": "正在加载服务商配置...",
"defaultBadge": "默认",
@@ -22,7 +23,7 @@
"setDefault": "设为默认",
"edit": "编辑",
"delete": "删除",
"empty": "暂未配置服务商。点击“添加服务商”开始。",
"empty": "暂未获取到模型配置。",
"confirmDelete": "确定删除这个服务商吗?",
"notices": {
"loadError": "加载服务商配置失败。",
@@ -68,7 +69,7 @@
"window7d": "最近 7 天",
"window30d": "最近 30 天",
"windowAll": "全部时间",
"showingRecords": "共显示 {count} 条记录",
"showingRecords": "共显示 {{count}} 条记录",
"chart": {
"total": "总计",
"input": "输入",
@@ -78,17 +79,17 @@
"row": {
"noUsageInfo": "无用量信息",
"errorParsingUsage": "用量解析错误",
"input": "输入:{count}",
"output": "输出:{count}",
"cacheRead": "缓存读取:{count}",
"cacheWrite": "缓存写入:{count}",
"input": "输入:{{count}}",
"output": "输出:{{count}}",
"cacheRead": "缓存读取:{{count}}",
"cacheWrite": "缓存写入:{{count}}",
"noUsageReported": "未上报用量",
"parseError": "解析失败",
"viewContent": "查看内容",
"error": "错误"
},
"pagination": {
"pageOf": "第 {page} / {total} 页",
"pageOf": "第 {{page}} / {{total}} 页",
"prev": "上一页",
"next": "下一页"
},

View File

@@ -1,167 +0,0 @@
import { useEffect, useState } from 'react';
import { getProviderDocsUrl, getProviderPlaceholder } from '../../../lib/providers';
import { useLocale } from '../../../i18n';
import { useModelsCopy } from '../copy';
import DialogSurface from './DialogSurface';
import type { ProviderEditorValues, ProviderListItem } from './provider-types';
type ProviderEditorDialogProps = {
open: boolean;
item: ProviderListItem | null;
saving: boolean;
error: string | null;
onClose: () => void;
onSave: (values: ProviderEditorValues) => void;
onSwitchProvider: () => void;
};
const FIELD_CLASS_NAME = [
'w-full rounded-[12px] border border-black/10 bg-white px-4 py-3 text-[14px] text-[#171717]',
'outline-none transition-colors placeholder:text-[#99A0AE] focus:border-black/20',
'dark:border-white/10 dark:bg-[#222225] dark:text-[#f3f4f6] dark:placeholder:text-gray-500 dark:focus:border-white/20',
].join(' ');
export default function ProviderEditorDialog({
open,
item,
saving,
error,
onClose,
onSave,
onSwitchProvider,
}: ProviderEditorDialogProps) {
const locale = useLocale();
const t = useModelsCopy();
const [label, setLabel] = useState('');
const [apiKey, setApiKey] = useState('');
const [baseUrl, setBaseUrl] = useState('');
const [model, setModel] = useState('');
useEffect(() => {
if (!item) return;
setLabel(item.account.label || item.vendor?.name || '');
setApiKey('');
setBaseUrl(item.account.baseUrl || item.vendor?.defaultBaseUrl || '');
setModel(item.account.model || item.vendor?.defaultModelId || '');
}, [item]);
if (!item) return null;
const provider = item.vendor;
const isNew = Boolean(item.isNew);
const showBaseUrl = Boolean(provider?.showBaseUrl);
const showModel = Boolean(provider?.showModelId);
const docsUrl = getProviderDocsUrl(provider, locale);
const apiKeyPlaceholder = getProviderPlaceholder(provider, locale) || t('models.providers.editor.apiKeyPlaceholder');
return (
<DialogSurface
open={open}
onClose={onClose}
title={isNew ? t('models.providers.editor.addTitle') : t('models.providers.editor.editTitle')}
subtitle={t('models.providers.editor.subtitle')}
closeLabel={t('models.common.closeDialog')}
widthClassName="max-w-[640px]"
>
<div className="space-y-6">
<div className="flex items-center gap-4 rounded-[16px] bg-white p-5 shadow-sm dark:bg-[#1f1f22]">
<div className="flex h-12 w-12 items-center justify-center overflow-hidden rounded-[14px] bg-white text-[24px] dark:bg-[#222225]">
{provider?.icon || t('models.common.ai')}
</div>
<div className="min-w-0">
<div className="mb-1 text-[16px] font-medium text-[#171717] dark:text-[#f3f4f6]">
{provider?.name || item.account.label}
</div>
<div className="flex flex-wrap items-center gap-3 text-[13px] text-[#2B7FFF] dark:text-blue-400">
<button type="button" className="hover:underline" onClick={onSwitchProvider}>
{t('models.providers.editor.switchProvider')}
</button>
{docsUrl ? (
<a href={docsUrl} target="_blank" rel="noreferrer" className="hover:underline">
{t('models.providers.editor.viewDocs')}
</a>
) : null}
</div>
</div>
</div>
{error ? (
<div className="rounded-[14px] border border-red-200 bg-red-50 px-4 py-3 text-[13px] text-red-600 dark:border-red-500/25 dark:bg-red-500/10 dark:text-red-300">
{error}
</div>
) : null}
<div className="space-y-5">
<label className="block">
<span className="mb-2 block text-[15px] font-semibold text-[#171717] dark:text-[#f3f4f6]">
{t('models.providers.editor.displayName')}
</span>
<input
value={label}
onChange={(event) => setLabel(event.target.value)}
placeholder={provider?.name || t('models.providers.editor.displayNamePlaceholder')}
className={FIELD_CLASS_NAME}
/>
</label>
<label className="block">
<span className="mb-2 block text-[15px] font-semibold text-[#171717] dark:text-[#f3f4f6]">
{t('models.providers.editor.apiKey')}
</span>
<input
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
type="password"
placeholder={apiKeyPlaceholder}
className={[FIELD_CLASS_NAME, 'font-mono'].join(' ')}
/>
<span className="mt-2 block text-[12px] text-[#99A0AE] dark:text-gray-500">
{t('models.providers.editor.apiKeyHint')}
</span>
</label>
{showBaseUrl ? (
<label className="block">
<span className="mb-2 block text-[15px] font-semibold text-[#171717] dark:text-[#f3f4f6]">
{t('models.providers.editor.baseUrl')}
</span>
<input
value={baseUrl}
onChange={(event) => setBaseUrl(event.target.value)}
placeholder={provider?.defaultBaseUrl || t('models.providers.editor.baseUrlPlaceholder')}
className={[FIELD_CLASS_NAME, 'font-mono'].join(' ')}
/>
</label>
) : null}
{showModel ? (
<label className="block">
<span className="mb-2 block text-[15px] font-semibold text-[#171717] dark:text-[#f3f4f6]">
{t('models.providers.editor.defaultModel')}
</span>
<input
value={model}
onChange={(event) => setModel(event.target.value)}
placeholder={provider?.modelIdPlaceholder || provider?.defaultModelId || t('models.providers.editor.defaultModelPlaceholder')}
className={[FIELD_CLASS_NAME, 'font-mono'].join(' ')}
/>
</label>
) : null}
</div>
<div className="flex justify-end border-t border-black/6 pt-6 dark:border-white/8">
<button
type="button"
className="rounded-full bg-[#007AFF] px-6 py-2.5 text-[14px] font-medium text-white transition-colors hover:bg-[#0066FF] disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => onSave({ label, apiKey, baseUrl, model })}
disabled={saving}
>
{saving ? t('models.providers.editor.saving') : isNew ? t('models.providers.editor.add') : t('models.providers.editor.save')}
</button>
</div>
</div>
</DialogSurface>
);
}

View File

@@ -1,48 +0,0 @@
import type { ProviderTypeInfo } from '../../../lib/providers';
import { useModelsCopy } from '../copy';
import DialogSurface from './DialogSurface';
type ProviderPickerDialogProps = {
open: boolean;
providers: ProviderTypeInfo[];
onClose: () => void;
onSelect: (provider: ProviderTypeInfo) => void;
};
export default function ProviderPickerDialog({
open,
providers,
onClose,
onSelect,
}: ProviderPickerDialogProps) {
const t = useModelsCopy();
return (
<DialogSurface
open={open}
onClose={onClose}
title={t('models.providers.picker.title')}
subtitle={t('models.providers.picker.subtitle')}
closeLabel={t('models.common.closeDialog')}
widthClassName="max-w-[800px]"
>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{providers.map((provider) => (
<button
key={provider.id}
type="button"
className="flex cursor-pointer flex-col items-center justify-center gap-3 rounded-[16px] border border-black/6 bg-white/60 p-5 text-center transition-all duration-200 hover:bg-white dark:border-white/8 dark:bg-[#1f1f22] dark:hover:bg-[#2a2a2d]"
onClick={() => onSelect(provider)}
>
<div className="flex h-12 w-12 items-center justify-center overflow-hidden rounded-[14px] bg-black/5 text-[24px] dark:bg-[#222225]">
{provider.icon || t('models.common.ai')}
</div>
<span className="text-[14px] font-medium text-[#171717] dark:text-[#f3f4f6]">
{provider.name}
</span>
</button>
))}
</div>
</DialogSurface>
);
}

View File

@@ -1,162 +1,85 @@
import { useEffect, useState } from 'react';
import {
PROVIDER_TYPE_INFO,
type ProviderAccount,
type ProviderTypeInfo,
type ProviderVendorInfo,
type ProviderWithKeyInfo,
} from '../../../lib/providers';
import { hotelStaffChatModelConfigUsingGet } from '../../../api/chat';
import type { ModelConfigDTO } from '../../../api/types';
import { onGatewayEvent } from '../../../lib/gateway-client';
import { hostApiFetch } from '../../../lib/host-api';
import { isRuntimeChangedGatewayEvent, runtimeEventHasTopic } from '../../../lib/runtime-events';
import ProviderEditorDialog from './ProviderEditorDialog';
import ProviderPickerDialog from './ProviderPickerDialog';
import type { DisplayVendor, ProviderEditorValues, ProviderListItem } from './provider-types';
import { useModelsCopy } from '../copy';
type NoticeState = {
tone: 'success' | 'error';
message: string;
};
type ProviderSnapshot = {
accounts: ProviderAccount[];
statuses: ProviderWithKeyInfo[];
vendors: ProviderVendorInfo[];
defaultAccountId: string | null;
};
const VISIBLE_PROVIDERS = PROVIDER_TYPE_INFO.filter((provider) => !provider.hidden);
const ACTION_BUTTON_CLASS_NAME = [
'rounded-full border px-3 py-1.5 text-[12px] transition-colors',
'border-[#E5E8EE] text-[#525866] hover:border-[#2B7FFF] hover:text-[#2B7FFF]',
'dark:border-[#2a2a2d] dark:text-gray-300 dark:hover:border-[#3b82f6] dark:hover:text-white',
].join(' ');
function formatErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error && error.message) return error.message;
if (typeof error === 'string' && error) return error;
return fallback;
}
function hasConfiguredCredentials(account: ProviderAccount, status?: ProviderWithKeyInfo): boolean {
if (account.authMode === 'oauth_device' || account.authMode === 'oauth_browser' || account.authMode === 'local') {
return true;
}
return status?.hasKey ?? false;
function normalizeText(value: string | undefined): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed || null;
}
function legacyStatusToAccount(status: ProviderWithKeyInfo): ProviderAccount {
return {
id: status.id,
vendorId: status.type,
label: status.name,
authMode: status.type === 'ollama' ? 'local' : 'api_key',
baseUrl: status.baseUrl,
apiProtocol: status.apiProtocol,
headers: status.headers,
model: status.model,
fallbackModels: status.fallbackModels,
fallbackAccountIds: status.fallbackProviderIds,
enabled: status.enabled,
isDefault: false,
createdAt: status.createdAt,
updatedAt: status.updatedAt,
};
function normalizeModelConfig(response: unknown): ModelConfigDTO {
if (!response || typeof response !== 'object') {
return {};
}
const candidate = response as ModelConfigDTO & { data?: unknown };
if (
typeof candidate.providerName === 'string'
|| typeof candidate.apiKey === 'string'
|| typeof candidate.baseUrl === 'string'
|| typeof candidate.model === 'string'
) {
return {
providerName: candidate.providerName,
apiKey: candidate.apiKey,
baseUrl: candidate.baseUrl,
model: candidate.model,
};
}
if (candidate.data && typeof candidate.data === 'object') {
return normalizeModelConfig(candidate.data);
}
return {};
}
function buildProviderItems(
accounts: ProviderAccount[],
statuses: ProviderWithKeyInfo[],
vendors: DisplayVendor[],
defaultAccountId: string | null,
): ProviderListItem[] {
const vendorMap = new Map<string, DisplayVendor>();
for (const vendor of VISIBLE_PROVIDERS) {
vendorMap.set(vendor.id, vendor);
}
for (const vendor of vendors) {
vendorMap.set(vendor.id, vendor);
}
const statusMap = new Map(statuses.map((status) => [status.id, status]));
if (accounts.length > 0) {
return [...accounts]
.map((account) => ({
account,
vendor: vendorMap.get(account.vendorId),
status: statusMap.get(account.id),
}))
.sort((left, right) => {
if (left.account.id === defaultAccountId) return -1;
if (right.account.id === defaultAccountId) return 1;
return right.account.updatedAt.localeCompare(left.account.updatedAt);
});
}
return statuses.map((status) => ({
account: legacyStatusToAccount(status),
vendor: vendorMap.get(status.type),
status,
}));
function hasModelConfig(config: ModelConfigDTO | null): boolean {
return Boolean(
normalizeText(config?.providerName)
|| normalizeText(config?.apiKey)
|| normalizeText(config?.baseUrl)
|| normalizeText(config?.model),
);
}
function buildProviderAccountId(providerId: ProviderTypeInfo['id']): string {
const suffix = Math.random().toString(36).slice(2, 8);
return `${providerId}-${Date.now().toString(36)}-${suffix}`;
function maskSecret(value: string | undefined): string | null {
const secret = normalizeText(value);
if (!secret) return null;
if (secret.includes('*')) return secret;
if (secret.length <= 4) return '*'.repeat(secret.length);
if (secret.length <= 8) return `${secret.slice(0, 2)}***${secret.slice(-2)}`;
return `${secret.slice(0, 4)}***${secret.slice(-4)}`;
}
export default function ProvidersSection() {
const t = useModelsCopy();
const [loading, setLoading] = useState(true);
const [items, setItems] = useState<ProviderListItem[]>([]);
const [vendors, setVendors] = useState<DisplayVendor[]>(VISIBLE_PROVIDERS);
const [defaultAccountId, setDefaultAccountId] = useState<string | null>(null);
const [notice, setNotice] = useState<NoticeState | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [editorItem, setEditorItem] = useState<ProviderListItem | null>(null);
const [saving, setSaving] = useState(false);
const [editorError, setEditorError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [modelConfig, setModelConfig] = useState<ModelConfigDTO | null>(null);
async function loadProviders(showLoading = true): Promise<void> {
async function loadModelConfig(showLoading = true): Promise<void> {
if (showLoading) {
setLoading(true);
}
try {
const [accounts, statuses, vendorList, defaultInfo] = await Promise.all([
hostApiFetch<ProviderAccount[]>('/api/provider-accounts'),
hostApiFetch<ProviderWithKeyInfo[]>('/api/providers'),
hostApiFetch<ProviderVendorInfo[]>('/api/provider-vendors'),
hostApiFetch<{ accountId: string | null }>('/api/provider-accounts/default'),
]);
const nextSnapshot: ProviderSnapshot = {
accounts: Array.isArray(accounts) ? accounts : [],
statuses: Array.isArray(statuses) ? statuses : [],
vendors: Array.isArray(vendorList) ? vendorList : [],
defaultAccountId: defaultInfo?.accountId ?? null,
};
const nextVendors = nextSnapshot.vendors.length > 0 ? nextSnapshot.vendors : VISIBLE_PROVIDERS;
setVendors(nextVendors);
setDefaultAccountId(nextSnapshot.defaultAccountId);
setItems(buildProviderItems(
nextSnapshot.accounts,
nextSnapshot.statuses,
nextVendors,
nextSnapshot.defaultAccountId,
));
} catch (error) {
setNotice({
tone: 'error',
message: formatErrorMessage(error, t('models.providers.notices.loadError')),
});
const response = await hotelStaffChatModelConfigUsingGet({});
setModelConfig(normalizeModelConfig(response));
setError(null);
} catch (loadError) {
setError(formatErrorMessage(loadError, t('models.providers.notices.loadError')));
} finally {
if (showLoading) {
setLoading(false);
@@ -165,183 +88,57 @@ export default function ProvidersSection() {
}
useEffect(() => {
void loadProviders();
void loadModelConfig();
}, []);
useEffect(() => {
return onGatewayEvent((event) => {
if (!isRuntimeChangedGatewayEvent(event)) return;
if (!runtimeEventHasTopic(event, 'providers', 'models')) return;
void loadProviders(false);
void loadModelConfig(false);
});
}, []);
async function handleSetDefault(accountId: string): Promise<void> {
try {
await hostApiFetch<{ success: boolean; error?: string }>('/api/provider-accounts/default', {
method: 'PUT',
body: JSON.stringify({ accountId }),
});
await loadProviders(false);
setDefaultAccountId(accountId);
setNotice({ tone: 'success', message: t('models.providers.notices.defaultSuccess') });
} catch (error) {
setNotice({
tone: 'error',
message: formatErrorMessage(error, t('models.providers.notices.defaultError')),
});
}
}
const fields = [
{
key: 'providerName',
label: t('models.providers.providerName', undefined, 'Provider'),
value: normalizeText(modelConfig?.providerName),
},
{
key: 'apiKey',
label: t('models.providers.editor.apiKey'),
value: maskSecret(modelConfig?.apiKey),
mono: true,
},
{
key: 'baseUrl',
label: t('models.providers.editor.baseUrl'),
value: normalizeText(modelConfig?.baseUrl),
mono: true,
},
{
key: 'model',
label: t('models.providers.editor.defaultModel'),
value: normalizeText(modelConfig?.model),
mono: true,
},
] as const;
async function handleDelete(accountId: string): Promise<void> {
const confirmed = window.confirm(t('models.providers.confirmDelete'));
if (!confirmed) return;
try {
await hostApiFetch<{ success: boolean; error?: string }>(`/api/provider-accounts/${encodeURIComponent(accountId)}`, {
method: 'DELETE',
});
await loadProviders(false);
setNotice({ tone: 'success', message: t('models.providers.notices.deleteSuccess') });
} catch (error) {
setNotice({
tone: 'error',
message: formatErrorMessage(error, t('models.providers.notices.deleteError')),
});
}
}
function handleSelectProvider(provider: ProviderTypeInfo): void {
const account: ProviderAccount = {
id: buildProviderAccountId(provider.id),
vendorId: provider.id,
label: provider.name,
authMode: provider.requiresApiKey ? 'api_key' : 'local',
baseUrl: provider.defaultBaseUrl,
apiProtocol: provider.apiProtocol,
model: provider.defaultModelId,
enabled: true,
isDefault: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
setEditorError(null);
setPickerOpen(false);
setEditorItem({
account,
vendor: provider,
status: undefined,
isNew: true,
});
}
function handleEdit(item: ProviderListItem): void {
setEditorError(null);
setEditorItem({
...item,
isNew: false,
});
}
async function handleSave(values: ProviderEditorValues): Promise<void> {
if (!editorItem) return;
setSaving(true);
setEditorError(null);
const trimmedLabel = values.label.trim() || editorItem.vendor?.name || editorItem.account.label;
const trimmedApiKey = values.apiKey.trim();
const trimmedBaseUrl = values.baseUrl.trim();
const trimmedModel = values.model.trim();
try {
if (editorItem.isNew) {
const account: ProviderAccount = {
...editorItem.account,
label: trimmedLabel,
baseUrl: trimmedBaseUrl || undefined,
model: trimmedModel || undefined,
updatedAt: new Date().toISOString(),
};
await hostApiFetch<{ success: boolean; error?: string }>('/api/provider-accounts', {
method: 'POST',
body: JSON.stringify({
account,
apiKey: trimmedApiKey || undefined,
}),
});
if (!defaultAccountId) {
await hostApiFetch<{ success: boolean; error?: string }>('/api/provider-accounts/default', {
method: 'PUT',
body: JSON.stringify({ accountId: account.id }),
});
}
setNotice({ tone: 'success', message: t('models.providers.notices.saveSuccess') });
} else {
await hostApiFetch<{ success: boolean; error?: string }>(
`/api/provider-accounts/${encodeURIComponent(editorItem.account.id)}`,
{
method: 'PUT',
body: JSON.stringify({
updates: {
label: trimmedLabel,
baseUrl: trimmedBaseUrl || undefined,
model: trimmedModel || undefined,
enabled: true,
},
apiKey: trimmedApiKey || undefined,
}),
},
);
setNotice({ tone: 'success', message: t('models.providers.notices.saveSuccess') });
}
await loadProviders(false);
setEditorItem(null);
} catch (error) {
setEditorError(formatErrorMessage(error, t('models.providers.notices.saveError')));
} finally {
setSaving(false);
}
}
const hasConfig = hasModelConfig(modelConfig);
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-lg font-medium text-[#171717] dark:text-[#f3f4f6]">{t('models.providers.title')}</h3>
<p className="mt-1 text-sm text-[#99A0AE] dark:text-gray-500">
{t('models.providers.subtitle')}
</p>
</div>
<button
type="button"
className="rounded-full bg-[#2B7FFF] px-4 py-2 text-[14px] font-medium text-white transition-colors hover:bg-[#1769ff]"
onClick={() => {
setEditorError(null);
setPickerOpen(true);
}}
>
{t('models.providers.add')}
</button>
<div>
<h3 className="text-lg font-medium text-[#171717] dark:text-[#f3f4f6]">{t('models.providers.title')}</h3>
<p className="mt-1 text-sm text-[#99A0AE] dark:text-gray-500">
{t('models.providers.subtitle')}
</p>
</div>
{notice ? (
<div
className={[
'rounded-[14px] border px-4 py-3 text-[13px]',
notice.tone === 'success'
? 'border-[#d7e7ff] bg-[#f4f8ff] text-[#355d92] dark:border-[#26456e] dark:bg-[#112033] dark:text-[#bfd6ff]'
: 'border-red-200 bg-red-50 text-red-600 dark:border-red-500/25 dark:bg-red-500/10 dark:text-red-300',
].join(' ')}
>
{notice.message}
{error ? (
<div className="rounded-[14px] border border-red-200 bg-red-50 px-4 py-3 text-[13px] text-red-600 dark:border-red-500/25 dark:bg-red-500/10 dark:text-red-300">
{error}
</div>
) : null}
@@ -352,121 +149,37 @@ export default function ProvidersSection() {
</div>
) : null}
{!loading ? (
<div className="space-y-4">
{items.map((item) => {
const configured = hasConfiguredCredentials(item.account, item.status);
const isDefault = item.account.id === defaultAccountId;
return (
{!loading && hasConfig ? (
<div className="rounded-[14px] border border-[#E5E8EE] bg-white p-5 dark:border-[#2a2a2d] dark:bg-[#1f1f22]">
<div className="grid gap-4 md:grid-cols-2">
{fields.map((field) => (
<div
key={item.account.id}
className="rounded-[14px] border border-[#E5E8EE] bg-white p-5 transition-colors hover:bg-[#f8fafc] dark:border-[#2a2a2d] dark:bg-[#1f1f22] dark:hover:bg-[#2a2a2d]"
key={field.key}
className="rounded-xl bg-[#F8FAFC] px-4 py-3 dark:bg-[#18181b]"
>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex min-w-0 items-start gap-4">
<div className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full border border-gray-200 bg-gray-100 text-xl dark:border-[#2a2a2d] dark:bg-[#222225]">
{item.vendor?.icon || t('models.common.ai')}
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[16px] font-medium text-[#171717] dark:text-[#f3f4f6]">
{item.account.label || item.vendor?.name}
</span>
{isDefault ? (
<span className="rounded-full bg-[#e6f4ea] px-2 py-0.5 text-[11px] font-medium text-[#167a3a] dark:bg-[#17361f] dark:text-[#8de0a8]">
{t('models.providers.defaultBadge')}
</span>
) : null}
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm text-[#99A0AE] dark:text-gray-500">
{item.account.baseUrl ? (
<span className="max-w-[260px] truncate">{item.account.baseUrl}</span>
) : null}
{item.account.model ? (
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs dark:bg-[#222225]">
{item.account.model}
</span>
) : null}
<span
className={[
'inline-flex items-center text-xs',
configured ? 'text-green-600 dark:text-green-400' : 'text-orange-500 dark:text-orange-300',
].join(' ')}
>
{configured ? t('models.providers.configured') : t('models.providers.notConfigured')}
</span>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{!isDefault && configured ? (
<button
type="button"
className={ACTION_BUTTON_CLASS_NAME}
onClick={() => {
void handleSetDefault(item.account.id);
}}
>
{t('models.providers.setDefault')}
</button>
) : null}
<button type="button" className={ACTION_BUTTON_CLASS_NAME} onClick={() => handleEdit(item)}>
{t('models.providers.edit')}
</button>
<button
type="button"
className="rounded-full border border-red-200 px-3 py-1.5 text-[12px] text-red-500 transition-colors hover:bg-red-50 dark:border-red-500/25 dark:text-red-300 dark:hover:bg-red-500/10"
onClick={() => {
void handleDelete(item.account.id);
}}
>
{t('models.providers.delete')}
</button>
</div>
<div className="text-[12px] font-medium uppercase tracking-[0.08em] text-[#99A0AE] dark:text-gray-500">
{field.label}
</div>
<div
className={[
'mt-2 text-sm text-[#171717] dark:text-[#f3f4f6]',
field.mono ? 'break-all font-mono' : '',
field.value ? '' : 'text-[#99A0AE] dark:text-gray-500',
].join(' ')}
>
{field.value || t('models.providers.notConfigured')}
</div>
</div>
);
})}
{items.length === 0 ? (
<div className="rounded-[14px] border border-dashed border-gray-300 px-6 py-12 text-center text-sm text-gray-500 dark:border-[#2a2a2d] dark:text-gray-400">
{t('models.providers.empty')}
</div>
) : null}
))}
</div>
</div>
) : null}
<ProviderPickerDialog
open={pickerOpen}
providers={VISIBLE_PROVIDERS}
onClose={() => setPickerOpen(false)}
onSelect={handleSelectProvider}
/>
<ProviderEditorDialog
open={Boolean(editorItem)}
item={editorItem}
saving={saving}
error={editorError}
onClose={() => {
setSaving(false);
setEditorError(null);
setEditorItem(null);
}}
onSave={(values) => {
void handleSave(values);
}}
onSwitchProvider={() => {
setEditorItem(null);
setPickerOpen(true);
}}
/>
{!loading && !hasConfig ? (
<div className="rounded-[14px] border border-dashed border-gray-300 px-6 py-12 text-center text-sm text-gray-500 dark:border-[#2a2a2d] dark:text-gray-400">
{t('models.providers.empty')}
</div>
) : null}
</div>
);
}

View File

@@ -1,22 +0,0 @@
import type {
ProviderAccount,
ProviderTypeInfo,
ProviderVendorInfo,
ProviderWithKeyInfo,
} from '../../../lib/providers';
export type DisplayVendor = ProviderVendorInfo | ProviderTypeInfo;
export type ProviderListItem = {
account: ProviderAccount;
vendor?: DisplayVendor;
status?: ProviderWithKeyInfo;
isNew?: boolean;
};
export type ProviderEditorValues = {
label: string;
apiKey: string;
baseUrl: string;
model: string;
};

View File

@@ -7,13 +7,13 @@ export default function ModelsPage() {
return (
<section className="h-full w-full min-h-0">
<div className="flex h-full w-full min-h-0 flex-col rounded-[16px] bg-white p-[20px] dark:bg-[#1b1b1d]">
<div className="mb-[20px] flex items-end justify-between gap-4 border-b border-[#E5E8EE] pb-[20px] dark:border-[#2a2a2d]">
<div className="flex min-w-0 items-end gap-[8px]">
<span className="text-[24px] font-medium leading-[32px] text-[#171717] dark:text-gray-100">
<div className="flex h-full w-full min-h-0 flex-col rounded-2xl bg-white p-5 dark:bg-[#1b1b1d]">
<div className="mb-5 flex items-end justify-between gap-4 border-b border-[#E5E8EE] pb-5 dark:border-[#2a2a2d]">
<div className="flex min-w-0 items-end gap-2">
<span className="text-[24px] font-medium leading-8 text-[#171717] dark:text-gray-100">
{t('models.page.title')}
</span>
<span className="pb-[3px] text-[12px] leading-[16px] text-[#99A0AE] dark:text-gray-500">
<span className="pb-0.75 text-[12px] leading-4 text-[#99A0AE] dark:text-gray-500">
{t('models.page.subtitle')}
</span>
</div>