@kernel-sig/console 0.1.0

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.
Files changed (198) hide show
  1. package/.env.example +50 -0
  2. package/LICENSE +127 -0
  3. package/Makefile +66 -0
  4. package/README.md +279 -0
  5. package/backend/.dockerignore +12 -0
  6. package/backend/Dockerfile +43 -0
  7. package/backend/alembic/env.py +75 -0
  8. package/backend/alembic/script.py.mako +25 -0
  9. package/backend/alembic/versions/0001_initial.py +88 -0
  10. package/backend/alembic/versions/350e2d9b6553_add_pr_comment_table.py +48 -0
  11. package/backend/alembic/versions/4f8342e727fe_sig_info_roster_fields_and_branch_.py +39 -0
  12. package/backend/alembic/versions/5a31ade90136_add_repository_credential_pull_request_.py +293 -0
  13. package/backend/alembic/versions/87a008142f17_add_classification_attention_and_ai_.py +436 -0
  14. package/backend/alembic/versions/a1c7d90e4b52_branch_belongs_to_a_repository.py +72 -0
  15. package/backend/alembic/versions/b41c9d7e5f28_extend_pr_kind_categories.py +43 -0
  16. package/backend/alembic/versions/c8e4f2a71b93_add_release_kind.py +40 -0
  17. package/backend/alembic/versions/d1275091dfdc_add_needs_detail_flag_to_pull_request.py +44 -0
  18. package/backend/alembic/versions/e5b27c9d3a41_downgrade_cve_without_ids.py +54 -0
  19. package/backend/alembic/versions/ee3159a4eff0_add_sig_meeting_member_and_release_.py +164 -0
  20. package/backend/alembic/versions/fcf3c186d63b_attention_rule_subscriptions.py +42 -0
  21. package/backend/alembic.ini +40 -0
  22. package/backend/app/__init__.py +0 -0
  23. package/backend/app/api/__init__.py +0 -0
  24. package/backend/app/api/deps.py +74 -0
  25. package/backend/app/api/v1/__init__.py +0 -0
  26. package/backend/app/api/v1/ai.py +386 -0
  27. package/backend/app/api/v1/analytics.py +48 -0
  28. package/backend/app/api/v1/attention.py +142 -0
  29. package/backend/app/api/v1/auth.py +106 -0
  30. package/backend/app/api/v1/classification.py +192 -0
  31. package/backend/app/api/v1/health.py +29 -0
  32. package/backend/app/api/v1/issues.py +143 -0
  33. package/backend/app/api/v1/pulls.py +388 -0
  34. package/backend/app/api/v1/repositories.py +273 -0
  35. package/backend/app/api/v1/router.py +32 -0
  36. package/backend/app/api/v1/sig.py +565 -0
  37. package/backend/app/api/v1/users.py +87 -0
  38. package/backend/app/api/v1/webhooks.py +135 -0
  39. package/backend/app/core/__init__.py +0 -0
  40. package/backend/app/core/config.py +72 -0
  41. package/backend/app/core/crypto.py +74 -0
  42. package/backend/app/core/db.py +79 -0
  43. package/backend/app/core/exceptions.py +60 -0
  44. package/backend/app/core/logging.py +69 -0
  45. package/backend/app/core/permissions.py +87 -0
  46. package/backend/app/core/queue.py +57 -0
  47. package/backend/app/core/security.py +69 -0
  48. package/backend/app/domain/__init__.py +0 -0
  49. package/backend/app/domain/attention.py +618 -0
  50. package/backend/app/domain/classification.py +689 -0
  51. package/backend/app/domain/meeting.py +408 -0
  52. package/backend/app/domain/release.py +163 -0
  53. package/backend/app/domain/review.py +416 -0
  54. package/backend/app/domain/sig.py +178 -0
  55. package/backend/app/domain/sig_info.py +201 -0
  56. package/backend/app/integrations/__init__.py +0 -0
  57. package/backend/app/integrations/atomgit/__init__.py +0 -0
  58. package/backend/app/integrations/atomgit/client.py +505 -0
  59. package/backend/app/integrations/atomgit/models.py +311 -0
  60. package/backend/app/integrations/llm/__init__.py +0 -0
  61. package/backend/app/integrations/llm/prompts.py +222 -0
  62. package/backend/app/integrations/llm/provider.py +326 -0
  63. package/backend/app/main.py +242 -0
  64. package/backend/app/middleware/__init__.py +0 -0
  65. package/backend/app/middleware/audit.py +129 -0
  66. package/backend/app/middleware/request_context.py +42 -0
  67. package/backend/app/models/__init__.py +112 -0
  68. package/backend/app/models/ai.py +214 -0
  69. package/backend/app/models/attention.py +131 -0
  70. package/backend/app/models/attention_settings.py +61 -0
  71. package/backend/app/models/audit.py +53 -0
  72. package/backend/app/models/base.py +35 -0
  73. package/backend/app/models/classification.py +144 -0
  74. package/backend/app/models/credential.py +56 -0
  75. package/backend/app/models/issue.py +132 -0
  76. package/backend/app/models/meeting.py +189 -0
  77. package/backend/app/models/pull_request.py +288 -0
  78. package/backend/app/models/repository.py +196 -0
  79. package/backend/app/models/sig.py +194 -0
  80. package/backend/app/models/user.py +44 -0
  81. package/backend/app/schemas/__init__.py +0 -0
  82. package/backend/app/schemas/ai.py +120 -0
  83. package/backend/app/schemas/attention.py +43 -0
  84. package/backend/app/schemas/auth.py +26 -0
  85. package/backend/app/schemas/classification.py +58 -0
  86. package/backend/app/schemas/common.py +43 -0
  87. package/backend/app/schemas/pull_request.py +214 -0
  88. package/backend/app/schemas/repository.py +94 -0
  89. package/backend/app/schemas/sig.py +246 -0
  90. package/backend/app/schemas/user.py +79 -0
  91. package/backend/app/services/__init__.py +0 -0
  92. package/backend/app/services/ai_service.py +569 -0
  93. package/backend/app/services/analytics_service.py +390 -0
  94. package/backend/app/services/attention_queue.py +451 -0
  95. package/backend/app/services/attention_service.py +432 -0
  96. package/backend/app/services/auth_service.py +74 -0
  97. package/backend/app/services/classification_service.py +658 -0
  98. package/backend/app/services/credential_service.py +105 -0
  99. package/backend/app/services/pull_query.py +273 -0
  100. package/backend/app/services/release_service.py +446 -0
  101. package/backend/app/services/repository_service.py +132 -0
  102. package/backend/app/services/sig_service.py +385 -0
  103. package/backend/app/services/sync_service.py +752 -0
  104. package/backend/app/services/user_service.py +68 -0
  105. package/backend/app/worker.py +389 -0
  106. package/backend/entrypoint.sh +10 -0
  107. package/backend/pyproject.toml +68 -0
  108. package/backend/tests/test_analysis_api.py +154 -0
  109. package/backend/tests/test_atomgit_client.py +360 -0
  110. package/backend/tests/test_atomgit_models.py +257 -0
  111. package/backend/tests/test_attention.py +291 -0
  112. package/backend/tests/test_audit_middleware.py +135 -0
  113. package/backend/tests/test_classification.py +498 -0
  114. package/backend/tests/test_config.py +41 -0
  115. package/backend/tests/test_crypto.py +68 -0
  116. package/backend/tests/test_exceptions.py +62 -0
  117. package/backend/tests/test_health.py +63 -0
  118. package/backend/tests/test_llm_provider.py +320 -0
  119. package/backend/tests/test_meeting_domain.py +169 -0
  120. package/backend/tests/test_permissions.py +69 -0
  121. package/backend/tests/test_pull_query_wiring.py +66 -0
  122. package/backend/tests/test_pull_schemas.py +82 -0
  123. package/backend/tests/test_release_domain.py +82 -0
  124. package/backend/tests/test_review_parser.py +301 -0
  125. package/backend/tests/test_schemas_user.py +97 -0
  126. package/backend/tests/test_security.py +92 -0
  127. package/cli/index.js +338 -0
  128. package/compose.yaml +105 -0
  129. package/frontend/.dockerignore +4 -0
  130. package/frontend/Dockerfile +27 -0
  131. package/frontend/index.html +14 -0
  132. package/frontend/nginx.conf +47 -0
  133. package/frontend/package-lock.json +5020 -0
  134. package/frontend/package.json +35 -0
  135. package/frontend/src/api/ai.ts +124 -0
  136. package/frontend/src/api/analytics.ts +66 -0
  137. package/frontend/src/api/attention.ts +181 -0
  138. package/frontend/src/api/auth.ts +74 -0
  139. package/frontend/src/api/classification.ts +170 -0
  140. package/frontend/src/api/issues.ts +90 -0
  141. package/frontend/src/api/pulls.ts +233 -0
  142. package/frontend/src/api/repositories.ts +95 -0
  143. package/frontend/src/api/sig.ts +232 -0
  144. package/frontend/src/app/antd-theme.ts +94 -0
  145. package/frontend/src/app/providers.tsx +59 -0
  146. package/frontend/src/app/router.tsx +411 -0
  147. package/frontend/src/app/search.ts +30 -0
  148. package/frontend/src/components/ClassificationBadge.tsx +58 -0
  149. package/frontend/src/components/GateBadge.tsx +13 -0
  150. package/frontend/src/components/SeverityBadge.tsx +20 -0
  151. package/frontend/src/components/layout/AppShell.tsx +16 -0
  152. package/frontend/src/components/layout/AuthLayout.tsx +32 -0
  153. package/frontend/src/components/layout/Sidebar.tsx +223 -0
  154. package/frontend/src/components/layout/TopBar.tsx +47 -0
  155. package/frontend/src/components/pulls/DiscussionTimeline.tsx +145 -0
  156. package/frontend/src/components/pulls/FacetRail.tsx +199 -0
  157. package/frontend/src/components/pulls/LabelChips.tsx +87 -0
  158. package/frontend/src/components/ui/alert.tsx +30 -0
  159. package/frontend/src/components/ui/badge.tsx +53 -0
  160. package/frontend/src/components/ui/button.tsx +51 -0
  161. package/frontend/src/components/ui/card.tsx +64 -0
  162. package/frontend/src/components/ui/chart-theme.ts +65 -0
  163. package/frontend/src/components/ui/data-table.tsx +39 -0
  164. package/frontend/src/components/ui/echart.tsx +70 -0
  165. package/frontend/src/components/ui/empty-state.tsx +22 -0
  166. package/frontend/src/components/ui/input.tsx +39 -0
  167. package/frontend/src/components/ui/lazy-chart.tsx +21 -0
  168. package/frontend/src/components/ui/skeleton.tsx +19 -0
  169. package/frontend/src/hooks/use-current-repository.ts +44 -0
  170. package/frontend/src/hooks/use-current-user.ts +38 -0
  171. package/frontend/src/lib/api-client.ts +93 -0
  172. package/frontend/src/lib/css-color.ts +60 -0
  173. package/frontend/src/lib/utils.ts +45 -0
  174. package/frontend/src/main.tsx +22 -0
  175. package/frontend/src/pages/attention/AttentionQueuePage.tsx +316 -0
  176. package/frontend/src/pages/attention/RuleSettingsPanel.tsx +177 -0
  177. package/frontend/src/pages/branches/BranchDetailPage.tsx +632 -0
  178. package/frontend/src/pages/branches/BranchListPage.tsx +308 -0
  179. package/frontend/src/pages/dashboard/DashboardPage.tsx +657 -0
  180. package/frontend/src/pages/issues/IssueListPage.tsx +284 -0
  181. package/frontend/src/pages/login/LoginPage.tsx +95 -0
  182. package/frontend/src/pages/meetings/MeetingDetailPage.tsx +403 -0
  183. package/frontend/src/pages/meetings/MeetingListPage.tsx +264 -0
  184. package/frontend/src/pages/members/MembersPage.tsx +534 -0
  185. package/frontend/src/pages/pulls/PullDetailPage.tsx +833 -0
  186. package/frontend/src/pages/pulls/PullListPage.tsx +399 -0
  187. package/frontend/src/pages/settings/AiSettingsPage.tsx +449 -0
  188. package/frontend/src/pages/settings/SettingsPage.tsx +321 -0
  189. package/frontend/src/pages/setup/SetupPage.tsx +144 -0
  190. package/frontend/src/styles/globals.css +147 -0
  191. package/frontend/tsconfig.json +22 -0
  192. package/frontend/vite.config.ts +57 -0
  193. package/package.json +48 -0
  194. package/scripts/e2e-auth-flow.py +131 -0
  195. package/scripts/e2e-pr-detail.py +128 -0
  196. package/scripts/e2e-verify.py +773 -0
  197. package/scripts/verify-ai-pipeline.py +616 -0
  198. package/scripts/verify-analysis-pipeline.py +303 -0
