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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,7 @@
"providers": { "providers": {
"title": "AI 服务商", "title": "AI 服务商",
"subtitle": "管理你的 AI 模型与 API Key。", "subtitle": "管理你的 AI 模型与 API Key。",
"providerName": "Provider",
"add": "添加服务商", "add": "添加服务商",
"loading": "正在加载服务商配置...", "loading": "正在加载服务商配置...",
"defaultBadge": "默认", "defaultBadge": "默认",
@@ -22,7 +23,7 @@
"setDefault": "设为默认", "setDefault": "设为默认",
"edit": "编辑", "edit": "编辑",
"delete": "删除", "delete": "删除",
"empty": "暂未配置服务商。点击“添加服务商”开始。", "empty": "暂未获取到模型配置。",
"confirmDelete": "确定删除这个服务商吗?", "confirmDelete": "确定删除这个服务商吗?",
"notices": { "notices": {
"loadError": "加载服务商配置失败。", "loadError": "加载服务商配置失败。",
@@ -68,7 +69,7 @@
"window7d": "最近 7 天", "window7d": "最近 7 天",
"window30d": "最近 30 天", "window30d": "最近 30 天",
"windowAll": "全部时间", "windowAll": "全部时间",
"showingRecords": "共显示 {count} 条记录", "showingRecords": "共显示 {{count}} 条记录",
"chart": { "chart": {
"total": "总计", "total": "总计",
"input": "输入", "input": "输入",
@@ -78,17 +79,17 @@
"row": { "row": {
"noUsageInfo": "无用量信息", "noUsageInfo": "无用量信息",
"errorParsingUsage": "用量解析错误", "errorParsingUsage": "用量解析错误",
"input": "输入:{count}", "input": "输入:{{count}}",
"output": "输出:{count}", "output": "输出:{{count}}",
"cacheRead": "缓存读取:{count}", "cacheRead": "缓存读取:{{count}}",
"cacheWrite": "缓存写入:{count}", "cacheWrite": "缓存写入:{{count}}",
"noUsageReported": "未上报用量", "noUsageReported": "未上报用量",
"parseError": "解析失败", "parseError": "解析失败",
"viewContent": "查看内容", "viewContent": "查看内容",
"error": "错误" "error": "错误"
}, },
"pagination": { "pagination": {
"pageOf": "第 {page} / {total} 页", "pageOf": "第 {{page}} / {{total}} 页",
"prev": "上一页", "prev": "上一页",
"next": "下一页" "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 { useEffect, useState } from 'react';
import { import { hotelStaffChatModelConfigUsingGet } from '../../../api/chat';
PROVIDER_TYPE_INFO, import type { ModelConfigDTO } from '../../../api/types';
type ProviderAccount,
type ProviderTypeInfo,
type ProviderVendorInfo,
type ProviderWithKeyInfo,
} from '../../../lib/providers';
import { onGatewayEvent } from '../../../lib/gateway-client'; import { onGatewayEvent } from '../../../lib/gateway-client';
import { hostApiFetch } from '../../../lib/host-api';
import { isRuntimeChangedGatewayEvent, runtimeEventHasTopic } from '../../../lib/runtime-events'; 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'; 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 { function formatErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error && error.message) return error.message; if (error instanceof Error && error.message) return error.message;
if (typeof error === 'string' && error) return error; if (typeof error === 'string' && error) return error;
return fallback; return fallback;
} }
function hasConfiguredCredentials(account: ProviderAccount, status?: ProviderWithKeyInfo): boolean { function normalizeText(value: string | undefined): string | null {
if (account.authMode === 'oauth_device' || account.authMode === 'oauth_browser' || account.authMode === 'local') { if (typeof value !== 'string') return null;
return true; const trimmed = value.trim();
} return trimmed || null;
return status?.hasKey ?? false;
} }
function legacyStatusToAccount(status: ProviderWithKeyInfo): ProviderAccount { function normalizeModelConfig(response: unknown): ModelConfigDTO {
return { if (!response || typeof response !== 'object') {
id: status.id, return {};
vendorId: status.type, }
label: status.name,
authMode: status.type === 'ollama' ? 'local' : 'api_key', const candidate = response as ModelConfigDTO & { data?: unknown };
baseUrl: status.baseUrl,
apiProtocol: status.apiProtocol, if (
headers: status.headers, typeof candidate.providerName === 'string'
model: status.model, || typeof candidate.apiKey === 'string'
fallbackModels: status.fallbackModels, || typeof candidate.baseUrl === 'string'
fallbackAccountIds: status.fallbackProviderIds, || typeof candidate.model === 'string'
enabled: status.enabled, ) {
isDefault: false, return {
createdAt: status.createdAt, providerName: candidate.providerName,
updatedAt: status.updatedAt, apiKey: candidate.apiKey,
}; baseUrl: candidate.baseUrl,
model: candidate.model,
};
}
if (candidate.data && typeof candidate.data === 'object') {
return normalizeModelConfig(candidate.data);
}
return {};
} }
function buildProviderItems( function hasModelConfig(config: ModelConfigDTO | null): boolean {
accounts: ProviderAccount[], return Boolean(
statuses: ProviderWithKeyInfo[], normalizeText(config?.providerName)
vendors: DisplayVendor[], || normalizeText(config?.apiKey)
defaultAccountId: string | null, || normalizeText(config?.baseUrl)
): ProviderListItem[] { || normalizeText(config?.model),
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 buildProviderAccountId(providerId: ProviderTypeInfo['id']): string { function maskSecret(value: string | undefined): string | null {
const suffix = Math.random().toString(36).slice(2, 8); const secret = normalizeText(value);
return `${providerId}-${Date.now().toString(36)}-${suffix}`; 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() { export default function ProvidersSection() {
const t = useModelsCopy(); const t = useModelsCopy();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [items, setItems] = useState<ProviderListItem[]>([]); const [error, setError] = useState<string | null>(null);
const [vendors, setVendors] = useState<DisplayVendor[]>(VISIBLE_PROVIDERS); const [modelConfig, setModelConfig] = useState<ModelConfigDTO | null>(null);
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);
async function loadProviders(showLoading = true): Promise<void> { async function loadModelConfig(showLoading = true): Promise<void> {
if (showLoading) { if (showLoading) {
setLoading(true); setLoading(true);
} }
try { try {
const [accounts, statuses, vendorList, defaultInfo] = await Promise.all([ const response = await hotelStaffChatModelConfigUsingGet({});
hostApiFetch<ProviderAccount[]>('/api/provider-accounts'), setModelConfig(normalizeModelConfig(response));
hostApiFetch<ProviderWithKeyInfo[]>('/api/providers'), setError(null);
hostApiFetch<ProviderVendorInfo[]>('/api/provider-vendors'), } catch (loadError) {
hostApiFetch<{ accountId: string | null }>('/api/provider-accounts/default'), setError(formatErrorMessage(loadError, t('models.providers.notices.loadError')));
]);
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')),
});
} finally { } finally {
if (showLoading) { if (showLoading) {
setLoading(false); setLoading(false);
@@ -165,183 +88,57 @@ export default function ProvidersSection() {
} }
useEffect(() => { useEffect(() => {
void loadProviders(); void loadModelConfig();
}, []); }, []);
useEffect(() => { useEffect(() => {
return onGatewayEvent((event) => { return onGatewayEvent((event) => {
if (!isRuntimeChangedGatewayEvent(event)) return; if (!isRuntimeChangedGatewayEvent(event)) return;
if (!runtimeEventHasTopic(event, 'providers', 'models')) return; if (!runtimeEventHasTopic(event, 'providers', 'models')) return;
void loadProviders(false); void loadModelConfig(false);
}); });
}, []); }, []);
async function handleSetDefault(accountId: string): Promise<void> { const fields = [
try { {
await hostApiFetch<{ success: boolean; error?: string }>('/api/provider-accounts/default', { key: 'providerName',
method: 'PUT', label: t('models.providers.providerName', undefined, 'Provider'),
body: JSON.stringify({ accountId }), value: normalizeText(modelConfig?.providerName),
}); },
await loadProviders(false); {
setDefaultAccountId(accountId); key: 'apiKey',
setNotice({ tone: 'success', message: t('models.providers.notices.defaultSuccess') }); label: t('models.providers.editor.apiKey'),
} catch (error) { value: maskSecret(modelConfig?.apiKey),
setNotice({ mono: true,
tone: 'error', },
message: formatErrorMessage(error, t('models.providers.notices.defaultError')), {
}); 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 hasConfig = hasModelConfig(modelConfig);
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);
}
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3"> <div>
<div> <h3 className="text-lg font-medium text-[#171717] dark:text-[#f3f4f6]">{t('models.providers.title')}</h3>
<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">
<p className="mt-1 text-sm text-[#99A0AE] dark:text-gray-500"> {t('models.providers.subtitle')}
{t('models.providers.subtitle')} </p>
</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> </div>
{notice ? ( {error ? (
<div <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">
className={[ {error}
'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}
</div> </div>
) : null} ) : null}
@@ -352,121 +149,37 @@ export default function ProvidersSection() {
</div> </div>
) : null} ) : null}
{!loading ? ( {!loading && hasConfig ? (
<div className="space-y-4"> <div className="rounded-[14px] border border-[#E5E8EE] bg-white p-5 dark:border-[#2a2a2d] dark:bg-[#1f1f22]">
{items.map((item) => { <div className="grid gap-4 md:grid-cols-2">
const configured = hasConfiguredCredentials(item.account, item.status); {fields.map((field) => (
const isDefault = item.account.id === defaultAccountId;
return (
<div <div
key={item.account.id} key={field.key}
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]" 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="text-[12px] font-medium uppercase tracking-[0.08em] text-[#99A0AE] dark:text-gray-500">
<div className="flex min-w-0 items-start gap-4"> {field.label}
<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]"> </div>
{item.vendor?.icon || t('models.common.ai')} <div
</div> className={[
'mt-2 text-sm text-[#171717] dark:text-[#f3f4f6]',
<div className="min-w-0"> field.mono ? 'break-all font-mono' : '',
<div className="flex flex-wrap items-center gap-2"> field.value ? '' : 'text-[#99A0AE] dark:text-gray-500',
<span className="text-[16px] font-medium text-[#171717] dark:text-[#f3f4f6]"> ].join(' ')}
{item.account.label || item.vendor?.name} >
</span> {field.value || t('models.providers.notConfigured')}
{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> </div>
</div> </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} ) : null}
<ProviderPickerDialog {!loading && !hasConfig ? (
open={pickerOpen} <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">
providers={VISIBLE_PROVIDERS} {t('models.providers.empty')}
onClose={() => setPickerOpen(false)} </div>
onSelect={handleSelectProvider} ) : null}
/>
<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);
}}
/>
</div> </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 ( return (
<section className="h-full w-full min-h-0"> <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="flex h-full w-full min-h-0 flex-col rounded-2xl bg-white p-5 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="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-[8px]"> <div className="flex min-w-0 items-end gap-2">
<span className="text-[24px] font-medium leading-[32px] text-[#171717] dark:text-gray-100"> <span className="text-[24px] font-medium leading-8 text-[#171717] dark:text-gray-100">
{t('models.page.title')} {t('models.page.title')}
</span> </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')} {t('models.page.subtitle')}
</span> </span>
</div> </div>