feat: implement hotel staff chat model configuration API and update localization files
This commit is contained in:
@@ -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 || {}),
|
||||
});
|
||||
@@ -16,4 +16,4 @@ export * from './recentConversation';
|
||||
export * from './conversationMessageList';
|
||||
export * from './recommendedQuestionList';
|
||||
export * from './chatConfig';
|
||||
export * from './modelConfig';
|
||||
export * from './chat';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "次へ"
|
||||
},
|
||||
|
||||
@@ -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": "下一页"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user