dsh-plugin-mobile-link 0.8.0 → 0.8.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/lib/cli.js +8 -9
- package/lib/client.js +972 -95
- package/lib/handoff.js +1 -1
- package/lib/history/fold-summary.js +85 -32
- package/lib/history/fold.js +11 -1
- package/lib/history/question-record.js +186 -0
- package/lib/i18n.js +177 -0
- package/lib/image-originals.js +121 -0
- package/lib/image-variants.js +315 -0
- package/lib/index.js +50 -0
- package/lib/journal.js +2 -0
- package/lib/link.js +83 -10
- package/lib/router.js +63 -2
- package/lib/session-scope.js +89 -0
- package/lib/status.js +13 -10
- package/package.json +1 -1
package/lib/handoff.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* 连接器写给**进程外**消费者的交接文件。
|
|
3
3
|
*
|
|
4
4
|
* 为什么需要它:跑在 DSH 宿主进程之外的脚本(`scripts/dev/dsh-probe.mjs`、`verify-simulator.sh`、
|
|
5
|
-
* `
|
|
5
|
+
* `ios/DSHMobile/UITestHarness/host.mjs` 等)与 iOS 的集成测试辅助都没有进程内上下文,拿不到注入的服务;
|
|
6
6
|
* 它们需要一个本机可读的"带凭据本机地址"。契约见
|
|
7
7
|
* `specs/001-connector-host-compat/contracts/out-of-process-handoff.md`。
|
|
8
8
|
*
|
|
@@ -28,6 +28,9 @@
|
|
|
28
28
|
|
|
29
29
|
import { FOLD_RECORD_TYPE, isHumanMessage } from './fold.js'
|
|
30
30
|
import { bytesOf, trimRecordForWire } from './records.js'
|
|
31
|
+
import {
|
|
32
|
+
isQuestionTool, questionRecordFor, resultTextOf,
|
|
33
|
+
} from './question-record.js'
|
|
31
34
|
import { truncateSafe } from '../unicode.js'
|
|
32
35
|
|
|
33
36
|
/// `_link/hello` 声明的新能力位。老客户端不认识它,继续用 `fold: true`。
|
|
@@ -198,19 +201,7 @@ export function previewOf(args) {
|
|
|
198
201
|
return truncateSafe(flat, SUMMARY_PREVIEW_CHARS)
|
|
199
202
|
}
|
|
200
203
|
|
|
201
|
-
///
|
|
202
|
-
function resultTextOf(data) {
|
|
203
|
-
const parts = []
|
|
204
|
-
const blocks = [data?.message?.content, data?.content].find((value) => Array.isArray(value)) ?? []
|
|
205
|
-
for (const block of blocks) {
|
|
206
|
-
if (typeof block?.text === 'string' && block.text.length > 0) parts.push(block.text)
|
|
207
|
-
}
|
|
208
|
-
for (const key of ['output', 'result', 'stdout', 'stderr', 'text']) {
|
|
209
|
-
if (typeof data?.[key] === 'string' && data[key].length > 0) parts.push(data[key])
|
|
210
|
-
}
|
|
211
|
-
return parts.join('\n')
|
|
212
|
-
}
|
|
213
|
-
|
|
204
|
+
/// 工具结果是否失败。正文只用来量字节数(`resultTextOf`,与作答记录共用),不下发。
|
|
214
205
|
function isErrorResult(data) {
|
|
215
206
|
if (data?.message?.isError === true || data?.isError === true) return true
|
|
216
207
|
const error = data?.error ?? data?.message?.error
|
|
@@ -307,36 +298,54 @@ export function projectTurnUnit(events) {
|
|
|
307
298
|
const out = []
|
|
308
299
|
const summaries = []
|
|
309
300
|
let group = null
|
|
301
|
+
/// 本轮的提问调用(callId → 调用信息)。跨分组保留:调用与结果之间可能夹着
|
|
302
|
+
/// 一条中间讲话,按组记账会丢掉配对。
|
|
303
|
+
const questionCalls = new Map()
|
|
304
|
+
/// 已结算、等摘要落位后再下发的记录(作答记录与图片结果,seq 升序)。
|
|
305
|
+
/// 它们都产生在过程段内部:摘要的 seq 是段起点,必须排在它们之前,
|
|
306
|
+
/// 否则一页记录会倒序。
|
|
307
|
+
let pendingRecords = []
|
|
310
308
|
|
|
311
309
|
const openGroup = (event) => {
|
|
312
310
|
if (group !== null) return
|
|
313
311
|
group = { startSeq: event.seq, endSeq: event.seq, time: event.time, steps: [], calls: new Map() }
|
|
314
312
|
}
|
|
315
313
|
|
|
314
|
+
/// 段内产生的可见记录排在它所属的过程摘要**之后**(摘要的 seq 是段起点,
|
|
315
|
+
/// 记录的是结果 seq),所以分组还开着时先攒着,flush 时一次放出。
|
|
316
|
+
const emitPending = () => {
|
|
317
|
+
if (pendingRecords.length === 0) return
|
|
318
|
+
pendingRecords.sort((lhs, rhs) => lhs.seq - rhs.seq)
|
|
319
|
+
for (const pending of pendingRecords) out.push(record(pending))
|
|
320
|
+
pendingRecords = []
|
|
321
|
+
}
|
|
322
|
+
|
|
316
323
|
const flush = () => {
|
|
317
324
|
const current = group
|
|
318
325
|
group = null
|
|
319
|
-
if (current
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
startSeq: current.startSeq,
|
|
325
|
-
endSeq: current.endSeq,
|
|
326
|
-
startedAt,
|
|
327
|
-
durationMs,
|
|
328
|
-
endReason,
|
|
329
|
-
activities,
|
|
330
|
-
groups: [{
|
|
326
|
+
if (current !== null && current.steps.length > 0) {
|
|
327
|
+
const activities = countActivities(current.steps)
|
|
328
|
+
const summary = {
|
|
329
|
+
v: SUMMARY_FOLD_VERSION,
|
|
330
|
+
turn,
|
|
331
331
|
startSeq: current.startSeq,
|
|
332
332
|
endSeq: current.endSeq,
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
333
|
+
startedAt,
|
|
334
|
+
durationMs,
|
|
335
|
+
endReason,
|
|
336
|
+
activities,
|
|
337
|
+
groups: [{
|
|
338
|
+
startSeq: current.startSeq,
|
|
339
|
+
endSeq: current.endSeq,
|
|
340
|
+
activities: { ...activities },
|
|
341
|
+
toolCount: current.steps.filter((step) => step.kind === 'tools').length,
|
|
342
|
+
steps: current.steps,
|
|
343
|
+
}],
|
|
344
|
+
}
|
|
345
|
+
summaries.push(summary)
|
|
346
|
+
out.push(record({ type: FOLD_RECORD_TYPE, seq: current.startSeq, time: current.time, data: summary }))
|
|
337
347
|
}
|
|
338
|
-
|
|
339
|
-
out.push(record({ type: FOLD_RECORD_TYPE, seq: current.startSeq, time: current.time, data: summary }))
|
|
348
|
+
emitPending()
|
|
340
349
|
}
|
|
341
350
|
|
|
342
351
|
for (const event of input) {
|
|
@@ -431,12 +440,36 @@ export function projectTurnUnit(events) {
|
|
|
431
440
|
}
|
|
432
441
|
group.steps.push(step)
|
|
433
442
|
const callId = event?.data?.callId
|
|
434
|
-
if (typeof callId === 'string')
|
|
443
|
+
if (typeof callId === 'string') {
|
|
444
|
+
group.calls.set(callId, step)
|
|
445
|
+
if (isQuestionTool(event?.data?.name)) {
|
|
446
|
+
questionCalls.set(callId, {
|
|
447
|
+
callId,
|
|
448
|
+
name: event.data.name,
|
|
449
|
+
arguments: event.data.arguments,
|
|
450
|
+
})
|
|
451
|
+
}
|
|
452
|
+
}
|
|
435
453
|
group.endSeq = event.seq
|
|
436
454
|
break
|
|
437
455
|
}
|
|
438
456
|
case 'tool/result': {
|
|
439
457
|
const callId = event?.data?.message?.toolCallId ?? event?.data?.toolCallId ?? event?.data?.callId
|
|
458
|
+
// 作答记录先取:即使结果在摘要里找不到对应步骤(调用在上一段/上一页),
|
|
459
|
+
// 已经结算的答案仍然要下发(SR-02.16)。
|
|
460
|
+
const call = questionCalls.get(callId)
|
|
461
|
+
if (call !== undefined) {
|
|
462
|
+
const question = questionRecordFor({
|
|
463
|
+
call,
|
|
464
|
+
resultText: resultTextOf(event?.data),
|
|
465
|
+
seq: event.seq,
|
|
466
|
+
time: event.time,
|
|
467
|
+
})
|
|
468
|
+
if (question !== null) {
|
|
469
|
+
if (group === null) out.push(record(question))
|
|
470
|
+
else pendingRecords.push(question)
|
|
471
|
+
}
|
|
472
|
+
}
|
|
440
473
|
const step = group?.calls.get(callId)
|
|
441
474
|
// 结果找不到对应调用(日志被裁剪、旧格式)就丢掉:摘要里不凭空造一步。
|
|
442
475
|
if (step === undefined) break
|
|
@@ -445,6 +478,26 @@ export function projectTurnUnit(events) {
|
|
|
445
478
|
step.resultSeq = event.seq
|
|
446
479
|
if (text !== '') step.bytes = Buffer.byteLength(text, 'utf8')
|
|
447
480
|
group.endSeq = event.seq
|
|
481
|
+
// A tool can deliver a picture (send_image, read_image). The process
|
|
482
|
+
// summary deliberately drops tool-result text, but dropping the image
|
|
483
|
+
// would erase the only user-visible deliverable of that turn. Keep a
|
|
484
|
+
// metadata-only result record; the client renders its image card.
|
|
485
|
+
const images = blocksOf(event).filter((block) => block?.type === 'image')
|
|
486
|
+
if (images.length > 0) {
|
|
487
|
+
pendingRecords.push({
|
|
488
|
+
type: 'tool/result',
|
|
489
|
+
seq: event.seq,
|
|
490
|
+
time: event.time,
|
|
491
|
+
data: {
|
|
492
|
+
...(Number.isInteger(event?.data?.turn) ? { turn: event.data.turn } : {}),
|
|
493
|
+
...(Number.isInteger(event?.data?.step) ? { step: event.data.step } : {}),
|
|
494
|
+
message: {
|
|
495
|
+
toolCallId: callId,
|
|
496
|
+
content: images.map(attachmentBlockForWire),
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
})
|
|
500
|
+
}
|
|
448
501
|
break
|
|
449
502
|
}
|
|
450
503
|
default: {
|
package/lib/history/fold.js
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
* (现有 `_link/sessionWindow`),取回原事件即可,不需要新端点。
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import { questionRecordsOfEvents } from './question-record.js'
|
|
21
|
+
|
|
20
22
|
/// 客户端按这个类型画过程节点;以 `+` 开头,不会与宿主事件类型相撞。
|
|
21
23
|
export const FOLD_RECORD_TYPE = '+link/fold'
|
|
22
24
|
|
|
@@ -177,8 +179,16 @@ export function foldProcessRuns(records) {
|
|
|
177
179
|
}
|
|
178
180
|
flush()
|
|
179
181
|
|
|
182
|
+
// 作答记录是用户自己的输入,不是过程:折叠把它的调用与结果收进摘要后,
|
|
183
|
+
// 单独把它放回页里(SR-02.16)。按 seq 合并不是"重排页面",而是让摘要
|
|
184
|
+
// (起点 seq)与记录(结果 seq)落在原来的先后关系上;稳定排序保证同 seq 的
|
|
185
|
+
// 两类记录不互换。
|
|
186
|
+
const merged = out
|
|
187
|
+
.concat(questionRecordsOfEvents(events).map((entry) => entry.record))
|
|
188
|
+
.sort((lhs, rhs) => lhs.seq - rhs.seq)
|
|
189
|
+
|
|
180
190
|
return {
|
|
181
|
-
records:
|
|
191
|
+
records: merged.map((event) => ({ type: 'event', event })),
|
|
182
192
|
folds,
|
|
183
193
|
}
|
|
184
194
|
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 作答记录(`+link/question`):把 `ask_user_question` 的问题与答案单独下发。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要:折叠模式把 `tool/call` / `tool/result` 整段收进摘要,正文与参数都不
|
|
5
|
+
* 下发;但用户刚做出的选择属于**用户自己的输入**,不能因为它来自工具结果就跟着
|
|
6
|
+
* 过程一起消失(SR-02.16)。桌面把这种记录渲染成一条用户消息气泡,手机侧沿用同一
|
|
7
|
+
* 语义:它是会话内容,不是过程。
|
|
8
|
+
*
|
|
9
|
+
* 形状:
|
|
10
|
+
*
|
|
11
|
+
* +link/question {
|
|
12
|
+
* callId,
|
|
13
|
+
* questions: [{ id, question, header? }],
|
|
14
|
+
* answers: [{ id, selected: string[], custom?: string }]
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* - 只发**已结算**的答案批次;`{pending:true}` 的计时提问、失败或取消的调用不发。
|
|
18
|
+
* - 问题文字从 `tool/call` 参数里取;参数缺失/形状不认识时给空数组,客户端至少
|
|
19
|
+
* 还能显示答案(AR-02.16.01 的降级口径)。
|
|
20
|
+
* - 文字按码元截断且不切开代理对(与预览、搜索片段同一套 `truncateSafe`)。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { truncateSafe } from '../unicode.js'
|
|
24
|
+
|
|
25
|
+
/** 记录类型。以 `+` 开头,不会与宿主事件类型相撞。 */
|
|
26
|
+
export const QUESTION_RECORD_TYPE = '+link/question'
|
|
27
|
+
|
|
28
|
+
/** 会带来问题清单的工具名。 */
|
|
29
|
+
const QUESTION_TOOLS = new Set(['ask_user_question', 'request_user_input'])
|
|
30
|
+
|
|
31
|
+
/** 单个字段(问题、标题、自定义答案)下发的上限;超出截断并加省略号。 */
|
|
32
|
+
export const QUESTION_TEXT_LIMIT = 1000
|
|
33
|
+
|
|
34
|
+
export function isQuestionTool(name) {
|
|
35
|
+
return typeof name === 'string' && QUESTION_TOOLS.has(name)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 工具结果的正文。与摘要的字节统计、搜索的 `docText` 同一组键。
|
|
40
|
+
* 只取文本块与文本字段,图片/文件不在其中。
|
|
41
|
+
*/
|
|
42
|
+
export function resultTextOf(data) {
|
|
43
|
+
const parts = []
|
|
44
|
+
const blocks = [data?.message?.content, data?.content].find((value) => Array.isArray(value)) ?? []
|
|
45
|
+
for (const block of blocks) {
|
|
46
|
+
if (typeof block?.text === 'string' && block.text.length > 0) parts.push(block.text)
|
|
47
|
+
}
|
|
48
|
+
for (const key of ['output', 'result', 'stdout', 'stderr', 'text']) {
|
|
49
|
+
if (typeof data?.[key] === 'string' && data[key].length > 0) parts.push(data[key])
|
|
50
|
+
}
|
|
51
|
+
return parts.join('\n')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseJson(value) {
|
|
55
|
+
if (typeof value !== 'string') return undefined
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(value)
|
|
58
|
+
} catch {
|
|
59
|
+
return undefined
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 截到上限并补省略号;截断点不落在 emoji 中间。 */
|
|
64
|
+
function cap(text) {
|
|
65
|
+
if (typeof text !== 'string') return ''
|
|
66
|
+
if (text.length <= QUESTION_TEXT_LIMIT) return text
|
|
67
|
+
return `${truncateSafe(text, QUESTION_TEXT_LIMIT - 1)}…`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* `tool/call` 参数里的问题清单。
|
|
72
|
+
*
|
|
73
|
+
* 严格配对:任何一项缺 `id` / `question` 就整份不要——半份问题清单会把答案配到
|
|
74
|
+
* 错的问题上,比没有文字更糟。`multi_select` / `multiSelect` 两种拼写都收
|
|
75
|
+
* (宿主 schema 用蛇形,旧夹具可能用驼峰),展示只需要文字。
|
|
76
|
+
*/
|
|
77
|
+
export function questionsOf(argumentsValue) {
|
|
78
|
+
const parsed = typeof argumentsValue === 'object' && argumentsValue !== null
|
|
79
|
+
? argumentsValue
|
|
80
|
+
: parseJson(argumentsValue)
|
|
81
|
+
if (parsed === null || typeof parsed !== 'object') return []
|
|
82
|
+
if (!Array.isArray(parsed.questions) || parsed.questions.length === 0) return []
|
|
83
|
+
const out = []
|
|
84
|
+
for (const question of parsed.questions) {
|
|
85
|
+
if (question === null || typeof question !== 'object') return []
|
|
86
|
+
if (typeof question.id !== 'string' || question.id === '') return []
|
|
87
|
+
if (typeof question.question !== 'string') return []
|
|
88
|
+
out.push({
|
|
89
|
+
id: question.id,
|
|
90
|
+
question: cap(question.question),
|
|
91
|
+
...(typeof question.header === 'string' && question.header !== ''
|
|
92
|
+
? { header: cap(question.header) }
|
|
93
|
+
: {}),
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
return out
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 工具结果里的答案批次;没结算、形状不认识时返回 null。
|
|
101
|
+
*
|
|
102
|
+
* `answers: []` 保留为空数组(调用方据此不发记录):它表示"没有可显示的答案",
|
|
103
|
+
* 与"结果还没回来"不是同一件事。
|
|
104
|
+
*/
|
|
105
|
+
export function answersOf(resultText) {
|
|
106
|
+
const parsed = parseJson(resultText)
|
|
107
|
+
if (parsed === null || typeof parsed !== 'object') return null
|
|
108
|
+
if (parsed.pending === true) return null
|
|
109
|
+
if (!Array.isArray(parsed.answers)) return null
|
|
110
|
+
const out = []
|
|
111
|
+
for (const answer of parsed.answers) {
|
|
112
|
+
if (answer === null || typeof answer !== 'object') return null
|
|
113
|
+
if (typeof answer.id !== 'string' || answer.id === '') return null
|
|
114
|
+
if (!Array.isArray(answer.selected) || !answer.selected.every((item) => typeof item === 'string')) {
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
if (answer.custom !== undefined && typeof answer.custom !== 'string') return null
|
|
118
|
+
out.push({
|
|
119
|
+
id: answer.id,
|
|
120
|
+
selected: answer.selected,
|
|
121
|
+
...(typeof answer.custom === 'string' && answer.custom.trim() !== ''
|
|
122
|
+
? { custom: cap(answer.custom) }
|
|
123
|
+
: {}),
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
return out
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 一次提问调用 + 它的结果 → 一条待下发的记录。
|
|
131
|
+
*
|
|
132
|
+
* @param {object} input
|
|
133
|
+
* @param {{callId?: string, name?: string, arguments?: unknown}} input.call
|
|
134
|
+
* @param {string} input.resultText 工具结果正文
|
|
135
|
+
* @param {number} input.seq 结果事件的 seq(记录的排序键)
|
|
136
|
+
* @param {number} [input.time] 结果事件时间
|
|
137
|
+
* @returns {object|null} 不是提问、没结算、或 callId 缺失时返回 null
|
|
138
|
+
*/
|
|
139
|
+
export function questionRecordFor({ call, resultText, seq, time }) {
|
|
140
|
+
if (!isQuestionTool(call?.name)) return null
|
|
141
|
+
if (typeof call?.callId !== 'string' || call.callId === '') return null
|
|
142
|
+
if (!Number.isInteger(seq)) return null
|
|
143
|
+
const answers = answersOf(resultText)
|
|
144
|
+
if (answers === null || answers.length === 0) return null
|
|
145
|
+
return {
|
|
146
|
+
type: QUESTION_RECORD_TYPE,
|
|
147
|
+
seq,
|
|
148
|
+
...(Number.isFinite(time) ? { time } : {}),
|
|
149
|
+
data: {
|
|
150
|
+
callId: call.callId,
|
|
151
|
+
questions: questionsOf(call.arguments),
|
|
152
|
+
answers,
|
|
153
|
+
},
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 从原始记录里取出提问结果,供 v1/v2 两条折叠路径共用。
|
|
159
|
+
* @param {Array<{type:string,event:object}>} records
|
|
160
|
+
* @returns {Array<{seq:number,record:object}>} 按 seq 升序
|
|
161
|
+
*/
|
|
162
|
+
export function questionRecordsOfEvents(events) {
|
|
163
|
+
const calls = new Map()
|
|
164
|
+
const out = []
|
|
165
|
+
for (const event of events) {
|
|
166
|
+
if (event?.type === 'tool/call' && isQuestionTool(event?.data?.name)) {
|
|
167
|
+
const callId = event?.data?.callId
|
|
168
|
+
if (typeof callId === 'string' && callId !== '') {
|
|
169
|
+
calls.set(callId, { callId, name: event.data.name, arguments: event.data.arguments })
|
|
170
|
+
}
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
if (event?.type !== 'tool/result') continue
|
|
174
|
+
const callId = event?.data?.message?.toolCallId ?? event?.data?.toolCallId ?? event?.data?.callId
|
|
175
|
+
const call = calls.get(callId)
|
|
176
|
+
if (call === undefined) continue
|
|
177
|
+
const record = questionRecordFor({
|
|
178
|
+
call,
|
|
179
|
+
resultText: resultTextOf(event.data),
|
|
180
|
+
seq: event.seq,
|
|
181
|
+
time: event.time,
|
|
182
|
+
})
|
|
183
|
+
if (record !== null) out.push({ seq: event.seq, record })
|
|
184
|
+
}
|
|
185
|
+
return out
|
|
186
|
+
}
|
package/lib/i18n.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// CLI and status hints are user-facing too. Keep their catalogs separate from the
|
|
2
|
+
// panel bundle (which is hand-inlined) so the headless path can be localized
|
|
3
|
+
// without pulling the browser client into the CLI.
|
|
4
|
+
const CATALOGS = {
|
|
5
|
+
en: {
|
|
6
|
+
'cli.registered': 'This computer is registered. Two steps remain:',
|
|
7
|
+
'cli.step1': 'Restart DSH so the connector uses this identity;',
|
|
8
|
+
'cli.step2': 'Scan the QR code below with the phone app (same as the Mobile link panel).',
|
|
9
|
+
'cli.code': 'Pairing code ${code} (valid about ${minutes} minutes, one-time)',
|
|
10
|
+
'cli.hint': 'You can also open the Mobile link panel for a new code; the connector writes the tokenized endpoint to ~/.dsh/mobile-link/endpoint.json.',
|
|
11
|
+
'cli.prefetchFailed': 'Could not prefetch a pairing code: ${error}',
|
|
12
|
+
'cli.prefetchHint': 'After restarting DSH, open the Mobile link panel and scan its QR code with the phone app.',
|
|
13
|
+
'status.config': 'The local address you configured is wrong or stale.',
|
|
14
|
+
'status.hostService': 'This host did not expose its local address to the connector; the plugin is probably not loaded into the running host.',
|
|
15
|
+
'status.dshWebUrl': 'The local address in the environment variable is stale.',
|
|
16
|
+
'status.unreachable': 'The local DSH is unreachable; check that the host is running.',
|
|
17
|
+
'status.desktop': 'You are using the official desktop app (the connector is installed in its profile).',
|
|
18
|
+
'status.web': 'You are using the command-line web host.',
|
|
19
|
+
'status.unknown': 'The host could not be identified; if it cannot connect, follow the troubleshooting section.',
|
|
20
|
+
'status.unregistered': 'This computer is not registered with the relay yet. Check the relay address in the Mobile link panel, then click Register.',
|
|
21
|
+
},
|
|
22
|
+
'zh-Hans': {
|
|
23
|
+
'cli.registered': '这台电脑已登记。接下来还有两步:',
|
|
24
|
+
'cli.step1': '重启 DSH,让连接器用上这个身份;',
|
|
25
|
+
'cli.step2': '手机 App 的「扫码配对」扫下面的二维码(与 DSH 界面左下角「移动端连接」里那张等价)。',
|
|
26
|
+
'cli.code': '配对码 ${code}(约 ${minutes} 分钟有效,一次性)',
|
|
27
|
+
'cli.hint': '也可以在这台电脑上点 DSH 界面左下角的「移动端连接」取新码;连接器还会把带令牌地址写进 ~/.dsh/mobile-link/endpoint.json。',
|
|
28
|
+
'cli.prefetchFailed': '(这次没能预先申请配对码:${error})',
|
|
29
|
+
'cli.prefetchHint': '重启 DSH 后,在这台电脑的 DSH 界面左下角点「移动端连接」,用手机 App 的「扫码配对」扫里面的二维码。',
|
|
30
|
+
'status.config': '你手工配置的本机地址不对或已失效',
|
|
31
|
+
'status.hostService': '当前宿主没有把本机访问方式交给插件(多为插件没装进正在运行的宿主)',
|
|
32
|
+
'status.dshWebUrl': '环境变量里的本机地址已过期',
|
|
33
|
+
'status.unreachable': '本机 DSH 不可达;请确认宿主正在运行',
|
|
34
|
+
'status.desktop': '你正在用官方桌面版(连接器已装进它的档案)',
|
|
35
|
+
'status.web': '你正在用命令行 web 版',
|
|
36
|
+
'status.unknown': '无法识别当前宿主;若连不上,请按"排查"一节处理',
|
|
37
|
+
'status.unregistered': '这台电脑还没有登记到中转。在「移动端连接」面板里确认中转地址,然后点「登记」。',
|
|
38
|
+
},
|
|
39
|
+
'zh-Hant': {
|
|
40
|
+
'cli.registered': '這台電腦已登記。接下來還有兩步:',
|
|
41
|
+
'cli.step1': '重新啟動 DSH,讓連接器使用這個身分;',
|
|
42
|
+
'cli.step2': '用手機 App 的「掃描 QR code」掃描下方 QR code(與 DSH 介面左下角「手機連結」裡的那張相同)。',
|
|
43
|
+
'cli.code': '配對碼 ${code}(約 ${minutes} 分鐘內有效,一次性)',
|
|
44
|
+
'cli.hint': '也可以在這台電腦上點 DSH 介面左下角的「手機連結」取得新碼;連接器也會把帶權杖的位址寫入 ~/.dsh/mobile-link/endpoint.json。',
|
|
45
|
+
'cli.prefetchFailed': '(這次無法預先取得配對碼:${error})',
|
|
46
|
+
'cli.prefetchHint': '重新啟動 DSH 後,在這台電腦的 DSH 介面左下角點「手機連結」,用手機 App 的「掃描 QR code」掃描裡面的 QR code。',
|
|
47
|
+
'status.config': '你手動設定的本機位址不正確或已失效',
|
|
48
|
+
'status.hostService': '目前宿主沒有把本機存取方式交給外掛(多為外掛沒有載入執行中的宿主)',
|
|
49
|
+
'status.dshWebUrl': '環境變數中的本機位址已過期',
|
|
50
|
+
'status.unreachable': '本機 DSH 無法連線;請確認宿主正在執行',
|
|
51
|
+
'status.desktop': '你正在使用官方桌面版(連接器已安裝在它的設定檔中)',
|
|
52
|
+
'status.web': '你正在使用命令列 web 版',
|
|
53
|
+
'status.unknown': '無法辨識目前宿主;若無法連線,請依「疑難排解」一節處理',
|
|
54
|
+
'status.unregistered': '這台電腦尚未登記到中繼。在「手機連結」面板確認中繼位址,然後點「登記」。',
|
|
55
|
+
},
|
|
56
|
+
ja: {
|
|
57
|
+
'cli.registered': 'このコンピューターは登録されました。残り 2 ステップです:',
|
|
58
|
+
'cli.step1': 'DSH を再起動して、コネクタがこの ID を使うようにします。',
|
|
59
|
+
'cli.step2': 'スマートフォンアプリで下の QR コードをスキャンします(「モバイルリンク」パネルのものと同じです)。',
|
|
60
|
+
'cli.code': 'ペアリングコード ${code}(約 ${minutes} 分間有効、1 回限り)',
|
|
61
|
+
'cli.hint': 'DSH 左下の「モバイルリンク」から新しいコードを取得することもできます。コネクタはトークン付きのエンドポイントを ~/.dsh/mobile-link/endpoint.json に書き込みます。',
|
|
62
|
+
'cli.prefetchFailed': '(ペアリングコードを事前に取得できませんでした:${error})',
|
|
63
|
+
'cli.prefetchHint': 'DSH を再起動した後、DSH 左下の「モバイルリンク」を開き、スマートフォンアプリで QR コードをスキャンしてください。',
|
|
64
|
+
'status.config': '手動で設定したローカルアドレスが正しくないか、期限切れです。',
|
|
65
|
+
'status.hostService': 'このホストはローカルアドレスをコネクタに渡していません。プラグインが実行中のホストに読み込まれていない可能性があります。',
|
|
66
|
+
'status.dshWebUrl': '環境変数のローカルアドレスが期限切れです。',
|
|
67
|
+
'status.unreachable': 'ローカルの DSH に到達できません。ホストが実行中か確認してください。',
|
|
68
|
+
'status.desktop': '公式デスクトップアプリを使用しています(コネクタはそのプロファイルにインストールされています)。',
|
|
69
|
+
'status.web': 'コマンドライン web ホストを使用しています。',
|
|
70
|
+
'status.unknown': 'ホストを特定できません。接続できない場合はトラブルシューティングの節を参照してください。',
|
|
71
|
+
'status.unregistered': 'このコンピューターはまだリレーに登録されていません。「モバイルリンク」パネルでリレーアドレスを確認し、「登録」をクリックしてください。',
|
|
72
|
+
},
|
|
73
|
+
ko: {
|
|
74
|
+
'cli.registered': '이 컴퓨터가 등록되었습니다. 두 단계가 남았습니다:',
|
|
75
|
+
'cli.step1': 'DSH를 다시 시작하여 커넥터가 이 ID를 사용하도록 합니다.',
|
|
76
|
+
'cli.step2': '휴대폰 앱으로 아래 QR 코드를 스캔합니다(모바일 링크 패널과 동일).',
|
|
77
|
+
'cli.code': '페어링 코드 ${code}(약 ${minutes}분 유효, 일회용)',
|
|
78
|
+
'cli.hint': 'DSH 왼쪽 아래의 모바일 링크 패널에서 새 코드를 받을 수도 있습니다. 커넥터는 토큰이 포함된 주소를 ~/.dsh/mobile-link/endpoint.json에 기록합니다.',
|
|
79
|
+
'cli.prefetchFailed': '(페어링 코드를 미리 가져오지 못했습니다: ${error})',
|
|
80
|
+
'cli.prefetchHint': 'DSH를 다시 시작한 뒤 DSH 왼쪽 아래의 모바일 링크 패널을 열고 휴대폰 앱으로 QR 코드를 스캔하세요.',
|
|
81
|
+
'status.config': '직접 설정한 로컬 주소가 잘못되었거나 만료되었습니다.',
|
|
82
|
+
'status.hostService': '이 호스트가 로컬 주소를 커넥터에 전달하지 않았습니다. 플러그인이 실행 중인 호스트에 로드되지 않았을 수 있습니다.',
|
|
83
|
+
'status.dshWebUrl': '환경 변수의 로컬 주소가 만료되었습니다.',
|
|
84
|
+
'status.unreachable': '로컬 DSH에 연결할 수 없습니다. 호스트가 실행 중인지 확인하세요.',
|
|
85
|
+
'status.desktop': '공식 데스크톱 앱을 사용 중입니다(커넥터가 해당 프로필에 설치되어 있습니다).',
|
|
86
|
+
'status.web': '명령줄 web 호스트를 사용 중입니다.',
|
|
87
|
+
'status.unknown': '호스트를 식별할 수 없습니다. 연결할 수 없으면 문제 해결 절을 따르세요.',
|
|
88
|
+
'status.unregistered': '이 컴퓨터는 아직 릴레이에 등록되지 않았습니다. 모바일 링크 패널에서 릴레이 주소를 확인한 뒤 등록을 클릭하세요.',
|
|
89
|
+
},
|
|
90
|
+
es: {
|
|
91
|
+
'cli.registered': 'Este equipo está registrado. Quedan dos pasos:',
|
|
92
|
+
'cli.step1': 'Reinicia DSH para que el conector use esta identidad;',
|
|
93
|
+
'cli.step2': 'Escanea el código QR de abajo con la app del teléfono (el mismo del panel Mobile link).',
|
|
94
|
+
'cli.code': 'Código de emparejamiento ${code} (válido unos ${minutes} minutos, de un solo uso)',
|
|
95
|
+
'cli.hint': 'También puedes abrir el panel Mobile link para obtener un código nuevo; el conector escribe el endpoint con token en ~/.dsh/mobile-link/endpoint.json.',
|
|
96
|
+
'cli.prefetchFailed': 'No se pudo obtener un código de emparejamiento por adelantado: ${error}',
|
|
97
|
+
'cli.prefetchHint': 'Después de reiniciar DSH, abre el panel Mobile link y escanea su código QR con la app del teléfono.',
|
|
98
|
+
'status.config': 'La dirección local que configuraste es incorrecta o ha caducado.',
|
|
99
|
+
'status.hostService': 'Este host no expuso su dirección local al conector; probablemente el plugin no está cargado en el host en ejecución.',
|
|
100
|
+
'status.dshWebUrl': 'La dirección local de la variable de entorno ha caducado.',
|
|
101
|
+
'status.unreachable': 'No se puede acceder al DSH local; comprueba que el host esté en ejecución.',
|
|
102
|
+
'status.desktop': 'Estás usando la app de escritorio oficial (el conector está instalado en su perfil).',
|
|
103
|
+
'status.web': 'Estás usando el host web de línea de comandos.',
|
|
104
|
+
'status.unknown': 'No se pudo identificar el host; si no conecta, sigue la sección de solución de problemas.',
|
|
105
|
+
'status.unregistered': 'Este equipo aún no está registrado en el relay. Comprueba la dirección del relay en el panel Mobile link y pulsa Registrar.',
|
|
106
|
+
},
|
|
107
|
+
'pt-BR': {
|
|
108
|
+
'cli.registered': 'Este computador está registrado. Faltam dois passos:',
|
|
109
|
+
'cli.step1': 'Reinicie o DSH para que o conector use esta identidade;',
|
|
110
|
+
'cli.step2': 'Escaneie o QR code abaixo com o app do celular (o mesmo do painel Mobile link).',
|
|
111
|
+
'cli.code': 'Código de pareamento ${code} (válido por cerca de ${minutes} minutos, uso único)',
|
|
112
|
+
'cli.hint': 'Você também pode abrir o painel Mobile link para obter um novo código; o conector grava o endpoint com token em ~/.dsh/mobile-link/endpoint.json.',
|
|
113
|
+
'cli.prefetchFailed': 'Não foi possível obter um código de pareamento antecipadamente: ${error}',
|
|
114
|
+
'cli.prefetchHint': 'Depois de reiniciar o DSH, abra o painel Mobile link e escaneie o QR code com o app do celular.',
|
|
115
|
+
'status.config': 'O endereço local configurado está incorreto ou expirou.',
|
|
116
|
+
'status.hostService': 'Este host não expôs o endereço local ao conector; provavelmente o plugin não está carregado no host em execução.',
|
|
117
|
+
'status.dshWebUrl': 'O endereço local da variável de ambiente expirou.',
|
|
118
|
+
'status.unreachable': 'O DSH local não está acessível; verifique se o host está em execução.',
|
|
119
|
+
'status.desktop': 'Você está usando o app de desktop oficial (o conector está instalado no perfil dele).',
|
|
120
|
+
'status.web': 'Você está usando o host web de linha de comando.',
|
|
121
|
+
'status.unknown': 'Não foi possível identificar o host; se não conectar, siga a seção de solução de problemas.',
|
|
122
|
+
'status.unregistered': 'Este computador ainda não está registrado no relay. Verifique o endereço do relay no painel Mobile link e clique em Registrar.',
|
|
123
|
+
},
|
|
124
|
+
fr: {
|
|
125
|
+
'cli.registered': 'Cet ordinateur est enregistré. Il reste deux étapes :',
|
|
126
|
+
'cli.step1': "Redémarrez DSH pour que le connecteur utilise cette identité ;",
|
|
127
|
+
'cli.step2': "Scannez le QR code ci-dessous avec l'application mobile (le même que dans le panneau Mobile link).",
|
|
128
|
+
'cli.code': 'Code d’appairage ${code} (valable environ ${minutes} minutes, à usage unique)',
|
|
129
|
+
'cli.hint': "Vous pouvez aussi ouvrir le panneau Mobile link pour obtenir un nouveau code ; le connecteur écrit l'endpoint avec jeton dans ~/.dsh/mobile-link/endpoint.json.",
|
|
130
|
+
'cli.prefetchFailed': "Impossible de précharger un code d'appairage : ${error}",
|
|
131
|
+
'cli.prefetchHint': "Après avoir redémarré DSH, ouvrez le panneau Mobile link et scannez son QR code avec l'application mobile.",
|
|
132
|
+
'status.config': "L'adresse locale configurée est incorrecte ou obsolète.",
|
|
133
|
+
'status.hostService': "Cet hôte n'a pas exposé son adresse locale au connecteur ; le plugin n'est probablement pas chargé dans l'hôte en cours.",
|
|
134
|
+
'status.dshWebUrl': "L'adresse locale de la variable d'environnement est obsolète.",
|
|
135
|
+
'status.unreachable': 'Le DSH local est inaccessible ; vérifiez que l’hôte est en cours d’exécution.',
|
|
136
|
+
'status.desktop': "Vous utilisez l'application de bureau officielle (le connecteur est installé dans son profil).",
|
|
137
|
+
'status.web': 'Vous utilisez l’hôte web en ligne de commande.',
|
|
138
|
+
'status.unknown': "L'hôte n'a pas pu être identifié ; si la connexion échoue, suivez la section de dépannage.",
|
|
139
|
+
'status.unregistered': "Cet ordinateur n'est pas encore enregistré auprès du relais. Vérifiez l'adresse du relais dans le panneau Mobile link, puis cliquez sur Enregistrer.",
|
|
140
|
+
},
|
|
141
|
+
de: {
|
|
142
|
+
'cli.registered': 'Dieser Computer ist registriert. Es fehlen noch zwei Schritte:',
|
|
143
|
+
'cli.step1': 'DSH neu starten, damit der Connector diese Identität verwendet;',
|
|
144
|
+
'cli.step2': 'Den QR-Code unten mit der Telefon-App scannen (derselbe wie im Mobile-link-Panel).',
|
|
145
|
+
'cli.code': 'Kopplungscode ${code} (etwa ${minutes} Minuten gültig, einmalig)',
|
|
146
|
+
'cli.hint': 'Du kannst auch im Mobile-link-Panel einen neuen Code holen; der Connector schreibt den Endpunkt mit Token nach ~/.dsh/mobile-link/endpoint.json.',
|
|
147
|
+
'cli.prefetchFailed': 'Kopplungscode konnte nicht vorab geholt werden: ${error}',
|
|
148
|
+
'cli.prefetchHint': 'Nach dem Neustart von DSH das Mobile-link-Panel öffnen und den QR-Code mit der Telefon-App scannen.',
|
|
149
|
+
'status.config': 'Die manuell konfigurierte lokale Adresse ist falsch oder veraltet.',
|
|
150
|
+
'status.hostService': 'Dieser Host hat dem Connector seine lokale Adresse nicht übergeben; das Plugin ist vermutlich nicht im laufenden Host geladen.',
|
|
151
|
+
'status.dshWebUrl': 'Die lokale Adresse in der Umgebungsvariable ist veraltet.',
|
|
152
|
+
'status.unreachable': 'Das lokale DSH ist nicht erreichbar; prüfe, ob der Host läuft.',
|
|
153
|
+
'status.desktop': 'Du verwendest die offizielle Desktop-App (der Connector ist in ihrem Profil installiert).',
|
|
154
|
+
'status.web': 'Du verwendest den Web-Host der Kommandozeile.',
|
|
155
|
+
'status.unknown': 'Der Host konnte nicht erkannt werden; wenn keine Verbindung möglich ist, folge dem Abschnitt zur Fehlerbehebung.',
|
|
156
|
+
'status.unregistered': 'Dieser Computer ist noch nicht beim Relay registriert. Prüfe die Relay-Adresse im Mobile-link-Panel und klicke auf Registrieren.',
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
const ALIASES = { zh: 'zh-Hans', 'zh-Hans': 'zh-Hans', 'zh-CN': 'zh-Hans', 'zh-Hant': 'zh-Hant', 'zh-TW': 'zh-Hant', 'zh-HK': 'zh-Hant', ja: 'ja', ko: 'ko', es: 'es', pt: 'pt-BR', 'pt-BR': 'pt-BR', fr: 'fr', de: 'de', en: 'en' }
|
|
160
|
+
let language = 'zh-Hans'
|
|
161
|
+
export function resolveLanguage(tag) {
|
|
162
|
+
if (!tag) return null
|
|
163
|
+
const value = String(tag).trim().replace('_', '-')
|
|
164
|
+
if (ALIASES[value]) return ALIASES[value]
|
|
165
|
+
const base = value.toLowerCase().split('-')[0]
|
|
166
|
+
return ALIASES[base] ?? null
|
|
167
|
+
}
|
|
168
|
+
export function setLanguage(tag) {
|
|
169
|
+
const next = resolveLanguage(tag)
|
|
170
|
+
if (next) language = next
|
|
171
|
+
return language
|
|
172
|
+
}
|
|
173
|
+
export function currentLanguage() { return language }
|
|
174
|
+
export function T(key, vars = {}) {
|
|
175
|
+
const text = CATALOGS[language]?.[key] ?? CATALOGS['zh-Hans'][key] ?? key
|
|
176
|
+
return text.replace(/\$\{(\w+)\}/g, (m, name) => (name in vars ? String(vars[name]) : m))
|
|
177
|
+
}
|