diff --git a/apps/web/src/components/providers/ProviderTable/ProviderTable.module.scss b/apps/web/src/components/providers/ProviderTable/ProviderTable.module.scss index ce35296e4..43e15d1f6 100644 --- a/apps/web/src/components/providers/ProviderTable/ProviderTable.module.scss +++ b/apps/web/src/components/providers/ProviderTable/ProviderTable.module.scss @@ -173,9 +173,12 @@ $table-columns: 92px minmax(132px, 0.72fr) minmax(180px, 1fr) 96px 72px minmax(2 } .cellUrl { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; font-size: 12px; color: var(--text-secondary); - @include text-ellipsis; @include provider-card-layout { grid-area: url; @@ -184,6 +187,31 @@ $table-columns: 92px minmax(132px, 0.72fr) minmax(180px, 1fr) 96px 72px minmax(2 } } +.urlText { + min-width: 0; + @include text-ellipsis; + + @include provider-card-layout { + white-space: normal; + overflow-wrap: anywhere; + } +} + +.urlLink { + flex-shrink: 0; + display: inline-flex; + align-items: center; + padding: 2px; + color: var(--text-secondary); + border-radius: $radius-sm; + transition: color $transition-fast, background $transition-fast; + + &:hover { + color: var(--text-primary); + background: var(--surface-subtle); + } +} + .cellNumeric { text-align: right; diff --git a/apps/web/src/components/providers/ProviderTable/ProviderTable.test.tsx b/apps/web/src/components/providers/ProviderTable/ProviderTable.test.tsx index 2b1488bcd..5ac71deb0 100644 --- a/apps/web/src/components/providers/ProviderTable/ProviderTable.test.tsx +++ b/apps/web/src/components/providers/ProviderTable/ProviderTable.test.tsx @@ -378,4 +378,35 @@ describe('ProviderTable', () => { expect(getText(renderedRows[1])).toContain('status_bar.no_requests'); expect(renderedRows[1].findAllByType(ProviderStatusBar)).toHaveLength(0); }); + + it('renders an external homepage link derived from the base url', () => { + const rows = buildProviderRows({ + ...emptyInput, + codex: [{ apiKey: 'k1', baseUrl: 'https://low.example.com/v1' }], + }); + const renderer = renderTable(rows); + + const link = getRows(renderer)[0] + .findAll((node) => node.type === 'a') + .find((node) => node.props['aria-label'] === 'ai_providers.open_homepage'); + expect(link).toBeTruthy(); + expect(link?.props.href).toBe('https://low.example.com'); + expect(link?.props.target).toBe('_blank'); + expect(link?.props.rel).toBe('noopener noreferrer'); + }); + + it('skips the homepage link when the base url is missing or malformed', () => { + const rows = buildProviderRows({ + ...emptyInput, + codex: [ + { apiKey: 'k2', baseUrl: '' }, + { apiKey: 'k3', baseUrl: 'https://' }, + ], + }); + const renderer = renderTable(rows); + + for (const row of getRows(renderer)) { + expect(row.findAll((node) => node.type === 'a')).toHaveLength(0); + } + }); }); diff --git a/apps/web/src/components/providers/ProviderTable/ProviderTable.tsx b/apps/web/src/components/providers/ProviderTable/ProviderTable.tsx index e63869e5e..5adfff701 100644 --- a/apps/web/src/components/providers/ProviderTable/ProviderTable.tsx +++ b/apps/web/src/components/providers/ProviderTable/ProviderTable.tsx @@ -12,6 +12,7 @@ import { Button } from '@/components/ui/Button'; import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; import { IconCheck, + IconExternalLink, IconEye, IconPencil, IconTrash2, @@ -19,6 +20,7 @@ import { } from '@/components/ui/icons'; import { ProviderStatusBar } from '../ProviderStatusBar'; import { getProviderKindIcon, PROVIDER_KIND_LABELS } from './kindMeta'; +import { resolveProviderHomepage } from './homepage'; import type { ProviderRow } from './rowData'; import styles from './ProviderTable.module.scss'; @@ -211,6 +213,7 @@ export function ProviderTable({ {rows.map((row) => { const kindLabel = PROVIDER_KIND_LABELS[row.kind]; + const homepageUrl = resolveProviderHomepage(row.baseUrl); return (
- {row.baseUrl || '—'} + {row.baseUrl ? ( + <> + {homepageUrl && ( + + + + )} + {row.baseUrl} + + ) : ( + '—' + )}
diff --git a/apps/web/src/components/providers/ProviderTable/homepage.test.ts b/apps/web/src/components/providers/ProviderTable/homepage.test.ts new file mode 100644 index 000000000..46b3c2c91 --- /dev/null +++ b/apps/web/src/components/providers/ProviderTable/homepage.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { resolveProviderHomepage } from './homepage'; + +describe('resolveProviderHomepage', () => { + it('strips the path and keeps scheme and host', () => { + expect(resolveProviderHomepage('https://sharedchat.cc/v1')).toBe('https://sharedchat.cc'); + expect(resolveProviderHomepage('https://api.example.com')).toBe('https://api.example.com'); + expect(resolveProviderHomepage('https://example.com/api/chat')).toBe('https://example.com'); + }); + + it('keeps ports and ip addresses', () => { + expect(resolveProviderHomepage('http://127.0.0.1:8317')).toBe('http://127.0.0.1:8317'); + expect(resolveProviderHomepage('https://localhost:8443/v1')).toBe('https://localhost:8443'); + }); + + it('assumes https when the scheme is missing', () => { + expect(resolveProviderHomepage('sharedchat.cc/v1')).toBe('https://sharedchat.cc'); + }); + + it('returns null for empty or malformed urls', () => { + expect(resolveProviderHomepage('')).toBeNull(); + expect(resolveProviderHomepage(' ')).toBeNull(); + expect(resolveProviderHomepage('https://')).toBeNull(); + expect(resolveProviderHomepage('not a url at all')).toBeNull(); + }); +}); diff --git a/apps/web/src/components/providers/ProviderTable/homepage.ts b/apps/web/src/components/providers/ProviderTable/homepage.ts new file mode 100644 index 000000000..a3c8c0129 --- /dev/null +++ b/apps/web/src/components/providers/ProviderTable/homepage.ts @@ -0,0 +1,12 @@ +const SCHEME_RE = /^[a-z][a-z0-9+.-]*:\/\//i; + +export const resolveProviderHomepage = (baseUrl: string): string | null => { + const trimmed = baseUrl.trim(); + if (!trimmed) return null; + const withScheme = SCHEME_RE.test(trimmed) ? trimmed : `https://${trimmed}`; + try { + return new URL(withScheme).origin || null; + } catch { + return null; + } +}; diff --git a/apps/web/src/i18n/locales/en.json b/apps/web/src/i18n/locales/en.json index 417eeeb20..6818006f4 100644 --- a/apps/web/src/i18n/locales/en.json +++ b/apps/web/src/i18n/locales/en.json @@ -724,6 +724,7 @@ "add_config_menu_aria": "Choose a provider type to add", "row_actions_aria": "More actions", "detail_button": "Details", + "open_homepage": "Open homepage", "detail_section_headers": "Custom Headers", "table_empty_title": "No provider configurations", "table_empty_desc": "Click \"Add Configuration\" in the top-right corner to create your first one", diff --git a/apps/web/src/i18n/locales/zh-CN.json b/apps/web/src/i18n/locales/zh-CN.json index 6aa6e584c..0c4c9b5a9 100644 --- a/apps/web/src/i18n/locales/zh-CN.json +++ b/apps/web/src/i18n/locales/zh-CN.json @@ -724,6 +724,7 @@ "add_config_menu_aria": "选择要添加的提供商类型", "row_actions_aria": "更多操作", "detail_button": "详情", + "open_homepage": "访问主站", "detail_section_headers": "自定义 Header", "table_empty_title": "暂无提供商配置", "table_empty_desc": "点击右上角“添加配置”创建第一个配置", diff --git a/apps/web/src/i18n/locales/zh-TW.json b/apps/web/src/i18n/locales/zh-TW.json index 4c04f78dd..c34c97b03 100644 --- a/apps/web/src/i18n/locales/zh-TW.json +++ b/apps/web/src/i18n/locales/zh-TW.json @@ -724,6 +724,7 @@ "add_config_menu_aria": "選擇要新增的提供商類型", "row_actions_aria": "更多操作", "detail_button": "詳情", + "open_homepage": "訪問主站", "detail_section_headers": "自訂 Header", "table_empty_title": "暫無提供商設定", "table_empty_desc": "點擊右上角「新增設定」建立第一個設定",