Skip to content
Merged
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
33 changes: 33 additions & 0 deletions cmd/capi/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"))
Expand Down
76 changes: 58 additions & 18 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1673,7 +1673,10 @@ function AccountHome({

<section className="account-section">
<div className="account-section-title">
<h2>API 密钥</h2>
<div>
<p className="eyebrow">API Keys</p>
<h2>API 密钥</h2>
</div>
<button className="primary-button" onClick={createKey}>创建密钥</button>
</div>
{newSecret && <code className="one-time-secret">{newSecret}</code>}
Expand All @@ -1694,7 +1697,12 @@ function AccountHome({
</section>

<section className="account-section">
<div className="account-section-title"><h2>可用模型</h2></div>
<div className="account-section-title">
<div>
<p className="eyebrow">Models</p>
<h2>可用模型</h2>
</div>
</div>
<div className="account-model-grid">
{models.map((model) => (
<article key={model.id}>
Expand All @@ -1704,6 +1712,7 @@ function AccountHome({
<code>{model.id}</code>
</article>
))}
{models.length === 0 && <div className="account-model-empty">暂无可用模型,管理员配置渠道后将在此展示</div>}
</div>
</section>
</section>
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -3358,9 +3368,21 @@ function ChannelsView({
<div className="channel-form-wide channel-model-field">
<div className="field-label-row">
<span>模型</span>
<small>来源:模板,可手动修改;保存后可从上游拉取校准</small>
<small>可手动填写,或从上游拉取后多选</small>
</div>
<textarea value={models} onChange={(event) => setModels(event.target.value)} placeholder="多个模型用逗号分隔,或点『获取模型』从上游拉取" />
<div className="channel-model-actions">
<button type="button" className="secondary-button compact-button" onClick={() => {
if (provider !== "codex" && !baseUrl.trim()) {
setMessage("请先填写 Base URL 再获取模型");
return;
}
setMessage("");
setPickerOpen(true);
}}>
获取模型
</button>
</div>
<textarea value={models} onChange={(event) => setModels(event.target.value)} placeholder="多个模型用逗号分隔,也可以保存后拉取上游模型" />
</div>
</div>
<div className="channel-card-actions">
Expand All @@ -3370,6 +3392,21 @@ function ChannelsView({
</div>
</form>
)}
{creating && pickerOpen && (
<ModelPickerModal
subtitle={`${name.trim() || channelTemplateFor(provider).name} · 勾选需要接入的模型`}
current={modelTextToList(models)}
loadModels={async () => {
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)}
/>
)}
<div className="channels-stack">
{channels.map((channel) => (
<ChannelEditor key={channel.id} channel={channel} onUpdate={onUpdate} onImport={onImport} onDelete={onDelete} onSyncModels={onSyncModels} onCheck={onCheck} />
Expand Down Expand Up @@ -3687,9 +3724,15 @@ function ChannelEditor({
</details>
{pickerOpen && (
<ModelPickerModal
channelId={channel.id}
channelName={channel.name}
subtitle={`${channel.name} · 勾选需要接入的模型`}
current={models.split(",").map((model) => 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)}
/>
Expand All @@ -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<string[]>;
onConfirm: (models: string[]) => Promise<void>;
onClose: () => void;
}) {
Expand All @@ -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<string>();
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;
Expand All @@ -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;
Expand Down Expand Up @@ -3804,7 +3844,7 @@ function ModelPickerModal({
<div className="modal-head">
<div>
<strong>选择上游模型</strong>
<span>{channelName} · 勾选需要接入的模型</span>
<span>{subtitle}</span>
</div>
<button type="button" className="icon-button" onClick={onClose}>×</button>
</div>
Expand Down
124 changes: 122 additions & 2 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Loading