@weibaohui/dsh-sync 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -2
- package/client/bundle.js +73 -15
- package/client/index.js +73 -15
- package/docs/demo.gif +0 -0
- package/package.json +3 -2
- package/src/index.js +155 -9
package/README.md
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
**多机同步插件**:让多台机器上的 dsh 通过一个私有 GitCode 仓库保持一致——技能、会话、设置、插件清单都能同步。
|
|
7
7
|
|
|
8
|
+

|
|
9
|
+
|
|
8
10
|
## 核心功能
|
|
9
11
|
|
|
10
12
|
- **四类内容可同步**(各有独立开关):
|
|
@@ -13,7 +15,9 @@
|
|
|
13
15
|
- 设置(settings.yaml,默认开)
|
|
14
16
|
- 插件清单(各 profile 的依赖与配置,默认开)
|
|
15
17
|
- **分支 → PR → 合并**:每台机器的变更以 PR 形式提交,冲突显化为一个待合并的 PR,绝不静默覆盖
|
|
16
|
-
-
|
|
18
|
+
- **同步前自动回填**:每次推送前先把远端新增、本机没动过的内容拉回本机,本机快照不会误删别的机器推上来的新技能/新配置
|
|
19
|
+
- **AI 智能对齐**:点「AI 智能对齐」,先自动回填远端新增,再由 AI 对两边都改过的文件做语义合并(动手前自动备份本机文件),合并后自动推送;仍冲突的 PR 顺手解决
|
|
20
|
+
- **AI 一键解决冲突**:出现冲突时设置页冒出「AI 解决冲突」按钮,点击后自动分析两边改动并合并,确定性的 git 操作不用你动手
|
|
17
21
|
- **安全**:强制私有仓库(公共仓库直接拒绝保存);访问 token 只写不回读
|
|
18
22
|
- **拉取安全**:pull 只回写本地没动过的远端变更,本地改过的内容不会被覆盖
|
|
19
23
|
|
|
@@ -31,4 +35,8 @@ dsh plugin --profile web add @weibaohui/dsh-sync -w
|
|
|
31
35
|
2. 打开 Web UI → **设置页 → dsh-sync**,填入仓库地址与 access token,保存
|
|
32
36
|
3. 按需开关四类同步内容
|
|
33
37
|
4. 之后每次修改,通过同步操作把本机变更推成 PR;多机之间即可保持一致
|
|
34
|
-
5.
|
|
38
|
+
5. 日常可点「AI 智能对齐」让 AI 先回填远端新增、语义合并双方改动;出现冲突时设置页会出现「AI 解决冲突」按钮,点一下即可
|
|
39
|
+
|
|
40
|
+
## 联系我 :飞书群
|
|
41
|
+
|
|
42
|
+

