dsh-offpeak 3.1.6 → 3.1.7

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.
@@ -1,116 +1,116 @@
1
- /**
2
- * dsh-offpeak — 「高峰拦截列表」的纯逻辑(零 React、零依赖,可单测)。
3
- *
4
- * 与 host 判定**同源**:列表项(`officialModelPatterns`)是**模型名**,匹配语义为**精确匹配**
5
- * (大小写不敏感、去首尾空白;v3.1.6 起不再有关键字/正则的包含匹配)。这里只做四件事:
6
- * 1. 某模型名是否在列表里(= 是否被拦);
7
- * 2. 单行勾选 / 取消勾选 → 新的列表;
8
- * 3. 关键词过滤(只影响展示与「全选」范围);
9
- * 4. 批量勾选 → 新的列表。
10
- *
11
- * **判定与存储逻辑都不在这里**:勾选只是往同一份列表里增删**同名条目**。
12
- */
13
-
14
- /** 一行的判定状态。 */
15
- export interface ModelRowState {
16
- /** 命中的列表项(精确匹配下最多一项)。 */
17
- matched: string[]
18
- /** 列表里存在与模型名**完全相同**的条目(= 勾选状态)。 */
19
- explicit: boolean
20
- /** 是否已被拦截(精确匹配下等价于 `explicit`)。 */
21
- covered: boolean
22
- }
23
-
24
- /** 单个列表项是否命中模型名:**精确匹配**(大小写不敏感、去首尾空白)。 */
25
- export function matchesPattern(model: string, pattern: string): boolean {
26
- if (typeof model !== 'string' || typeof pattern !== 'string') return false
27
- const a = model.trim().toLowerCase()
28
- const b = pattern.trim().toLowerCase()
29
- return a !== '' && b !== '' && a === b
30
- }
31
-
32
- /** 算出某模型名在给定列表下的行状态。 */
33
- export function rowState(model: string, patterns: readonly string[]): ModelRowState {
34
- const list = Array.isArray(patterns) ? patterns : []
35
- const matched = list.filter((p) => matchesPattern(model, p))
36
- return { matched, explicit: matched.length > 0, covered: matched.length > 0 }
37
- }
38
-
39
- /** 给一行生成提示文案。 */
40
- export function rowHint(state: ModelRowState): string {
41
- if (state.explicit) return '已拦截'
42
- return ''
43
- }
44
-
45
- /**
46
- * 勾选/取消勾选**单个**模型 → 新列表。
47
- *
48
- * - 已存在**完全同名**条目 → 删掉它(取消勾选;若仍有更宽的关键字覆盖,行会变成
49
- * 只读并提示是哪个关键字盖着 —— 那种情况需要改关键字,而不是逐个模型取消);
50
- * - 否则 → 把模型名作为一个新条目追加。
51
- *
52
- * 纯函数:不修改入参,返回新数组(去重、保持原有顺序)。
53
- */
54
- export function toggleModel(patterns: readonly string[], model: string): string[] {
55
- const list = Array.isArray(patterns) ? [...patterns] : []
56
- if (typeof model !== 'string' || model === '') return list
57
- const at = list.indexOf(model)
58
- if (at !== -1) {
59
- list.splice(at, 1)
60
- return list
61
- }
62
- if (!list.includes(model)) list.push(model)
63
- return list
64
- }
65
-
66
- /** 模型行(面板与纯函数共用的最小形状)。 */
67
- export interface ModelLike {
68
- provider: string
69
- model: string
70
- }
71
-
72
- /**
73
- * 关键词过滤:大小写不敏感的**子串**匹配,命中范围是 `provider/model`(只按 model 也能命中)。
74
- *
75
- * 只作用于**展示**与「全选」范围 —— 模型集合、拦截列表本身都不变(空关键词 = 不过滤)。
76
- */
77
- export function filterRows<T extends ModelLike>(rows: readonly T[], keyword: string): T[] {
78
- const list = Array.isArray(rows) ? rows : []
79
- const q = typeof keyword === 'string' ? keyword.trim().toLowerCase() : ''
80
- if (q === '') return [...list]
81
- return list.filter((r) => `${r.provider}/${r.model}`.toLowerCase().includes(q))
82
- }
83
-
84
- /** 当前过滤结果是否已**全部**被拦截(决定「全选」按钮的文案与动作)。 */
85
- export function allCovered(patterns: readonly string[], rows: readonly ModelLike[]): boolean {
86
- const list = Array.isArray(rows) ? rows : []
87
- if (list.length === 0) return false
88
- return list.every((r) => rowState(r.model, patterns).covered)
89
- }
90
-
91
- /**
92
- * 批量勾选:对**传入的这些行**(= 当前过滤结果)执行全选 / 取消全选。
93
- *
94
- * - `checked = true`:把还没被覆盖的模型名作为显式条目补进列表(已有条目不动);
95
- * - `checked = false`:把与这些模型名**完全同名**的条目删掉(被更宽关键字盖住的行仍会被拦,
96
- * 与单行勾选的语义一致 —— 那种情况要改关键字)。
97
- *
98
- * 纯函数:不修改入参,返回新数组。
99
- */
100
- export function bulkToggle(patterns: readonly string[], rows: readonly ModelLike[], checked: boolean): string[] {
101
- const before = Array.isArray(patterns) ? patterns : []
102
- const list = [...before]
103
- const models = (Array.isArray(rows) ? rows : []).map((r) => r.model)
104
- if (checked) {
105
- for (const m of models) {
106
- if (typeof m !== 'string' || m === '') continue
107
- // 已被(关键字或显式条目)拦住的模型不重复追加 —— 避免把列表写成一堆冗余条目。
108
- // 覆盖判定用**进入本函数时**的列表:语义稳定(「给当前未覆盖的过滤结果补显式条目」)。
109
- if (rowState(m, before).covered) continue
110
- if (!list.includes(m)) list.push(m)
111
- }
112
- return list
113
- }
114
- const drop = new Set(models)
115
- return list.filter((p) => !drop.has(p))
116
- }
1
+ /**
2
+ * dsh-offpeak — 「高峰拦截列表」的纯逻辑(零 React、零依赖,可单测)。
3
+ *
4
+ * 与 host 判定**同源**:列表项(`officialModelPatterns`)是**模型名**,匹配语义为**精确匹配**
5
+ * (大小写不敏感、去首尾空白;v3.1.6 起不再有关键字/正则的包含匹配)。这里只做四件事:
6
+ * 1. 某模型名是否在列表里(= 是否被拦);
7
+ * 2. 单行勾选 / 取消勾选 → 新的列表;
8
+ * 3. 关键词过滤(只影响展示与「全选」范围);
9
+ * 4. 批量勾选 → 新的列表。
10
+ *
11
+ * **判定与存储逻辑都不在这里**:勾选只是往同一份列表里增删**同名条目**。
12
+ */
13
+
14
+ /** 一行的判定状态。 */
15
+ export interface ModelRowState {
16
+ /** 命中的列表项(精确匹配下最多一项)。 */
17
+ matched: string[]
18
+ /** 列表里存在与模型名**完全相同**的条目(= 勾选状态)。 */
19
+ explicit: boolean
20
+ /** 是否已被拦截(精确匹配下等价于 `explicit`)。 */
21
+ covered: boolean
22
+ }
23
+
24
+ /** 单个列表项是否命中模型名:**精确匹配**(大小写不敏感、去首尾空白)。 */
25
+ export function matchesPattern(model: string, pattern: string): boolean {
26
+ if (typeof model !== 'string' || typeof pattern !== 'string') return false
27
+ const a = model.trim().toLowerCase()
28
+ const b = pattern.trim().toLowerCase()
29
+ return a !== '' && b !== '' && a === b
30
+ }
31
+
32
+ /** 算出某模型名在给定列表下的行状态。 */
33
+ export function rowState(model: string, patterns: readonly string[]): ModelRowState {
34
+ const list = Array.isArray(patterns) ? patterns : []
35
+ const matched = list.filter((p) => matchesPattern(model, p))
36
+ return { matched, explicit: matched.length > 0, covered: matched.length > 0 }
37
+ }
38
+
39
+ /** 给一行生成提示文案。 */
40
+ export function rowHint(state: ModelRowState): string {
41
+ if (state.explicit) return '已拦截'
42
+ return ''
43
+ }
44
+
45
+ /**
46
+ * 勾选/取消勾选**单个**模型 → 新列表。
47
+ *
48
+ * - 已存在**完全同名**条目 → 删掉它(取消勾选;若仍有更宽的关键字覆盖,行会变成
49
+ * 只读并提示是哪个关键字盖着 —— 那种情况需要改关键字,而不是逐个模型取消);
50
+ * - 否则 → 把模型名作为一个新条目追加。
51
+ *
52
+ * 纯函数:不修改入参,返回新数组(去重、保持原有顺序)。
53
+ */
54
+ export function toggleModel(patterns: readonly string[], model: string): string[] {
55
+ const list = Array.isArray(patterns) ? [...patterns] : []
56
+ if (typeof model !== 'string' || model === '') return list
57
+ const at = list.indexOf(model)
58
+ if (at !== -1) {
59
+ list.splice(at, 1)
60
+ return list
61
+ }
62
+ if (!list.includes(model)) list.push(model)
63
+ return list
64
+ }
65
+
66
+ /** 模型行(面板与纯函数共用的最小形状)。 */
67
+ export interface ModelLike {
68
+ provider: string
69
+ model: string
70
+ }
71
+
72
+ /**
73
+ * 关键词过滤:大小写不敏感的**子串**匹配,命中范围是 `provider/model`(只按 model 也能命中)。
74
+ *
75
+ * 只作用于**展示**与「全选」范围 —— 模型集合、拦截列表本身都不变(空关键词 = 不过滤)。
76
+ */
77
+ export function filterRows<T extends ModelLike>(rows: readonly T[], keyword: string): T[] {
78
+ const list = Array.isArray(rows) ? rows : []
79
+ const q = typeof keyword === 'string' ? keyword.trim().toLowerCase() : ''
80
+ if (q === '') return [...list]
81
+ return list.filter((r) => `${r.provider}/${r.model}`.toLowerCase().includes(q))
82
+ }
83
+
84
+ /** 当前过滤结果是否已**全部**被拦截(决定「全选」按钮的文案与动作)。 */
85
+ export function allCovered(patterns: readonly string[], rows: readonly ModelLike[]): boolean {
86
+ const list = Array.isArray(rows) ? rows : []
87
+ if (list.length === 0) return false
88
+ return list.every((r) => rowState(r.model, patterns).covered)
89
+ }
90
+
91
+ /**
92
+ * 批量勾选:对**传入的这些行**(= 当前过滤结果)执行全选 / 取消全选。
93
+ *
94
+ * - `checked = true`:把还没被覆盖的模型名作为显式条目补进列表(已有条目不动);
95
+ * - `checked = false`:把与这些模型名**完全同名**的条目删掉(被更宽关键字盖住的行仍会被拦,
96
+ * 与单行勾选的语义一致 —— 那种情况要改关键字)。
97
+ *
98
+ * 纯函数:不修改入参,返回新数组。
99
+ */
100
+ export function bulkToggle(patterns: readonly string[], rows: readonly ModelLike[], checked: boolean): string[] {
101
+ const before = Array.isArray(patterns) ? patterns : []
102
+ const list = [...before]
103
+ const models = (Array.isArray(rows) ? rows : []).map((r) => r.model)
104
+ if (checked) {
105
+ for (const m of models) {
106
+ if (typeof m !== 'string' || m === '') continue
107
+ // 已被(关键字或显式条目)拦住的模型不重复追加 —— 避免把列表写成一堆冗余条目。
108
+ // 覆盖判定用**进入本函数时**的列表:语义稳定(「给当前未覆盖的过滤结果补显式条目」)。
109
+ if (rowState(m, before).covered) continue
110
+ if (!list.includes(m)) list.push(m)
111
+ }
112
+ return list
113
+ }
114
+ const drop = new Set(models)
115
+ return list.filter((p) => !drop.has(p))
116
+ }
@@ -1,185 +1,185 @@
1
- /**
2
- * dsh-offpeak — 「高峰拦截列表」面板(状态徽标展开区)。
3
- *
4
- * 形态(v3.1.6):
5
- * - 列表 = DSH 当前全部模型(`GET /session-guard/models`:各路由自报 + 本插件观察到的真实目标),
6
- * 每行一个模型、**复选框在行右侧**、按 provider 分组;
7
- * - 顶部**关键词过滤框** + **全选 / 取消全选**(只作用于当前过滤结果)+ **刷新**(重拉模型列表)
8
- * + **热重载**(先让 host 重读配置文件,再拉一次);
9
- * - **已拦截**:勾选即该模型名进列表(`POST /session-guard/rpc {action:'setModelPatterns'}` →
10
- * 写 `config/offpeak.json` 并立即 reload;0.1.7 客户端没有 settingsScope,这是本线唯一
11
- * 写入口)。列表项**精确匹配模型名**(大小写不敏感),没有关键字/正则的批量覆盖,
12
- * 因此每一项都能用它的 `×` **撤销**。
13
- *
14
- * 行状态与过滤/批量逻辑都在 `model-list.ts`(纯函数、可单测);host 侧判定逻辑不变。
15
- */
16
- import { useEffect, useState } from 'react'
17
- import type { JSX } from 'react'
18
- import { allCovered, bulkToggle, filterRows, rowState, toggleModel } from './model-list'
19
-
20
- /** `GET /session-guard/models` 的一行。 */
21
- export interface CatalogModel {
22
- provider: string
23
- model: string
24
- source?: string
25
- }
26
-
27
- export function ModelListPanel(): JSX.Element {
28
- const [loading, setLoading] = useState(true)
29
- const [models, setModels] = useState<readonly CatalogModel[]>([])
30
- const [patterns, setPatterns] = useState<readonly string[]>([])
31
- const [error, setError] = useState<string | null>(null)
32
- const [writeError, setWriteError] = useState<string | null>(null)
33
- const [keyword, setKeyword] = useState('')
34
- const [pending, setPending] = useState(false)
35
-
36
- const load = async () => {
37
- setLoading(true)
38
- setError(null)
39
- const [modelsRes, settingsRes] = await Promise.allSettled([
40
- fetch('/session-guard/models').then((r) => r.json()),
41
- fetch('/session-guard/settings').then((r) => r.json()),
42
- ])
43
- const modelsBody = modelsRes.status === 'fulfilled' ? modelsRes.value : null
44
- if (modelsBody?.ok && Array.isArray(modelsBody.models)) setModels(modelsBody.models)
45
- else setError('模型列表不可用(host 需为 v3.1.5+:重启 DSH 后 /session-guard/models 才存在)')
46
-
47
- const settingsBody = settingsRes.status === 'fulfilled' ? settingsRes.value : null
48
- const list = settingsBody?.settings?.officialModelPatterns
49
- if (Array.isArray(list)) setPatterns(list)
50
- setLoading(false)
51
- }
52
-
53
- /** 热重载:让 host 重读配置文件,然后重拉列表与模型目录。 */
54
- const hotReload = async () => {
55
- try {
56
- await fetch('/session-guard/rpc', {
57
- method: 'POST',
58
- headers: { 'content-type': 'application/json' },
59
- body: JSON.stringify({ action: 'reloadConfig' }),
60
- })
61
- } catch {
62
- // fail-open:重读失败也照常重拉一次
63
- }
64
- await load()
65
- }
66
-
67
- useEffect(() => {
68
- // 只在挂载(= 展开)时拉一次;按钮负责后续更新。
69
- void load()
70
- }, [])
71
-
72
- /** 写回拦截列表(本线:写配置文件 + reload;失败只提示,不改本地展示)。 */
73
- const commit = async (next: string[]) => {
74
- setPending(true)
75
- setWriteError(null)
76
- const before = patterns
77
- setPatterns(next) // 乐观更新,失败时回滚
78
- try {
79
- const res = await fetch('/session-guard/rpc', {
80
- method: 'POST',
81
- headers: { 'content-type': 'application/json' },
82
- body: JSON.stringify({ action: 'setModelPatterns', patterns: next }),
83
- })
84
- const body = await res.json().catch(() => null)
85
- if (!res.ok || body?.ok !== true) {
86
- setPatterns(before)
87
- setWriteError(String(body?.error ?? `写入失败(HTTP ${res.status})`))
88
- } else if (Array.isArray(body.result?.patterns)) {
89
- setPatterns(body.result.patterns)
90
- }
91
- } catch (e) {
92
- setPatterns(before)
93
- setWriteError(`写入失败:${e instanceof Error ? e.message : String(e)}`)
94
- } finally {
95
- setPending(false)
96
- }
97
- }
98
-
99
- const filtered = filterRows(models, keyword)
100
- const providers = [...new Set(filtered.map((r) => r.provider))].sort()
101
- const everyCovered = allCovered(patterns, filtered)
102
-
103
- return (
104
- <div className="sgMlBody">
105
- <div className="sgMlFilterRow">
106
- <input
107
- className="sgMlInput"
108
- type="text"
109
- value={keyword}
110
- placeholder="过滤模型(provider/model 子串)"
111
- onChange={(e) => setKeyword(e.currentTarget.value)}
112
- />
113
- <button
114
- type="button"
115
- className="sgMlBtn"
116
- disabled={pending || filtered.length === 0}
117
- onClick={() => void commit(bulkToggle(patterns, filtered, !everyCovered))}
118
- title="只作用于当前过滤结果"
119
- >
120
- {everyCovered ? '取消全选' : '全选'}
121
- </button>
122
- <button type="button" className="sgMlBtn" disabled={loading} onClick={() => void load()} title="重新获取模型列表">
123
- {loading ? '获取中…' : '刷新'}
124
- </button>
125
- <button type="button" className="sgMlBtn" disabled={loading} onClick={() => void hotReload()} title="重读配置文件后再拉一次">
126
- 热重载
127
- </button>
128
- </div>
129
- {patterns.length > 0 && (
130
- <div className="sgMlPatterns">
131
- <span className="sgMlSentinel">已拦截</span>
132
- {patterns.map((p) => (
133
- <span key={p} className="sgMlChip">
134
- {p}
135
- <button
136
- type="button"
137
- className="sgMlChipUndo"
138
- disabled={pending}
139
- title="撤销这一项"
140
- aria-label={`撤销 ${p}`}
141
- onClick={() => void commit(patterns.filter((x) => x !== p))}
142
- >
143
- ×
144
- </button>
145
- </span>
146
- ))}
147
- </div>
148
- )}
149
- {writeError !== null && <p className="sgMlError">✗ {writeError}</p>}
150
- {error !== null && <p className="sgMlHint">{error}</p>}
151
- {error === null && !loading && filtered.length === 0 && (
152
- <p className="sgMlHint">{models.length === 0 ? '没有可枚举的模型。' : '没有匹配的模型。'}</p>
153
- )}
154
- {filtered.length > 0 && (
155
- <ul className="sgMlList">
156
- {providers.map((provider) => (
157
- <li key={provider}>
158
- <div className="sgMlProvider">{provider}</div>
159
- <ul className="sgMlList">
160
- {filtered
161
- .filter((r) => r.provider === provider)
162
- .map((r) => {
163
- const state = rowState(r.model, patterns)
164
- return (
165
- <li key={`${provider}/${r.model}`} className="sgMlItem">
166
- <label className="sgMlCheck" title="勾选 = 加入高峰拦截列表">
167
- <span className="sgMlModel">{r.model}</span>
168
- <input
169
- type="checkbox"
170
- checked={state.covered}
171
- disabled={pending}
172
- onChange={() => void commit(toggleModel(patterns, r.model))}
173
- />
174
- </label>
175
- </li>
176
- )
177
- })}
178
- </ul>
179
- </li>
180
- ))}
181
- </ul>
182
- )}
183
- </div>
184
- )
185
- }
1
+ /**
2
+ * dsh-offpeak — 「高峰拦截列表」面板(状态徽标展开区)。
3
+ *
4
+ * 形态(v3.1.6):
5
+ * - 列表 = DSH 当前全部模型(`GET /session-guard/models`:各路由自报 + 本插件观察到的真实目标),
6
+ * 每行一个模型、**复选框在行右侧**、按 provider 分组;
7
+ * - 顶部**关键词过滤框** + **全选 / 取消全选**(只作用于当前过滤结果)+ **刷新**(重拉模型列表)
8
+ * + **热重载**(先让 host 重读配置文件,再拉一次);
9
+ * - **已拦截**:勾选即该模型名进列表(`POST /session-guard/rpc {action:'setModelPatterns'}` →
10
+ * 写 `config/offpeak.json` 并立即 reload;0.1.7 客户端没有 settingsScope,这是本线唯一
11
+ * 写入口)。列表项**精确匹配模型名**(大小写不敏感),没有关键字/正则的批量覆盖,
12
+ * 因此每一项都能用它的 `×` **撤销**。
13
+ *
14
+ * 行状态与过滤/批量逻辑都在 `model-list.ts`(纯函数、可单测);host 侧判定逻辑不变。
15
+ */
16
+ import { useEffect, useState } from 'react'
17
+ import type { JSX } from 'react'
18
+ import { allCovered, bulkToggle, filterRows, rowState, toggleModel } from './model-list'
19
+
20
+ /** `GET /session-guard/models` 的一行。 */
21
+ export interface CatalogModel {
22
+ provider: string
23
+ model: string
24
+ source?: string
25
+ }
26
+
27
+ export function ModelListPanel(): JSX.Element {
28
+ const [loading, setLoading] = useState(true)
29
+ const [models, setModels] = useState<readonly CatalogModel[]>([])
30
+ const [patterns, setPatterns] = useState<readonly string[]>([])
31
+ const [error, setError] = useState<string | null>(null)
32
+ const [writeError, setWriteError] = useState<string | null>(null)
33
+ const [keyword, setKeyword] = useState('')
34
+ const [pending, setPending] = useState(false)
35
+
36
+ const load = async () => {
37
+ setLoading(true)
38
+ setError(null)
39
+ const [modelsRes, settingsRes] = await Promise.allSettled([
40
+ fetch('/session-guard/models').then((r) => r.json()),
41
+ fetch('/session-guard/settings').then((r) => r.json()),
42
+ ])
43
+ const modelsBody = modelsRes.status === 'fulfilled' ? modelsRes.value : null
44
+ if (modelsBody?.ok && Array.isArray(modelsBody.models)) setModels(modelsBody.models)
45
+ else setError('模型列表不可用(host 需为 v3.1.5+:重启 DSH 后 /session-guard/models 才存在)')
46
+
47
+ const settingsBody = settingsRes.status === 'fulfilled' ? settingsRes.value : null
48
+ const list = settingsBody?.settings?.officialModelPatterns
49
+ if (Array.isArray(list)) setPatterns(list)
50
+ setLoading(false)
51
+ }
52
+
53
+ /** 热重载:让 host 重读配置文件,然后重拉列表与模型目录。 */
54
+ const hotReload = async () => {
55
+ try {
56
+ await fetch('/session-guard/rpc', {
57
+ method: 'POST',
58
+ headers: { 'content-type': 'application/json' },
59
+ body: JSON.stringify({ action: 'reloadConfig' }),
60
+ })
61
+ } catch {
62
+ // fail-open:重读失败也照常重拉一次
63
+ }
64
+ await load()
65
+ }
66
+
67
+ useEffect(() => {
68
+ // 只在挂载(= 展开)时拉一次;按钮负责后续更新。
69
+ void load()
70
+ }, [])
71
+
72
+ /** 写回拦截列表(本线:写配置文件 + reload;失败只提示,不改本地展示)。 */
73
+ const commit = async (next: string[]) => {
74
+ setPending(true)
75
+ setWriteError(null)
76
+ const before = patterns
77
+ setPatterns(next) // 乐观更新,失败时回滚
78
+ try {
79
+ const res = await fetch('/session-guard/rpc', {
80
+ method: 'POST',
81
+ headers: { 'content-type': 'application/json' },
82
+ body: JSON.stringify({ action: 'setModelPatterns', patterns: next }),
83
+ })
84
+ const body = await res.json().catch(() => null)
85
+ if (!res.ok || body?.ok !== true) {
86
+ setPatterns(before)
87
+ setWriteError(String(body?.error ?? `写入失败(HTTP ${res.status})`))
88
+ } else if (Array.isArray(body.result?.patterns)) {
89
+ setPatterns(body.result.patterns)
90
+ }
91
+ } catch (e) {
92
+ setPatterns(before)
93
+ setWriteError(`写入失败:${e instanceof Error ? e.message : String(e)}`)
94
+ } finally {
95
+ setPending(false)
96
+ }
97
+ }
98
+
99
+ const filtered = filterRows(models, keyword)
100
+ const providers = [...new Set(filtered.map((r) => r.provider))].sort()
101
+ const everyCovered = allCovered(patterns, filtered)
102
+
103
+ return (
104
+ <div className="sgMlBody">
105
+ <div className="sgMlFilterRow">
106
+ <input
107
+ className="sgMlInput"
108
+ type="text"
109
+ value={keyword}
110
+ placeholder="过滤模型(provider/model 子串)"
111
+ onChange={(e) => setKeyword(e.currentTarget.value)}
112
+ />
113
+ <button
114
+ type="button"
115
+ className="sgMlBtn"
116
+ disabled={pending || filtered.length === 0}
117
+ onClick={() => void commit(bulkToggle(patterns, filtered, !everyCovered))}
118
+ title="只作用于当前过滤结果"
119
+ >
120
+ {everyCovered ? '取消全选' : '全选'}
121
+ </button>
122
+ <button type="button" className="sgMlBtn" disabled={loading} onClick={() => void load()} title="重新获取模型列表">
123
+ {loading ? '获取中…' : '刷新'}
124
+ </button>
125
+ <button type="button" className="sgMlBtn" disabled={loading} onClick={() => void hotReload()} title="重读配置文件后再拉一次">
126
+ 热重载
127
+ </button>
128
+ </div>
129
+ {patterns.length > 0 && (
130
+ <div className="sgMlPatterns">
131
+ <span className="sgMlSentinel">已拦截</span>
132
+ {patterns.map((p) => (
133
+ <span key={p} className="sgMlChip">
134
+ {p}
135
+ <button
136
+ type="button"
137
+ className="sgMlChipUndo"
138
+ disabled={pending}
139
+ title="撤销这一项"
140
+ aria-label={`撤销 ${p}`}
141
+ onClick={() => void commit(patterns.filter((x) => x !== p))}
142
+ >
143
+ ×
144
+ </button>
145
+ </span>
146
+ ))}
147
+ </div>
148
+ )}
149
+ {writeError !== null && <p className="sgMlError">✗ {writeError}</p>}
150
+ {error !== null && <p className="sgMlHint">{error}</p>}
151
+ {error === null && !loading && filtered.length === 0 && (
152
+ <p className="sgMlHint">{models.length === 0 ? '没有可枚举的模型。' : '没有匹配的模型。'}</p>
153
+ )}
154
+ {filtered.length > 0 && (
155
+ <ul className="sgMlList">
156
+ {providers.map((provider) => (
157
+ <li key={provider}>
158
+ <div className="sgMlProvider">{provider}</div>
159
+ <ul className="sgMlList">
160
+ {filtered
161
+ .filter((r) => r.provider === provider)
162
+ .map((r) => {
163
+ const state = rowState(r.model, patterns)
164
+ return (
165
+ <li key={`${provider}/${r.model}`} className="sgMlItem">
166
+ <label className="sgMlCheck" title="勾选 = 加入高峰拦截列表">
167
+ <span className="sgMlModel">{r.model}</span>
168
+ <input
169
+ type="checkbox"
170
+ checked={state.covered}
171
+ disabled={pending}
172
+ onChange={() => void commit(toggleModel(patterns, r.model))}
173
+ />
174
+ </label>
175
+ </li>
176
+ )
177
+ })}
178
+ </ul>
179
+ </li>
180
+ ))}
181
+ </ul>
182
+ )}
183
+ </div>
184
+ )
185
+ }