diff --git a/cmd/capi/main.go b/cmd/capi/main.go
index a64a825..cf07ecf 100644
--- a/cmd/capi/main.go
+++ b/cmd/capi/main.go
@@ -928,6 +928,7 @@ func (s *Server) registerRoutes(router *gin.Engine) {
admin.DELETE("/api-keys/:id", s.deleteAPIKey)
admin.GET("/channels", s.listChannels)
admin.POST("/channels", s.createChannel)
+ admin.POST("/channel-model-preview", s.previewChannelModelsFromConnection)
admin.POST("/channels/:id/import-openai-accounts", s.importOpenAIAccounts)
admin.POST("/channels/:id/openai-oauth/start", s.startOpenAIOAuth)
admin.POST("/channels/:id/openai-oauth/complete", s.completeOpenAIOAuth)
@@ -3660,6 +3661,38 @@ func (s *Server) previewUpstreamModels(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"models": modelIDs})
}
+// previewChannelModelsFromConnection lists upstream models for connection
+// parameters that are not yet saved as a channel, so the create form can pull
+// and pick models before the channel exists.
+func (s *Server) previewChannelModelsFromConnection(c *gin.Context) {
+ var body struct {
+ Provider string `json:"provider"`
+ BaseURL string `json:"baseUrl"`
+ UpstreamAPIKey string `json:"upstreamApiKey"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{"message": "Invalid JSON body"}})
+ return
+ }
+ channel := Channel{
+ Provider: strings.TrimSpace(body.Provider),
+ BaseURL: strings.TrimSpace(body.BaseURL),
+ }
+ upstreamKey := ""
+ for _, line := range strings.Split(body.UpstreamAPIKey, "\n") {
+ if trimmed := strings.TrimSpace(line); trimmed != "" {
+ upstreamKey = trimmed
+ break
+ }
+ }
+ modelIDs, err := s.fetchUpstreamModelIDs(channel, upstreamKey)
+ if err != nil {
+ c.JSON(http.StatusBadGateway, gin.H{"error": gin.H{"message": err.Error()}})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"models": modelIDs})
+}
+
func (s *Server) checkChannel(c *gin.Context) {
s.mu.Lock()
channel := s.findChannel(c.Param("id"))
diff --git a/src/App.tsx b/src/App.tsx
index a7f3435..d7aeecc 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1673,7 +1673,10 @@ function AccountHome({
{newSecret && {newSecret}}
@@ -1694,7 +1697,12 @@ function AccountHome({
- 可用模型
+
{models.map((model) => (
@@ -1704,6 +1712,7 @@ function AccountHome({
{model.id}
))}
+ {models.length === 0 &&
暂无可用模型,管理员配置渠道后将在此展示
}
@@ -3271,6 +3280,7 @@ function ChannelsView({
const [upstreamApiKey, setUpstreamApiKey] = useState("");
const [message, setMessage] = useState("");
const [busy, setBusy] = useState(false);
+ const [pickerOpen, setPickerOpen] = useState(false);
function applyTemplate(nextProvider: string) {
const template = channelTemplateFor(nextProvider);
@@ -3358,9 +3368,21 @@ function ChannelsView({
模型
- 来源:模板,可手动修改;保存后可从上游拉取校准
+ 可手动填写,或从上游拉取后多选
+
+
@@ -3370,6 +3392,21 @@ function ChannelsView({
)}
+ {creating && pickerOpen && (
+ {
+ const data = await fetchJson<{ models?: string[] }>("/api/channel-model-preview", {
+ method: "POST",
+ body: JSON.stringify({ provider, baseUrl: baseUrl.trim(), upstreamApiKey })
+ });
+ return arrayOf(data.models);
+ }}
+ onConfirm={async (selectedModels) => { setModels(selectedModels.join(", ")); }}
+ onClose={() => setPickerOpen(false)}
+ />
+ )}
{channels.map((channel) => (
@@ -3687,9 +3724,15 @@ function ChannelEditor({
{pickerOpen && (
model.trim()).filter(Boolean)}
+ loadModels={async () => {
+ const data = await fetchJson<{ models?: string[] }>(`/api/channels/${channel.id}/upstream-models`, {
+ method: "POST",
+ body: JSON.stringify({})
+ });
+ return arrayOf(data.models);
+ }}
onConfirm={async (selectedModels) => { await onSyncModels(channel.id, selectedModels); }}
onClose={() => setPickerOpen(false)}
/>
@@ -3699,15 +3742,15 @@ function ChannelEditor({
}
function ModelPickerModal({
- channelId,
- channelName,
+ subtitle,
current,
+ loadModels,
onConfirm,
onClose
}: {
- channelId: string;
- channelName: string;
+ subtitle: string;
current: string[];
+ loadModels: () => Promise;
onConfirm: (models: string[]) => Promise;
onClose: () => void;
}) {
@@ -3724,13 +3767,10 @@ function ModelPickerModal({
setLoading(true);
setError("");
try {
- const data = await fetchJson<{ models?: string[] }>(`/api/channels/${channelId}/upstream-models`, {
- method: "POST",
- body: JSON.stringify({})
- });
+ const list = await loadModels();
if (cancelled) return;
const seen = new Set();
- const unique = arrayOf(data.models).map((model) => model.trim()).filter((model) => {
+ const unique = arrayOf(list).map((model) => model.trim()).filter((model) => {
if (!model) return false;
const key = model.toLowerCase();
if (seen.has(key)) return false;
@@ -3749,9 +3789,9 @@ function ModelPickerModal({
return () => {
cancelled = true;
};
- // Fetch once per open; `current` is only used to seed the initial checkboxes.
+ // Load once when the dialog opens; current/loadModels only seed initial state.
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [channelId]);
+ }, []);
const normalizedQuery = query.trim().toLowerCase();
const filtered = normalizedQuery ? upstream.filter((model) => model.toLowerCase().includes(normalizedQuery)) : upstream;
@@ -3804,7 +3844,7 @@ function ModelPickerModal({
选择上游模型
- {channelName} · 勾选需要接入的模型
+ {subtitle}
diff --git a/src/styles.css b/src/styles.css
index 79c0229..2e94534 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -3403,6 +3403,11 @@ button.table-row:hover {
gap: 14px;
}
+.account-section-title > div {
+ display: grid;
+ gap: 3px;
+}
+
.account-content {
display: grid;
width: min(1120px, calc(100% - 40px));
@@ -3447,8 +3452,12 @@ button.table-row:hover {
.check-in-section {
overflow: hidden;
background:
- radial-gradient(circle at 100% 0, color-mix(in srgb, var(--blue) 14%, transparent), transparent 44%),
- var(--surface-solid);
+ radial-gradient(circle at 100% 0, color-mix(in srgb, var(--blue) 22%, transparent), transparent 48%),
+ var(--glass);
+ border: 1px solid var(--glass-border);
+ box-shadow: var(--glass-shadow);
+ -webkit-backdrop-filter: blur(26px) saturate(180%);
+ backdrop-filter: blur(26px) saturate(180%);
}
.check-in-section .account-section-title > div {
@@ -3585,6 +3594,17 @@ button.table-row:hover {
gap: 12px;
}
+.account-model-empty {
+ grid-column: 1 / -1;
+ padding: 26px 18px;
+ color: var(--muted);
+ text-align: center;
+ font-size: 13px;
+ background: color-mix(in srgb, var(--group) 60%, transparent);
+ border: 1px dashed var(--hairline);
+ border-radius: 14px;
+}
+
.account-model-grid article {
display: grid;
gap: 9px;
@@ -6223,3 +6243,103 @@ input[type="radio"]:active {
animation: none;
}
}
+
+/* ============================================================
+ iOS 26 liquid-glass surface system
+ Frosted, layered cards with ambient depth behind them.
+ ============================================================ */
+
+/* Glass tokens + ambient page wash — console scope. */
+.app-shell {
+ --glass: linear-gradient(158deg, rgba(255, 255, 255, 0.92) 0%, rgba(255, 255, 255, 0.64) 100%);
+ --glass-border: rgba(255, 255, 255, 0.72);
+ --glass-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.6),
+ 0 1px 2px rgba(17, 24, 39, 0.04),
+ 0 12px 34px rgba(17, 24, 39, 0.08);
+ background:
+ radial-gradient(1120px 620px at 6% -8%, rgba(0, 122, 255, 0.10), transparent 58%),
+ radial-gradient(960px 560px at 102% 2%, rgba(48, 176, 199, 0.10), transparent 56%),
+ radial-gradient(900px 760px at 50% 118%, rgba(255, 149, 0, 0.06), transparent 60%),
+ var(--bg);
+}
+
+.app-shell[data-theme="dark"] {
+ --glass: linear-gradient(158deg, rgba(58, 58, 66, 0.72) 0%, rgba(28, 28, 34, 0.6) 100%);
+ --glass-border: rgba(255, 255, 255, 0.1);
+ --glass-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.08),
+ 0 2px 6px rgba(0, 0, 0, 0.3),
+ 0 18px 44px rgba(0, 0, 0, 0.48);
+ background:
+ radial-gradient(1120px 620px at 6% -8%, rgba(10, 132, 255, 0.18), transparent 58%),
+ radial-gradient(960px 560px at 102% 2%, rgba(48, 176, 199, 0.15), transparent 56%),
+ radial-gradient(900px 760px at 50% 120%, rgba(120, 88, 255, 0.14), transparent 60%),
+ var(--bg);
+}
+
+/* Glass tokens + ambient page wash — account / auth scope. */
+.account-page,
+.auth-page {
+ --glass: linear-gradient(158deg, rgba(255, 255, 255, 0.94) 0%, rgba(255, 255, 255, 0.66) 100%);
+ --glass-border: rgba(255, 255, 255, 0.75);
+ --glass-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.65),
+ 0 1px 2px rgba(17, 24, 39, 0.04),
+ 0 16px 40px rgba(17, 24, 39, 0.09);
+ background:
+ radial-gradient(1080px 640px at 4% -10%, rgba(0, 122, 255, 0.12), transparent 56%),
+ radial-gradient(940px 560px at 104% 4%, rgba(48, 176, 199, 0.10), transparent 54%),
+ radial-gradient(880px 720px at 52% 120%, rgba(175, 82, 222, 0.07), transparent 60%),
+ var(--bg);
+}
+
+.account-page[data-theme="dark"],
+.auth-page[data-theme="dark"] {
+ --glass: linear-gradient(158deg, rgba(58, 58, 66, 0.7) 0%, rgba(24, 24, 30, 0.58) 100%);
+ --glass-border: rgba(255, 255, 255, 0.12);
+ --glass-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.08),
+ 0 2px 6px rgba(0, 0, 0, 0.35),
+ 0 22px 52px rgba(0, 0, 0, 0.5);
+ background:
+ radial-gradient(1080px 640px at 4% -10%, rgba(10, 132, 255, 0.22), transparent 56%),
+ radial-gradient(940px 560px at 104% 4%, rgba(48, 176, 199, 0.16), transparent 54%),
+ radial-gradient(880px 720px at 52% 122%, rgba(175, 82, 222, 0.16), transparent 60%),
+ var(--bg);
+}
+
+/* Apply the glass material to the main card families (token-driven per theme). */
+.metric,
+.panel,
+.account-section:not(.check-in-section),
+.settings-group,
+.model-card,
+.channel-card,
+.flow-panel,
+.hero-strip,
+.model-hero,
+.log-inspector,
+.source-guide-item {
+ background: var(--glass);
+ border: 1px solid var(--glass-border);
+ box-shadow: var(--glass-shadow);
+ -webkit-backdrop-filter: blur(26px) saturate(185%);
+ backdrop-filter: blur(26px) saturate(185%);
+}
+
+/* Override the older solid dark-theme card fills so glass wins in dark mode. */
+.app-shell[data-theme="dark"] .metric,
+.app-shell[data-theme="dark"] .panel,
+.app-shell[data-theme="dark"] .settings-group,
+.app-shell[data-theme="dark"] .model-card,
+.app-shell[data-theme="dark"] .channel-card,
+.app-shell[data-theme="dark"] .flow-panel,
+.app-shell[data-theme="dark"] .hero-strip,
+.app-shell[data-theme="dark"] .model-hero,
+.app-shell[data-theme="dark"] .log-inspector,
+.app-shell[data-theme="dark"] .source-guide-item {
+ background: var(--glass);
+ border-color: var(--glass-border);
+ box-shadow: var(--glass-shadow);
+}