@dsh-plus/agent-preset-rea 0.1.0 → 0.1.1

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@dsh-plus/agent-preset-rea",
3
- "version": "0.1.0",
4
- "description": "dsh-plus service + UI plugin: 注册逆向工程 agent preset(id=rea)——官方 standard 全量行镜像 + 预设作用域的 REA MCP 客户端(mcp__rea__*)、随包 REA 官方 skill 与 persona 提示词卡片",
3
+ "version": "0.1.1",
4
+ "description": "dsh-plus service + UI plugin: 注册逆向工程 agent preset(id=rea)——官方 standard 全量行镜像 + 预设作用域的 REA MCP 客户端(mcp__rea__*)、随包 REA 官方 skill、提示词卡片与 REA/skill 的受管安装更新",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",
@@ -42,6 +42,7 @@
42
42
  "devDependencies": {
43
43
  "@deepseek-ai/cordis": "4.0.5-alpha.2",
44
44
  "@deepseek-ai/dsh-agent-preset-registry": "0.2.1-alpha.3",
45
+ "@deepseek-ai/dsh-host-webserver": "0.2.1-alpha.3",
45
46
  "@deepseek-ai/schemastery": "3.18.5-alpha.2",
46
47
  "@types/react": "~18.3.31",
47
48
  "react": "^18.2.0",
@@ -0,0 +1,76 @@
1
+ /**
2
+ * 受管安装/更新的端点通道(状态查询 + 三类动作)。
3
+ * 配置读写仍走 `ctx.remote.settings` 直连(见 card.tsx),这里只补官方传输没有的动作。
4
+ *
5
+ * 响应都经类型谓词在边界收窄:宿主契约漂移时立即报错,而不是渲染期白屏。
6
+ * @module agent-preset-rea/client/api
7
+ */
8
+ import { getJson, postJson } from '@dsh-plus/shared/client'
9
+ import type { JobActionDto, JobDto, StatusDto } from '../dto.ts'
10
+
11
+ /** 载荷类型谓词(与 shared/client 的 fetch 同形;此处按本插件契约自持)。 */
12
+ type Guard<T> = (value: unknown) => value is T
13
+
14
+ const ROUTE_BASE = '/dsh-plus/agent-preset-rea'
15
+
16
+ /** JobDto 守卫。 */
17
+ function isJob(value: unknown): value is JobDto {
18
+ if (typeof value !== 'object' || value === null) return false
19
+ const job = value as Record<string, unknown>
20
+ return (
21
+ typeof job['id'] === 'number' &&
22
+ typeof job['kind'] === 'string' &&
23
+ typeof job['state'] === 'string' &&
24
+ Array.isArray(job['log']) &&
25
+ typeof job['progress'] === 'object' &&
26
+ job['progress'] !== null
27
+ )
28
+ }
29
+
30
+ /** StatusDto 守卫(结构性字段缺席即判不符)。 */
31
+ function isStatus(value: unknown): value is StatusDto {
32
+ if (typeof value !== 'object' || value === null) return false
33
+ const body = value as Record<string, unknown>
34
+ const install = body['install']
35
+ const skill = body['skill']
36
+ const mcp = body['mcp']
37
+ return (
38
+ typeof install === 'object' &&
39
+ install !== null &&
40
+ typeof skill === 'object' &&
41
+ skill !== null &&
42
+ typeof mcp === 'object' &&
43
+ mcp !== null &&
44
+ typeof body['config'] === 'object' &&
45
+ body['config'] !== null
46
+ )
47
+ }
48
+
49
+ /** JobActionDto 守卫。 */
50
+ const isJobAction: Guard<JobActionDto> = (value: unknown): value is JobActionDto => {
51
+ if (typeof value !== 'object' || value === null) return false
52
+ const job = (value as Record<string, unknown>)['job']
53
+ return job === null || job === undefined || isJob(job)
54
+ }
55
+
56
+ /** 查询安装/skill/MCP 行状态与当前作业。 */
57
+ export async function fetchStatus(): Promise<StatusDto> {
58
+ return getJson<StatusDto>(`${ROUTE_BASE}/status`, isStatus)
59
+ }
60
+
61
+ /**
62
+ * 启动作业。
63
+ * @param kind - `install` = 安装/更新 REA 包;`skill` = 拉取并替换 skill 副本。
64
+ * @param proxyPort - 草稿里当前填的代理端口(未保存也即时生效)。
65
+ */
66
+ export async function startJob(
67
+ kind: 'install' | 'skill',
68
+ proxyPort: number,
69
+ ): Promise<JobActionDto> {
70
+ return postJson<JobActionDto>(`${ROUTE_BASE}/${kind}`, { proxyPort }, isJobAction)
71
+ }
72
+
73
+ /** 取消当前作业。 */
74
+ export async function cancelJob(): Promise<JobActionDto> {
75
+ return postJson<JobActionDto>(`${ROUTE_BASE}/cancel`, undefined, isJobAction)
76
+ }
@@ -1,16 +1,20 @@
1
1
  /**
2
- * 「逆向工程」预设配置卡片:唯一字段是 persona 提示词。
2
+ * 「逆向工程」预设配置卡片:提示词(活动字段)+ 代理端口(活动字段)+ 安装维护区。
3
3
  *
4
4
  * 经 injectPluginConfigCard 注册(插件页 plugins.row.config / plugins.bundle.config,
5
5
  * view 分发 summary/page);外壳与草稿语义走 @dsh-plus/shared/client 套件
6
6
  * (CardChrome + useNamespaceDraft:staged 草稿、脏判定、revision fencing、
7
- * Host 更新不覆盖在途编辑)。
7
+ * Host 更新不覆盖在途编辑),安装维护区见 client/install.tsx。
8
8
  *
9
- * 为什么只有这一个字段:`Config` 里只有 `personaPrefix` 标了 `.volatile()`
10
- * (对用户的承诺=必须给编辑器);MCP 参数、超时、附加 skill 根等属部署方平面,
11
- * 由 profile 的 cordis.patch.yml 行级配置覆盖,因此卡片底部只作说明。
12
- * 保存后 loader 原位提交活动引用并广播 `loader/volatile-update`,宿主半据此换代
13
- * 注册预设声明——新建会话即用新提示词,已存在会话保持原装配。
9
+ * 两个活动字段(`Config` 里标 `.volatile()` 的)都在这里给出编辑器:
10
+ * - `personaPrefix`:system-prompt 的 persona 前缀段;
11
+ * - `proxyPort`:安装/更新时的本机代理端口。
12
+ * 其余字段(安装位置、MCP 参数、超时、附加 skill 根)属部署方平面,由 profile 的
13
+ * cordis.patch.yml 行级配置覆盖,卡片只读展示关键事实。
14
+ *
15
+ * 安装维护为什么不放在注册期自动做:启动期 `npx`/npm 安装会跑依赖安装脚本
16
+ * (原生 prebuild 下载),卡住即吊死预设激活与整个名册;因此改成显式动作
17
+ * ——后台作业 + 轮询实时进度,作业完成后宿主半换代注册,新建会话即用新事实。
14
18
  * @module agent-preset-rea/client/card
15
19
  */
16
20
 
@@ -21,11 +25,14 @@ import {
21
25
  type NamespaceSettingsApi,
22
26
  type PluginConfigViewProps,
23
27
  type Scope,
28
+ TextField,
24
29
  useNamespaceDraft,
25
30
  } from '@dsh-plus/shared/client'
26
31
  import type { ReactElement } from 'react'
27
- import { type ConfigValue, type Draft, draftFromValue, toPatch } from './draft.ts'
32
+
33
+ import { type ConfigValue, type Draft, draftFromValue, parseProxyPort, toPatch } from './draft.ts'
28
34
  import type { Translate } from './i18n.ts'
35
+ import { InstallSection } from './install.tsx'
29
36
 
30
37
  export interface CardProps extends PluginConfigViewProps {
31
38
  t: Translate
@@ -33,12 +40,13 @@ export interface CardProps extends PluginConfigViewProps {
33
40
  api: NamespaceSettingsApi
34
41
  }
35
42
 
36
- /** 卡片动作:放弃 / 保存(仅脏且可写时可用)。 */
43
+ /** 卡片动作:放弃 / 保存(脏且可写且输入合法时才可用)。 */
37
44
  function cardActions(options: {
38
45
  t: Translate
39
46
  dirty: boolean
40
47
  saving: boolean
41
48
  disabled: boolean
49
+ invalid: boolean
42
50
  onDiscard(): void
43
51
  onSave(): void
44
52
  }): CardAction[] {
@@ -53,7 +61,7 @@ function cardActions(options: {
53
61
  key: 'save',
54
62
  label: options.t(options.saving ? 'saving' : 'save'),
55
63
  variant: 'primary',
56
- disabled: !options.dirty || options.saving || options.disabled,
64
+ disabled: !options.dirty || options.saving || options.disabled || options.invalid,
57
65
  onClick: options.onSave,
58
66
  },
59
67
  ]
@@ -78,6 +86,7 @@ export function ReaCard(props: CardProps): ReactElement | string | null {
78
86
  }
79
87
 
80
88
  const disabled = state.disabled
89
+ const port = parseProxyPort(draft.proxyPort)
81
90
  return (
82
91
  <CardChrome
83
92
  prefix="apr"
@@ -92,6 +101,7 @@ export function ReaCard(props: CardProps): ReactElement | string | null {
92
101
  dirty: state.dirty,
93
102
  saving: state.saving,
94
103
  disabled,
104
+ invalid: port === null,
95
105
  onDiscard: state.reset,
96
106
  onSave: state.save,
97
107
  })}
@@ -115,6 +125,21 @@ export function ReaCard(props: CardProps): ReactElement | string | null {
115
125
  <p className="apr-hint">{t('promptHint')}</p>
116
126
  {draft.personaPrefix.trim() === '' ? <p className="apr-hint">{t('promptEmpty')}</p> : null}
117
127
  </div>
128
+
129
+ <TextField
130
+ prefix="apr"
131
+ id="apr-proxy"
132
+ label={t('proxyPort')}
133
+ hint={t('proxyPortHint')}
134
+ invalid={port === null}
135
+ invalidLabel={t('proxyPortInvalid')}
136
+ numeric
137
+ value={draft.proxyPort}
138
+ disabled={disabled}
139
+ onEdit={(text) => state.edit({ proxyPort: text })}
140
+ />
141
+
142
+ <InstallSection t={t} disabled={disabled} proxyPort={port} />
118
143
  </CardChrome>
119
144
  )
120
145
  }
@@ -4,9 +4,9 @@
4
4
  * 构建产物为 window.__ModuleLoader__.load({id, factory}) 形式的 CJS factory
5
5
  * (包装见 tsdown.config.ts);样式沿用官方 data-plugin-css 约定,HMR 据此卸载。
6
6
  *
7
- * 卡片只编辑 `personaPrefix`(宿主半唯一的活动字段);配置读写经共享层直连
8
- * `ctx.remote.settings`(官方 `settingsScope` 服务不存在、由 `configForms` 取代),
9
- * 与 notify-email 同机制。
7
+ * 卡片编辑两个活动字段(`personaPrefix`、`proxyPort`),并驱动宿主半的受管安装/更新
8
+ * 作业(同源自定义端点,见 client/api.ts);配置读写经共享层直连 `ctx.remote.settings`
9
+ * (官方 `settingsScope` 服务不存在、由 `configForms` 取代),与 notify-email 同机制。
10
10
  * @module @dsh-plus/agent-preset-rea/client
11
11
  */
12
12
  import type { Context } from '@deepseek-ai/cordis'
@@ -1,28 +1,55 @@
1
1
  /**
2
2
  * 卡片草稿的纯逻辑(无 JSX,node --test 可直接测):命名空间解析值 ⇄ 编辑草稿。
3
3
  *
4
- * 命名空间值是整行配置的脱敏视图(本卡片只编辑 `personaPrefix`,其余字段属
5
- * 部署方平面,不进卡片),因此读取时按字段收窄:值缺席或类型不符一律回落到
6
- * 空串(= 空 persona),保存时只回写这一个字段(settings.update 深合并)。
4
+ * 卡片编辑两个活动字段(`personaPrefix` 提示词、`proxyPort` 安装代理端口),其余字段
5
+ * 属部署方平面不进卡片;命名空间值是整行配置的脱敏视图,读取时按字段收窄:
6
+ * 值缺席或类型不符一律回落默认(提示词空串、代理 0)。
7
+ *
8
+ * 代理端口以文本承载:越界/非法不静默改写,标 `invalid` 并禁用保存(见 card.tsx),
9
+ * 避免「输入 99999 被悄悄存成 65535」这类静默数据损坏。
7
10
  * @module agent-preset-rea/client/draft
8
11
  */
9
12
 
10
13
  /** 命名空间解析值中本卡片消费的部分。 */
11
14
  export interface ConfigValue {
12
15
  personaPrefix?: unknown
16
+ proxyPort?: unknown
13
17
  }
14
18
 
15
19
  /** 编辑草稿。 */
16
20
  export interface Draft {
17
21
  personaPrefix: string
22
+ /** 代理端口文本(空串与 `'0'` 都表示不用代理)。 */
23
+ proxyPort: string
18
24
  }
19
25
 
20
- /** 解析值 → 草稿(非字符串一律视为空串)。 */
26
+ /** 代理端口上限。 */
27
+ export const PROXY_PORT_MAX = 65535
28
+
29
+ /** 解析值 → 草稿(类型不符一律回落默认)。 */
21
30
  export function draftFromValue(value: ConfigValue): Draft {
22
- return { personaPrefix: typeof value.personaPrefix === 'string' ? value.personaPrefix : '' }
31
+ return {
32
+ personaPrefix: typeof value.personaPrefix === 'string' ? value.personaPrefix : '',
33
+ proxyPort: typeof value.proxyPort === 'number' ? String(value.proxyPort) : '',
34
+ }
35
+ }
36
+
37
+ /**
38
+ * 代理端口文本 → 端口号。
39
+ * @returns 空串按 0(不用代理);非纯数字或越界返回 null(调用方据此判非法)。
40
+ */
41
+ export function parseProxyPort(text: string): number | null {
42
+ const trimmed = text.trim()
43
+ if (trimmed === '') return 0
44
+ if (!/^\d+$/.test(trimmed)) return null
45
+ const port = Number(trimmed)
46
+ return Number.isInteger(port) && port >= 0 && port <= PROXY_PORT_MAX ? port : null
23
47
  }
24
48
 
25
49
  /** 草稿 → 提交形状(只回写本卡片负责的字段)。 */
26
50
  export function toPatch(draft: Draft): Record<string, unknown> {
27
- return { personaPrefix: draft.personaPrefix }
51
+ return {
52
+ personaPrefix: draft.personaPrefix,
53
+ proxyPort: parseProxyPort(draft.proxyPort) ?? 0,
54
+ }
28
55
  }
@@ -9,21 +9,66 @@ export const NS = 'dsh-plus-agent-preset-rea'
9
9
 
10
10
  const ownZh = {
11
11
  title: '逆向工程预设',
12
- description: 'REA 提示词:留空即空 persona,工具指引与运行时上下文照常由官方装配注入。',
13
- summaryLine: '编辑「逆向工程」预设的 persona 提示词',
12
+ description: 'REA 提示词与安装维护:提示词留空即空 persona,工具指引与运行时上下文照常注入。',
13
+ summaryLine: '编辑「逆向工程」预设的提示词,并安装/更新 REA 与 skill',
14
14
  prompt: '系统提示词(persona 前缀)',
15
15
  promptHint:
16
16
  '写入 system-prompt 的 persona 前缀段,仅对「逆向工程」预设的新会话生效;留空 = 不注入 persona 段(官方工具指引、项目指令与运行时上下文不受影响)。保存后新建会话即用新值。',
17
17
  promptPlaceholder: '例如:先做证据分级,再动手改二进制;每一步给出可复核的证据链……',
18
18
  promptEmpty: '当前为空:该预设不注入 persona 段。',
19
19
  saveFailed: '保存失败:',
20
+
21
+ installSection: 'REA 安装与更新',
22
+ installHint:
23
+ '安装/更新在后台执行:把 rea-agents 装进下面的受管目录(先装 staging 再换目录,失败不留半截安装树),作业完成后本预设自动换代——新建会话即带 mcp__rea__* 工具。安装位置与 MCP 命令属部署方平面(profile 的 cordis.patch.yml),此处只读展示。',
24
+ installPackage: 'REA 包',
25
+ installPath: '受管安装目录',
26
+ installVersion: '版本',
27
+ installTarget: '目标版本',
28
+ installed: '已安装',
29
+ notInstalled: '未安装',
30
+ skillSource: 'REA skill',
31
+ skillBundled: '随包副本',
32
+ skillUpdated: '手动更新副本',
33
+ skillFiles: '文件',
34
+ updatedAt: '更新时间',
35
+ mcpRow: 'MCP 行',
36
+ mcpInstalled: '已挂载(受管安装入口)',
37
+ mcpCustom: '已挂载(显式 mcpCommand)',
38
+ mcpMissing: '未挂载:未安装 REA,该行已禁用(不会在启动期拉取)',
39
+ mcpOff: '已按配置关闭',
40
+ commandLabel: '命令',
41
+ refreshStatus: '刷新状态',
42
+ installAction: '安装 / 更新 REA',
43
+ skillAction: '更新 skill',
44
+ cancelAction: '取消作业',
45
+ statusLoading: '正在读取安装状态…',
46
+ statusFailed: '状态查询失败:',
47
+ actionFailed: '操作失败:',
48
+ jobRunning: '作业进行中',
49
+ jobOk: '作业完成',
50
+ jobError: '作业失败',
51
+ jobCancelled: '作业已取消',
52
+ jobInstall: '安装 REA',
53
+ jobSkill: '更新 skill',
54
+ jobPhase: '阶段',
55
+ jobLog: '日志',
56
+ jobStartedAt: '开始于',
57
+ jobFinishedAt: '结束于',
58
+ jobElapsed: '已用时',
59
+ jobLogEmpty: '(暂无输出)',
60
+
61
+ proxyPort: '安装代理端口',
62
+ proxyPortHint:
63
+ '安装/更新时供 npm 与原生依赖(node-pty)prebuild 下载使用的本机代理端口;0 或留空 = 直连。未保存也用于本次安装,保存后长期生效。',
64
+ proxyPortInvalid: '代理端口必须是 0..65535 的整数。',
20
65
  } as const
21
66
 
22
67
  const ownEn = {
23
68
  title: 'Reverse-engineering preset',
24
69
  description:
25
- 'REA prompt: leave it empty for a blank persona; official tool guidance and runtime context stay in place.',
26
- summaryLine: 'Edit the persona prompt of the “逆向工程” preset',
70
+ 'REA prompt and installation: an empty prompt means a blank persona; tool guidance and runtime context stay in place.',
71
+ summaryLine: 'Edit the “逆向工程” prompt and install/update REA and its skill',
27
72
  prompt: 'System prompt (persona prefix)',
28
73
  promptHint:
29
74
  'Written to the persona-prefix section of the system prompt for new sessions of this preset only. Empty means no persona section (official tool guidance, project instructions and runtime context are unaffected). New sessions pick up the saved value.',
@@ -31,6 +76,52 @@ const ownEn = {
31
76
  'e.g. Grade the evidence first, then patch the binary; every step needs a checkable chain of evidence…',
32
77
  promptEmpty: 'Currently empty: this preset injects no persona section.',
33
78
  saveFailed: 'Save failed: ',
79
+
80
+ installSection: 'REA installation',
81
+ installHint:
82
+ 'Install/update runs in the background: rea-agents goes into the managed directory below (staged install, then directory swap, so a failure leaves no half tree). When the job finishes the preset re-registers — new sessions get the mcp__rea__* tools. The install location and MCP command belong to the deployment plane (profile cordis.patch.yml) and are shown read-only.',
83
+ installPackage: 'REA package',
84
+ installPath: 'Managed install dir',
85
+ installVersion: 'Version',
86
+ installTarget: 'Target version',
87
+ installed: 'Installed',
88
+ notInstalled: 'Not installed',
89
+ skillSource: 'REA skill',
90
+ skillBundled: 'Bundled copy',
91
+ skillUpdated: 'Manually updated copy',
92
+ skillFiles: 'Files',
93
+ updatedAt: 'Updated at',
94
+ mcpRow: 'MCP row',
95
+ mcpInstalled: 'Mounted (managed install entry)',
96
+ mcpCustom: 'Mounted (explicit mcpCommand)',
97
+ mcpMissing:
98
+ 'Not mounted: REA is not installed, the row stays disabled (nothing is fetched at boot)',
99
+ mcpOff: 'Disabled by config',
100
+ commandLabel: 'Command',
101
+ refreshStatus: 'Refresh status',
102
+ installAction: 'Install / update REA',
103
+ skillAction: 'Update skill',
104
+ cancelAction: 'Cancel job',
105
+ statusLoading: 'Reading install status…',
106
+ statusFailed: 'Status query failed: ',
107
+ actionFailed: 'Action failed: ',
108
+ jobRunning: 'Job running',
109
+ jobOk: 'Job finished',
110
+ jobError: 'Job failed',
111
+ jobCancelled: 'Job cancelled',
112
+ jobInstall: 'Install REA',
113
+ jobSkill: 'Update skill',
114
+ jobPhase: 'Phase',
115
+ jobLog: 'Log',
116
+ jobStartedAt: 'Started',
117
+ jobFinishedAt: 'Finished',
118
+ jobElapsed: 'Elapsed',
119
+ jobLogEmpty: '(no output yet)',
120
+
121
+ proxyPort: 'Install proxy port',
122
+ proxyPortHint:
123
+ 'Local proxy port used by npm and by the native node-pty prebuild download during install/update; 0 or empty means direct. The unsaved value is used for the next install and applies permanently once saved.',
124
+ proxyPortInvalid: 'Proxy port must be an integer in 0..65535.',
34
125
  } as const
35
126
 
36
127
  export type DictKey = keyof typeof commonZh | keyof typeof ownZh
@@ -0,0 +1,274 @@
1
+ /**
2
+ * 卡片的「REA 安装与更新」区:只读状态表 + 三个动作(安装/更新 REA、更新 skill、
3
+ * 取消作业)+ 运行中作业的阶段进度条、已用时与日志尾部。
4
+ *
5
+ * 状态与作业都来自宿主半的同源端点(见 client/api.ts 与宿主 routes.ts):这里只管
6
+ * 轮询与呈现。作业在跑时按 {@link PROGRESS_POLL_MS} 轮询——进度是宿主侧的真事实,
7
+ * 浏览器不自己造百分比。
8
+ * @module agent-preset-rea/client/install
9
+ */
10
+ import { Button } from '@dsh-plus/shared/client'
11
+ import { type ReactElement, useCallback, useEffect, useRef, useState } from 'react'
12
+
13
+ import type { JobDto, StatusDto } from '../dto.ts'
14
+ import { cancelJob, fetchStatus, startJob } from './api.ts'
15
+ import type { Translate } from './i18n.ts'
16
+
17
+ /** 作业运行中的状态轮询间隔(毫秒)。 */
18
+ const PROGRESS_POLL_MS = 1000
19
+
20
+ /** 日志展示尾部行数(宿主已按 200 行截断,这里再收一道以控高度)。 */
21
+ const LOG_TAIL = 12
22
+
23
+ /** 错误对象 → 人话。 */
24
+ function messageOf(error: unknown): string {
25
+ return error instanceof Error ? error.message : String(error)
26
+ }
27
+
28
+ /** 起止时间 → 已用时文本。 */
29
+ function elapsedOf(job: JobDto): string {
30
+ const start = Date.parse(job.startedAt)
31
+ if (Number.isNaN(start)) return '—'
32
+ const end = job.finishedAt === null ? Date.now() : Date.parse(job.finishedAt)
33
+ const seconds = Math.max(0, Math.round((end - start) / 1000))
34
+ if (seconds < 60) return `${seconds}s`
35
+ return `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, '0')}s`
36
+ }
37
+
38
+ /** 作业状态 → 徽标类名后缀(视觉区分运行中/成功/失败)。 */
39
+ const STATE_CLASS = {
40
+ running: 'Running',
41
+ ok: 'Ok',
42
+ error: 'Error',
43
+ cancelled: 'Cancelled',
44
+ } as const
45
+
46
+ /** 作业进度块:状态徽标 + 阶段 + 进度条 + 日志尾部。 */
47
+ function JobPanel(props: { t: Translate; job: JobDto }): ReactElement {
48
+ const { t, job } = props
49
+ const stateKey =
50
+ job.state === 'running'
51
+ ? 'jobRunning'
52
+ : job.state === 'ok'
53
+ ? 'jobOk'
54
+ : job.state === 'error'
55
+ ? 'jobError'
56
+ : 'jobCancelled'
57
+ const lines = job.log.slice(-LOG_TAIL)
58
+ return (
59
+ <div className="apr-job">
60
+ <div className="apr-jobHead">
61
+ <span className={`apr-pill apr-pill${STATE_CLASS[job.state]}`}>{t(stateKey)}</span>
62
+ <span className="apr-jobTitle">
63
+ {t(job.kind === 'install' ? 'jobInstall' : 'jobSkill')}
64
+ </span>
65
+ <span className="apr-hint">
66
+ {t('jobPhase')} {job.progress.step}/{job.progress.steps}·{job.progress.phase}
67
+ </span>
68
+ <span className="apr-hint">
69
+ {t('jobElapsed')} {elapsedOf(job)}
70
+ </span>
71
+ </div>
72
+ <div
73
+ className="apr-bar"
74
+ role="progressbar"
75
+ aria-valuemin={0}
76
+ aria-valuemax={100}
77
+ aria-valuenow={job.progress.percent}
78
+ >
79
+ <div className="apr-barFill" style={{ width: `${job.progress.percent}%` }} />
80
+ </div>
81
+ {job.error === null ? null : <p className="apr-error">{job.error}</p>}
82
+ <details className="apr-logBox" open={job.state !== 'ok'}>
83
+ <summary className="apr-logSummary">{t('jobLog')}</summary>
84
+ <pre className="apr-log">{lines.length === 0 ? t('jobLogEmpty') : lines.join('\n')}</pre>
85
+ </details>
86
+ </div>
87
+ )
88
+ }
89
+
90
+ /** 一行状态(键 + 值)。 */
91
+ function StatusRow(props: { label: string; children: React.ReactNode }): ReactElement {
92
+ return (
93
+ <div className="apr-installRow">
94
+ <dt className="apr-installKey">{props.label}</dt>
95
+ <dd className="apr-installVal">{props.children}</dd>
96
+ </div>
97
+ )
98
+ }
99
+
100
+ /** 只读状态表:REA 包 / 受管目录 / skill 来源 / MCP 行形态。 */
101
+ function StatusTable(props: { t: Translate; status: StatusDto }): ReactElement {
102
+ const { t, status } = props
103
+ const skillLabel = status.skill.source === 'updated' ? t('skillUpdated') : t('skillBundled')
104
+ const mcpLabel = !status.mcp.enabled
105
+ ? status.config.mcpEnabled
106
+ ? t('mcpMissing')
107
+ : t('mcpOff')
108
+ : status.mcp.mode === 'custom'
109
+ ? t('mcpCustom')
110
+ : t('mcpInstalled')
111
+ return (
112
+ <dl className="apr-install">
113
+ <StatusRow label={t('installPackage')}>
114
+ <span className={`apr-pill ${status.install.installed ? 'apr-pillOk' : 'apr-pillMiss'}`}>
115
+ {t(status.install.installed ? 'installed' : 'notInstalled')}
116
+ </span>
117
+ {status.install.version === null ? null : (
118
+ <span className="apr-mono">{status.install.version}</span>
119
+ )}
120
+ <span className="apr-hint">
121
+ {t('installTarget')} {status.config.reaVersion}
122
+ </span>
123
+ </StatusRow>
124
+ <StatusRow label={t('installPath')}>
125
+ <code className="apr-mono">{status.install.root}</code>
126
+ </StatusRow>
127
+ <StatusRow label={t('skillSource')}>
128
+ <span className="apr-mono">{skillLabel}</span>
129
+ <span className="apr-hint">
130
+ {t('skillFiles')} {status.skill.files}
131
+ {status.skill.version === null ? '' : ` · ${status.skill.version}`}
132
+ </span>
133
+ {status.skill.updatedAt === null ? null : (
134
+ <span className="apr-hint">
135
+ {t('updatedAt')} {status.skill.updatedAt}
136
+ </span>
137
+ )}
138
+ </StatusRow>
139
+ <StatusRow label={t('mcpRow')}>
140
+ <span className="apr-mono">{mcpLabel}</span>
141
+ {status.mcp.command === null ? null : (
142
+ <code className="apr-mono">
143
+ {status.mcp.command} {status.mcp.args.join(' ')}
144
+ </code>
145
+ )}
146
+ </StatusRow>
147
+ </dl>
148
+ )
149
+ }
150
+
151
+ export interface InstallSectionProps {
152
+ t: Translate
153
+ /** 设置只读(不可写)时禁用全部动作。 */
154
+ disabled: boolean
155
+ /** 草稿里的代理端口(非法为 null → 禁用动作,不静默取 0)。 */
156
+ proxyPort: number | null
157
+ }
158
+
159
+ export function InstallSection(props: InstallSectionProps): ReactElement {
160
+ const { t, disabled, proxyPort } = props
161
+ const [status, setStatus] = useState<StatusDto | null>(null)
162
+ const [error, setError] = useState<string | null>(null)
163
+ const [busy, setBusy] = useState(false)
164
+ const alive = useRef(true)
165
+ useEffect(
166
+ () => () => {
167
+ alive.current = false
168
+ },
169
+ [],
170
+ )
171
+
172
+ const load = useCallback(async (): Promise<void> => {
173
+ try {
174
+ const next = await fetchStatus()
175
+ if (!alive.current) return
176
+ setStatus(next)
177
+ setError(null)
178
+ } catch (failure) {
179
+ if (alive.current) setError(`${t('statusFailed')}${messageOf(failure)}`)
180
+ }
181
+ }, [t])
182
+
183
+ useEffect(() => {
184
+ void load()
185
+ }, [load])
186
+
187
+ const running = status?.job?.state === 'running'
188
+ useEffect(() => {
189
+ if (running !== true) return
190
+ const timer = window.setInterval(() => {
191
+ void load()
192
+ }, PROGRESS_POLL_MS)
193
+ return () => {
194
+ window.clearInterval(timer)
195
+ }
196
+ }, [running, load])
197
+
198
+ const runAction = useCallback(
199
+ (action: () => Promise<{ job: JobDto | null }>): void => {
200
+ setBusy(true)
201
+ setError(null)
202
+ action()
203
+ .then((result) => {
204
+ if (!alive.current) return
205
+ setStatus((prev) => (prev === null ? prev : { ...prev, job: result.job }))
206
+ void load()
207
+ })
208
+ .catch((failure: unknown) => {
209
+ if (alive.current) setError(`${t('actionFailed')}${messageOf(failure)}`)
210
+ })
211
+ .finally(() => {
212
+ if (alive.current) setBusy(false)
213
+ })
214
+ },
215
+ [load, t],
216
+ )
217
+
218
+ const blocked = disabled || busy || running === true
219
+ const job = status?.job ?? null
220
+ return (
221
+ <section className="apr-field">
222
+ <div className="apr-head">
223
+ <span className="apr-label">{t('installSection')}</span>
224
+ <Button
225
+ prefix="apr"
226
+ label={t('refreshStatus')}
227
+ disabled={busy || running === true}
228
+ onClick={() => {
229
+ void load()
230
+ }}
231
+ />
232
+ </div>
233
+ {status === null ? (
234
+ <p className="apr-hint">{t('statusLoading')}</p>
235
+ ) : (
236
+ <>
237
+ <StatusTable t={t} status={status} />
238
+ <div className="apr-btnRow">
239
+ <Button
240
+ prefix="apr"
241
+ variant="primary"
242
+ label={t('installAction')}
243
+ disabled={blocked || proxyPort === null}
244
+ onClick={() => {
245
+ runAction(() => startJob('install', proxyPort ?? 0))
246
+ }}
247
+ />
248
+ <Button
249
+ prefix="apr"
250
+ label={t('skillAction')}
251
+ disabled={blocked || proxyPort === null}
252
+ onClick={() => {
253
+ runAction(() => startJob('skill', proxyPort ?? 0))
254
+ }}
255
+ />
256
+ {running === true ? (
257
+ <Button
258
+ prefix="apr"
259
+ label={t('cancelAction')}
260
+ disabled={busy}
261
+ onClick={() => {
262
+ runAction(() => cancelJob())
263
+ }}
264
+ />
265
+ ) : null}
266
+ </div>
267
+ </>
268
+ )}
269
+ {job === null ? null : <JobPanel t={t} job={job} />}
270
+ {error === null ? null : <p className="apr-error">{error}</p>}
271
+ <p className="apr-hint">{t('installHint')}</p>
272
+ </section>
273
+ )
274
+ }