dsh-agentone 0.5.36 → 0.5.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -10
- package/lib/auth.js +35 -6
- package/lib/client.js +9 -5
- package/lib/index.js +45 -12
- package/lib/instance.js +1 -1
- package/lib/model.js +97 -11
- package/lib/page.js +15 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
# dsh-agentone
|
|
2
2
|
|
|
3
3
|
AgentOne 平台集成插件(dsh web 版 / 桌面版通用)。安装后让独立 dsh 接入公司
|
|
4
|
-
AgentOne
|
|
4
|
+
AgentOne 平台:IDOne 登录、平台模型、SkillHub 技能、套餐申请、dsh 插件管理、
|
|
5
5
|
lark-cli / ccpg-cli 状态探测。
|
|
6
6
|
|
|
7
7
|
## 能力总览(M1–M4 已全部交付)
|
|
8
8
|
|
|
9
|
-
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
- **平台登录(M1)**:设备登录流(一次性授权码),入口按平台能力面自动
|
|
10
|
+
路由——平台开启邮箱锚点模式(IDOne 统一登录)时走 IDOne,否则回退
|
|
11
|
+
飞书;凭据保存在本机 `$DSH_HOME/agentone/credentials.json`(0600)。
|
|
12
|
+
access 令牌临期自动轮换(single-flight),refresh 失效自动回到未登录态。
|
|
12
13
|
- **平台模型(M2)**:登录后自动把 dsh 的 llm provider 指向 AgentOne
|
|
13
14
|
OpenAI 兼容代理(`/api/plugin/v1`),模型密钥不下发到本机;对话经平台
|
|
14
15
|
走 TokenHub 按用户计量。平台 403 的 `plan_required` 错误码原样透传,
|
|
@@ -46,7 +47,7 @@ lark-cli / ccpg-cli 状态探测。
|
|
|
46
47
|
每个会话基线加载,project 级指令优先);幂等不重复注入,用户已有语言
|
|
47
48
|
偏好或删除标记块后不再介入(本机只做一次决策,记录于
|
|
48
49
|
`$DSH_HOME/agentone/language-default.json`)。
|
|
49
|
-
- **首启引导**:dsh
|
|
50
|
+
- **首启引导**:dsh 首次启动弹出平台登录引导(order -50,早于官方
|
|
50
51
|
「添加 API Key」),登录一次即跳过官方密钥引导;暗色主题适配。
|
|
51
52
|
|
|
52
53
|
所有平台请求统一走 `lib/http.js`(默认 10s 超时、技能包下载 60s),
|
|
@@ -63,15 +64,18 @@ dsh plugin --profile web add dsh-agentone
|
|
|
63
64
|
|
|
64
65
|
## 使用
|
|
65
66
|
|
|
66
|
-
打开 dsh web 的 `/agentone/` 页面(或 dsh 设置 → AgentOne
|
|
67
|
-
|
|
67
|
+
打开 dsh web 的 `/agentone/` 页面(或 dsh 设置 → AgentOne)→「IDOne 登录」
|
|
68
|
+
(平台未开启统一登录时为「飞书登录」)→ 浏览器完成平台 OAuth → 自动跳回
|
|
69
|
+
并显示已登录账号。授权完成页会等待
|
|
68
70
|
模型配置就绪后自动关闭;配置失败时展示原因并提供「重试同步」。
|
|
69
71
|
|
|
70
72
|
登录成功后插件自动完成模型配置(也可点「重新同步模型」手动触发):
|
|
71
73
|
|
|
72
74
|
- `$DSH_HOME/settings.yaml` 写入 `llm-pi-ai.providers.platform`(指向平台
|
|
73
75
|
`/api/plugin/v1` 代理)与 `agent-default-model`,dsh 模型选择器出现
|
|
74
|
-
|
|
76
|
+
「平台模型」;模型列表按系列聚簇 + 系列内版本降序写入(插件侧按平台
|
|
77
|
+
`sort_models_by_version` 同一规则兜底重排,旧版平台返回的上游原序也能得到
|
|
78
|
+
稳定顺序,默认模型仍取平台下发的 `default_model`);
|
|
75
79
|
- `$DSH_HOME/.credentials.yaml` 写入 `refs.AGENTONE_API_KEY`(0600),内容
|
|
76
80
|
是 30 天插件令牌而非真实模型密钥——密钥不出平台,TokenHub 按用户计量;
|
|
77
81
|
- 只覆盖 `platform` 命名空间,用户自己配置的其它 provider 原样保留;
|
|
@@ -86,8 +90,8 @@ dsh plugin --profile web add dsh-agentone
|
|
|
86
90
|
|
|
87
91
|
```
|
|
88
92
|
插件页 ──POST /agentone/api/login──▶ 本插件后端
|
|
89
|
-
◀─ {login_url} ──
|
|
90
|
-
浏览器 ──login_url──▶
|
|
93
|
+
◀─ {login_url, provider} ──
|
|
94
|
+
浏览器 ──login_url──▶ 平台 OAuth(IDOne 优先,飞书兜底)──▶ /api/auth/plugin/landing
|
|
91
95
|
landing(JS, Bearer) ──POST /api/auth/plugin/grant──▶ {redirect_url(一次性 code)}
|
|
92
96
|
浏览器 ──redirect_url──▶ 本插件 /agentone/callback?code
|
|
93
97
|
插件后端 ──POST /api/auth/plugin/exchange──▶ {access_token}
|
package/lib/auth.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// AgentOne 设备登录流(一次性授权码)。
|
|
2
2
|
//
|
|
3
3
|
// 流程(与平台 app/api/plugin_auth.py 契约对应):
|
|
4
|
-
// 1. buildLoginUrl
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
4
|
+
// 1. buildLoginUrl:拼平台 OAuth 入口(IDOne 优先、飞书兜底,见
|
|
5
|
+
// pickLoginProvider),redirect 指向平台 landing 页并携带
|
|
6
|
+
// callback_url/state(经 OAuth state 往返透传);
|
|
7
|
+
// 2. 浏览器完成登录 → 平台回调页写 hermeshub_token → landing 页用平台
|
|
8
|
+
// 令牌换一次性 code → 跳转 callback_url(即本插件的 /agentone/callback
|
|
9
|
+
// 路由);
|
|
8
10
|
// 3. exchangeCode:code 换新签发的平台访问令牌。
|
|
9
11
|
import { randomBytes } from 'node:crypto';
|
|
10
12
|
|
|
@@ -14,10 +16,37 @@ export function newLoginState() {
|
|
|
14
16
|
return randomBytes(16).toString('hex');
|
|
15
17
|
}
|
|
16
18
|
|
|
17
|
-
export function buildLoginUrl(platformUrl, callbackUrl, state) {
|
|
19
|
+
export function buildLoginUrl(platformUrl, callbackUrl, state, provider = 'feishu') {
|
|
18
20
|
const landing = `/api/auth/plugin/landing?callback_url=${encodeURIComponent(callbackUrl)}&state=${encodeURIComponent(state)}`;
|
|
19
21
|
const redirect = encodeURIComponent(landing);
|
|
20
|
-
|
|
22
|
+
const entry = provider === 'idone' ? '/api/auth/casdoor/login' : '/api/auth/feishu/login';
|
|
23
|
+
return `${platformUrl.replace(/\/+$/, '')}${entry}?redirect=${redirect}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 匿名拉取平台能力面(登录入口开关)。登录前无令牌,走公开的
|
|
28
|
+
* /api/auth/capabilities(门户登录页同源依赖)。任何失败(超时/非 2xx/
|
|
29
|
+
* 解析错)返回 null,调用方按能力未知处理。
|
|
30
|
+
*/
|
|
31
|
+
export async function fetchAuthCapabilities(platformUrl) {
|
|
32
|
+
try {
|
|
33
|
+
const response = await platformFetch(`${platformUrl.replace(/\/+$/, '')}/api/auth/capabilities`, {}, 3000);
|
|
34
|
+
if (!response.ok) return null;
|
|
35
|
+
return await response.json();
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 按 capabilities 选登录入口:仅当 IDOne 已启用且邮箱锚点模式开启
|
|
43
|
+
* (平台统一登录语义,未绑定用户由后端按邮箱自动认人)才走 IDOne;
|
|
44
|
+
* 其余情况(未启用 / 非锚点 / 查询失败)一律回退飞书。
|
|
45
|
+
*/
|
|
46
|
+
export function pickLoginProvider(caps) {
|
|
47
|
+
return caps?.casdoor_oauth_enabled === true && caps?.casdoor_email_anchor_enabled === true
|
|
48
|
+
? 'idone'
|
|
49
|
+
: 'feishu';
|
|
21
50
|
}
|
|
22
51
|
|
|
23
52
|
export async function exchangeCode(platformUrl, code) {
|
package/lib/client.js
CHANGED
|
@@ -176,6 +176,8 @@ window.__ModuleLoader__.load({
|
|
|
176
176
|
const { complete } = props;
|
|
177
177
|
const [phase, setPhase] = React.useState('checking'); // checking | idle | waiting
|
|
178
178
|
const [error, setError] = React.useState('');
|
|
179
|
+
// 登录入口名(服务端按平台能力面路由:IDOne 优先、飞书兜底)
|
|
180
|
+
const [providerName, setProviderName] = React.useState('飞书');
|
|
179
181
|
|
|
180
182
|
React.useEffect(() => {
|
|
181
183
|
let cancelled = false;
|
|
@@ -183,8 +185,10 @@ window.__ModuleLoader__.load({
|
|
|
183
185
|
try {
|
|
184
186
|
const response = await fetch('/agentone/api/status');
|
|
185
187
|
const status = await response.json();
|
|
186
|
-
if (
|
|
187
|
-
|
|
188
|
+
if (cancelled) return;
|
|
189
|
+
if (status?.login_provider === 'idone') setProviderName('IDOne');
|
|
190
|
+
if (status?.logged_in) complete();
|
|
191
|
+
else setPhase('idle');
|
|
188
192
|
} catch {
|
|
189
193
|
if (!cancelled) setPhase('idle');
|
|
190
194
|
}
|
|
@@ -245,8 +249,8 @@ window.__ModuleLoader__.load({
|
|
|
245
249
|
'p',
|
|
246
250
|
{ style: bodyStyle },
|
|
247
251
|
phase === 'waiting'
|
|
248
|
-
?
|
|
249
|
-
:
|
|
252
|
+
? `已打开授权窗口,完成${providerName}授权后这里会自动继续…`
|
|
253
|
+
: `使用公司${providerName}账号登录,公司托管的模型与技能会自动配置,无需任何 API Key。`,
|
|
250
254
|
),
|
|
251
255
|
error
|
|
252
256
|
? React.createElement('p', { style: { ...bodyStyle, color: '#c0392b' } }, error)
|
|
@@ -261,7 +265,7 @@ window.__ModuleLoader__.load({
|
|
|
261
265
|
onClick: startLogin,
|
|
262
266
|
disabled: phase === 'waiting',
|
|
263
267
|
},
|
|
264
|
-
phase === 'waiting' ? '等待授权…' :
|
|
268
|
+
phase === 'waiting' ? '等待授权…' : `${providerName}登录`,
|
|
265
269
|
),
|
|
266
270
|
React.createElement(
|
|
267
271
|
'button',
|
package/lib/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// dsh-agentone 主入口:挂 /agentone/* 路由(dsh webServer 原生 Node handler)。
|
|
2
2
|
//
|
|
3
|
-
// M1
|
|
3
|
+
// M1(登录,IDOne 优先、飞书兜底)页面与 API:
|
|
4
4
|
// GET /agentone/ 状态页(登录/登出/模型同步状态)
|
|
5
|
-
// GET /agentone/api/status {platform_url, logged_in, user_email, model}
|
|
6
|
-
// POST /agentone/api/login {callback_url} → {login_url}(浏览器打开)
|
|
5
|
+
// GET /agentone/api/status {platform_url, logged_in, user_email, model, login_provider}
|
|
6
|
+
// POST /agentone/api/login {callback_url} → {login_url, provider}(浏览器打开)
|
|
7
7
|
// GET /agentone/callback?code&state 平台 landing 跳回:换令牌后自动配置模型
|
|
8
8
|
// POST /agentone/api/logout 清除本地凭据并移除平台模型配置
|
|
9
9
|
// POST /agentone/api/model/sync 重新拉取平台模型并写入 settings.yaml
|
|
@@ -21,10 +21,12 @@ import {
|
|
|
21
21
|
applyPlatformPlan,
|
|
22
22
|
buildLoginUrl,
|
|
23
23
|
exchangeCode,
|
|
24
|
+
fetchAuthCapabilities,
|
|
24
25
|
fetchPlatformCapabilities,
|
|
25
26
|
fetchPlatformPlan,
|
|
26
27
|
fetchPlatformProfile,
|
|
27
28
|
newLoginState,
|
|
29
|
+
pickLoginProvider,
|
|
28
30
|
refreshPlatformToken,
|
|
29
31
|
revokePlatformToken,
|
|
30
32
|
} from './auth.js';
|
|
@@ -61,6 +63,29 @@ export const Config = z.object({
|
|
|
61
63
|
/** 本次进程内的登录 CSRF state(/callback 比对后即失效)。 */
|
|
62
64
|
const pendingStates = new Set();
|
|
63
65
|
|
|
66
|
+
// 登录入口判定缓存:key = platformUrl(避免切换平台后沿用旧判定)。
|
|
67
|
+
// 成功缓存 5 分钟(平台配置热重载后慢收敛可接受);查询失败只缓存 30 秒
|
|
68
|
+
// (避免平台瞬时抖动把入口钉死在飞书,也不让未登录状态轮询反复吃超时)。
|
|
69
|
+
const PROVIDER_CACHE_OK_MS = 5 * 60 * 1000;
|
|
70
|
+
const PROVIDER_CACHE_FAIL_MS = 30 * 1000;
|
|
71
|
+
const loginProviderCache = new Map();
|
|
72
|
+
|
|
73
|
+
/** 查平台能力面决定登录入口('idone' | 'feishu'),永抛错、永阻断登录。 */
|
|
74
|
+
async function resolveLoginProvider(platformUrl, log) {
|
|
75
|
+
const key = String(platformUrl || '').replace(/\/+$/, '');
|
|
76
|
+
const cached = loginProviderCache.get(key);
|
|
77
|
+
if (cached && Date.now() - cached.at < cached.ttl) return cached.provider;
|
|
78
|
+
const caps = await fetchAuthCapabilities(key);
|
|
79
|
+
const provider = pickLoginProvider(caps);
|
|
80
|
+
loginProviderCache.set(key, {
|
|
81
|
+
provider,
|
|
82
|
+
at: Date.now(),
|
|
83
|
+
ttl: caps ? PROVIDER_CACHE_OK_MS : PROVIDER_CACHE_FAIL_MS,
|
|
84
|
+
});
|
|
85
|
+
if (!caps) log?.warn?.(`[dsh-agentone] capabilities unavailable, login falls back to feishu: ${key}`);
|
|
86
|
+
return provider;
|
|
87
|
+
}
|
|
88
|
+
|
|
64
89
|
function sendJson(response, status, data) {
|
|
65
90
|
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
66
91
|
response.end(JSON.stringify(data));
|
|
@@ -451,7 +476,7 @@ const BUILTIN_PLUGIN_DESCRIPTIONS = {
|
|
|
451
476
|
'dsh-im':
|
|
452
477
|
'IM 桥核心:让 dsh 接入飞书等聊天工具——群内 @机器人 干活、收通知与审批卡片',
|
|
453
478
|
[PLUGIN_PACKAGE_NAME]:
|
|
454
|
-
'AgentOne
|
|
479
|
+
'AgentOne 平台集成:IDOne 登录、平台模型、SkillHub 技能与 CLI 授权(本插件)',
|
|
455
480
|
};
|
|
456
481
|
|
|
457
482
|
/** 汇总 profile 插件状态:dependencies(去掉 in-box)+ 已装版本 + 激活态。 */
|
|
@@ -880,10 +905,13 @@ export function apply(ctx, config) {
|
|
|
880
905
|
modelRetryAttempts = 0;
|
|
881
906
|
}
|
|
882
907
|
}
|
|
908
|
+
// 登录入口判定:UI 登录卡文案需要;托管实例不显示登录入口,跳过查询
|
|
909
|
+
const loginProvider = isInstanceManaged() ? 'feishu' : await resolveLoginProvider(config.platformUrl, log);
|
|
883
910
|
sendJson(response, 200, {
|
|
884
911
|
platform_url: config.platformUrl,
|
|
885
912
|
logged_in: Boolean(userEmail),
|
|
886
913
|
user_email: userEmail,
|
|
914
|
+
login_provider: loginProvider,
|
|
887
915
|
// 托管实例:凭据由平台注入,页面据此显示「平台托管」并隐藏登录/登出
|
|
888
916
|
managed: isInstanceManaged(),
|
|
889
917
|
source: credentials?.source ?? null,
|
|
@@ -940,7 +968,12 @@ export function apply(ctx, config) {
|
|
|
940
968
|
}
|
|
941
969
|
const state = newLoginState();
|
|
942
970
|
pendingStates.add(state);
|
|
943
|
-
|
|
971
|
+
// 入口按平台能力面路由:IDOne(锚点模式)优先,飞书兜底
|
|
972
|
+
const provider = await resolveLoginProvider(config.platformUrl, log);
|
|
973
|
+
sendJson(response, 200, {
|
|
974
|
+
login_url: buildLoginUrl(config.platformUrl, callbackUrl, state, provider),
|
|
975
|
+
provider,
|
|
976
|
+
});
|
|
944
977
|
} catch (error) {
|
|
945
978
|
log.warn(`[dsh-agentone] login failed: ${error?.message || error}`);
|
|
946
979
|
sendJson(response, 500, { error: '发起登录失败' });
|
|
@@ -1030,7 +1063,7 @@ export function apply(ctx, config) {
|
|
|
1030
1063
|
try {
|
|
1031
1064
|
const credentials = await ensureFreshToken(config, log);
|
|
1032
1065
|
if (!credentials) {
|
|
1033
|
-
sendJson(response, 401, { error: '
|
|
1066
|
+
sendJson(response, 401, { error: '请先完成 AgentOne 登录' });
|
|
1034
1067
|
return;
|
|
1035
1068
|
}
|
|
1036
1069
|
const result = await syncPlatformSkillhub(config, log, credentials);
|
|
@@ -1049,7 +1082,7 @@ export function apply(ctx, config) {
|
|
|
1049
1082
|
try {
|
|
1050
1083
|
const credentials = await ensureFreshToken(config, log);
|
|
1051
1084
|
if (!credentials) {
|
|
1052
|
-
sendJson(response, 401, { error: '
|
|
1085
|
+
sendJson(response, 401, { error: '请先完成 AgentOne 登录' });
|
|
1053
1086
|
return;
|
|
1054
1087
|
}
|
|
1055
1088
|
const body = await readJsonBody(request);
|
|
@@ -1082,7 +1115,7 @@ export function apply(ctx, config) {
|
|
|
1082
1115
|
try {
|
|
1083
1116
|
const credentials = await ensureFreshToken(config, log);
|
|
1084
1117
|
if (!credentials) {
|
|
1085
|
-
sendJson(response, 401, { error: '
|
|
1118
|
+
sendJson(response, 401, { error: '请先完成 AgentOne 登录' });
|
|
1086
1119
|
return;
|
|
1087
1120
|
}
|
|
1088
1121
|
const result = await syncPlanSkills(config, log, credentials);
|
|
@@ -1102,7 +1135,7 @@ export function apply(ctx, config) {
|
|
|
1102
1135
|
try {
|
|
1103
1136
|
const credentials = await ensureFreshToken(config, log);
|
|
1104
1137
|
if (!credentials) {
|
|
1105
|
-
sendJson(response, 401, { error: '
|
|
1138
|
+
sendJson(response, 401, { error: '请先完成 AgentOne 登录' });
|
|
1106
1139
|
return;
|
|
1107
1140
|
}
|
|
1108
1141
|
const query = new URL(request.url, 'http://localhost').searchParams.get('q') || '';
|
|
@@ -1136,7 +1169,7 @@ export function apply(ctx, config) {
|
|
|
1136
1169
|
try {
|
|
1137
1170
|
const credentials = await loadCredentials(config);
|
|
1138
1171
|
if (!credentials) {
|
|
1139
|
-
sendJson(response, 401, { error: '
|
|
1172
|
+
sendJson(response, 401, { error: '请先完成 AgentOne 登录' });
|
|
1140
1173
|
return;
|
|
1141
1174
|
}
|
|
1142
1175
|
const body = await readJsonBody(request);
|
|
@@ -1583,7 +1616,7 @@ export function apply(ctx, config) {
|
|
|
1583
1616
|
try {
|
|
1584
1617
|
const credentials = await loadCredentials(config);
|
|
1585
1618
|
if (!credentials) {
|
|
1586
|
-
sendJson(response, 401, { error: '
|
|
1619
|
+
sendJson(response, 401, { error: '请先完成 AgentOne 登录' });
|
|
1587
1620
|
return;
|
|
1588
1621
|
}
|
|
1589
1622
|
const plan = await fetchPlatformPlan(config.platformUrl, credentials.access_token);
|
|
@@ -1602,7 +1635,7 @@ export function apply(ctx, config) {
|
|
|
1602
1635
|
try {
|
|
1603
1636
|
const credentials = await loadCredentials(config);
|
|
1604
1637
|
if (!credentials) {
|
|
1605
|
-
sendJson(response, 401, { error: '
|
|
1638
|
+
sendJson(response, 401, { error: '请先完成 AgentOne 登录' });
|
|
1606
1639
|
return;
|
|
1607
1640
|
}
|
|
1608
1641
|
const body = await readJsonBody(request);
|
package/lib/instance.js
CHANGED
package/lib/model.js
CHANGED
|
@@ -145,6 +145,90 @@ export function buildLogoutSettingsYaml(existingText) {
|
|
|
145
145
|
return stringify(settings);
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
// 模型选择器展示顺序:同一系列(模型 id 首段,如 deepseek / glm / kimi)聚簇,
|
|
149
|
+
// 系列内版本降序,容量降配档(kimi-k3-256k)排在本版本标准档之后。
|
|
150
|
+
//
|
|
151
|
+
// 为什么插件也要排一遍:平台 /api/plugin/v1/models 的 data 顺序在旧版平台上
|
|
152
|
+
// 直接沿用 AirRouter /models 的返回顺序(上游一变用户看到的列表就跟着变),
|
|
153
|
+
// 平台侧的排序修复要等部署才生效。插件把平台返回的列表按同一套规则重排后
|
|
154
|
+
// 再写进 dsh,旧平台也能拿到稳定顺序。规则与
|
|
155
|
+
// app/services/dsh_model_capabilities.py 的 sort_models_by_version 完全一致
|
|
156
|
+
// (系列聚簇 + 数字段补零取负降序 + 字母段字母序 + 容量段垫底),
|
|
157
|
+
// 平台已经排好时这里是幂等的,不会出现两边顺序漂移。
|
|
158
|
+
//
|
|
159
|
+
// 只改展示顺序:defaultModel 仍取平台下发的 default_model,排序不参与决策
|
|
160
|
+
//(平台未下发时回退列表首项,此时取排序后的首项,比上游原序更确定)。
|
|
161
|
+
const VERSION_SLOTS = 6;
|
|
162
|
+
const VERSION_NUMBERS = /\d+/g;
|
|
163
|
+
// 容量限定段(256k / 1m):同版本的降配档,不是更新的版本号。
|
|
164
|
+
// 必须连着字母一起匹配:VERSION_NUMBERS 切出来的只有数字部分。
|
|
165
|
+
const CAPACITY_QUALIFIER = /\d+(?:k|m|b)\b/g;
|
|
166
|
+
|
|
167
|
+
/** 模型 id → 系列键(id 首段小写;没有连字符的裸 id 整体作为分组键)。 */
|
|
168
|
+
function modelFamily(id) {
|
|
169
|
+
const lowered = String(id ?? '').trim().toLowerCase();
|
|
170
|
+
const at = lowered.indexOf('-');
|
|
171
|
+
return at > 0 ? lowered.slice(0, at) : lowered;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 系列内的版本比较键:数字段取负(升序即版本降序)、字母段、容量降配标记。 */
|
|
175
|
+
function versionKey(id) {
|
|
176
|
+
const lowered = String(id ?? '').trim().toLowerCase();
|
|
177
|
+
const capacityHits = lowered.match(CAPACITY_QUALIFIER) ?? [];
|
|
178
|
+
const numbers = (lowered.replace(CAPACITY_QUALIFIER, ' ').match(VERSION_NUMBERS) ?? [])
|
|
179
|
+
.slice(0, VERSION_SLOTS)
|
|
180
|
+
.map((part) => -Number(part));
|
|
181
|
+
while (numbers.length < VERSION_SLOTS) numbers.push(0);
|
|
182
|
+
return {
|
|
183
|
+
numbers,
|
|
184
|
+
words: lowered.split('-').filter((part) => part && !/^\d+$/.test(part)),
|
|
185
|
+
downgraded: capacityHits.length > 0,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Python 字符串/元组比较口径(逐码点),与平台 sorted() 的结果对齐。 */
|
|
190
|
+
function compareStrings(left, right) {
|
|
191
|
+
if (left === right) return 0;
|
|
192
|
+
return left < right ? -1 : 1;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function compareVersionKeys(left, right) {
|
|
196
|
+
for (let index = 0; index < VERSION_SLOTS; index += 1) {
|
|
197
|
+
if (left.numbers[index] !== right.numbers[index]) return left.numbers[index] - right.numbers[index];
|
|
198
|
+
}
|
|
199
|
+
const shared = Math.min(left.words.length, right.words.length);
|
|
200
|
+
for (let index = 0; index < shared; index += 1) {
|
|
201
|
+
const compared = compareStrings(left.words[index], right.words[index]);
|
|
202
|
+
if (compared !== 0) return compared;
|
|
203
|
+
}
|
|
204
|
+
if (left.words.length !== right.words.length) return left.words.length - right.words.length;
|
|
205
|
+
if (left.downgraded !== right.downgraded) return left.downgraded ? 1 : -1;
|
|
206
|
+
return 0;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* 按系列聚簇、系列内版本降序排模型条目(只改展示顺序,不改集合成员)。
|
|
211
|
+
*
|
|
212
|
+
* 入参可以是平台条目对象(取 .id)或字符串 id;排序是确定性的,同样的输入
|
|
213
|
+
* 必得同样的输出,完全不依赖平台 / AirRouter 的返回顺序。
|
|
214
|
+
*/
|
|
215
|
+
export function sortPlatformModels(models) {
|
|
216
|
+
const entries = (Array.isArray(models) ? models : []).map((item, index) => ({
|
|
217
|
+
item,
|
|
218
|
+
index,
|
|
219
|
+
id: typeof item === 'string' ? item : String(item?.id ?? ''),
|
|
220
|
+
}));
|
|
221
|
+
entries.sort(
|
|
222
|
+
(left, right) =>
|
|
223
|
+
compareStrings(modelFamily(left.id), modelFamily(right.id))
|
|
224
|
+
// 原始下标兜底:与 Python sorted 的稳定性同口径,完全相同键保持原序
|
|
225
|
+
|| compareVersionKeys(versionKey(left.id), versionKey(right.id))
|
|
226
|
+
|| compareStrings(left.id.trim().toLowerCase(), right.id.trim().toLowerCase())
|
|
227
|
+
|| left.index - right.index,
|
|
228
|
+
);
|
|
229
|
+
return entries.map((entry) => entry.item);
|
|
230
|
+
}
|
|
231
|
+
|
|
148
232
|
export async function fetchPlatformModels(platformUrl, accessToken) {
|
|
149
233
|
const response = await platformFetch(`${platformUrl.replace(/\/+$/, '')}/api/plugin/v1/models`, {
|
|
150
234
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
@@ -155,17 +239,19 @@ export async function fetchPlatformModels(platformUrl, accessToken) {
|
|
|
155
239
|
throw await platformError(response, '获取平台模型列表失败');
|
|
156
240
|
}
|
|
157
241
|
const data = await response.json().catch(() => ({}));
|
|
158
|
-
const models =
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
242
|
+
const models = sortPlatformModels(
|
|
243
|
+
Array.isArray(data.data)
|
|
244
|
+
? data.data
|
|
245
|
+
.map((item) =>
|
|
246
|
+
typeof item === 'string'
|
|
247
|
+
? item
|
|
248
|
+
: item?.id
|
|
249
|
+
? { id: item.id, input: item.input, reasoning: item.reasoning, contextWindow: item.contextWindow }
|
|
250
|
+
: null,
|
|
251
|
+
)
|
|
252
|
+
.filter(Boolean)
|
|
253
|
+
: [],
|
|
254
|
+
);
|
|
169
255
|
if (models.length === 0) throw new Error('平台返回的可用模型列表为空');
|
|
170
256
|
const direct =
|
|
171
257
|
data.direct && typeof data.direct.base_url === 'string' && typeof data.direct.api_key === 'string'
|
package/lib/page.js
CHANGED
|
@@ -1115,7 +1115,7 @@ const renderAbout = (s) => {
|
|
|
1115
1115
|
const lines = [
|
|
1116
1116
|
aboutVersionRow(),
|
|
1117
1117
|
'<div class="kv"><span class="k">登录账号</span><span class="v">' + esc(s.user_email || '未登录') + '</span></div>',
|
|
1118
|
-
'<div class="kv"><span class="k">授权方式</span><span class="v muted">' + (s.managed ? '平台托管(实例内自动授权,无需登录)' : '飞书登录') + '</span></div>',
|
|
1118
|
+
'<div class="kv"><span class="k">授权方式</span><span class="v muted">' + (s.managed ? '平台托管(实例内自动授权,无需登录)' : (s.login_provider === 'idone' ? 'IDOne 登录' : '飞书登录')) + '</span></div>',
|
|
1119
1119
|
'<div class="kv"><span class="k">所属套餐</span><span class="v">' + esc(s.capabilities?.plan_name || '—') + '</span></div>',
|
|
1120
1120
|
'<div class="kv"><span class="k">平台地址</span><span class="v muted">' + esc(s.platform_url || '—') + '</span></div>',
|
|
1121
1121
|
'<div class="kv"><span class="k">开发者</span><span class="v"><a href="mailto:chumingjun@cc-pg.cn">chumingjun@cc-pg.cn</a></span></div>',
|
|
@@ -1173,6 +1173,16 @@ const syncModelNow = (btn) => withLoading(
|
|
|
1173
1173
|
},
|
|
1174
1174
|
null,
|
|
1175
1175
|
);
|
|
1176
|
+
// 登录入口文案:平台统一登录(IDOne)时覆盖静态默认的飞书文案。静态默认
|
|
1177
|
+
// 「飞书登录」保留——capabilities 失败回退飞书的场景它就是正确文案(smoke
|
|
1178
|
+
// 也断言状态页 HTML 含该默认值)。已点「发起登录」的按钮不回写(dataset.opened)。
|
|
1179
|
+
const applyLoginProvider = (s) => {
|
|
1180
|
+
if (s?.login_provider !== 'idone') return;
|
|
1181
|
+
const btn = $('login-btn');
|
|
1182
|
+
if (btn && !btn.dataset.opened) btn.textContent = 'IDOne 登录';
|
|
1183
|
+
const sub = loginCard.querySelector('p:not(.big)');
|
|
1184
|
+
if (sub) sub.textContent = 'IDOne 账号一键授权,无需任何 API Key;套餐审批通过后模型自动配置。';
|
|
1185
|
+
};
|
|
1176
1186
|
const refresh = async () => {
|
|
1177
1187
|
let s;
|
|
1178
1188
|
try {
|
|
@@ -1206,6 +1216,7 @@ const refresh = async () => {
|
|
|
1206
1216
|
bootCard.hidden = true;
|
|
1207
1217
|
loginCard.hidden = s.logged_in;
|
|
1208
1218
|
mainCards.hidden = !s.logged_in;
|
|
1219
|
+
applyLoginProvider(s);
|
|
1209
1220
|
// 托管态不给登出按钮:授权来自容器 env,登出既清不掉根因、重启又会自动登录
|
|
1210
1221
|
logoutBtn.hidden = !s.logged_in || Boolean(s.managed);
|
|
1211
1222
|
if (s.logged_in && s.model) {
|
|
@@ -1286,7 +1297,9 @@ $('login-btn').onclick = async () => {
|
|
|
1286
1297
|
if (!resp.ok) { notify(data.error || '发起登录失败', 'err'); return; }
|
|
1287
1298
|
// 新标签页(顶层上下文)打开:iframe 内跨源 localStorage 被浏览器分区隔离
|
|
1288
1299
|
window.open(data.login_url, '_blank');
|
|
1289
|
-
// 按钮保持可点:授权窗被用户关掉后可立即重新发起,无需等 2
|
|
1300
|
+
// 按钮保持可点:授权窗被用户关掉后可立即重新发起,无需等 2 分钟;
|
|
1301
|
+
// 标记已发起,避免 5s 轮询的 applyLoginProvider 把提示文案改回入口名
|
|
1302
|
+
btn.dataset.opened = '1';
|
|
1290
1303
|
btn.textContent = '已打开授权窗口,完成后自动继续(可重开)';
|
|
1291
1304
|
};
|
|
1292
1305
|
$('sync-btn').onclick = () => syncModelNow($('sync-btn'));
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-agentone",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"description": "AgentOne
|
|
3
|
+
"version": "0.5.38",
|
|
4
|
+
"description": "AgentOne 平台集成:IDOne 登录(飞书兜底)、平台模型、SkillHub 技能、套餐与 CLI;lark-cli 首装一键初始化飞书应用;平台托管实例免登录自动授权",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"smoke": "node scripts/env-matrix-smoke.mjs"
|