diff --git a/apps/ui/src/App.tsx b/apps/ui/src/App.tsx
index 8f2b7c4..14f363a 100644
--- a/apps/ui/src/App.tsx
+++ b/apps/ui/src/App.tsx
@@ -507,14 +507,25 @@ const App = () => {
const handleSiteTest = useCallback(
async (id: string) => {
try {
- const result = await apiFetch<{ models: Array<{ id: string }> }>(
- `/api/channels/${id}/test`,
- {
- method: "POST",
- },
- );
+ const result = await apiFetch<{
+ models: Array<{ id: string }>;
+ token_summary?: {
+ total: number;
+ success: number;
+ failed: number;
+ };
+ }>(`/api/channels/${id}/test`, {
+ method: "POST",
+ });
await loadSites();
- setNotice(`连通测试完成,模型数 ${result.models?.length ?? 0}`);
+ const modelCount = result.models?.length ?? 0;
+ const summary = result.token_summary;
+ const detail = summary
+ ? `,令牌成功 ${summary.success}/${summary.total}${
+ summary.failed > 0 ? `,失败 ${summary.failed}` : ""
+ }`
+ : "";
+ setNotice(`连通测试完成,模型数 ${modelCount}${detail}`);
} catch (error) {
setNotice((error as Error).message);
}
diff --git a/apps/ui/src/core/constants.ts b/apps/ui/src/core/constants.ts
index 427ba2b..2bedc9f 100644
--- a/apps/ui/src/core/constants.ts
+++ b/apps/ui/src/core/constants.ts
@@ -29,7 +29,7 @@ export const initialSiteForm: SiteForm = {
checkin_url: "",
system_token: "",
system_userid: "",
- checkin_enabled: true,
+ checkin_enabled: false,
call_tokens: [
{
name: "主调用令牌",
diff --git a/apps/ui/src/features/DashboardView.tsx b/apps/ui/src/features/DashboardView.tsx
index 173efec..614619e 100644
--- a/apps/ui/src/features/DashboardView.tsx
+++ b/apps/ui/src/features/DashboardView.tsx
@@ -1,7 +1,7 @@
import type { DashboardData } from "../core/types";
type DashboardViewProps = {
- dashboard: DashboardData | null;
+ dashboard: DashboardData | null;
};
/**
@@ -14,220 +14,226 @@ type DashboardViewProps = {
* Dashboard JSX element.
*/
export const DashboardView = ({ dashboard }: DashboardViewProps) => {
- if (!dashboard) {
- return (
-
- 暂无数据
-
- );
- }
- const errorRate = dashboard.summary.total_requests
- ? Math.round(
- (dashboard.summary.total_errors / dashboard.summary.total_requests) *
- 100,
- )
- : 0;
- return (
-
-
-
-
- 总请求
-
-
- {dashboard.summary.total_requests}
-
-
- 最近窗口
-
-
-
-
- 总 Tokens
-
-
- {dashboard.summary.total_tokens}
-
-
- 累计消耗
-
-
-
-
- 错误率
-
-
{errorRate}%
-
- 平均延迟 {Math.round(dashboard.summary.avg_latency)}ms
-
-
-
-
-
-
-
- 按日趋势
-
-
-
-
-
-
- |
- 日期
- |
-
- 请求
- |
-
- Tokens
- |
-
-
-
- {dashboard.byDay.map((row) => (
-
- |
- {row.day}
- |
-
- {row.requests}
- |
-
- {row.tokens}
- |
-
- ))}
-
-
-
-
-
-
-
- 模型排行
-
-
-
-
-
-
- |
- 模型
- |
-
- 请求
- |
-
- Tokens
- |
-
-
-
- {dashboard.byModel.map((row) => (
-
- |
- {row.model ?? "-"}
- |
-
- {row.requests}
- |
-
- {row.tokens}
- |
-
- ))}
-
-
-
-
-
-
-
-
-
- 渠道贡献
-
-
-
-
-
-
- |
- 渠道
- |
-
- 请求
- |
-
- Tokens
- |
-
-
-
- {dashboard.byChannel.map((row) => (
-
- |
- {row.channel_name ?? "-"}
- |
-
- {row.requests}
- |
-
- {row.tokens}
- |
-
- ))}
-
-
-
-
-
-
-
- 令牌贡献
-
-
-
-
-
-
- |
- 令牌
- |
-
- 请求
- |
-
- Tokens
- |
-
-
-
- {dashboard.byToken.map((row) => (
-
- |
- {row.token_name ?? "-"}
- |
-
- {row.requests}
- |
-
- {row.tokens}
- |
-
- ))}
-
-
-
-
-
-
- );
+ if (!dashboard) {
+ return (
+
+ 暂无数据
+
+ );
+ }
+ const errorRate = dashboard.summary.total_requests
+ ? Math.round(
+ (dashboard.summary.total_errors / dashboard.summary.total_requests) *
+ 100,
+ )
+ : 0;
+ return (
+
+
+
+
+ 总请求
+
+
+ {dashboard.summary.total_requests}
+
+
+ 最近窗口
+
+
+
+
+ 总 Tokens
+
+
+ {dashboard.summary.total_tokens}
+
+
+ 累计消耗
+
+
+
+
+ 错误率
+
+
{errorRate}%
+
+ 平均延迟 {Math.round(dashboard.summary.avg_latency)}ms
+
+
+
+
+
+
+
+ 按日趋势
+
+
+
+
+
+
+ |
+ 日期
+ |
+
+ 请求
+ |
+
+ Tokens
+ |
+
+
+
+ {dashboard.byDay.map((row) => (
+
+ |
+ {row.day}
+ |
+
+ {row.requests}
+ |
+
+ {row.tokens}
+ |
+
+ ))}
+
+
+
+
+
+
+
+ 模型排行
+
+
+
+
+
+
+ |
+ 模型
+ |
+
+ 请求
+ |
+
+ Tokens
+ |
+
+
+
+ {dashboard.byModel.map((row) => (
+
+ |
+ {row.model ?? "-"}
+ |
+
+ {row.requests}
+ |
+
+ {row.tokens}
+ |
+
+ ))}
+
+
+
+
+
+
+
+
+
+ 渠道贡献
+
+
+
+
+
+
+ |
+ 渠道
+ |
+
+ 请求
+ |
+
+ Tokens
+ |
+
+
+
+ {dashboard.byChannel.map((row) => (
+
+ |
+ {row.channel_name ?? "-"}
+ |
+
+ {row.requests}
+ |
+
+ {row.tokens}
+ |
+
+ ))}
+
+
+
+
+
+
+
+ 令牌贡献
+
+
+
+
+
+
+ |
+ 令牌
+ |
+
+ 请求
+ |
+
+ Tokens
+ |
+
+
+
+ {dashboard.byToken.map((row) => (
+
+ |
+ {row.token_name ?? "-"}
+ |
+
+ {row.requests}
+ |
+
+ {row.tokens}
+ |
+
+ ))}
+
+
+
+
+
+
+ );
};
diff --git a/apps/ui/src/features/ModelsView.tsx b/apps/ui/src/features/ModelsView.tsx
index c961566..9c626c6 100644
--- a/apps/ui/src/features/ModelsView.tsx
+++ b/apps/ui/src/features/ModelsView.tsx
@@ -1,7 +1,7 @@
import type { ModelItem } from "../core/types";
type ModelsViewProps = {
- models: ModelItem[];
+ models: ModelItem[];
};
/**
@@ -14,40 +14,43 @@ type ModelsViewProps = {
* Models JSX element.
*/
export const ModelsView = ({ models }: ModelsViewProps) => (
-
-
-
- 模型广场
-
-
- {models.length} 个模型
-
-
-
-
-
-
- |
- 模型
- |
-
- 渠道
- |
-
-
-
- {models.map((model) => (
-
- |
- {model.id}
- |
-
- {model.channels.map((channel) => channel.name).join(" / ")}
- |
-
- ))}
-
-
-
-
+
+
+
+ 模型广场
+
+
+ {models.length} 个模型
+
+
+
+
+
+
+ |
+ 模型
+ |
+
+ 渠道
+ |
+
+
+
+ {models.map((model) => (
+
+ |
+ {model.id}
+ |
+
+ {model.channels.map((channel) => channel.name).join(" / ")}
+ |
+
+ ))}
+
+
+
+
);
diff --git a/apps/worker/src/routes/channels.ts b/apps/worker/src/routes/channels.ts
index 5bb0174..395a0ec 100644
--- a/apps/worker/src/routes/channels.ts
+++ b/apps/worker/src/routes/channels.ts
@@ -8,8 +8,10 @@ import {
listChannels,
updateChannel,
} from "../services/channel-repo";
+import { listCallTokens } from "../services/channel-call-token-repo";
+import { modelsToJson } from "../services/channel-models";
import {
- fetchChannelModels,
+ testChannelTokens,
updateChannelTestResult,
} from "../services/channel-testing";
import { generateToken } from "../utils/crypto";
@@ -164,28 +166,49 @@ channels.post("/:id/test", async (c) => {
return jsonError(c, 404, "channel_not_found", "channel_not_found");
}
- const result = await fetchChannelModels(
- String(channel.base_url),
- String(channel.api_key),
- );
+ const callTokenRows = await listCallTokens(c.env.DB, {
+ channelIds: [id],
+ });
+ const tokens =
+ callTokenRows.length > 0
+ ? callTokenRows.map((row) => ({
+ id: row.id,
+ name: row.name,
+ api_key: row.api_key,
+ }))
+ : [
+ {
+ id: "primary",
+ name: "主调用令牌",
+ api_key: String(channel.api_key),
+ },
+ ];
- if (!result.ok) {
+ const summary = await testChannelTokens(String(channel.base_url), tokens);
+ if (!summary.ok) {
await updateChannelTestResult(c.env.DB, id, {
ok: false,
- elapsed: result.elapsed,
+ elapsed: summary.elapsed,
});
return jsonError(c, 502, "channel_unreachable", "channel_unreachable");
}
- const payload = result.payload ?? { data: [] };
- const models = Array.isArray(payload) ? payload : (payload?.data ?? []);
+ const models = summary.models.map((id) => ({ id }));
await updateChannelTestResult(c.env.DB, id, {
ok: true,
- elapsed: result.elapsed,
- modelsJson: JSON.stringify(payload),
+ elapsed: summary.elapsed,
+ modelsJson: modelsToJson(summary.models),
});
- return c.json({ ok: true, models });
+ return c.json({
+ ok: true,
+ models,
+ token_summary: {
+ total: summary.total,
+ success: summary.success,
+ failed: summary.failed,
+ },
+ });
});
export default channels;
diff --git a/apps/worker/src/services/channel-models.ts b/apps/worker/src/services/channel-models.ts
index 6edaf97..5d73804 100644
--- a/apps/worker/src/services/channel-models.ts
+++ b/apps/worker/src/services/channel-models.ts
@@ -26,7 +26,7 @@ export function normalizeModelsInput(input: unknown): string[] {
return [];
}
if (Array.isArray(input)) {
- return input.map((item) => String(item)).filter((item) => item.length > 0);
+ return input.map((item) => toModelId(item)).filter((item) => item.length > 0);
}
if (typeof input === "string") {
return input
diff --git a/apps/worker/src/services/channel-testing.ts b/apps/worker/src/services/channel-testing.ts
index 343adf0..b811243 100644
--- a/apps/worker/src/services/channel-testing.ts
+++ b/apps/worker/src/services/channel-testing.ts
@@ -10,6 +10,30 @@ export type ChannelTestResult = {
payload?: unknown[] | { data?: unknown[] };
};
+export type ChannelToken = {
+ id?: string;
+ name?: string;
+ api_key: string;
+};
+
+export type ChannelTokenTestItem = {
+ tokenId?: string;
+ tokenName?: string;
+ ok: boolean;
+ elapsed: number;
+ models: string[];
+};
+
+export type ChannelTokenTestSummary = {
+ ok: boolean;
+ total: number;
+ success: number;
+ failed: number;
+ elapsed: number;
+ models: string[];
+ items: ChannelTokenTestItem[];
+};
+
export async function fetchChannelModels(
baseUrl: string,
apiKey: string,
@@ -39,6 +63,75 @@ export async function fetchChannelModels(
return { ok: true, elapsed, models, payload };
}
+/**
+ * Tests channel models with multiple API keys and aggregates results.
+ *
+ * Args:
+ * baseUrl: Upstream base URL.
+ * tokens: List of call tokens to test.
+ * fetcher: Optional fetcher override for tests.
+ *
+ * Returns:
+ * Summary of token test results and aggregated model IDs.
+ */
+export async function testChannelTokens(
+ baseUrl: string,
+ tokens: ChannelToken[],
+ fetcher: (
+ baseUrl: string,
+ apiKey: string,
+ ) => Promise = fetchChannelModels,
+): Promise {
+ if (tokens.length === 0) {
+ return {
+ ok: false,
+ total: 0,
+ success: 0,
+ failed: 0,
+ elapsed: 0,
+ models: [],
+ items: [],
+ };
+ }
+
+ const items: ChannelTokenTestItem[] = [];
+ const modelSet = new Set();
+ let success = 0;
+ let totalElapsed = 0;
+
+ for (const token of tokens) {
+ const result = await fetcher(baseUrl, token.api_key);
+ totalElapsed += result.elapsed;
+ if (result.ok) {
+ success += 1;
+ for (const model of result.models) {
+ modelSet.add(model);
+ }
+ }
+ items.push({
+ tokenId: token.id,
+ tokenName: token.name,
+ ok: result.ok,
+ elapsed: result.elapsed,
+ models: result.models,
+ });
+ }
+
+ const total = tokens.length;
+ const failed = total - success;
+ const elapsed = Math.round(totalElapsed / total);
+
+ return {
+ ok: success > 0,
+ total,
+ success,
+ failed,
+ elapsed,
+ models: Array.from(modelSet),
+ items,
+ };
+}
+
export async function updateChannelTestResult(
db: D1Database,
id: string,
diff --git a/helloagents/CHANGELOG.md b/helloagents/CHANGELOG.md
index c9dd760..b14e615 100644
--- a/helloagents/CHANGELOG.md
+++ b/helloagents/CHANGELOG.md
@@ -89,6 +89,16 @@
- 类型: 微调(无方案包)
- 文件: apps/worker/src/routes/proxy.ts
+## [0.8.0] - 2026-02-22
+
+### 变更
+- **[channels]**: 连通测试改为遍历调用令牌并汇总模型与结果统计
+ - 方案: [202602222335_multi-token-test-checkin-default-off](archive/2026-02/202602222335_multi-token-test-checkin-default-off/)
+- **[admin-ui]**: 新增站点默认关闭自动签到,连通测试提示令牌统计
+ - 方案: [202602222335_multi-token-test-checkin-default-off](archive/2026-02/202602222335_multi-token-test-checkin-default-off/)
+- **[tests]**: 新增多令牌连通测试聚合单测
+ - 方案: [202602222335_multi-token-test-checkin-default-off](archive/2026-02/202602222335_multi-token-test-checkin-default-off/)
+
### 修复
- **[checkin]**: 签到结果增加二次校验与非 JSON 响应判定
diff --git a/helloagents/modules/admin-ui.md b/helloagents/modules/admin-ui.md
index ed788aa..76bf6db 100644
--- a/helloagents/modules/admin-ui.md
+++ b/helloagents/modules/admin-ui.md
@@ -18,10 +18,11 @@
- 前端状态集中在单页
- 站点管理采用列表视图,支持分页(默认 10 条),新增/编辑使用弹窗,保留启禁/连通测试操作
- 站点类型支持 new-api / done-hub / subapi / chatgpt / claude / gemini,类型驱动表单字段
-- 站点表单提供系统令牌、userid、自动签到开关与外部签到地址(new-api 默认开启)
+- 站点表单提供系统令牌、userid、自动签到开关与外部签到地址(new-api 默认关闭)
- 站点支持多调用令牌(备注 + 令牌值)
- chatgpt/claude/gemini 站点支持 base_url 为空时自动填充官方地址
- done-hub 站点仅使用 base_url,不再维护多地址输入
+- 站点连通测试提示令牌成功/失败统计
- 使用日志支持本地分页(默认 50 条)与固定高度滚动,并展示令牌、输入/输出 tokens、首 token 延迟、流式与推理强度
- 令牌管理采用列表视图(默认每页 10 条),生成令牌使用弹窗表单,并展示创建时间与前缀
- 站点管理内置一键签到入口,展示今日签到结果(基于后台写回的 `last_checkin_*`)
diff --git a/helloagents/modules/channels.md b/helloagents/modules/channels.md
index d145bc1..5f7dc74 100644
--- a/helloagents/modules/channels.md
+++ b/helloagents/modules/channels.md
@@ -27,8 +27,8 @@
## 行为规范
- `base_url` 保存为无尾斜杠格式
- 创建渠道时可传入自定义 `id`,未提供则自动生成
-- 连通性测试调用 `/v1/models`
-- 测试成功后更新 `models_json`
+- 连通性测试会对调用令牌逐个调用 `/v1/models` 并汇总模型
+- 至少一枚令牌测试成功时更新 `models_json` 并返回成功/失败统计
- New API 兼容层支持 `type`/`group`/`priority` 等字段映射,并保留扩展字段到 `metadata_json`
- New API 标签(tag)存储在 `metadata_json.tag`,标签接口按该字段批量更新
- New API 分组列表从 `group_name` 字段解析,空时返回 `default`
diff --git a/helloagents/modules/models.md b/helloagents/modules/models.md
index 73d3709..b3a1c3a 100644
--- a/helloagents/modules/models.md
+++ b/helloagents/modules/models.md
@@ -11,6 +11,7 @@
## 行为规范
- 仅聚合启用状态的渠道
- 优先使用渠道连通性测试返回的模型数据
+- 模型归一化支持字符串数组与对象数组(对象取 `id` 字段)
## 依赖关系
- `channels` 表