|
package/client/bundle.js
CHANGED
|
@@ -109,6 +109,13 @@ window.__ModuleLoader__.load({
|
|
|
109
109
|
conflictHint: '检测到未合并的同步 PR(两台机器改了同一文件)。点击下方按钮,AI 会读取本机令牌、分析两边改动、解决冲突并合并 PR。',
|
|
110
110
|
conflictPending: '有未解决的冲突 PR',
|
|
111
111
|
resolveBtn: 'AI 解决冲突',
|
|
112
|
+
alignBtn: 'AI 智能对齐',
|
|
113
|
+
alignTitle: 'AI 智能对齐',
|
|
114
|
+
alignHint: '先自动拉回远端新增内容(不覆盖本机改动),再由 AI 语义合并两边都改过的文件;动手前自动备份本机文件,合并后自动推送,仍冲突的 PR 会顺手解掉。',
|
|
115
|
+
alignFiles: '待语义合并文件',
|
|
116
|
+
alignNoFiles: '无双方改动——确定性同步已处理全部差异',
|
|
117
|
+
alignBackup: '备份目录',
|
|
118
|
+
reconcileLine: '远端回填 {applied} 项 · 待 AI 对齐 {both} 项',
|
|
112
119
|
openChat: '打开对话',
|
|
113
120
|
running: '执行中…(可能需要几分钟)',
|
|
114
121
|
runDone: '解决完成',
|
|
@@ -154,6 +161,13 @@ window.__ModuleLoader__.load({
|
|
|
154
161
|
conflictHint: 'An unmerged sync PR exists (two machines edited the same file). Click below: the AI reads the local token, analyzes both sides, resolves the conflict and merges the PR.',
|
|
155
162
|
conflictPending: 'Unresolved conflict PR',
|
|
156
163
|
resolveBtn: 'AI resolve conflict',
|
|
164
|
+
alignBtn: 'AI align',
|
|
165
|
+
alignTitle: 'AI smart align',
|
|
166
|
+
alignHint: 'Pulls remote-only changes first (never overwrites local edits), then the AI semantically merges files both sides changed; live files are backed up before merging, the result is pushed automatically, and any still-conflicting PR is resolved in the same run.',
|
|
167
|
+
alignFiles: 'Files to semantically merge',
|
|
168
|
+
alignNoFiles: 'No both-side changes — deterministic sync handled everything',
|
|
169
|
+
alignBackup: 'Backup dir',
|
|
170
|
+
reconcileLine: 'pulled {applied} remote items · {both} awaiting AI align',
|
|
157
171
|
openChat: 'Open chat',
|
|
158
172
|
running: 'Running… (may take minutes)',
|
|
159
173
|
runDone: 'Resolved',
|
|
@@ -259,28 +273,33 @@ window.__ModuleLoader__.load({
|
|
|
259
273
|
return h('div', { className: 'sk-toast' }, text)
|
|
260
274
|
}
|
|
261
275
|
|
|
262
|
-
// ──
|
|
263
|
-
//
|
|
276
|
+
// ── Agent-run dialog: action-button pattern for two modes ──
|
|
277
|
+
// conflict: user opens it, then clicks run (needs pending PR info)
|
|
278
|
+
// align: parent already POSTed (which ran a deterministic sync first),
|
|
279
|
+
// dialog opens with the job streaming and the both-modified list
|
|
280
|
+
// Both poll the same shape of job endpoint and can open the agent session.
|
|
264
281
|
|
|
265
|
-
function
|
|
266
|
-
const
|
|
282
|
+
function AgentRunDialog({ t, mode, pending, initial, onClose, onToast }) {
|
|
283
|
+
const align = mode === 'align'
|
|
284
|
+
const endpoint = align ? API + '/align/run' : API + '/conflict/run'
|
|
285
|
+
const [job, setJob] = useState(initial && initial.jobId ? { jobId: initial.jobId, status: 'running', output: '', code: null } : null)
|
|
267
286
|
const [busy, setBusy] = useState(false)
|
|
268
287
|
useEffect(() => {
|
|
269
288
|
if (job === null || job.status !== 'running') return
|
|
270
289
|
const timer = setInterval(() => {
|
|
271
|
-
getJson(
|
|
290
|
+
getJson(endpoint + '?id=' + encodeURIComponent(job.jobId))
|
|
272
291
|
.then(d => setJob(prev => prev && { ...prev, status: d.status, output: d.output || '', code: d.code, sessionId: d.sessionId || prev.sessionId }))
|
|
273
292
|
.catch(() => {})
|
|
274
293
|
}, 2000)
|
|
275
294
|
if (typeof timer.unref === 'function') timer.unref()
|
|
276
295
|
return () => clearInterval(timer)
|
|
277
|
-
}, [job && job.status])
|
|
296
|
+
}, [job && job.status, endpoint])
|
|
278
297
|
const doRun = async () => {
|
|
279
298
|
setBusy(true)
|
|
280
299
|
try {
|
|
281
|
-
const r = await fetch(
|
|
300
|
+
const r = await fetch(endpoint, {
|
|
282
301
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
283
|
-
body: JSON.stringify({ branch: pending && pending.branch, prNumber: pending && pending.prNumber }),
|
|
302
|
+
body: JSON.stringify(align ? {} : { branch: pending && pending.branch, prNumber: pending && pending.prNumber }),
|
|
284
303
|
})
|
|
285
304
|
const d = await r.json().catch(() => ({}))
|
|
286
305
|
if (!r.ok) throw new Error(d.error || 'HTTP ' + r.status)
|
|
@@ -295,12 +314,19 @@ window.__ModuleLoader__.load({
|
|
|
295
314
|
}
|
|
296
315
|
const row = (label, value) => h('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 12, padding: '3px 0' } },
|
|
297
316
|
h('span', { className: 'sk-dir' }, label), h('span', { className: 'sk-hint', style: { wordBreak: 'break-all', textAlign: 'right' } }, value))
|
|
298
|
-
|
|
317
|
+
const files = initial && Array.isArray(initial.bothModified) ? initial.bothModified : null
|
|
318
|
+
return h(SkDialog, { title: align ? t('alignTitle') : t('conflictTitle'), onClose, wide: true },
|
|
299
319
|
h('div', { style: { display: 'flex', flexDirection: 'column', gap: 10, minWidth: 380 } },
|
|
300
|
-
h('div', { className: 'sk-hint' }, t('conflictHint')),
|
|
301
|
-
pending && h('div', null,
|
|
320
|
+
h('div', { className: 'sk-hint' }, align ? t('alignHint') : t('conflictHint')),
|
|
321
|
+
!align && pending && h('div', null,
|
|
302
322
|
row('PR', '#' + (pending.prNumber || '-')),
|
|
303
323
|
row('Branch', pending.branch || '-')),
|
|
324
|
+
align && h('div', { className: 'sk-card' },
|
|
325
|
+
h('div', { className: 'sk-dir', style: { marginBottom: 4 } }, t('alignFiles')),
|
|
326
|
+
files && files.length
|
|
327
|
+
? h('pre', { style: { margin: 0, whiteSpace: 'pre-wrap', fontSize: 12, maxHeight: 120, overflow: 'auto' } }, files.join('\n'))
|
|
328
|
+
: h('div', { className: 'sk-hint' }, t('alignNoFiles')),
|
|
329
|
+
initial && initial.backupDir && row(t('alignBackup'), initial.backupDir)),
|
|
304
330
|
job !== null && h('div', null,
|
|
305
331
|
h('div', { className: 'sk-dir', style: { margin: '4px 0' } },
|
|
306
332
|
t('outputLabel') + ' · ' + (job.status === 'running' ? t('running') : job.status === 'done' ? t('runDone') : t('runFailed') + (job.code != null ? ' (' + job.code + ')' : ''))),
|
|
@@ -308,7 +334,7 @@ window.__ModuleLoader__.load({
|
|
|
308
334
|
job.output || '…')),
|
|
309
335
|
h('div', { className: 'sk-dlg-foot', style: { marginTop: 0 } },
|
|
310
336
|
job !== null && job.sessionId && sessionsSvc() && h(ButtonLite, { onClick: openChat }, t('openChat')),
|
|
311
|
-
h(ButtonLite, { primary: true, disabled: busy || (job !== null && job.status === 'running'), onClick: doRun },
|
|
337
|
+
!align && h(ButtonLite, { primary: true, disabled: busy || (job !== null && job.status === 'running'), onClick: doRun },
|
|
312
338
|
job !== null && job.status === 'running' ? t('running') : t('resolveBtn')))))
|
|
313
339
|
}
|
|
314
340
|
|
|
@@ -317,7 +343,10 @@ window.__ModuleLoader__.load({
|
|
|
317
343
|
function SettingsSection({ t }) {
|
|
318
344
|
const [status, setStatus] = useState(null)
|
|
319
345
|
const [busy, setBusy] = useState(false)
|
|
346
|
+
const [alignBusy, setAlignBusy] = useState(false)
|
|
320
347
|
const [conflictOpen, setConflictOpen] = useState(false)
|
|
348
|
+
const [alignOpen, setAlignOpen] = useState(false)
|
|
349
|
+
const [alignInitial, setAlignInitial] = useState(null)
|
|
321
350
|
const [toastText, setToastText] = useState(null)
|
|
322
351
|
const [repoUrl, setRepoUrl] = useState('')
|
|
323
352
|
const [branch, setBranch] = useState('')
|
|
@@ -356,6 +385,20 @@ window.__ModuleLoader__.load({
|
|
|
356
385
|
} catch (e) { onToast(t('syncFailed') + ': ' + e.message, 4000) }
|
|
357
386
|
finally { setBusy(false); refresh() }
|
|
358
387
|
}
|
|
388
|
+
// AI smart align: POST runs a deterministic sync first (remote-only files
|
|
389
|
+
// pulled back), then streams the semantic-merge agent run; dialog opens
|
|
390
|
+
// already running with the both-modified file list.
|
|
391
|
+
const doAlign = async () => {
|
|
392
|
+
setAlignBusy(true)
|
|
393
|
+
try {
|
|
394
|
+
const r = await fetch(API + '/align/run', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
|
|
395
|
+
const d = await r.json().catch(() => ({}))
|
|
396
|
+
if (!r.ok) throw new Error(d.error || 'HTTP ' + r.status)
|
|
397
|
+
setAlignInitial({ jobId: d.jobId, bothModified: d.bothModified || [], backupDir: d.backupDir })
|
|
398
|
+
setAlignOpen(true)
|
|
399
|
+
} catch (e) { onToast(e.message || t('operationFailed'), 4000) }
|
|
400
|
+
finally { setAlignBusy(false); refresh() }
|
|
401
|
+
}
|
|
359
402
|
const putSettings = async (patch) => {
|
|
360
403
|
const r = await fetch(API + '/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) })
|
|
361
404
|
const d = await r.json().catch(() => ({}))
|
|
@@ -405,6 +448,13 @@ window.__ModuleLoader__.load({
|
|
|
405
448
|
row(t('branchLabel'), status.branch || '-'),
|
|
406
449
|
row(t('dirLabel'), status.dir),
|
|
407
450
|
row(t('lastSyncLabel'), status.lastSyncAt ? formatTime(status.lastSyncAt) : t('repoMissing'))),
|
|
451
|
+
(() => {
|
|
452
|
+
const rec = status.lastResult && status.lastResult.reconcile
|
|
453
|
+
const applied = rec && Array.isArray(rec.applied) ? rec.applied.length : 0
|
|
454
|
+
const both = rec && Array.isArray(rec.bothModified) ? rec.bothModified.length : 0
|
|
455
|
+
if (!rec || (!applied && !both)) return null
|
|
456
|
+
return h(Tag, { tone: both ? 'danger' : 'accent' }, t('reconcileLine', { applied, both }))
|
|
457
|
+
})(),
|
|
408
458
|
h('div', null,
|
|
409
459
|
h('div', { className: 'sk-dir', style: { margin: '4px 0' } }, t('groupHint')),
|
|
410
460
|
h('div', { className: 'sk-toggles' },
|
|
@@ -436,7 +486,12 @@ window.__ModuleLoader__.load({
|
|
|
436
486
|
h('div', { className: 'sk-toolbar' },
|
|
437
487
|
h(ButtonLite, { onClick: doSave }, t('save')),
|
|
438
488
|
h('span', { className: 'sk-spacer' }),
|
|
439
|
-
h(ButtonLite, {
|
|
489
|
+
h(ButtonLite, {
|
|
490
|
+
disabled: alignBusy || status.syncing || !status.repoUrl || !status.hasToken,
|
|
491
|
+
title: !status.repoUrl || !status.hasToken ? t('notConfigured') : undefined,
|
|
492
|
+
onClick: doAlign,
|
|
493
|
+
}, alignBusy ? t('running') : t('alignBtn')),
|
|
494
|
+
h(ButtonLite, { primary: true, disabled: busy || status.syncing || alignBusy, onClick: doSync }, busy ? t('syncing') : t('syncNow'))))
|
|
440
495
|
} catch (renderErr) {
|
|
441
496
|
;(globalThis.__skErrors = globalThis.__skErrors || []).push('body: ' + (renderErr && renderErr.message))
|
|
442
497
|
body = h('div', { className: 'sk-card', style: { color: 'var(--dsw-alias-state-error-primary)' } },
|
|
@@ -445,8 +500,11 @@ window.__ModuleLoader__.load({
|
|
|
445
500
|
|
|
446
501
|
return h('div', { className: 'sk-page' },
|
|
447
502
|
h('div', { className: 'sk-body' }, body),
|
|
448
|
-
conflictOpen && status && status.pendingConflict && h(
|
|
449
|
-
t, pending: status.pendingConflict, onClose: () => setConflictOpen(false), onToast,
|
|
503
|
+
conflictOpen && status && status.pendingConflict && h(AgentRunDialog, {
|
|
504
|
+
t, mode: 'conflict', pending: status.pendingConflict, onClose: () => setConflictOpen(false), onToast,
|
|
505
|
+
}),
|
|
506
|
+
alignOpen && h(AgentRunDialog, {
|
|
507
|
+
t, mode: 'align', initial: alignInitial, onClose: () => setAlignOpen(false), onToast,
|
|
450
508
|
}),
|
|
451
509
|
toastText && h(InToast, { text: toastText }),
|
|
452
510
|
)
|
package/client/index.js
CHANGED
|
@@ -99,6 +99,13 @@ const ZH = {
|
|
|
99
99
|
conflictHint: '检测到未合并的同步 PR(两台机器改了同一文件)。点击下方按钮,AI 会读取本机令牌、分析两边改动、解决冲突并合并 PR。',
|
|
100
100
|
conflictPending: '有未解决的冲突 PR',
|
|
101
101
|
resolveBtn: 'AI 解决冲突',
|
|
102
|
+
alignBtn: 'AI 智能对齐',
|
|
103
|
+
alignTitle: 'AI 智能对齐',
|
|
104
|
+
alignHint: '先自动拉回远端新增内容(不覆盖本机改动),再由 AI 语义合并两边都改过的文件;动手前自动备份本机文件,合并后自动推送,仍冲突的 PR 会顺手解掉。',
|
|
105
|
+
alignFiles: '待语义合并文件',
|
|
106
|
+
alignNoFiles: '无双方改动——确定性同步已处理全部差异',
|
|
107
|
+
alignBackup: '备份目录',
|
|
108
|
+
reconcileLine: '远端回填 {applied} 项 · 待 AI 对齐 {both} 项',
|
|
102
109
|
openChat: '打开对话',
|
|
103
110
|
running: '执行中…(可能需要几分钟)',
|
|
104
111
|
runDone: '解决完成',
|
|
@@ -144,6 +151,13 @@ const EN = {
|
|
|
144
151
|
conflictHint: 'An unmerged sync PR exists (two machines edited the same file). Click below: the AI reads the local token, analyzes both sides, resolves the conflict and merges the PR.',
|
|
145
152
|
conflictPending: 'Unresolved conflict PR',
|
|
146
153
|
resolveBtn: 'AI resolve conflict',
|
|
154
|
+
alignBtn: 'AI align',
|
|
155
|
+
alignTitle: 'AI smart align',
|
|
156
|
+
alignHint: 'Pulls remote-only changes first (never overwrites local edits), then the AI semantically merges files both sides changed; live files are backed up before merging, the result is pushed automatically, and any still-conflicting PR is resolved in the same run.',
|
|
157
|
+
alignFiles: 'Files to semantically merge',
|
|
158
|
+
alignNoFiles: 'No both-side changes — deterministic sync handled everything',
|
|
159
|
+
alignBackup: 'Backup dir',
|
|
160
|
+
reconcileLine: 'pulled {applied} remote items · {both} awaiting AI align',
|
|
147
161
|
openChat: 'Open chat',
|
|
148
162
|
running: 'Running… (may take minutes)',
|
|
149
163
|
runDone: 'Resolved',
|
|
@@ -249,28 +263,33 @@ function InToast({ text }) {
|
|
|
249
263
|
return h('div', { className: 'sk-toast' }, text)
|
|
250
264
|
}
|
|
251
265
|
|
|
252
|
-
// ──
|
|
253
|
-
//
|
|
266
|
+
// ── Agent-run dialog: action-button pattern for two modes ──
|
|
267
|
+
// conflict: user opens it, then clicks run (needs pending PR info)
|
|
268
|
+
// align: parent already POSTed (which ran a deterministic sync first),
|
|
269
|
+
// dialog opens with the job streaming and the both-modified list
|
|
270
|
+
// Both poll the same shape of job endpoint and can open the agent session.
|
|
254
271
|
|
|
255
|
-
function
|
|
256
|
-
const
|
|
272
|
+
function AgentRunDialog({ t, mode, pending, initial, onClose, onToast }) {
|
|
273
|
+
const align = mode === 'align'
|
|
274
|
+
const endpoint = align ? API + '/align/run' : API + '/conflict/run'
|
|
275
|
+
const [job, setJob] = useState(initial && initial.jobId ? { jobId: initial.jobId, status: 'running', output: '', code: null } : null)
|
|
257
276
|
const [busy, setBusy] = useState(false)
|
|
258
277
|
useEffect(() => {
|
|
259
278
|
if (job === null || job.status !== 'running') return
|
|
260
279
|
const timer = setInterval(() => {
|
|
261
|
-
getJson(
|
|
280
|
+
getJson(endpoint + '?id=' + encodeURIComponent(job.jobId))
|
|
262
281
|
.then(d => setJob(prev => prev && { ...prev, status: d.status, output: d.output || '', code: d.code, sessionId: d.sessionId || prev.sessionId }))
|
|
263
282
|
.catch(() => {})
|
|
264
283
|
}, 2000)
|
|
265
284
|
if (typeof timer.unref === 'function') timer.unref()
|
|
266
285
|
return () => clearInterval(timer)
|
|
267
|
-
}, [job && job.status])
|
|
286
|
+
}, [job && job.status, endpoint])
|
|
268
287
|
const doRun = async () => {
|
|
269
288
|
setBusy(true)
|
|
270
289
|
try {
|
|
271
|
-
const r = await fetch(
|
|
290
|
+
const r = await fetch(endpoint, {
|
|
272
291
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
273
|
-
body: JSON.stringify({ branch: pending && pending.branch, prNumber: pending && pending.prNumber }),
|
|
292
|
+
body: JSON.stringify(align ? {} : { branch: pending && pending.branch, prNumber: pending && pending.prNumber }),
|
|
274
293
|
})
|
|
275
294
|
const d = await r.json().catch(() => ({}))
|
|
276
295
|
if (!r.ok) throw new Error(d.error || 'HTTP ' + r.status)
|
|
@@ -285,12 +304,19 @@ function ConflictDialog({ t, pending, onClose, onToast }) {
|
|
|
285
304
|
}
|
|
286
305
|
const row = (label, value) => h('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 12, padding: '3px 0' } },
|
|
287
306
|
h('span', { className: 'sk-dir' }, label), h('span', { className: 'sk-hint', style: { wordBreak: 'break-all', textAlign: 'right' } }, value))
|
|
288
|
-
|
|
307
|
+
const files = initial && Array.isArray(initial.bothModified) ? initial.bothModified : null
|
|
308
|
+
return h(SkDialog, { title: align ? t('alignTitle') : t('conflictTitle'), onClose, wide: true },
|
|
289
309
|
h('div', { style: { display: 'flex', flexDirection: 'column', gap: 10, minWidth: 380 } },
|
|
290
|
-
h('div', { className: 'sk-hint' }, t('conflictHint')),
|
|
291
|
-
pending && h('div', null,
|
|
310
|
+
h('div', { className: 'sk-hint' }, align ? t('alignHint') : t('conflictHint')),
|
|
311
|
+
!align && pending && h('div', null,
|
|
292
312
|
row('PR', '#' + (pending.prNumber || '-')),
|
|
293
313
|
row('Branch', pending.branch || '-')),
|
|
314
|
+
align && h('div', { className: 'sk-card' },
|
|
315
|
+
h('div', { className: 'sk-dir', style: { marginBottom: 4 } }, t('alignFiles')),
|
|
316
|
+
files && files.length
|
|
317
|
+
? h('pre', { style: { margin: 0, whiteSpace: 'pre-wrap', fontSize: 12, maxHeight: 120, overflow: 'auto' } }, files.join('\n'))
|
|
318
|
+
: h('div', { className: 'sk-hint' }, t('alignNoFiles')),
|
|
319
|
+
initial && initial.backupDir && row(t('alignBackup'), initial.backupDir)),
|
|
294
320
|
job !== null && h('div', null,
|
|
295
321
|
h('div', { className: 'sk-dir', style: { margin: '4px 0' } },
|
|
296
322
|
t('outputLabel') + ' · ' + (job.status === 'running' ? t('running') : job.status === 'done' ? t('runDone') : t('runFailed') + (job.code != null ? ' (' + job.code + ')' : ''))),
|
|
@@ -298,7 +324,7 @@ function ConflictDialog({ t, pending, onClose, onToast }) {
|
|
|
298
324
|
job.output || '…')),
|
|
299
325
|
h('div', { className: 'sk-dlg-foot', style: { marginTop: 0 } },
|
|
300
326
|
job !== null && job.sessionId && sessionsSvc() && h(ButtonLite, { onClick: openChat }, t('openChat')),
|
|
301
|
-
h(ButtonLite, { primary: true, disabled: busy || (job !== null && job.status === 'running'), onClick: doRun },
|
|
327
|
+
!align && h(ButtonLite, { primary: true, disabled: busy || (job !== null && job.status === 'running'), onClick: doRun },
|
|
302
328
|
job !== null && job.status === 'running' ? t('running') : t('resolveBtn')))))
|
|
303
329
|
}
|
|
304
330
|
|
|
@@ -307,7 +333,10 @@ function ConflictDialog({ t, pending, onClose, onToast }) {
|
|
|
307
333
|
function SettingsSection({ t }) {
|
|
308
334
|
const [status, setStatus] = useState(null)
|
|
309
335
|
const [busy, setBusy] = useState(false)
|
|
336
|
+
const [alignBusy, setAlignBusy] = useState(false)
|
|
310
337
|
const [conflictOpen, setConflictOpen] = useState(false)
|
|
338
|
+
const [alignOpen, setAlignOpen] = useState(false)
|
|
339
|
+
const [alignInitial, setAlignInitial] = useState(null)
|
|
311
340
|
const [toastText, setToastText] = useState(null)
|
|
312
341
|
const [repoUrl, setRepoUrl] = useState('')
|
|
313
342
|
const [branch, setBranch] = useState('')
|
|
@@ -346,6 +375,20 @@ function SettingsSection({ t }) {
|
|
|
346
375
|
} catch (e) { onToast(t('syncFailed') + ': ' + e.message, 4000) }
|
|
347
376
|
finally { setBusy(false); refresh() }
|
|
348
377
|
}
|
|
378
|
+
// AI smart align: POST runs a deterministic sync first (remote-only files
|
|
379
|
+
// pulled back), then streams the semantic-merge agent run; dialog opens
|
|
380
|
+
// already running with the both-modified file list.
|
|
381
|
+
const doAlign = async () => {
|
|
382
|
+
setAlignBusy(true)
|
|
383
|
+
try {
|
|
384
|
+
const r = await fetch(API + '/align/run', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })
|
|
385
|
+
const d = await r.json().catch(() => ({}))
|
|
386
|
+
if (!r.ok) throw new Error(d.error || 'HTTP ' + r.status)
|
|
387
|
+
setAlignInitial({ jobId: d.jobId, bothModified: d.bothModified || [], backupDir: d.backupDir })
|
|
388
|
+
setAlignOpen(true)
|
|
389
|
+
} catch (e) { onToast(e.message || t('operationFailed'), 4000) }
|
|
390
|
+
finally { setAlignBusy(false); refresh() }
|
|
391
|
+
}
|
|
349
392
|
const putSettings = async (patch) => {
|
|
350
393
|
const r = await fetch(API + '/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch) })
|
|
351
394
|
const d = await r.json().catch(() => ({}))
|
|
@@ -395,6 +438,13 @@ function SettingsSection({ t }) {
|
|
|
395
438
|
row(t('branchLabel'), status.branch || '-'),
|
|
396
439
|
row(t('dirLabel'), status.dir),
|
|
397
440
|
row(t('lastSyncLabel'), status.lastSyncAt ? formatTime(status.lastSyncAt) : t('repoMissing'))),
|
|
441
|
+
(() => {
|
|
442
|
+
const rec = status.lastResult && status.lastResult.reconcile
|
|
443
|
+
const applied = rec && Array.isArray(rec.applied) ? rec.applied.length : 0
|
|
444
|
+
const both = rec && Array.isArray(rec.bothModified) ? rec.bothModified.length : 0
|
|
445
|
+
if (!rec || (!applied && !both)) return null
|
|
446
|
+
return h(Tag, { tone: both ? 'danger' : 'accent' }, t('reconcileLine', { applied, both }))
|
|
447
|
+
})(),
|
|
398
448
|
h('div', null,
|
|
399
449
|
h('div', { className: 'sk-dir', style: { margin: '4px 0' } }, t('groupHint')),
|
|
400
450
|
h('div', { className: 'sk-toggles' },
|
|
@@ -426,7 +476,12 @@ function SettingsSection({ t }) {
|
|
|
426
476
|
h('div', { className: 'sk-toolbar' },
|
|
427
477
|
h(ButtonLite, { onClick: doSave }, t('save')),
|
|
428
478
|
h('span', { className: 'sk-spacer' }),
|
|
429
|
-
h(ButtonLite, {
|
|
479
|
+
h(ButtonLite, {
|
|
480
|
+
disabled: alignBusy || status.syncing || !status.repoUrl || !status.hasToken,
|
|
481
|
+
title: !status.repoUrl || !status.hasToken ? t('notConfigured') : undefined,
|
|
482
|
+
onClick: doAlign,
|
|
483
|
+
}, alignBusy ? t('running') : t('alignBtn')),
|
|
484
|
+
h(ButtonLite, { primary: true, disabled: busy || status.syncing || alignBusy, onClick: doSync }, busy ? t('syncing') : t('syncNow'))))
|
|
430
485
|
} catch (renderErr) {
|
|
431
486
|
;(globalThis.__skErrors = globalThis.__skErrors || []).push('body: ' + (renderErr && renderErr.message))
|
|
432
487
|
body = h('div', { className: 'sk-card', style: { color: 'var(--dsw-alias-state-error-primary)' } },
|
|
@@ -435,8 +490,11 @@ function SettingsSection({ t }) {
|
|
|
435
490
|
|
|
436
491
|
return h('div', { className: 'sk-page' },
|
|
437
492
|
h('div', { className: 'sk-body' }, body),
|
|
438
|
-
conflictOpen && status && status.pendingConflict && h(
|
|
439
|
-
t, pending: status.pendingConflict, onClose: () => setConflictOpen(false), onToast,
|
|
493
|
+
conflictOpen && status && status.pendingConflict && h(AgentRunDialog, {
|
|
494
|
+
t, mode: 'conflict', pending: status.pendingConflict, onClose: () => setConflictOpen(false), onToast,
|
|
495
|
+
}),
|
|
496
|
+
alignOpen && h(AgentRunDialog, {
|
|
497
|
+
t, mode: 'align', initial: alignInitial, onClose: () => setAlignOpen(false), onToast,
|
|
440
498
|
}),
|
|
441
499
|
toastText && h(InToast, { text: toastText }),
|
|
442
500
|
)
|
package/docs/demo.gif
ADDED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weibaohui/dsh-sync",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "dsh 插件 · 多机同步:让多台机器上的 dsh 通过一个私有 GitCode 仓库保持一致——技能、会话、设置、插件清单四类内容各有独立开关;变更走分支 → PR →
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"description": "dsh 插件 · 多机同步:让多台机器上的 dsh 通过一个私有 GitCode 仓库保持一致——技能、会话、设置、插件清单四类内容各有独立开关;变更走分支 → PR → 合并,推送前自动回填远端新增防误删,支持 AI 智能对齐(语义合并双方改动)与一键解决冲突;强制私有仓库,pull 不覆盖本地改动。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"dsh",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"files": [
|
|
31
31
|
"src",
|
|
32
32
|
"client",
|
|
33
|
+
"docs",
|
|
33
34
|
"cordis.patch.yml"
|
|
34
35
|
],
|
|
35
36
|
"scripts": {
|
package/src/index.js
CHANGED
|
@@ -489,6 +489,62 @@ async function runPull(binary, eff, { repoDir, state, logger, roots }) {
|
|
|
489
489
|
return { pulled: true, applied, skipped, changed: changed.length }
|
|
490
490
|
}
|
|
491
491
|
|
|
492
|
+
// ── Pre-push reconcile: pull remote changes back into live BEFORE the
|
|
493
|
+
// snapshot push. The push mirror is a full-directory overlay (rm + copy),
|
|
494
|
+
// so without this step a file another machine added — and this replica
|
|
495
|
+
// hasn't pulled yet — gets deleted in the push branch and flaps out of
|
|
496
|
+
// the remote. Remote-only and locally-untouched files are written back
|
|
497
|
+
// here (purely deterministic, never overwrites local work); files both
|
|
498
|
+
// sides changed are reported as `bothModified` for the AI align step /
|
|
499
|
+
// conflict PR. Remote deletions are never mirrored into live. ──
|
|
500
|
+
|
|
501
|
+
async function reconcileRemote(binary, eff, { repoDir, state, logger, roots }) {
|
|
502
|
+
const fs = require('node:fs')
|
|
503
|
+
try { await fs.promises.access(join(repoDir, '.git')) } catch { return { reconciled: false, noShadow: true } }
|
|
504
|
+
const remote = authedUrl(eff.repoUrl, eff.token)
|
|
505
|
+
const spec = syncSpec(eff, roots)
|
|
506
|
+
try { await gitExec(binary, ['fetch', remote, eff.branch], repoDir) } catch (e) {
|
|
507
|
+
if (!/Could not find|doesn't exist|no such|empty/i.test(String(e && e.message))) throw e
|
|
508
|
+
return { reconciled: false, empty: true }
|
|
509
|
+
}
|
|
510
|
+
const hasFetch = await gitExec(binary, ['rev-parse', '--verify', 'FETCH_HEAD'], repoDir).then(() => true).catch(() => false)
|
|
511
|
+
if (!hasFetch) return { reconciled: false, empty: true }
|
|
512
|
+
const lastSynced = state.lastSyncedCommit
|
|
513
|
+
if (!lastSynced) {
|
|
514
|
+
// never synced: nothing to diff against; just record the baseline
|
|
515
|
+
await gitExec(binary, ['checkout', eff.branch], repoDir).catch(() => {})
|
|
516
|
+
await gitExec(binary, ['reset', '--hard', 'FETCH_HEAD'], repoDir).catch(() => {})
|
|
517
|
+
state.lastSyncedCommit = await gitCurrentCommit(binary, repoDir)
|
|
518
|
+
return { reconciled: false, firstBaseline: true }
|
|
519
|
+
}
|
|
520
|
+
let changedRaw = ''
|
|
521
|
+
try { changedRaw = await gitExec(binary, ['diff', '--name-only', lastSynced, 'FETCH_HEAD'], repoDir) } catch {}
|
|
522
|
+
const changed = changedRaw.split(/\r?\n/).map(s => s.trim()).filter(Boolean)
|
|
523
|
+
const applied = [] // safely written back to live
|
|
524
|
+
const bothModified = [] // both sides changed → AI align / conflict PR
|
|
525
|
+
const remoteDeleted = [] // gone on remote; live keeps its copy
|
|
526
|
+
for (const p of changed) {
|
|
527
|
+
const livePath = resolveLivePath(spec, p)
|
|
528
|
+
if (!livePath) continue
|
|
529
|
+
let remoteBuf = null
|
|
530
|
+
try { remoteBuf = await gitShowBuf(binary, `FETCH_HEAD:${p}`, repoDir) } catch { remoteBuf = null }
|
|
531
|
+
if (remoteBuf === null) { remoteDeleted.push(p); continue }
|
|
532
|
+
let liveBuf = null
|
|
533
|
+
try { liveBuf = await fsP.readFile(livePath) } catch {}
|
|
534
|
+
let lastSyncedBuf = null
|
|
535
|
+
try { lastSyncedBuf = await gitShowBuf(binary, `${lastSynced}:${p}`, repoDir) } catch { lastSyncedBuf = Buffer.alloc(0) }
|
|
536
|
+
const untouched = liveBuf === null ? (lastSyncedBuf.length === 0) : Buffer.compare(liveBuf, lastSyncedBuf) === 0
|
|
537
|
+
if (!untouched) { bothModified.push({ shadowPath: p, livePath }); continue }
|
|
538
|
+
try { await atomicWriteFile(livePath, remoteBuf); applied.push(p) } catch {}
|
|
539
|
+
}
|
|
540
|
+
// advance shadow baseline to FETCH_HEAD — safe items now match live, so the
|
|
541
|
+
// subsequent push overlay keeps every remote-only file instead of deleting it
|
|
542
|
+
await gitExec(binary, ['checkout', eff.branch], repoDir).catch(() => {})
|
|
543
|
+
await gitExec(binary, ['reset', '--hard', 'FETCH_HEAD'], repoDir).catch(() => {})
|
|
544
|
+
state.lastSyncedCommit = await gitCurrentCommit(binary, repoDir)
|
|
545
|
+
return { reconciled: true, applied, bothModified, remoteDeleted, changed: changed.length }
|
|
546
|
+
}
|
|
547
|
+
|
|
492
548
|
// ── Conflict-resolution action button: in-process agent (same channel as
|
|
493
549
|
// skills-management share-run). The agent operates the shadow repo's git
|
|
494
550
|
// directly + merges the PR via REST. Only this step needs semantic
|
|
@@ -532,6 +588,46 @@ function substituteParams(template, params) {
|
|
|
532
588
|
return out
|
|
533
589
|
}
|
|
534
590
|
|
|
591
|
+
// ── AI align action button: semantic merge of files both sides changed.
|
|
592
|
+
// Deterministic reconcile (remote-only pull-back) already ran in the host
|
|
593
|
+
// before this prompt is built; the agent only does the semantic judgement
|
|
594
|
+
// on the reported both-modified files, then triggers a normal sync and
|
|
595
|
+
// falls back to conflict resolution if a PR still can't merge. ──
|
|
596
|
+
|
|
597
|
+
const ALIGN_PROMPT_ZH = [
|
|
598
|
+
'请执行 dsh-sync 的「AI 智能对齐」:把本机与远端都改过的文件做语义合并,然后触发一次同步完成推送。',
|
|
599
|
+
'',
|
|
600
|
+
'## 路径信息',
|
|
601
|
+
'- 影子仓库(git 工作树,只读用于取版本):{{shadowDir}}',
|
|
602
|
+
'- 本机 live 同步根:',
|
|
603
|
+
' - 技能(dsh):{{skillsDsh}}',
|
|
604
|
+
' - 技能(agents):{{skillsAgents}}',
|
|
605
|
+
' - 会话:{{sessions}}',
|
|
606
|
+
' - 设置文件:{{settingsFile}}',
|
|
607
|
+
' - 插件清单:{{profiles}}',
|
|
608
|
+
'- 影子路径 → live 路径映射:`skills/dsh/**` → 技能(dsh)根;`skills/agents/**` → 技能(agents)根;`skills/.skill-lock.json` → agents 根下 `.skill-lock.json`;`sessions/**` → 会话根;`settings/settings.yaml` → 设置文件;`plugins/**` → 插件清单根。',
|
|
609
|
+
'- 备份目录:{{backupDir}}(改动前把 live 原文件按影子相对路径复制进去)',
|
|
610
|
+
'- 本机 dsh web 地址:{{apiBase}}(用它触发同步,不需要令牌)',
|
|
611
|
+
'- 访问令牌:{{token}}(仅兜底直接调 GitCode API 时用,严禁回显)',
|
|
612
|
+
'',
|
|
613
|
+
'## 待合并文件(两边都改过,共 {{fileCount}} 个)',
|
|
614
|
+
'{{fileList}}',
|
|
615
|
+
'每个文件的三个版本:本机版直接读 live 路径;远端版 `git -C {{shadowDir}} show FETCH_HEAD:<影子路径>`;共同基线 `git -C {{shadowDir}} show {{lastSynced}}:<影子路径>`(可能不存在)。',
|
|
616
|
+
'',
|
|
617
|
+
'## 规则(硬性)',
|
|
618
|
+
'1. 只允许修改上面清单里的 live 文件;**不得删除**任何 live 文件或远端独有内容;不准碰同步根之外的文件。',
|
|
619
|
+
'2. 动手前把每个 live 原文件备份到 {{backupDir}}(保持影子相对路径的子目录结构)。',
|
|
620
|
+
'3. 文本文件(.md/.json/.yaml/.yml/明文 .jsonl)做三方语义合并,保留两边有效改动。技能/专家目录:两边各自新增的文件取并集(都保留),仅同名文件才合并内容。',
|
|
621
|
+
'4. settings.yaml 逐键保留双方;本机路径/机器相关字段以本机为准;任何 token/apiKey/密钥字段保留两边但**严禁在输出中回显密钥值**。',
|
|
622
|
+
'5. 二进制或压缩文件(.zst/.gz 及 session 日志二进制)不合并,保留本机版,在汇报里列出。',
|
|
623
|
+
'6. 只允许使用 bash 与 HTTP 请求工具;严禁使用 return/deliver/投递/IM 文件类工具;不要 printenv;令牌不得出现在任何输出或提交信息里。',
|
|
624
|
+
'7. 合并完成后触发确定性同步:`curl -s -X POST {{apiBase}}/dsh-sync/api/sync`,等待返回 JSON。',
|
|
625
|
+
'8. 再查状态:`curl -s {{apiBase}}/dsh-sync/api/status`。若 pendingConflict 非空(仍有冲突 PR):在影子仓库 `git fetch https://oauth2:<令牌>@gitcode.com/<owner>/<repo>.git <分支>` → checkout 该分支 → `git merge FETCH_HEAD` → 按上述规则解冲突 → `git add -A && git -c user.name=dsh-sync -c user.email=dsh-sync@local commit --no-edit` → push 回该分支 → 调 GitCode API 合并 PR(头用 `PRIVATE-TOKEN: <令牌>`,不要用 Authorization: Bearer;`PUT /repos/<owner>/<repo>/pulls/<编号>/merge`,body `{"merge_method":"squash"}`)。',
|
|
626
|
+
'9. 全程使用中文。最后汇报:备份了哪些文件、每个文件怎么合并的、同步触发结果、PR 编号与链接(若有)。',
|
|
627
|
+
'',
|
|
628
|
+
'若待合并文件清单为空,跳过合并直接执行第 7 步,并汇报同步结果。',
|
|
629
|
+
].join('\n')
|
|
630
|
+
|
|
535
631
|
// apiproxy client: dsh web 的 /api HTTP RPC(web 客户端同款),创建主对话级 session。
|
|
536
632
|
// 关键区别:apiproxy session.create 建的是 web 主对话级 agent(agentPreset=standard
|
|
537
633
|
// + dsh-base 全工具,含 bash——实测 tool/call=bash + pwd 跑通);而 agents.create 子 agent
|
|
@@ -551,7 +647,7 @@ async function apiproxy(method, payload) {
|
|
|
551
647
|
return res.value
|
|
552
648
|
}
|
|
553
649
|
|
|
554
|
-
async function
|
|
650
|
+
async function runAgentViaApiproxy({ prompt, dir, job, sessions, logger, token }) {
|
|
555
651
|
// token 经 prompt 内联({{token}})--apiproxy 主对话级 session 的 bash 是 host-plane
|
|
556
652
|
// executor,不继承 dsh web 进程的 process.env,故不能像 headless spawn 那样 env 注入
|
|
557
653
|
try {
|
|
@@ -611,18 +707,18 @@ async function runConflictViaApiproxy({ prompt, dir, job, sessions, logger, toke
|
|
|
611
707
|
return job
|
|
612
708
|
}
|
|
613
709
|
|
|
614
|
-
function
|
|
615
|
-
const id = '
|
|
710
|
+
function createAgentRunJob({ prompt, dir, jobs, logger, sessions, token }) {
|
|
711
|
+
const id = 'ag' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
|
|
616
712
|
const job = { id, status: 'running', startedAt: new Date().toISOString(), dir, output: '', code: null }
|
|
617
713
|
jobs.set(id, job)
|
|
618
714
|
// 走 apiproxy 创建主对话级 session(standard preset + dsh-base 全工具,含 bash),
|
|
619
|
-
// 不是 agents.create 子 agent(精简无 bash)。token 注入
|
|
715
|
+
// 不是 agents.create 子 agent(精简无 bash)。token 注入 prompt,events 经 ctx.sessions.get 流式读。
|
|
620
716
|
if (!sessions || typeof sessions.get !== 'function') {
|
|
621
717
|
job.status = 'error'
|
|
622
718
|
job.output = 'sessions 服务不可用(动态 ctx.inject 失败)'
|
|
623
719
|
return job
|
|
624
720
|
}
|
|
625
|
-
|
|
721
|
+
runAgentViaApiproxy({ prompt, dir, job, sessions, logger, token })
|
|
626
722
|
.catch(e => { job.status = 'error'; job.output = (job.output + '\n' + String(e && e.message)).slice(-CONFLICT_RUN_OUTPUT_CAP) })
|
|
627
723
|
return job
|
|
628
724
|
}
|
|
@@ -630,7 +726,7 @@ function createConflictRunJob({ prompt, dir, jobs, logger, sessions, token }) {
|
|
|
630
726
|
module.exports = {
|
|
631
727
|
name: 'dsh-sync',
|
|
632
728
|
inject: ['webServer', 'settings'],
|
|
633
|
-
__internals: { syncSpec, defaultRoots, parseRepoUrl, authedUrl, mirrorLiveToShadow, resolveLivePath, copyTree, gitExec, acquireLock, checkRepoPrivate, gitcodeRequest, ensureShadowRepo, runPush, runPull, gitCurrentCommit, atomicWriteFile, DEFAULT_SYNC_SETTINGS, CONFLICT_PROMPT_ZH, substituteParams },
|
|
729
|
+
__internals: { syncSpec, defaultRoots, parseRepoUrl, authedUrl, mirrorLiveToShadow, resolveLivePath, copyTree, gitExec, acquireLock, checkRepoPrivate, gitcodeRequest, ensureShadowRepo, runPush, runPull, reconcileRemote, gitCurrentCommit, atomicWriteFile, DEFAULT_SYNC_SETTINGS, CONFLICT_PROMPT_ZH, ALIGN_PROMPT_ZH, substituteParams },
|
|
634
730
|
|
|
635
731
|
apply(ctx, config = {}) {
|
|
636
732
|
const dh = dshHome()
|
|
@@ -709,6 +805,9 @@ module.exports = {
|
|
|
709
805
|
let result = { pushed: false, pulled: false }
|
|
710
806
|
try {
|
|
711
807
|
const ctx2 = { repoDir, instanceId: state.instanceId, state, logger: ctx.logger }
|
|
808
|
+
// reconcile first: pull remote-only/untouched changes into live so
|
|
809
|
+
// the full-snapshot push below never deletes another replica's adds
|
|
810
|
+
result.reconcile = await reconcileRemote(eff.gitBinary, eff, ctx2).catch(e => { result.reconcileError = String(e && e.message); return null })
|
|
712
811
|
result.push = await runPush(eff.gitBinary, eff, ctx2).catch(e => { result.pushError = String(e && e.message); return null })
|
|
713
812
|
result.pull = await runPull(eff.gitBinary, eff, ctx2).catch(e => { result.pullError = String(e && e.message); return null })
|
|
714
813
|
state.lastSyncAt = new Date().toISOString()
|
|
@@ -720,9 +819,11 @@ module.exports = {
|
|
|
720
819
|
return syncRun
|
|
721
820
|
}
|
|
722
821
|
|
|
723
|
-
// ──
|
|
822
|
+
// ── Agent-run jobs (action buttons → apiproxy 主对话级 session) ──
|
|
823
|
+
// conflictRunJobs: 冲突 PR 解决;alignRunJobs: AI 智能对齐(语义合并双方改动)
|
|
724
824
|
const conflictRunJobs = new Map()
|
|
725
|
-
|
|
825
|
+
const alignRunJobs = new Map()
|
|
826
|
+
// 动态注入 sessions 服务:agent run 走 apiproxy 创建主对话级 session 后,
|
|
726
827
|
// 用 ctx.sessions.get(sessionId).events 流式读 agent 输出(像 agents.create 事件泵,
|
|
727
828
|
// 但这个 agent 有 bash)
|
|
728
829
|
let sessionsSvc = null
|
|
@@ -830,7 +931,7 @@ module.exports = {
|
|
|
830
931
|
const prompt = substituteParams(CONFLICT_PROMPT_ZH, {
|
|
831
932
|
repoUrl: eff.repoUrl, shadowDir: repoDir, branch, prNumber, token: eff.token,
|
|
832
933
|
})
|
|
833
|
-
const job =
|
|
934
|
+
const job = createAgentRunJob({ prompt, dir: repoDir, jobs: conflictRunJobs, logger: ctx.logger, sessions: sessionsSvc, token: eff.token })
|
|
834
935
|
sendJson(res, 202, { jobId: job.id, status: job.status })
|
|
835
936
|
return
|
|
836
937
|
}
|
|
@@ -844,6 +945,51 @@ module.exports = {
|
|
|
844
945
|
return
|
|
845
946
|
}
|
|
846
947
|
|
|
948
|
+
// POST /dsh-sync/api/align/run → AI 智能对齐:先跑一次确定性同步
|
|
949
|
+
// (远端新增自动回填),再把两边都改过的文件交 agent 语义合并
|
|
950
|
+
if (req.method === 'POST' && apiPath.endsWith('/dsh-sync/api/align/run')) {
|
|
951
|
+
await stateLoaded
|
|
952
|
+
const eff = syncSettings()
|
|
953
|
+
if (!eff.repoUrl || !eff.token) { sendJson(res, 400, { error: '未配置仓库或令牌' }); return }
|
|
954
|
+
const baseCommit = state.lastSyncedCommit // 双方分叉的共同基线(reconcile 前)
|
|
955
|
+
let syncResult = null
|
|
956
|
+
try { syncResult = await runSync() }
|
|
957
|
+
catch (e) { sendJson(res, 400, { error: '同步预检失败:' + String(e && e.message || e) }); return }
|
|
958
|
+
const rec = syncResult && syncResult.reconcile
|
|
959
|
+
const both = (rec && Array.isArray(rec.bothModified)) ? rec.bothModified : []
|
|
960
|
+
const fileList = both.length
|
|
961
|
+
? both.map((f, i) => `${i + 1}. ${f.shadowPath}(本机:${displayPath(f.livePath)})`).join('\n')
|
|
962
|
+
: '(无——确定性同步已处理全部差异)'
|
|
963
|
+
const roots = defaultRoots()
|
|
964
|
+
const backupDir = join(syncDir, 'align-backups', new Date().toISOString().replace(/[:.]/g, '-'))
|
|
965
|
+
await fsP.mkdir(backupDir, { recursive: true })
|
|
966
|
+
const prompt = substituteParams(ALIGN_PROMPT_ZH, {
|
|
967
|
+
shadowDir: repoDir,
|
|
968
|
+
skillsDsh: roots.dshSkills, skillsAgents: roots.agentsSkills,
|
|
969
|
+
sessions: roots.sessions, settingsFile: roots.settingsFile, profiles: roots.profiles,
|
|
970
|
+
backupDir, apiBase: APIPROXY_BASE, token: eff.token,
|
|
971
|
+
fileCount: both.length, fileList,
|
|
972
|
+
lastSynced: baseCommit || '(无共同基线,仓库首次同步)',
|
|
973
|
+
})
|
|
974
|
+
const job = createAgentRunJob({ prompt, dir: repoDir, jobs: alignRunJobs, logger: ctx.logger, sessions: sessionsSvc, token: eff.token })
|
|
975
|
+
sendJson(res, 202, {
|
|
976
|
+
jobId: job.id, status: job.status,
|
|
977
|
+
bothModified: both.map(f => f.shadowPath),
|
|
978
|
+
backupDir: displayPath(backupDir),
|
|
979
|
+
reconcile: rec ? { applied: rec.applied, bothModified: rec.bothModified.length, remoteDeleted: rec.remoteDeleted } : null,
|
|
980
|
+
})
|
|
981
|
+
return
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// GET /dsh-sync/api/align/run?id= → job status/output
|
|
985
|
+
if (req.method === 'GET' && apiPath.endsWith('/dsh-sync/api/align/run')) {
|
|
986
|
+
const id = query.get('id') || ''
|
|
987
|
+
const job = alignRunJobs.get(id)
|
|
988
|
+
if (job === undefined) { sendJson(res, 404, { error: 'job not found' }); return }
|
|
989
|
+
sendJson(res, 200, { ...job, output: (job.output || '').slice(-32 * 1024) })
|
|
990
|
+
return
|
|
991
|
+
}
|
|
992
|
+
|
|
847
993
|
sendJson(res, 404, { error: 'not found' })
|
|
848
994
|
} catch (error) { sendJson(res, 400, { error: String(error && error.message || error) }) }
|
|
849
995
|
},
|