@@ -0,0 +1,449 @@
1
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
2
+ import { App as AntApp, Popconfirm, Select } from 'antd'
3
+ import { useState, type FormEvent } from 'react'
4
+
5
+ import {
6
+ aiApi,
7
+ TASK_META,
8
+ type AITask,
9
+ type LLMProvider,
10
+ type ProviderTestResult,
11
+ } from '@/api/ai'
12
+ import { useCurrentRepository } from '@/hooks/use-current-repository'
13
+ import { Alert } from '@/components/ui/alert'
14
+ import { Badge } from '@/components/ui/badge'
15
+ import { Button } from '@/components/ui/button'
16
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
17
+ import { Input, Label } from '@/components/ui/input'
18
+ import { EmptyState } from '@/components/ui/empty-state'
19
+ import { Skeleton } from '@/components/ui/skeleton'
20
+ import { ApiError } from '@/lib/api-client'
21
+ import { formatNumber, formatRelativeTime } from '@/lib/utils'
22
+
23
+ const TASKS: AITask[] = ['classify', 'summarize', 'risk_review', 'backport_verify']
24
+
25
+ /**
26
+ * 模型端点预设。
27
+ *
28
+ * base_url 少写或多写一段 /v1 是配置模型时最常见的错误,
29
+ * 而且报错信息往往只是一句 404 —— 给出预设比让使用者查文档省事得多。
30
+ */
31
+ const PRESETS = [
32
+ { value: 'https://api.deepseek.com/v1', label: 'DeepSeek' },
33
+ { value: 'https://dashscope.aliyuncs.com/compatible-mode/v1', label: '通义千问' },
34
+ { value: 'https://open.bigmodel.cn/api/paas/v4', label: '智谱 GLM' },
35
+ { value: 'https://api.moonshot.cn/v1', label: 'Kimi' },
36
+ { value: 'https://api.openai.com/v1', label: 'OpenAI' },
37
+ { value: 'http://localhost:11434/v1', label: '本地 Ollama' },
38
+ ]
39
+
40
+ export function AiSettingsPage() {
41
+ const queryClient = useQueryClient()
42
+ // 走上下文而不是 `message.xxx` 静态方法:后者在 StrictMode 下拿不到
43
+ // ConfigProvider 的 context,React 会打 error 级告警。
44
+ const { message } = AntApp.useApp()
45
+
46
+ const { data: providers, isLoading } = useQuery({
47
+ queryKey: ['ai-providers'],
48
+ queryFn: aiApi.providers,
49
+ })
50
+ const { data: routing } = useQuery({
51
+ queryKey: ['ai-routing'],
52
+ queryFn: aiApi.routing,
53
+ })
54
+ const { data: usage } = useQuery({
55
+ queryKey: ['ai-usage'],
56
+ queryFn: () => aiApi.usage(30),
57
+ })
58
+
59
+ const [name, setName] = useState('deepseek')
60
+ const [baseUrl, setBaseUrl] = useState(PRESETS[0].value)
61
+ const [model, setModel] = useState('deepseek-chat')
62
+ const [apiKey, setApiKey] = useState('')
63
+ const [testResults, setTestResults] = useState<Record<string, ProviderTestResult>>({})
64
+
65
+ const invalidate = () => {
66
+ queryClient.invalidateQueries({ queryKey: ['ai-providers'] })
67
+ queryClient.invalidateQueries({ queryKey: ['ai-routing'] })
68
+ }
69
+
70
+ const createProvider = useMutation({
71
+ mutationFn: () =>
72
+ aiApi.createProvider({
73
+ name,
74
+ base_url: baseUrl,
75
+ model,
76
+ api_key: apiKey || undefined,
77
+ is_default: !providers?.length,
78
+ }),
79
+ onSuccess: async () => {
80
+ // 明文 key 不应在界面上停留
81
+ setApiKey('')
82
+ invalidate()
83
+ },
84
+ })
85
+
86
+ const deleteProvider = useMutation({
87
+ mutationFn: (id: string) => aiApi.deleteProvider(id),
88
+ onSuccess: invalidate,
89
+ // 删除会被后端拒绝(端点仍被任务路由引用时返回冲突)。没有这一句的话,
90
+ // 使用者点了确认、浮层关掉、页面上什么都不变 —— 看上去像按钮坏了,
91
+ // 而真正该做的是去改那几条路由。后端的说明本身就把该做什么讲清楚了,
92
+ // 原样透出即可。
93
+ onError: (cause) =>
94
+ message.error(cause instanceof Error ? cause.message : '删除失败,请稍后重试'),
95
+ })
96
+
97
+ const setDefault = useMutation({
98
+ mutationFn: (id: string) => aiApi.updateProvider(id, { is_default: true }),
99
+ onSuccess: invalidate,
100
+ })
101
+
102
+ const testProvider = useMutation({
103
+ mutationFn: (id: string) => aiApi.testProvider(id),
104
+ onSuccess: (result, id) => {
105
+ setTestResults((current) => ({ ...current, [id]: result }))
106
+ invalidate()
107
+ },
108
+ })
109
+
110
+ const upsertRouting = useMutation({
111
+ mutationFn: ({ task, providerId }: { task: AITask; providerId: string }) =>
112
+ aiApi.upsertRouting(task, { provider_id: providerId }),
113
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ai-routing'] }),
114
+ })
115
+
116
+ const deleteRouting = useMutation({
117
+ mutationFn: (task: AITask) => aiApi.deleteRouting(task),
118
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ai-routing'] }),
119
+ })
120
+
121
+ function handleSubmit(event: FormEvent) {
122
+ event.preventDefault()
123
+ createProvider.mutate()
124
+ }
125
+
126
+ const hasProvider = Boolean(providers?.length)
127
+ // 「使用默认端点」是一个**真选项**(空串),不是占位文案:选中它意味着
128
+ // 删掉这条路由,而占位文案在 AntD 的浮层里点不了。原来靠原生 select
129
+ // 把 placeholder 渲染成一个可选项来达到同样的效果。
130
+ const providerOptions = [
131
+ { value: '', label: hasProvider ? '使用默认端点' : '请先添加端点' },
132
+ ...(providers ?? []).map((provider) => ({
133
+ value: provider.id,
134
+ label: `${provider.name} · ${provider.model}`,
135
+ })),
136
+ ]
137
+
138
+ return (
139
+ <div className="mx-auto max-w-3xl space-y-6">
140
+ <div>
141
+ <h1 className="text-base font-semibold tracking-tight">AI 对接</h1>
142
+ <p className="mt-1 text-sm text-[var(--color-text-secondary)]">
143
+ 接入 OpenAI 兼容协议的模型端点。平台按任务分配模型 —— 分类这类轻任务用便宜模型,
144
+ 代码风险审查才用强模型,全部走大模型会让成本失控。
145
+ </p>
146
+ </div>
147
+
148
+ <Alert tone="info">
149
+ AI 调用是本平台唯一按次计费的开销。内容未变时不重复调用,
150
+ 定时批量补判也有限流;用量见下方统计。
151
+ </Alert>
152
+
153
+ {/* ---- 用量 ---- */}
154
+ {usage && usage.analyses > 0 && (
155
+ <Card>
156
+ <CardHeader>
157
+ <CardTitle>近 30 天用量</CardTitle>
158
+ </CardHeader>
159
+ <CardContent className="grid grid-cols-2 gap-3 sm:grid-cols-4">
160
+ <Stat label="调用次数" value={formatNumber(usage.analyses)} />
161
+ <Stat label="成功" value={formatNumber(usage.succeeded)} />
162
+ <Stat label="失败" value={formatNumber(usage.failed)} />
163
+ <Stat
164
+ label="累计 token"
165
+ value={formatNumber(usage.prompt_tokens + usage.completion_tokens)}
166
+ />
167
+ </CardContent>
168
+ </Card>
169
+ )}
170
+
171
+ {/* ---- 模型端点 ---- */}
172
+ <Card>
173
+ <CardHeader>
174
+ <CardTitle>模型端点</CardTitle>
175
+ <CardDescription>
176
+ API Key 与 AtomGit token 一样加密存储(AES-256-GCM),界面不提供明文回显。
177
+ </CardDescription>
178
+ </CardHeader>
179
+ <CardContent className="space-y-4">
180
+ {isLoading ? (
181
+ <Skeleton className="h-16 w-full" />
182
+ ) : hasProvider ? (
183
+ <ul className="space-y-2">
184
+ {providers!.map((provider) => (
185
+ <ProviderRow
186
+ key={provider.id}
187
+ provider={provider}
188
+ testResult={testResults[provider.id]}
189
+ testing={testProvider.isPending && testProvider.variables === provider.id}
190
+ onTest={() => testProvider.mutate(provider.id)}
191
+ onSetDefault={() => setDefault.mutate(provider.id)}
192
+ onDelete={() => deleteProvider.mutate(provider.id)}
193
+ />
194
+ ))}
195
+ </ul>
196
+ ) : (
197
+ <EmptyState
198
+ title="尚未配置模型端点"
199
+ description="添加一个 OpenAI 兼容端点后,规则判不出来的分类可交由模型补判。"
200
+ />
201
+ )}
202
+
203
+ <form onSubmit={handleSubmit} className="space-y-3 border-t border-[var(--color-border-subtle)] pt-4">
204
+ <div className="grid gap-3 sm:grid-cols-2">
205
+ <div>
206
+ <Label htmlFor="provider-name">名称</Label>
207
+ <Input
208
+ id="provider-name"
209
+ value={name}
210
+ onChange={(event) => setName(event.target.value)}
211
+ required
212
+ />
213
+ </div>
214
+ <div>
215
+ <Label htmlFor="provider-model">模型名</Label>
216
+ <Input
217
+ id="provider-model"
218
+ value={model}
219
+ onChange={(event) => setModel(event.target.value)}
220
+ placeholder="deepseek-chat"
221
+ required
222
+ />
223
+ </div>
224
+ </div>
225
+
226
+ <div>
227
+ <Label htmlFor="provider-url">Base URL</Label>
228
+ <div className="flex gap-2">
229
+ <Input
230
+ id="provider-url"
231
+ value={baseUrl}
232
+ onChange={(event) => setBaseUrl(event.target.value)}
233
+ placeholder="https://api.deepseek.com/v1"
234
+ required
235
+ />
236
+ {/* 这里是个动作菜单而不是选择框:选完就把值填进上面的输入框,
237
+ value 恒为 null,于是浮层上永远显示占位文案而不是刚选中的项。 */}
238
+ <Select
239
+ aria-label="常见供应商"
240
+ className="w-36 shrink-0"
241
+ options={PRESETS}
242
+ value={null}
243
+ placeholder="预设…"
244
+ onChange={(value) => value && setBaseUrl(value)}
245
+ />
246
+ </div>
247
+ <p className="mt-1.5 text-xs text-[var(--color-text-muted)]">
248
+ 需包含版本段(通常以 <code>/v1</code> 结尾),平台会在此之后再拼
249
+ <code> /chat/completions</code>。本地 vLLM / Ollama 无需填 Key。
250
+ </p>
251
+ </div>
252
+
253
+ <div>
254
+ <Label htmlFor="provider-key">API Key</Label>
255
+ <Input
256
+ id="provider-key"
257
+ type="password"
258
+ autoComplete="off"
259
+ value={apiKey}
260
+ onChange={(event) => setApiKey(event.target.value)}
261
+ placeholder="留空表示该端点无需鉴权"
262
+ />
263
+ </div>
264
+
265
+ {createProvider.error instanceof ApiError && (
266
+ <Alert tone="danger">{createProvider.error.message}</Alert>
267
+ )}
268
+
269
+ <Button type="submit" size="sm" loading={createProvider.isPending}>
270
+ 添加端点
271
+ </Button>
272
+ </form>
273
+ </CardContent>
274
+ </Card>
275
+
276
+ {/* ---- 任务路由 ---- */}
277
+ <Card>
278
+ <CardHeader>
279
+ <CardTitle>任务路由</CardTitle>
280
+ <CardDescription>
281
+ 未配置路由的任务回落到默认端点。上线初期可以只配一条,
282
+ 等成本数据出来再细分。
283
+ </CardDescription>
284
+ </CardHeader>
285
+ <CardContent className="space-y-3">
286
+ {TASKS.map((task) => {
287
+ const current = routing?.find((item) => item.task === task)
288
+ return (
289
+ <div
290
+ key={task}
291
+ className="flex flex-wrap items-center gap-3 rounded-[var(--radius-control)] border border-[var(--color-border-subtle)] bg-[var(--color-surface-2)] px-3 py-2.5"
292
+ >
293
+ <div className="min-w-0 flex-1">
294
+ <div className="text-sm font-medium">{TASK_META[task].label}</div>
295
+ <div className="mt-0.5 text-xs text-[var(--color-text-muted)]">
296
+ {TASK_META[task].purpose}
297
+ </div>
298
+ </div>
299
+ <Select
300
+ aria-label={`${TASK_META[task].label} 使用的模型`}
301
+ className="w-52"
302
+ options={providerOptions}
303
+ disabled={!hasProvider}
304
+ value={current?.provider_id ?? ''}
305
+ onChange={(value) => {
306
+ if (value) {
307
+ upsertRouting.mutate({ task, providerId: value })
308
+ } else {
309
+ deleteRouting.mutate(task)
310
+ }
311
+ }}
312
+ />
313
+ </div>
314
+ )
315
+ })}
316
+ </CardContent>
317
+ </Card>
318
+
319
+ <Card>
320
+ <CardHeader>
321
+ <CardTitle>补判规则判不出的分类</CardTitle>
322
+ <CardDescription>
323
+ 内核补丁标题描述的是「改了什么」而非「属于哪一类」,因此相当一部分条目
324
+ 规则无法定性。这些条目会交给模型补判 —— 单次限量,避免误点产生意外账单。
325
+ </CardDescription>
326
+ </CardHeader>
327
+ <CardContent>
328
+ <ClassifyMissingButton disabled={!hasProvider} />
329
+ </CardContent>
330
+ </Card>
331
+ </div>
332
+ )
333
+ }
334
+
335
+ function ProviderRow({
336
+ provider,
337
+ testResult,
338
+ testing,
339
+ onTest,
340
+ onSetDefault,
341
+ onDelete,
342
+ }: {
343
+ provider: LLMProvider
344
+ testResult?: ProviderTestResult
345
+ testing: boolean
346
+ onTest: () => void
347
+ onSetDefault: () => void
348
+ onDelete: () => void
349
+ }) {
350
+ return (
351
+ <li className="rounded-[var(--radius-control)] border border-[var(--color-border-subtle)] bg-[var(--color-surface-2)] px-3 py-2.5">
352
+ <div className="flex flex-wrap items-center gap-2">
353
+ <span className="text-sm font-medium">{provider.name}</span>
354
+ {provider.is_default && <Badge tone="accent">默认</Badge>}
355
+ {!provider.enabled && <Badge tone="neutral">已停用</Badge>}
356
+ {provider.credential_id ? (
357
+ <Badge tone="success">已配置 Key</Badge>
358
+ ) : (
359
+ <Badge tone="neutral">无需鉴权</Badge>
360
+ )}
361
+ <div className="ml-auto flex items-center gap-1.5">
362
+ <Button variant="ghost" size="sm" onClick={onTest} loading={testing}>
363
+ 测试
364
+ </Button>
365
+ {!provider.is_default && (
366
+ <Button variant="ghost" size="sm" onClick={onSetDefault}>
367
+ 设为默认
368
+ </Button>
369
+ )}
370
+ {/* 删除要二次确认。这一条之前点了就执行:端点连同它的 Key 一起没了,
371
+ 而配一个端点要填四项、还要重新填一遍 Key。删错一次的成本,
372
+ 远高于每次删除多点一下。 */}
373
+ <Popconfirm
374
+ title="删除这个端点?"
375
+ description="它的访问密钥会一并删除。若仍有任务路由指向它,删除会被拒绝 —— 需要先把那些路由改到别的端点。"
376
+ okText="删除"
377
+ cancelText="取消"
378
+ okButtonProps={{ danger: true }}
379
+ onConfirm={onDelete}
380
+ >
381
+ <Button variant="ghost" size="sm">
382
+ 删除
383
+ </Button>
384
+ </Popconfirm>
385
+ </div>
386
+ </div>
387
+
388
+ <div className="mt-1 flex flex-wrap items-center gap-x-3 text-xs text-[var(--color-text-muted)]">
389
+ <code>{provider.base_url}</code>
390
+ <span>模型 {provider.model}</span>
391
+ {provider.last_used_at && <span>最近使用 {formatRelativeTime(provider.last_used_at)}</span>}
392
+ </div>
393
+
394
+ {testResult && (
395
+ <Alert tone={testResult.ok ? 'success' : 'danger'} className="mt-2">
396
+ {testResult.ok
397
+ ? `端点可用(${testResult.latency_ms} ms,模型回显:${testResult.model})`
398
+ : `连接失败:${testResult.detail}`}
399
+ </Alert>
400
+ )}
401
+ {provider.last_error && !testResult && (
402
+ <Alert tone="danger" className="mt-2">
403
+ 上次调用失败:{provider.last_error}
404
+ </Alert>
405
+ )}
406
+ </li>
407
+ )
408
+ }
409
+
410
+ function ClassifyMissingButton({ disabled }: { disabled: boolean }) {
411
+ const [message, setMessage] = useState<string | null>(null)
412
+ // 补判是**针对某个仓库**的动作("这个仓库里还有 5 条没分类"),
413
+ // 所以它跟随地址栏里的仓库,而不是随便挑一个
414
+ const { repository } = useCurrentRepository()
415
+
416
+ const mutation = useMutation({
417
+ mutationFn: (repositoryId: string) => aiApi.classifyMissing(repositoryId, 5),
418
+ onSuccess: (result) => setMessage(result.message),
419
+ })
420
+
421
+ if (!repository) return null
422
+
423
+ return (
424
+ <div className="space-y-2">
425
+ <Button
426
+ variant="secondary"
427
+ size="sm"
428
+ disabled={disabled}
429
+ loading={mutation.isPending}
430
+ onClick={() => mutation.mutate(repository.id)}
431
+ >
432
+ 立即补判 5 条
433
+ </Button>
434
+ {message && <p className="text-xs text-[var(--color-text-secondary)]">{message}</p>}
435
+ {mutation.error instanceof ApiError && (
436
+ <Alert tone="danger">{mutation.error.message}</Alert>
437
+ )}
438
+ </div>
439
+ )
440
+ }
441
+
442
+ function Stat({ label, value }: { label: string; value: string }) {
443
+ return (
444
+ <div>
445
+ <div className="text-xs text-[var(--color-text-muted)]">{label}</div>
446
+ <div className="mt-0.5 font-mono text-lg tabular-nums">{value}</div>
447
+ </div>
448
+ )
449
+ }