dsh-ai-courses 0.1.0 → 0.1.2
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/index.mjs +179 -14
- package/lib/client.js +204 -29
- package/package.json +1 -1
package/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { promises as fsp } from 'node:fs'
|
|
1
|
+
import { existsSync, promises as fsp, readFileSync } from 'node:fs'
|
|
2
2
|
import { readFile as readFileAsync, realpath } from 'node:fs/promises'
|
|
3
3
|
import { homedir, tmpdir } from 'node:os'
|
|
4
|
-
import { dirname, isAbsolute, join as pathJoin, relative as relativePath, resolve as resolvePath } from 'node:path'
|
|
4
|
+
import { dirname, isAbsolute, join, join as pathJoin, relative as relativePath, resolve, resolve as resolvePath } from 'node:path'
|
|
5
5
|
|
|
6
6
|
/* ============================================================================
|
|
7
7
|
* dsh-ai-courses — host 半边
|
|
@@ -938,6 +938,15 @@ export function createStatusStore ({ now = () => Date.now(), ttlMs = 30 * 60 * 1
|
|
|
938
938
|
})
|
|
939
939
|
},
|
|
940
940
|
|
|
941
|
+
/** 是否有会话正在推导课程(切换课程库根目录前用)。 */
|
|
942
|
+
hasRunning () {
|
|
943
|
+
clean() /* 过期的 running 行不该永久挡住换根 */
|
|
944
|
+
for (const row of rows.values()) {
|
|
945
|
+
if (row.phase === 'running') return true
|
|
946
|
+
}
|
|
947
|
+
return false
|
|
948
|
+
},
|
|
949
|
+
|
|
941
950
|
/** 读一条状态(脱敏后的普通对象;没有或已过期则 null)。 */
|
|
942
951
|
read (sessionId) {
|
|
943
952
|
const row = rows.get(sessionId)
|
|
@@ -1049,13 +1058,16 @@ export class StoreError extends Error {
|
|
|
1049
1058
|
*/
|
|
1050
1059
|
export function createSerialQueue () {
|
|
1051
1060
|
const tails = new Map()
|
|
1052
|
-
|
|
1061
|
+
function run (key, task) {
|
|
1053
1062
|
const prev = tails.get(key) ?? Promise.resolve()
|
|
1054
1063
|
const next = prev.then(task, task)
|
|
1055
1064
|
// 让队列不因单次失败而卡死:把结果吞掉,只保留"完成"信号
|
|
1056
1065
|
tails.set(key, next.then(() => undefined, () => undefined))
|
|
1057
1066
|
return next
|
|
1058
1067
|
}
|
|
1068
|
+
/** 等当前所有在途写完成(切换课程库根目录前用,避免写到一半换根)。 */
|
|
1069
|
+
run.drain = () => Promise.all([...tails.values()]).then(() => undefined)
|
|
1070
|
+
return run
|
|
1059
1071
|
}
|
|
1060
1072
|
|
|
1061
1073
|
/* ------------------------------------------------------------ 损坏恢复 */
|
|
@@ -1079,12 +1091,15 @@ function stamp (now) {
|
|
|
1079
1091
|
export function createStore ({ fs, root, now = () => new Date() }) {
|
|
1080
1092
|
const run = createSerialQueue()
|
|
1081
1093
|
const corruptions = [] // 供界面提示:本次进程内发生的降级
|
|
1094
|
+
/* 根目录**可切换**:配置界面改 courseRoot 后立即生效,不要求重启宿主。
|
|
1095
|
+
所有路径都从 currentRoot 现算,不要在创建 store 时缓存绝对路径。 */
|
|
1096
|
+
let currentRoot = root
|
|
1082
1097
|
|
|
1083
1098
|
const courseDir = (courseId) => {
|
|
1084
1099
|
if (!isCourseId(courseId)) {
|
|
1085
1100
|
throw new StoreError('bad-course-id', `非法的课程 id:${String(courseId)}`)
|
|
1086
1101
|
}
|
|
1087
|
-
return fs.join(
|
|
1102
|
+
return fs.join(currentRoot, courseId)
|
|
1088
1103
|
}
|
|
1089
1104
|
|
|
1090
1105
|
/**
|
|
@@ -1152,7 +1167,7 @@ export function createStore ({ fs, root, now = () => new Date() }) {
|
|
|
1152
1167
|
async function listCourses () {
|
|
1153
1168
|
let entries = []
|
|
1154
1169
|
try {
|
|
1155
|
-
entries = await fs.list(
|
|
1170
|
+
entries = await fs.list(currentRoot)
|
|
1156
1171
|
} catch (err) {
|
|
1157
1172
|
if (err && (err.code === 'ENOENT' || err.code === 'not-found' || err.code === 'FS_NOT_FOUND')) return []
|
|
1158
1173
|
throw err
|
|
@@ -1315,7 +1330,12 @@ export function createStore ({ fs, root, now = () => new Date() }) {
|
|
|
1315
1330
|
}
|
|
1316
1331
|
|
|
1317
1332
|
return {
|
|
1318
|
-
|
|
1333
|
+
/** 当前课程库根目录;切换后所有读写立即走新根。 */
|
|
1334
|
+
get root () { return currentRoot },
|
|
1335
|
+
/** 切换课程库根目录(配置界面用)。调用方应先用 drain() 等在途写结束。 */
|
|
1336
|
+
setRoot (next) { currentRoot = next },
|
|
1337
|
+
/** 等在途写结束(切根前用)。 */
|
|
1338
|
+
drain: () => run.drain(),
|
|
1319
1339
|
courseDir,
|
|
1320
1340
|
readJson,
|
|
1321
1341
|
writeJson,
|
|
@@ -1645,6 +1665,17 @@ export function createTaskRunners ({ store, generateOne, logger }) {
|
|
|
1645
1665
|
}
|
|
1646
1666
|
return out
|
|
1647
1667
|
},
|
|
1668
|
+
/**
|
|
1669
|
+
* 清空按 courseId 缓存的运行器。
|
|
1670
|
+
*
|
|
1671
|
+
* 切换课程库根目录时**必须**清:运行器的 loadInitial/persist 持有同一个 store,
|
|
1672
|
+
* 换根后如果不重建,旧根的任务快照会被当成新根同一 courseId 的任务,
|
|
1673
|
+
* 下一次落盘就会把旧状态写进新根(复制/搬迁课程库时最容易踩)。
|
|
1674
|
+
* 调用前必须确认没有活跃任务(setCourseRoot 已用 listActive 守住)。
|
|
1675
|
+
*/
|
|
1676
|
+
reset () {
|
|
1677
|
+
runners.clear()
|
|
1678
|
+
},
|
|
1648
1679
|
}
|
|
1649
1680
|
}
|
|
1650
1681
|
|
|
@@ -3042,6 +3073,7 @@ const RPC_STATUS = {
|
|
|
3042
3073
|
'read-only': 403,
|
|
3043
3074
|
conflict: 409,
|
|
3044
3075
|
'already-running': 409,
|
|
3076
|
+
'settings-write-failed': 500,
|
|
3045
3077
|
}
|
|
3046
3078
|
|
|
3047
3079
|
/* ------------------------------------------------------------ 输入校验器 */
|
|
@@ -3496,20 +3528,71 @@ function sendBinary (res, status, data, contentType) {
|
|
|
3496
3528
|
* client 只能调动这里列出的方法;每个方法自带输入校验。
|
|
3497
3529
|
* ========================================================================== */
|
|
3498
3530
|
|
|
3499
|
-
|
|
3531
|
+
const SETTINGS_FILE = 'ai-courses.settings.json'
|
|
3532
|
+
const SETTINGS_VERSION = 1
|
|
3533
|
+
|
|
3534
|
+
/** 课程库根目录必须是绝对路径(与 30-courses 的路径校验同一口径)。 */
|
|
3535
|
+
function isAbsoluteCourseRoot (p) {
|
|
3536
|
+
return typeof p === 'string' && /^([/\\]|[A-Za-z]:[\\/])/.test(p)
|
|
3537
|
+
}
|
|
3538
|
+
|
|
3539
|
+
/**
|
|
3540
|
+
* 读界面保存的 courseRoot 覆盖。
|
|
3541
|
+
*
|
|
3542
|
+
* `apply` 是同步的,这个文件又很小,所以启动时同步读;读坏了**不影响插件启动**,
|
|
3543
|
+
* 按「没有设置」处理并留一条 warn(课程库回退到 profile 配置 / 默认值)。
|
|
3544
|
+
*/
|
|
3545
|
+
function readRootOverride (file, home, logger) {
|
|
3546
|
+
try {
|
|
3547
|
+
if (!existsSync(file)) return null
|
|
3548
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'))
|
|
3549
|
+
const version = Number(parsed && parsed.schemaVersion)
|
|
3550
|
+
if (Number.isFinite(version) && version > SETTINGS_VERSION) {
|
|
3551
|
+
logger?.warn?.('[ai-courses] %s 的 schemaVersion=%s 比本版新,已忽略这份设置', file, String(version))
|
|
3552
|
+
return null
|
|
3553
|
+
}
|
|
3554
|
+
const raw = parsed && typeof parsed.courseRoot === 'string' ? parsed.courseRoot.trim() : ''
|
|
3555
|
+
if (!raw) return null
|
|
3556
|
+
const expanded = expandRoot(raw, home)
|
|
3557
|
+
if (!isAbsoluteCourseRoot(expanded)) {
|
|
3558
|
+
logger?.warn?.('[ai-courses] %s 里的 courseRoot 不是绝对路径,已忽略:%s', file, raw)
|
|
3559
|
+
return null
|
|
3560
|
+
}
|
|
3561
|
+
return expanded
|
|
3562
|
+
} catch (err) {
|
|
3563
|
+
logger?.warn?.('[ai-courses] 读取 %s 失败(按未设置处理):%s', file, err && err.message)
|
|
3564
|
+
return null
|
|
3565
|
+
}
|
|
3566
|
+
}
|
|
3567
|
+
|
|
3568
|
+
export function buildContext (ctx, config) {
|
|
3500
3569
|
config = normalizeConfig(config)
|
|
3501
3570
|
/* ⚠️ **不要访问未 inject 的服务**:cordis 会在读取 `ctx.env` 这类属性时直接抛
|
|
3502
3571
|
`cannot get property "env" without inject` —— 这是在装上实测才暴露的(语法与离线冒烟都看不出)。
|
|
3503
3572
|
home 只从进程环境取:课程库是插件自己的数据目录,与宿主 fs 服务无关。 */
|
|
3504
3573
|
/* home 必须拿到**绝对路径**:相对路径会让课程库落到进程 cwd 下(位置不确定)。 */
|
|
3505
3574
|
const home = process.env.HOME || process.env.USERPROFILE || homedir()
|
|
3506
|
-
const
|
|
3507
|
-
if (
|
|
3508
|
-
throw new Error(`[ai-courses] 课程库根目录必须是绝对路径,实际解析为:${JSON.stringify(
|
|
3509
|
-
}
|
|
3575
|
+
const configuredRoot = expandRoot(config?.courseRoot, home)
|
|
3576
|
+
if (!isAbsoluteCourseRoot(configuredRoot)) {
|
|
3577
|
+
throw new Error(`[ai-courses] 课程库根目录必须是绝对路径,实际解析为:${JSON.stringify(configuredRoot)}(courseRoot=${JSON.stringify(config?.courseRoot)}, home=${JSON.stringify(home)})`)
|
|
3578
|
+
}
|
|
3579
|
+
|
|
3580
|
+
/* 配置界面保存的覆盖值:按 profile 放,避免 web/desktop 两个 profile 互相改。
|
|
3581
|
+
DSH_PROFILE_DIR 优先;没有的话用 DSH_PROFILE 拼;再没有才落到 DSH_HOME。 */
|
|
3582
|
+
const dshHome = process.env.DSH_HOME || join(home, '.dsh')
|
|
3583
|
+
const profileDir = process.env.DSH_PROFILE_DIR ||
|
|
3584
|
+
(process.env.DSH_PROFILE ? join(dshHome, 'profiles', process.env.DSH_PROFILE) : '')
|
|
3585
|
+
const settingsPath = resolve(profileDir || dshHome, SETTINGS_FILE)
|
|
3586
|
+
const savedRoot = readRootOverride(settingsPath, home, ctx.logger)
|
|
3587
|
+
let hasOverride = !!savedRoot
|
|
3588
|
+
const root = savedRoot || configuredRoot
|
|
3589
|
+
const defaultRoot = expandRoot(CONFIG_DEFAULTS.courseRoot, home)
|
|
3590
|
+
|
|
3510
3591
|
/* 课程库用 node:fs 端口:宿主 `ctx.fs` 是**沙箱化**的(workpace-write 下写不进工作区外的
|
|
3511
3592
|
`~/.dsh/ai-courses`,且原型上没有删除能力)。真机实测见 06-fs-port.mjs 文件头与 doc/04 E-24…E-27。 */
|
|
3512
|
-
const
|
|
3593
|
+
const fsPort = createNodeFsPort()
|
|
3594
|
+
const store = createStore({ fs: fsPort, root })
|
|
3595
|
+
|
|
3513
3596
|
/* 会话工作区:只用于「内容里的引用」的 allowlist(不是课程库位置)。
|
|
3514
3597
|
用 `ctx.get` 而不是 `ctx.sandboxPolicy`:后者不在 inject 里,裸访问会抛;
|
|
3515
3598
|
取不到就回空串,readSource 会退回到「课程库 + 临时目录」这个更窄的白名单。 */
|
|
@@ -3557,12 +3640,78 @@ function buildContext (ctx, config) {
|
|
|
3557
3640
|
logger: ctx.logger,
|
|
3558
3641
|
})
|
|
3559
3642
|
|
|
3643
|
+
/** 当前课程库配置(配置界面读取)。 */
|
|
3644
|
+
function configInfo () {
|
|
3645
|
+
const source = hasOverride
|
|
3646
|
+
? 'settings'
|
|
3647
|
+
: (config.courseRoot === CONFIG_DEFAULTS.courseRoot ? 'default' : 'config')
|
|
3648
|
+
return {
|
|
3649
|
+
courseRoot: store.root,
|
|
3650
|
+
configuredRoot,
|
|
3651
|
+
defaultRoot,
|
|
3652
|
+
source,
|
|
3653
|
+
settingsPath,
|
|
3654
|
+
hasOverride,
|
|
3655
|
+
}
|
|
3656
|
+
}
|
|
3657
|
+
|
|
3658
|
+
/**
|
|
3659
|
+
* 切换课程库根目录(配置界面保存)。
|
|
3660
|
+
*
|
|
3661
|
+
* 顺序很重要:**先验证目标目录可写 → 再写设置 → 最后换内存根**。
|
|
3662
|
+
* 任何一步失败都保持旧根不动,避免「设置写了一半、课程库已经换了」。
|
|
3663
|
+
* 有生成任务在跑时直接拒绝:任务持有的是旧根下的 courseId/目录,切根会让它写到新根。
|
|
3664
|
+
*/
|
|
3665
|
+
async function setCourseRoot (raw) {
|
|
3666
|
+
const next = expandRoot(String(raw == null ? '' : raw).trim(), home)
|
|
3667
|
+
if (!isAbsoluteCourseRoot(next)) {
|
|
3668
|
+
throw new RpcError('invalid-input', `课程库目录必须是绝对路径:${next}`)
|
|
3669
|
+
}
|
|
3670
|
+
if (next === store.root) return configInfo()
|
|
3671
|
+
|
|
3672
|
+
const active = runners.listActive()
|
|
3673
|
+
if (active.length) {
|
|
3674
|
+
throw new RpcError('conflict', '有生成任务正在运行,等它结束再切换课程库目录', { active: active.length })
|
|
3675
|
+
}
|
|
3676
|
+
/* `/ai-course` 的推导也会往课程库里写(课程/大纲/进度);推导中途换根会让一次建课
|
|
3677
|
+
跨两个目录。它不在任务表里,单独用会话状态表拦一下。 */
|
|
3678
|
+
if (status.hasRunning()) {
|
|
3679
|
+
throw new RpcError('conflict', '有课程正在推导,等它完成再切换课程库目录')
|
|
3680
|
+
}
|
|
3681
|
+
await store.drain()
|
|
3682
|
+
|
|
3683
|
+
try {
|
|
3684
|
+
await fsPort.mkdir(next)
|
|
3685
|
+
const probe = fsPort.join(next, `.aic-write-probe-${process.pid}-${Date.now()}`)
|
|
3686
|
+
await fsPort.writeText(probe, '')
|
|
3687
|
+
await fsPort.remove(probe).catch(() => {})
|
|
3688
|
+
} catch (err) {
|
|
3689
|
+
throw new RpcError('invalid-input', `课程库目录不可写:${next}`, { reason: String(err && err.message) })
|
|
3690
|
+
}
|
|
3691
|
+
|
|
3692
|
+
try {
|
|
3693
|
+
await fsPort.writeText(settingsPath, JSON.stringify({ schemaVersion: SETTINGS_VERSION, courseRoot: next }, null, 2) + '\n')
|
|
3694
|
+
} catch (err) {
|
|
3695
|
+
throw new RpcError('settings-write-failed', `无法保存课程库设置:${settingsPath}`, { reason: String(err && err.message) })
|
|
3696
|
+
}
|
|
3697
|
+
|
|
3698
|
+
store.setRoot(next)
|
|
3699
|
+
/* 任务运行器按 courseId 缓存、持有同一个 store;换根后必须重建,
|
|
3700
|
+
否则复制/搬迁课程库时会把旧根的任务快照写进新根。 */
|
|
3701
|
+
runners.reset()
|
|
3702
|
+
hasOverride = true
|
|
3703
|
+
try { ctx.logger?.info?.('[ai-courses] 课程库已切换到 %s', next) } catch { /* logger 不是硬依赖 */ }
|
|
3704
|
+
return configInfo()
|
|
3705
|
+
}
|
|
3706
|
+
|
|
3560
3707
|
return {
|
|
3561
3708
|
store,
|
|
3562
3709
|
courses,
|
|
3563
|
-
root,
|
|
3710
|
+
get root () { return store.root },
|
|
3564
3711
|
status,
|
|
3565
3712
|
runners,
|
|
3713
|
+
configInfo,
|
|
3714
|
+
setCourseRoot,
|
|
3566
3715
|
/* 把「要用的会话」解析成 `subagents.start` 需要的 `Agent`。
|
|
3567
3716
|
两条入口共用:RPC 从请求体的 `agentId`(精确路由没有会话上下文,E-26)、
|
|
3568
3717
|
模型工具从 `exec.agent`(工具执行时本来就在会话里)。
|
|
@@ -3631,7 +3780,7 @@ const OUTLINE_SCHEMA = {
|
|
|
3631
3780
|
}
|
|
3632
3781
|
|
|
3633
3782
|
export function createMethodTableFor (deps) {
|
|
3634
|
-
const { courses, status, store, runners, resolveAgent } = deps
|
|
3783
|
+
const { courses, status, store, runners, resolveAgent, configInfo, setCourseRoot } = deps
|
|
3635
3784
|
/* 领域层的错误码 → RPC 错误码(40-rpc 按 code 决定 HTTP 状态) */
|
|
3636
3785
|
const wrap = (fn) => async (ctx, input) => {
|
|
3637
3786
|
try {
|
|
@@ -3645,6 +3794,22 @@ export function createMethodTableFor (deps) {
|
|
|
3645
3794
|
}
|
|
3646
3795
|
|
|
3647
3796
|
return createMethodTable([
|
|
3797
|
+
{
|
|
3798
|
+
name: 'config.get',
|
|
3799
|
+
input: { type: 'object', fields: {} },
|
|
3800
|
+
handle: wrap(async () => configInfo()),
|
|
3801
|
+
},
|
|
3802
|
+
{
|
|
3803
|
+
name: 'config.set',
|
|
3804
|
+
input: {
|
|
3805
|
+
type: 'object',
|
|
3806
|
+
fields: {
|
|
3807
|
+
/* 目录路径:只做「非空/长度」校验;绝对路径与可写性由 setCourseRoot 现场验。 */
|
|
3808
|
+
courseRoot: { type: 'string', required: true, maxLength: 4096 },
|
|
3809
|
+
},
|
|
3810
|
+
},
|
|
3811
|
+
handle: wrap(async (ctx, i) => setCourseRoot(i.courseRoot)),
|
|
3812
|
+
},
|
|
3648
3813
|
{
|
|
3649
3814
|
name: 'courses.list',
|
|
3650
3815
|
input: { type: 'object', fields: {} },
|
package/lib/client.js
CHANGED
|
@@ -173,9 +173,25 @@ const AIC_COURSES_CORE = (function () {
|
|
|
173
173
|
'library.title': '课程库',
|
|
174
174
|
'library.subtitleAll': '{n} 门课程 · 全部保存在本机',
|
|
175
175
|
'library.subtitleFiltered': '{n} / {total} 门课程(已按筛选显示)',
|
|
176
|
-
'library.
|
|
177
|
-
'
|
|
178
|
-
'
|
|
176
|
+
'library.config': '课程库设置',
|
|
177
|
+
'config.title': '课程库设置',
|
|
178
|
+
'config.courseRootLabel': '课程库目录(courseRoot)',
|
|
179
|
+
'config.courseRootHint': '默认 ~/.dsh/ai-courses;支持 ~ 展开;必须是绝对路径。保存后立即切换,旧目录里的课程不会被移动或删除。',
|
|
180
|
+
'config.current': '当前生效:{path}',
|
|
181
|
+
'config.source': '来源:{source}',
|
|
182
|
+
'config.sourceDefault': '默认值',
|
|
183
|
+
'config.sourceConfig': 'profile 插件配置',
|
|
184
|
+
'config.sourceSettings': '界面设置',
|
|
185
|
+
'config.defaultAction': '填入默认路径',
|
|
186
|
+
'config.save': '保存并切换',
|
|
187
|
+
'config.saving': '正在切换…',
|
|
188
|
+
'config.loading': '正在读取当前配置…',
|
|
189
|
+
'config.empty': '课程库目录不能为空',
|
|
190
|
+
'config.unsupported': '当前宿主不支持在线修改课程库目录;请升级插件,或在 profile 的 cordis.patch.yml 里修改 courseRoot。',
|
|
191
|
+
'config.saved': '课程库已切换到 {path}。',
|
|
192
|
+
'config.settingsPath': '配置保存位置:{path}',
|
|
193
|
+
'config.tasksHint': '有生成或课程推导任务在跑时不能切换目录,等任务结束后再试。',
|
|
194
|
+
'config.writeFailed': '无法保存课程库设置:{message}',
|
|
179
195
|
'library.categoryLabel': '分类:',
|
|
180
196
|
'library.categoryAll': '全部分类',
|
|
181
197
|
'library.searchPlaceholder': '按名称搜索…',
|
|
@@ -402,9 +418,25 @@ const AIC_COURSES_CORE = (function () {
|
|
|
402
418
|
'library.title': 'Course Library',
|
|
403
419
|
'library.subtitleAll': '{n} courses · all stored locally',
|
|
404
420
|
'library.subtitleFiltered': '{n} / {total} courses (filtered)',
|
|
405
|
-
'library.
|
|
406
|
-
'
|
|
407
|
-
'
|
|
421
|
+
'library.config': 'Course library settings',
|
|
422
|
+
'config.title': 'Course library settings',
|
|
423
|
+
'config.courseRootLabel': 'Course library directory (courseRoot)',
|
|
424
|
+
'config.courseRootHint': 'Default: ~/.dsh/ai-courses; ~ is expanded; must be an absolute path. Saving switches immediately; courses in the old directory are not moved or deleted.',
|
|
425
|
+
'config.current': 'In effect: {path}',
|
|
426
|
+
'config.source': 'Source: {source}',
|
|
427
|
+
'config.sourceDefault': 'default',
|
|
428
|
+
'config.sourceConfig': 'profile plugin config',
|
|
429
|
+
'config.sourceSettings': 'UI setting',
|
|
430
|
+
'config.defaultAction': 'Use default path',
|
|
431
|
+
'config.save': 'Save and switch',
|
|
432
|
+
'config.saving': 'Switching…',
|
|
433
|
+
'config.loading': 'Reading current configuration…',
|
|
434
|
+
'config.empty': 'The course library directory cannot be empty',
|
|
435
|
+
'config.unsupported': 'This host cannot change the course library directory online; upgrade the plugin or edit courseRoot in the profile cordis.patch.yml.',
|
|
436
|
+
'config.saved': 'Course library switched to {path}.',
|
|
437
|
+
'config.settingsPath': 'Settings saved at: {path}',
|
|
438
|
+
'config.tasksHint': 'A generation or course-derivation task is running; wait for it to finish before switching directories.',
|
|
439
|
+
'config.writeFailed': 'Could not save the course library settings: {message}',
|
|
408
440
|
'library.categoryLabel': 'Category:',
|
|
409
441
|
'library.categoryAll': 'All categories',
|
|
410
442
|
'library.searchPlaceholder': 'Search by name…',
|
|
@@ -1220,6 +1252,8 @@ const AIC_ICONS = {
|
|
|
1220
1252
|
prev: ['M12.5 5.5l-5 4.5 5 4.5'],
|
|
1221
1253
|
trash: ['M4.5 6.5h11', 'M8 6.5V5a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1.5', 'M6 6.5l.7 8a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9l.7-8', 'M8.6 9.5v3.4', 'M11.4 9.5v3.4'],
|
|
1222
1254
|
next: ['M7.5 5.5l5 4.5-5 4.5'],
|
|
1255
|
+
/* 课程库设置:三条滑杆 + 三个旋钮(比齿轮更像「可调参数」) */
|
|
1256
|
+
settings: ['M3.5 6h13', 'M3.5 10h13', 'M3.5 14h13', 'M7 4.2v3.6', 'M13 8.2v3.6', 'M9 12.2v3.6'],
|
|
1223
1257
|
}
|
|
1224
1258
|
|
|
1225
1259
|
/** 内容类型 → 图标键(未知类型回退「图文」的图标,06 §5)。 */
|
|
@@ -1767,7 +1801,7 @@ function LibraryView (props) {
|
|
|
1767
1801
|
h('div', null,
|
|
1768
1802
|
h('h1', { className: 'aic-h1' }, t('library.title')),
|
|
1769
1803
|
subtitle ? h('p', { className: 'aic-sub' }, subtitle) : null),
|
|
1770
|
-
h(
|
|
1804
|
+
h(IconButton, { name: 'settings', label: t('library.config'), onClick: props.onOpenConfig }),
|
|
1771
1805
|
),
|
|
1772
1806
|
props.notice ? h(Notice, null, props.notice) : null,
|
|
1773
1807
|
h(
|
|
@@ -2530,6 +2564,148 @@ function ConfirmModal (props) {
|
|
|
2530
2564
|
)
|
|
2531
2565
|
}
|
|
2532
2566
|
|
|
2567
|
+
/** config.* 的 RPC 错误 → 界面文案(老宿主没有这两个方法时给出可执行的替代路径)。 */
|
|
2568
|
+
function aicConfigErrorText (t, err) {
|
|
2569
|
+
const code = err && err.code
|
|
2570
|
+
if (code === 'unknown-method' || code === 'http-404') return t('config.unsupported')
|
|
2571
|
+
if (code === 'conflict') return t('config.tasksHint')
|
|
2572
|
+
if (code === 'invalid-input') {
|
|
2573
|
+
const reason = err && err.detail && err.detail.reason ? String(err.detail.reason) : ''
|
|
2574
|
+
return String((err && err.message) || err) + (reason ? `(${reason})` : '')
|
|
2575
|
+
}
|
|
2576
|
+
if (code === 'settings-write-failed') {
|
|
2577
|
+
const reason = err && err.detail && err.detail.reason ? String(err.detail.reason) : ''
|
|
2578
|
+
return t('config.writeFailed', { message: reason || String((err && err.message) || err) })
|
|
2579
|
+
}
|
|
2580
|
+
return String((err && err.message) || err)
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
/**
|
|
2584
|
+
* 课程库设置弹窗(06 §3 的配置入口):修改 `courseRoot`。
|
|
2585
|
+
*
|
|
2586
|
+
* 保存成功后 host 会**立即换根**(store.setRoot),所以这里只需要通知上层刷新列表,
|
|
2587
|
+
* 不提示重启。旧目录里的课程不会被移动/删除;切回去仍然能看到。
|
|
2588
|
+
*
|
|
2589
|
+
* 三个前置约束:
|
|
2590
|
+
* · 目录必须是绝对路径(`~` 会展开);
|
|
2591
|
+
* · 目标目录必须可写(host 会 mkdir + 写一个探针文件再删掉);
|
|
2592
|
+
* · 有生成任务在跑时不允许切换(任务持有旧根下的课程目录)。
|
|
2593
|
+
*/
|
|
2594
|
+
function ConfigModal (props) {
|
|
2595
|
+
const t = props.t
|
|
2596
|
+
const [path, setPath] = React.useState('')
|
|
2597
|
+
const [info, setInfo] = React.useState(null)
|
|
2598
|
+
const [loading, setLoading] = React.useState(true)
|
|
2599
|
+
const [busy, setBusy] = React.useState(false)
|
|
2600
|
+
const [error, setError] = React.useState('')
|
|
2601
|
+
const alive = React.useRef(true)
|
|
2602
|
+
React.useEffect(() => () => { alive.current = false }, [])
|
|
2603
|
+
|
|
2604
|
+
const load = () => {
|
|
2605
|
+
setLoading(true)
|
|
2606
|
+
setError('')
|
|
2607
|
+
aicRpc('config.get', {}).then(
|
|
2608
|
+
(data) => {
|
|
2609
|
+
if (!alive.current) return
|
|
2610
|
+
setInfo(data)
|
|
2611
|
+
setPath(data && data.courseRoot ? data.courseRoot : '')
|
|
2612
|
+
setLoading(false)
|
|
2613
|
+
},
|
|
2614
|
+
(err) => {
|
|
2615
|
+
if (!alive.current) return
|
|
2616
|
+
setLoading(false)
|
|
2617
|
+
setError(aicConfigErrorText(t, err))
|
|
2618
|
+
},
|
|
2619
|
+
)
|
|
2620
|
+
}
|
|
2621
|
+
React.useEffect(() => { load() }, [])
|
|
2622
|
+
|
|
2623
|
+
const save = () => {
|
|
2624
|
+
const value = String(path || '').trim()
|
|
2625
|
+
if (!value) { setError(t('config.empty')); return }
|
|
2626
|
+
setBusy(true)
|
|
2627
|
+
setError('')
|
|
2628
|
+
aicRpc('config.set', { courseRoot: value }).then(
|
|
2629
|
+
(data) => {
|
|
2630
|
+
if (!alive.current) return
|
|
2631
|
+
setBusy(false)
|
|
2632
|
+
if (props.onSaved) props.onSaved(data)
|
|
2633
|
+
},
|
|
2634
|
+
(err) => {
|
|
2635
|
+
if (!alive.current) return
|
|
2636
|
+
setBusy(false)
|
|
2637
|
+
setError(aicConfigErrorText(t, err))
|
|
2638
|
+
},
|
|
2639
|
+
)
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
const sourceLabel = info
|
|
2643
|
+
? (info.source === 'settings'
|
|
2644
|
+
? t('config.sourceSettings')
|
|
2645
|
+
: info.source === 'config' ? t('config.sourceConfig') : t('config.sourceDefault'))
|
|
2646
|
+
: ''
|
|
2647
|
+
|
|
2648
|
+
return h(
|
|
2649
|
+
ModalShell,
|
|
2650
|
+
{
|
|
2651
|
+
t: t,
|
|
2652
|
+
title: t('config.title'),
|
|
2653
|
+
onClose: busy ? () => {} : props.onClose,
|
|
2654
|
+
foot: h(
|
|
2655
|
+
'span',
|
|
2656
|
+
{ className: 'aic-modal__footrow' },
|
|
2657
|
+
h('button', {
|
|
2658
|
+
className: 'aic-btn aic-btn--sm aic-btn--quiet',
|
|
2659
|
+
type: 'button',
|
|
2660
|
+
disabled: busy || loading || !info,
|
|
2661
|
+
onClick: () => setPath((info && info.defaultRoot) || ''),
|
|
2662
|
+
}, t('config.defaultAction')),
|
|
2663
|
+
h('button', {
|
|
2664
|
+
className: 'aic-btn aic-btn--primary',
|
|
2665
|
+
type: 'button',
|
|
2666
|
+
disabled: busy || loading,
|
|
2667
|
+
onClick: save,
|
|
2668
|
+
}, busy ? t('config.saving') : t('config.save')),
|
|
2669
|
+
),
|
|
2670
|
+
},
|
|
2671
|
+
h(
|
|
2672
|
+
'div',
|
|
2673
|
+
{ className: 'aic-modal__body' },
|
|
2674
|
+
loading ? h('div', { className: 'aic-muted' }, t('config.loading')) : null,
|
|
2675
|
+
h(
|
|
2676
|
+
'div',
|
|
2677
|
+
{ className: 'aic-field' },
|
|
2678
|
+
h(FieldLabel, null, t('config.courseRootLabel')),
|
|
2679
|
+
h('input', {
|
|
2680
|
+
className: 'aic-input aic-input--block',
|
|
2681
|
+
type: 'text',
|
|
2682
|
+
value: path,
|
|
2683
|
+
placeholder: '~/.dsh/ai-courses',
|
|
2684
|
+
'aria-label': t('config.courseRootLabel'),
|
|
2685
|
+
disabled: busy || loading,
|
|
2686
|
+
onChange: (ev) => setPath(ev.target.value),
|
|
2687
|
+
}),
|
|
2688
|
+
h('div', { className: 'aic-muted' }, t('config.courseRootHint')),
|
|
2689
|
+
),
|
|
2690
|
+
info && !loading
|
|
2691
|
+
? h('div', { className: 'aic-muted', style: { wordBreak: 'break-all' } },
|
|
2692
|
+
t('config.current', { path: info.courseRoot }))
|
|
2693
|
+
: null,
|
|
2694
|
+
info && !loading
|
|
2695
|
+
? h('div', { className: 'aic-muted' }, t('config.source', { source: sourceLabel }))
|
|
2696
|
+
: null,
|
|
2697
|
+
info && !loading
|
|
2698
|
+
? h('div', { className: 'aic-muted', style: { wordBreak: 'break-all' } },
|
|
2699
|
+
t('config.settingsPath', { path: info.settingsPath }))
|
|
2700
|
+
: null,
|
|
2701
|
+
error ? h(Notice, null, error) : null,
|
|
2702
|
+
!loading && !info
|
|
2703
|
+
? h('div', null, h('button', { className: 'aic-btn aic-btn--sm', type: 'button', onClick: load }, t('library.retry')))
|
|
2704
|
+
: null,
|
|
2705
|
+
),
|
|
2706
|
+
)
|
|
2707
|
+
}
|
|
2708
|
+
|
|
2533
2709
|
/**
|
|
2534
2710
|
* 编辑弹窗。
|
|
2535
2711
|
* @param {object} props.target { course } | { ch } | { ch, kp } 由 kind 决定
|
|
@@ -4297,12 +4473,25 @@ function AicCoursesApp (props) {
|
|
|
4297
4473
|
const [pendingDelete, setPendingDelete] = React.useState(null)
|
|
4298
4474
|
const [deleting, setDeleting] = React.useState(false)
|
|
4299
4475
|
const [deleteError, setDeleteError] = React.useState('')
|
|
4476
|
+
const [configOpen, setConfigOpen] = React.useState(false)
|
|
4300
4477
|
|
|
4301
4478
|
const [libState, reloadLib] = useResource(() => aicRpc('courses.list', {}), [version])
|
|
4302
4479
|
|
|
4303
4480
|
const refresh = () => setVersion((v) => v + 1)
|
|
4304
4481
|
const updateFilter = (patch) => setFilter((prev) => Object.assign({}, prev, patch))
|
|
4305
4482
|
|
|
4483
|
+
/* 课程库设置保存成功:旧根下的课程可能已经不存在,退回课程库并重新取数。
|
|
4484
|
+
切换后**不重启**宿主即可看到新目录(host 的 store 已换根)。 */
|
|
4485
|
+
const configSaved = (info) => {
|
|
4486
|
+
setConfigOpen(false)
|
|
4487
|
+
setCourseId(null)
|
|
4488
|
+
setKpId(null)
|
|
4489
|
+
setView('library')
|
|
4490
|
+
setFilter({ category: '', tags: [], query: '' })
|
|
4491
|
+
setNotice(t('config.saved', { path: info.courseRoot }))
|
|
4492
|
+
reloadLib()
|
|
4493
|
+
}
|
|
4494
|
+
|
|
4306
4495
|
/**
|
|
4307
4496
|
* 删除课程(**破坏性、仅用户**,07 号文档 OP-7):先确认再删。
|
|
4308
4497
|
*
|
|
@@ -4335,27 +4524,6 @@ function AicCoursesApp (props) {
|
|
|
4335
4524
|
)
|
|
4336
4525
|
}
|
|
4337
4526
|
|
|
4338
|
-
/* 「+ 新建课程」= 在主会话发起 /ai-course(面板不收集表单,06 §1)。
|
|
4339
|
-
04 号文档 U-16:客户端拿主会话 Agent 的取法**未收敛** —— 拿不到就如实提示,不假装发起成功。 */
|
|
4340
|
-
const startCourse = () => {
|
|
4341
|
-
const line = '/ai-course'
|
|
4342
|
-
try {
|
|
4343
|
-
const remote = props.ctx && props.ctx.remote
|
|
4344
|
-
const commands = remote && remote.commands
|
|
4345
|
-
const sessions = props.ctx && props.ctx.get ? props.ctx.get('sessions') : null
|
|
4346
|
-
const agent = sessions && typeof sessions.active === 'function' ? sessions.active() : null
|
|
4347
|
-
if (commands && agent && typeof commands.execute === 'function') {
|
|
4348
|
-
const signal = typeof AbortController === 'function' ? new AbortController().signal : undefined
|
|
4349
|
-
Promise.resolve(commands.execute(agent, line, [], signal)).then(
|
|
4350
|
-
() => setNotice(t('library.newSent', { line: line })),
|
|
4351
|
-
() => setNotice(t('library.newUnavailable', { line: line })),
|
|
4352
|
-
)
|
|
4353
|
-
return
|
|
4354
|
-
}
|
|
4355
|
-
} catch (err) { /* 服务未 inject / 形状不符 → 走下面的提示路径 */ }
|
|
4356
|
-
setNotice(t('library.newUnavailable', { line: line }))
|
|
4357
|
-
}
|
|
4358
|
-
|
|
4359
4527
|
/* 主会话的大纲卡片 →「打开完整大纲 / 学习」:卡片发一条插件内导航请求,这里切页。
|
|
4360
4528
|
面板没挂载时(composed out)没有订阅者,`AIC_NAV.open` 静默丢弃 —— 卡片不是唯一入口。 */
|
|
4361
4529
|
React.useEffect(() => AIC_NAV.on((req) => {
|
|
@@ -4396,7 +4564,7 @@ function AicCoursesApp (props) {
|
|
|
4396
4564
|
onClearFilters: () => setFilter({ category: '', tags: [], query: '' }),
|
|
4397
4565
|
onOpenOutline: openOutline,
|
|
4398
4566
|
onOpenLesson: (id) => openLesson(id, null),
|
|
4399
|
-
|
|
4567
|
+
onOpenConfig: () => setConfigOpen(true),
|
|
4400
4568
|
onRetry: reloadLib,
|
|
4401
4569
|
onDelete: requestDelete,
|
|
4402
4570
|
})
|
|
@@ -4496,6 +4664,13 @@ function AicCoursesApp (props) {
|
|
|
4496
4664
|
onConfirm: confirmDelete,
|
|
4497
4665
|
})
|
|
4498
4666
|
: null,
|
|
4667
|
+
configOpen
|
|
4668
|
+
? h(ConfigModal, {
|
|
4669
|
+
t: props.t,
|
|
4670
|
+
onClose: () => setConfigOpen(false),
|
|
4671
|
+
onSaved: configSaved,
|
|
4672
|
+
})
|
|
4673
|
+
: null,
|
|
4499
4674
|
)
|
|
4500
4675
|
}
|
|
4501
4676
|
|