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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions apps/web/__tests__/routes/dashboard-admin-backup-restore_test.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion apps/web/src/assets/bing.svg

This file was deleted.

5 changes: 3 additions & 2 deletions apps/web/src/assets/icons/NOTICE
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
The SVGs in this directory are taken verbatim from lobe-icons, which Iconify
does not carry in any collection.
The SVGs in this directory are taken from lobe-icons, which Iconify does not
carry in any collection. Only microsoft.svg is modified: upstream titles it
"Azure".

https://github.com/lobehub/lobe-icons

Expand Down
1 change: 1 addition & 0 deletions apps/web/src/assets/icons/microsoft.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 7 additions & 1 deletion apps/web/src/components/backup-restore/backup-file.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import type { InferResponseType } from 'hono/client';
import { z } from 'zod';

export const BACKUP_FILE_VERSION = 17;
import type { api } from '../../api/client';

// Annotated with the gateway's own literal so a bump there fails this
// assignment rather than silently leaving the dashboard rejecting every backup
// file the current deployment writes.
export const BACKUP_FILE_VERSION: InferResponseType<typeof api.api.export.$get, 200>['version'] = 18;

const backupFileSchema = z.object({
version: z.literal(BACKUP_FILE_VERSION),
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/usage/chart-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,7 @@ function formatPlottedCost(value: number): string {
}

export function formatProvider(provider: string): string {
if (provider === 'microsoft-grounding') return 'Microsoft Grounding';
if (provider === 'web-iq') return 'Microsoft Web IQ';
if (provider === 'tavily') return 'Tavily';
if (provider === 'jina') return 'Jina';
return provider;
Expand Down
8 changes: 5 additions & 3 deletions apps/web/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1026,7 +1026,7 @@ const en = {
provider: {
disabled: 'Disabled',
tavily: 'Tavily',
microsoftGrounding: 'Microsoft Grounding',
webIq: 'Microsoft Web IQ',
jina: 'Jina',
},
passthrough: {
Expand All @@ -1038,8 +1038,8 @@ const en = {
},
providerDescTavily:
'Tavily is a search engine optimized for LLMs and RAG workflows.',
providerDescMicrosoftGrounding:
'Microsoft Grounding leverages Bing Search APIs for grounding LLM responses.',
providerDescWebIq:
'Microsoft Web IQ provides web search and page retrieval APIs built for AI agents.',
providerDescJina:
'Jina AI provides web search and content extraction APIs.',
getKeyLink: 'Get API key →',
Expand All @@ -1053,6 +1053,8 @@ const en = {
testing: 'Testing…',
testDisabledHint: 'Select a provider to enable testing.',
testResults: 'Test Results',
testedProvider: 'Provider: {{provider}}',
testedQuery: 'Query: {{query}}',
testSuccess:
'Connection test successful — {{count}} results returned.',
testFailed: 'Test failed: {{message}}',
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/i18n/locales/zh-Hans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -983,13 +983,12 @@ const zhHansCN = {
provider: {
disabled: '禁用',
tavily: 'Tavily',
microsoftGrounding: 'Microsoft Grounding',
webIq: 'Microsoft Web IQ',
jina: 'Jina',
},
passthrough: { title: '透传 OpenAI 搜索', description: '将 /alpha/search 和 Responses 托管搜索转发到指定的 Codex 或 OpenAI 兼容上游。', upstream: '搜索上游', model: '搜索模型', empty: '请添加一个已启用且包含聊天模型的 Codex 或自定义上游。' },
providerDescTavily: 'Tavily 是专为 LLM 和 RAG 工作流优化的搜索引擎。',
providerDescMicrosoftGrounding:
'Microsoft Grounding 利用 Bing Search API 为 LLM 提供答案依据。',
providerDescWebIq: 'Microsoft Web IQ 提供面向 AI agent 的网络搜索与网页抓取 API。',
providerDescJina: 'Jina AI 提供网络搜索和内容提取 API。',
getKeyLink: '获取 API 密钥 →',
apiKeyLabel: 'API 密钥',
Expand All @@ -1002,6 +1001,8 @@ const zhHansCN = {
testing: '测试中…',
testDisabledHint: '选择提供商以启用测试。',
testResults: '测试结果',
testedProvider: '提供商:{{provider}}',
testedQuery: '查询:{{query}}',
testSuccess: '连接测试成功 — 返回 {{count}} 条结果。',
testFailed: '测试失败:{{message}}',
pageAge: '{{age}} 前',
Expand Down
135 changes: 79 additions & 56 deletions apps/web/src/routes/dashboard-providers-search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,26 @@ import { useDashboardOutletContext } from './dashboard';
import { callApi } from '../api/auth';
import { api } from '../api/client';
import type { ControlPlaneModel, SearchConfig, UpstreamRecord } from '../api/types';
import bingIconUrl from '../assets/bing.svg';
import jinaIconUrl from '../assets/icons/jina.svg';
import microsoftIconUrl from '../assets/icons/microsoft.svg';
import tavilyIconUrl from '../assets/icons/tavily.svg';
import { getSessionToken } from '../auth/session';
import { AdminOnlyNotice } from '../components/admin-only-notice';
import { DashboardPageHeader } from '../components/ui/dashboard-page-header';
import { Dropdown, Input, Select } from '../components/ui/fluent-form-controls';
import { Dropdown, Input } from '../components/ui/fluent-form-controls';
import { Panel } from '../components/ui/panel';
import { fluentComponents } from '../fluent';

type SearchConfigTestResult = InferResponseType<typeof api.api['search-config']['test']['$post'], 200>;

const {
Badge,
Button,
Field,
Link,
MessageBar,
MessageBarBody,
MessageBarTitle,
Option,
Spinner,
Switch,
Expand Down Expand Up @@ -61,7 +63,7 @@ export function meta({}: Route.MetaArgs) {
const DEFAULT_CONFIG: SearchConfig = {
provider: 'disabled',
tavily: { apiKey: '' },
microsoftGrounding: { apiKey: '' },
webIq: { apiKey: '' },
jina: { apiKey: '' },
passthroughOpenAiSearch: { enabled: false, upstreamId: '', model: '' },
};
Expand Down Expand Up @@ -98,13 +100,13 @@ const PROVIDER_OPTIONS: ProviderOption[] = [
setApiKey: (c, k) => ({ ...c, tavily: { apiKey: k } }),
},
{
value: 'microsoft-grounding',
labelKey: 'dashboard.searchConfig.provider.microsoftGrounding',
iconUrl: bingIconUrl,
descKey: 'dashboard.searchConfig.providerDescMicrosoftGrounding',
url: 'https://www.microsoft.com/en-us/bing/apis',
getApiKey: c => c.microsoftGrounding.apiKey,
setApiKey: (c, k) => ({ ...c, microsoftGrounding: { apiKey: k } }),
value: 'web-iq',
labelKey: 'dashboard.searchConfig.provider.webIq',
iconUrl: microsoftIconUrl,
descKey: 'dashboard.searchConfig.providerDescWebIq',
url: 'https://webiq.microsoft.ai/profiles',
getApiKey: c => c.webIq.apiKey,
setApiKey: (c, k) => ({ ...c, webIq: { apiKey: k } }),
},
{
value: 'jina',
Expand Down Expand Up @@ -144,9 +146,16 @@ export default function DashboardProvidersSearch({ loaderData }: Route.Component
);

const activeOption = findProviderOption(draft.provider);
// The tested provider is whatever the gateway echoed back, which need not be
// one this build knows about; an unrecognized id is shown verbatim rather
// than collapsed onto a familiar one.
const testedOption = PROVIDER_OPTIONS.find(option => option.value === testResult?.provider);
const testedProviderLabel = testedOption ? t(testedOption.labelKey) : testResult?.provider;
const eligibleUpstreams = useMemo(() => eligibleSearchUpstreams(upstreams, models), [models, upstreams]);
const modelsForSelectedUpstream = useMemo(() => models.filter(model =>
model.kind === 'chat' && model.upstreams.some(binding => binding.id === draft.passthroughOpenAiSearch.upstreamId)), [draft.passthroughOpenAiSearch.upstreamId, models]);
const selectedUpstream = eligibleUpstreams.find(upstream => upstream.id === draft.passthroughOpenAiSearch.upstreamId);
const selectedModel = modelsForSelectedUpstream.find(model => model.id === draft.passthroughOpenAiSearch.model);

const setPassthroughUpstream = useCallback((upstreamId: string, preferredModel?: string) => {
const candidates = models.filter(model => model.kind === 'chat'
Expand Down Expand Up @@ -328,14 +337,41 @@ export default function DashboardProvidersSearch({ loaderData }: Route.Component
</div>
{draft.passthroughOpenAiSearch.enabled && <div className="grid grid-cols-2 gap-3 max-[620px]:grid-cols-1">
<Field label={t('dashboard.searchConfig.passthrough.upstream')}>
<Select value={draft.passthroughOpenAiSearch.upstreamId} onChange={(_, data) => setPassthroughUpstream(data.value)}>
{eligibleUpstreams.map(upstream => <option key={upstream.id} value={upstream.id}>{upstream.name}</option>)}
</Select>
<Dropdown
onOptionSelect={(_, data) => data.optionValue && setPassthroughUpstream(data.optionValue)}
selectedOptions={[draft.passthroughOpenAiSearch.upstreamId]}
value={selectedUpstream?.name ?? ''}
>
{eligibleUpstreams.map(upstream => (
<Option key={upstream.id} text={upstream.name} value={upstream.id}>
<DescribedOptionLabel
description={t(`dashboard.upstreams.providers.${upstream.kind}`)}
label={upstream.name}
/>
</Option>
))}
</Dropdown>
</Field>
<Field label={t('dashboard.searchConfig.passthrough.model')}>
<Select value={draft.passthroughOpenAiSearch.model} onChange={(_, data) => setDraft(current => ({ ...current, passthroughOpenAiSearch: { ...current.passthroughOpenAiSearch, model: data.value } }))}>
{modelsForSelectedUpstream.map(model => <option key={model.id} value={model.id}>{model.display_name ?? model.id}</option>)}
</Select>
<Dropdown
onOptionSelect={(_, data) => {
const model = data.optionValue;
if (!model) return;
setDraft(current => ({ ...current, passthroughOpenAiSearch: { ...current.passthroughOpenAiSearch, model } }));
setSaveSuccess(false);
}}
selectedOptions={[draft.passthroughOpenAiSearch.model]}
value={selectedModel ? modelLabel(selectedModel) : ''}
>
{modelsForSelectedUpstream.map(model => (
<Option key={model.id} text={modelLabel(model)} value={model.id}>
<DescribedOptionLabel
description={modelLabel(model) === model.id ? undefined : model.id}
label={modelLabel(model)}
/>
</Option>
))}
</Dropdown>
</Field>
</div>}
{eligibleUpstreams.length === 0 && <Text size={200} className="text-fui-fg3">{t('dashboard.searchConfig.passthrough.empty')}</Text>}
Expand Down Expand Up @@ -390,30 +426,12 @@ export default function DashboardProvidersSearch({ loaderData }: Route.Component
</Text>

<div className="flex items-center gap-[8px] flex-wrap">
{testResult.ok ? (
<span
className="text-[10px] font-bold uppercase px-[6px] py-[2px] rounded-[3px]"
style={{
backgroundColor: 'light-dark(#ddf6dd, #1b3a1b)',
color: 'light-dark(#0b6a0b, #6fcf6f)',
}}
>
OK
</span>
) : (
<span
className="text-[10px] font-bold uppercase px-[6px] py-[2px] rounded-[3px]"
style={{
backgroundColor: 'light-dark(#fde7e9, #3d1517)',
color: 'light-dark(#c50f1f, #e37b84)',
}}
>
Error
</span>
)}
<Badge appearance="tint" color={testResult.ok ? 'success' : 'danger'} size="small">
{testResult.ok ? 'OK' : 'Error'}
</Badge>
<Text size={200} className="text-fui-fg3">
Provider: {testResult.provider}
{testResult.query ? ` · Query: ${testResult.query}` : ''}
{t('dashboard.searchConfig.testedProvider', { provider: testedProviderLabel })}
{testResult.query ? ` · ${t('dashboard.searchConfig.testedQuery', { query: testResult.query })}` : ''}
</Text>
</div>

Expand Down Expand Up @@ -461,31 +479,36 @@ export default function DashboardProvidersSearch({ loaderData }: Route.Component
</div>
)
) : !testResult.ok ? (
<div
className="rounded-lg border border-solid p-[12px_14px] grid gap-[4px]"
style={{
borderColor: 'light-dark(#c50f1f, #e37b84)',
backgroundColor: 'light-dark(#fde7e9, #3d1517)',
}}
>
<Text
size={200}
weight="semibold"
style={{ color: 'light-dark(#c50f1f, #e37b84)' }}
>
{testResult.error.code}
</Text>
<Text size={200} className="text-fui-fg1">
<MessageBar intent="error">
<MessageBarBody>
<MessageBarTitle>{testResult.error.code}</MessageBarTitle>
{testResult.error.message}
</Text>
</div>
</MessageBarBody>
</MessageBar>
) : null}
</Panel>
)}
</section>
);
}

// A model's display name is often just its id; showing both would read as a
// stutter, so the second line only appears when it carries something new.
const modelLabel = (model: ControlPlaneModel) => model.display_name ?? model.id;

function DescribedOptionLabel({ description, label }: { description?: string; label: string }) {
return (
<span className="grid gap-[2px] min-w-0">
<span className="truncate">{label}</span>
{description && (
<Text size={100} className="text-fui-fg3 truncate">
{description}
</Text>
)}
</span>
);
}

function ProviderOptionLabel({ iconUrl, label }: { iconUrl?: string; label: string }) {
return (
<span className="flex items-center gap-2 min-w-0">
Expand Down
Loading