dsh-mobile-web 0.1.2 → 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/LICENSE-APACHE +202 -202
- package/NOTICE +26 -26
- package/README.md +128 -45
- package/assets/apple-touch-icon.png +0 -0
- package/assets/icon-192.png +0 -0
- package/assets/icon-512.png +0 -0
- package/assets/icon.svg +3 -11
- package/assets/mobile.bundle.js +1259 -326
- package/assets/theme.css +3492 -3439
- package/client.js +468 -141
- package/mobile-src/UPSTREAM.md +58 -50
- package/package.json +3 -2
- package/src/host/assets.js +9 -9
- package/src/host/cleanup.js +380 -0
- package/src/host/config.js +6 -0
- package/src/host/core.js +445 -18
- package/src/host/devices.js +226 -226
- package/src/host/http.js +8 -8
- package/src/host/pages.js +514 -514
- package/src/host/rpc/index.js +32 -32
- package/src/host/rpc/push.js +58 -58
- package/src/host/rpc/settings.js +88 -88
- package/src/host/rpc/subagent.js +27 -27
- package/src/host/state.js +30 -30
package/src/host/core.js
CHANGED
|
@@ -18,7 +18,7 @@ import { createServer } from 'node:http'
|
|
|
18
18
|
import { randomBytes, randomInt } from 'node:crypto'
|
|
19
19
|
import { homedir, networkInterfaces } from 'node:os'
|
|
20
20
|
import { join, basename, resolve as resolvePath, relative as relativePath, isAbsolute as isAbsolutePath, extname } from 'node:path'
|
|
21
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, realpathSync } from 'node:fs'
|
|
21
|
+
import { readFileSync, readdirSync, writeFileSync, existsSync, mkdirSync, statSync, realpathSync } from 'node:fs'
|
|
22
22
|
import { fileURLToPath } from 'node:url'
|
|
23
23
|
import { gzipSync, brotliCompressSync, constants as zlibConstants } from 'node:zlib'
|
|
24
24
|
import { textOfEventData, createMuxRuntime, createSseRuntime } from './sse.js'
|
|
@@ -28,8 +28,9 @@ import { createSubagentRpc } from './rpc/subagent.js'
|
|
|
28
28
|
import { createPushRpc } from './rpc/push.js'
|
|
29
29
|
import { ONLINE_GUARD_HTML, VIEWPORT_VH_HTML, MUTATION_BUS_HTML, mobilePageHtml, pushCheckPageHtml, manifestJson } from './pages.js'
|
|
30
30
|
import { createHostState } from './state.js'
|
|
31
|
+
import { listSessionFiles, normalizeSessionId, purgeSessionDir, purgeSessionFiles, purgeWorkspaceDir, sessionIdVariants } from './cleanup.js'
|
|
31
32
|
import { normalizePublicBase, loadPublicBase, savePublicBase, loadDevices, persistDevices, protectedSessionsFile, loadProtectedSessionIds, createDeviceAuth } from './devices.js'
|
|
32
|
-
import { USAGE_CACHE_TTL_MS, BALANCE_FETCH_TIMEOUT_MS, DEEPSEEK_BALANCE_URL, DEEPSEEK_BALANCE_NAME, DEEPSEEK_BALANCE_BASE_URL, SEND_MODE_DEFAULT, SEND_MODE_STEER, SEND_MODE_QUEUE, COMPRESSIBLE_TYPE, MIN_COMPRESS_BYTES, defaultDevicesFile, TOKEN_TTL_MS, IDLE_EXPIRE_MS, SWEEP_INTERVAL_MS, PENDING_TTL_MS, PENDING_SWEEP_MS, MAX_DEVICES, DEFAULT_MOBILE_PORT, DEFAULT_PAIR_CODE, SMALL_BODY, PROMPT_BODY, COMMAND_EXEC_TIMEOUT_MS, SKILL_CATALOG_TIMEOUT_MS, PERMISSION_LABELS, PERMISSION_DESCS, permissionLabel, permissionDesc, SSE_HEARTBEAT_MS, MAX_SSE_SUBSCRIBERS, MAX_FOLLOWED_PER_DEVICE, FOLLOW_RETRY_MS, OBSERVE_TTL_MS, MAX_FOLLOW_RESTARTS, SSE_MAX_LIFETIME_MS, MUX_ALWAYS_FORWARDED, FOLLOW_ASSISTANT_STREAM, STREAM_SEEN_INDEX_MAX, STREAM_SEQ_SLOTS, COOKIE_NAME, MOBILE_ROOT, API_PREFIX, BUNDLE_URL, MANIFEST_URL, ICON_SVG_URL, THEME_URL, ICON_192_URL, ICON_512_URL, APPLE_TOUCH_URL, WHALE_URL, PUSH_CHECK_URL, SHOT_PREFIX, SHOT_NAME, SHOT_TYPES, SHOT_DIR, PLUGIN_VERSION, UPDATE_FETCH_TIMEOUT_MS, compareVersions } from './config.js'
|
|
33
|
+
import { USAGE_CACHE_TTL_MS, BALANCE_FETCH_TIMEOUT_MS, DEEPSEEK_BALANCE_URL, DEEPSEEK_BALANCE_NAME, DEEPSEEK_BALANCE_BASE_URL, SEND_MODE_DEFAULT, SEND_MODE_STEER, SEND_MODE_QUEUE, COMPRESSIBLE_TYPE, MIN_COMPRESS_BYTES, defaultDevicesFile, TOKEN_TTL_MS, IDLE_EXPIRE_MS, SWEEP_INTERVAL_MS, PENDING_TTL_MS, PENDING_SWEEP_MS, MAX_DEVICES, DEFAULT_MOBILE_PORT, DEFAULT_PAIR_CODE, SMALL_BODY, PROMPT_BODY, MOBILE_FILE_MAX_BYTES, COMMAND_EXEC_TIMEOUT_MS, SKILL_CATALOG_TIMEOUT_MS, PERMISSION_LABELS, PERMISSION_DESCS, permissionLabel, permissionDesc, SSE_HEARTBEAT_MS, MAX_SSE_SUBSCRIBERS, MAX_FOLLOWED_PER_DEVICE, FOLLOW_RETRY_MS, OBSERVE_TTL_MS, MAX_FOLLOW_RESTARTS, SSE_MAX_LIFETIME_MS, MUX_ALWAYS_FORWARDED, FOLLOW_ASSISTANT_STREAM, STREAM_SEEN_INDEX_MAX, STREAM_SEQ_SLOTS, COOKIE_NAME, MOBILE_ROOT, API_PREFIX, BUNDLE_URL, MANIFEST_URL, ICON_SVG_URL, THEME_URL, ICON_192_URL, ICON_512_URL, APPLE_TOUCH_URL, WHALE_URL, PUSH_CHECK_URL, SHOT_PREFIX, SHOT_NAME, SHOT_TYPES, SHOT_DIR, FILE_PREFIX, FILE_TOKEN_TTL_MS, PLUGIN_VERSION, UPDATE_FETCH_TIMEOUT_MS, compareVersions } from './config.js'
|
|
33
34
|
import { assetCache, asset, bundleVersion, assetText, __dirname, ensureShotDir } from './assets.js'
|
|
34
35
|
import { compressCache, compressFor, send, sendJson, sendHtml, sendText, sendBuffer, readBody, readJsonBody, parseCookies, isLoopback, lanIPv4Addresses } from './http.js'
|
|
35
36
|
|
|
@@ -897,9 +898,23 @@ export function createHost(ctx, config) {
|
|
|
897
898
|
}
|
|
898
899
|
|
|
899
900
|
const toEvents = (records) => records.map((r) => ({ event: r.event }))
|
|
901
|
+
// 「加载更早」的窗口起点必须是**用户看得见**的那条消息。
|
|
902
|
+
// 宿主会把 time-context / skill-catalog / 插件注入这类 user/message 写进日志,
|
|
903
|
+
// 手机端一律隐藏它们;若把这种行当成 turn 起点,窗口就会以 assistant/message
|
|
904
|
+
// 开头 —— 界面上下拉加载出现的第一行直接是思考/过程卡,自己的提问气泡不见了。
|
|
905
|
+
const TRIGGER_SOURCE_KINDS = new Set([
|
|
906
|
+
'agent-message', 'team-message', 'subagent-settled', 'webhook',
|
|
907
|
+
'schedule', 'tool-jobs', 'cordis-host-runner', 'goal',
|
|
908
|
+
])
|
|
900
909
|
const isUserMessage = (record) => {
|
|
901
910
|
const event = record && record.event
|
|
902
|
-
|
|
911
|
+
if (event === null || typeof event !== 'object' || event.type !== 'user/message') return false
|
|
912
|
+
const data = event.data
|
|
913
|
+
const source = data !== null && typeof data === 'object' ? data.source : undefined
|
|
914
|
+
const kind = source !== null && typeof source === 'object' && typeof source.kind === 'string' ? source.kind : ''
|
|
915
|
+
if (kind === '' || kind === 'user') return true
|
|
916
|
+
// 触发类唤醒消息要独立成卡片,属于可见行,可以作为窗口起点。
|
|
917
|
+
return TRIGGER_SOURCE_KINDS.has(kind)
|
|
903
918
|
}
|
|
904
919
|
const firstUserIndex = (records) => {
|
|
905
920
|
for (let index = 0; index < records.length; index += 1) {
|
|
@@ -918,7 +933,9 @@ export function createHost(ctx, config) {
|
|
|
918
933
|
}
|
|
919
934
|
|
|
920
935
|
const first = await pageWindow(beforeSeq)
|
|
921
|
-
// 首屏(无 beforeSeq
|
|
936
|
+
// 首屏(无 beforeSeq):只取最新一页,**不做任何对齐/补齐**。
|
|
937
|
+
// 用户要求:点进会话直接看到最新的聊天记录(最底部),不要先对齐到「上一条提问」,
|
|
938
|
+
// 也不要为了对齐而多翻页 —— 那会让进入后的落点不可预期、还多一次宿主查询。
|
|
922
939
|
if (beforeSeq === undefined) {
|
|
923
940
|
return {
|
|
924
941
|
events: toEvents(first.records),
|
|
@@ -1283,6 +1300,8 @@ export function createHost(ctx, config) {
|
|
|
1283
1300
|
}
|
|
1284
1301
|
// base64 会再胖 1/3:8MB 原图 ≈ 11MB JSON,手机上再大就卡了。
|
|
1285
1302
|
const PREVIEW_IMAGE_MAX_BYTES = 8 * 1024 * 1024
|
|
1303
|
+
// 文本预览上限(与源码 FilePreviewSheet 的约定一致:256KiB)。
|
|
1304
|
+
const PREVIEW_TEXT_MAX_BYTES = 256 * 1024
|
|
1286
1305
|
|
|
1287
1306
|
/**
|
|
1288
1307
|
* 四个 Agent 预设的中文名与一句话说明。
|
|
@@ -2947,6 +2966,129 @@ export function createHost(ctx, config) {
|
|
|
2947
2966
|
return await operation()
|
|
2948
2967
|
}
|
|
2949
2968
|
|
|
2969
|
+
/** 会话 cwd(兼容 session- 前缀的两种 id 写法)。 */
|
|
2970
|
+
function cwdOfSession(sessionId) {
|
|
2971
|
+
for (const variant of sessionIdVariants(sessionId)) {
|
|
2972
|
+
const cwd = sessionCwd(variant)
|
|
2973
|
+
if (typeof cwd === 'string' && cwd !== '') return cwd
|
|
2974
|
+
}
|
|
2975
|
+
return ''
|
|
2976
|
+
}
|
|
2977
|
+
|
|
2978
|
+
/**
|
|
2979
|
+
* 会话「永久删除」:先归档(所有设备立即从列表消失)→ 会话目录进回收站 →
|
|
2980
|
+
* 清投影缓存/待处理项 → 广播 session/removed。删除不可恢复。
|
|
2981
|
+
* 运行中的会话一律拒绝,受保护会话需要明确 allowProtected。
|
|
2982
|
+
*/
|
|
2983
|
+
async function purgeSession(sessionId, options = {}) {
|
|
2984
|
+
const rawId = String(sessionId || '')
|
|
2985
|
+
const id = normalizeSessionId(rawId)
|
|
2986
|
+
if (id === '') return { ok: false, error: { code: 'bad-request', message: '缺少 sessionId' } }
|
|
2987
|
+
const protectedIds = loadProtectedSessionIds()
|
|
2988
|
+
if (options.allowProtected !== true) {
|
|
2989
|
+
const isProtected = protectedIds.has(rawId) || protectedIds.has(`session-${id}`) || protectedIds.has(id)
|
|
2990
|
+
if (isProtected) return { ok: false, error: { code: 'protected', message: '该会话受保护,不能从手机端永久删除' } }
|
|
2991
|
+
}
|
|
2992
|
+
if (options.force !== true) {
|
|
2993
|
+
try {
|
|
2994
|
+
const { items = [] } = await sc().list({}, new AbortController().signal)
|
|
2995
|
+
const item = items.find((it) => normalizeSessionId(it.sessionId) === id)
|
|
2996
|
+
if (item && item.running === true) {
|
|
2997
|
+
return { ok: false, error: { code: 'running', message: '会话正在运行,先停止再删除' } }
|
|
2998
|
+
}
|
|
2999
|
+
} catch {}
|
|
3000
|
+
// 注意:不能只看 liveAgentOf()。宿主的 session.create/resolveAgent 会给
|
|
3001
|
+
// 刚建的空会话挂一个活 Agent(非运行中),用它当判据会让「新建的空会话」
|
|
3002
|
+
// 永远删不掉。是否真的在跑,以 controller.list() 的 running 为准。
|
|
3003
|
+
}
|
|
3004
|
+
// 墓碑:先归档,列表立刻不再显示(即使宿主内存里还有这条会话)。
|
|
3005
|
+
for (const variant of sessionIdVariants(id)) {
|
|
3006
|
+
try {
|
|
3007
|
+
const reg = wr()
|
|
3008
|
+
if (reg && typeof reg.archiveSession === 'function') await reg.archiveSession(variant, { stopActivity: true })
|
|
3009
|
+
} catch {}
|
|
3010
|
+
}
|
|
3011
|
+
const result = purgeSessionDir(id)
|
|
3012
|
+
if (!result.ok) return result
|
|
3013
|
+
// 待处理审批/提问属于已删会话:放开等待链,避免桌面审批卡死。
|
|
3014
|
+
for (const [rpcId, entry] of [...pending.approvals]) {
|
|
3015
|
+
if (normalizeSessionId(entry.sessionId) !== id) continue
|
|
3016
|
+
entry.resolve(undefined)
|
|
3017
|
+
pending.approvals.delete(rpcId)
|
|
3018
|
+
if (entry.approvalId !== undefined) pending.byApprovalId.delete(entry.approvalId)
|
|
3019
|
+
}
|
|
3020
|
+
for (const [rpcId, entry] of [...pending.questions]) {
|
|
3021
|
+
if (normalizeSessionId(entry.sessionId) !== id) continue
|
|
3022
|
+
entry.resolve(undefined)
|
|
3023
|
+
pending.questions.delete(rpcId)
|
|
3024
|
+
}
|
|
3025
|
+
// 广播:手机端立刻把该行收起(两种 id 写法都发,客户端按自己的写法匹配)。
|
|
3026
|
+
for (const variant of sessionIdVariants(id)) emitMux({ type: 'session/removed', sessionId: variant })
|
|
3027
|
+
return { ok: true, value: { sessionId: id, dir: result.dir, bytes: result.bytes } }
|
|
3028
|
+
}
|
|
3029
|
+
|
|
3030
|
+
/**
|
|
3031
|
+
* 手机端目录浏览的 fs 兜底:宿主的 directoryPickerController 可能是
|
|
3032
|
+
* native 模式(只有原生对话框,没有 list 能力),此时 picker.list 会抛
|
|
3033
|
+
* 「needs the browse capability」。手机端没有原生对话框,必须自己列目录。
|
|
3034
|
+
* 只列子目录(文件不能当工作区);入口路径仍由调用方决定(默认 home)。
|
|
3035
|
+
*/
|
|
3036
|
+
function listDirectoryWithFs(requested) {
|
|
3037
|
+
const home = (() => {
|
|
3038
|
+
try {
|
|
3039
|
+
return realpathSync(homedir())
|
|
3040
|
+
} catch {
|
|
3041
|
+
return homedir()
|
|
3042
|
+
}
|
|
3043
|
+
})()
|
|
3044
|
+
let dir
|
|
3045
|
+
try {
|
|
3046
|
+
dir = requested !== undefined && requested !== '' ? resolvePath(requested) : home
|
|
3047
|
+
if (!statSync(dir).isDirectory()) {
|
|
3048
|
+
return { ok: false, error: { code: 'bad-request', message: '不是目录' } }
|
|
3049
|
+
}
|
|
3050
|
+
dir = realpathSync(dir)
|
|
3051
|
+
} catch (err) {
|
|
3052
|
+
return { ok: false, error: { code: 'not-found', message: err && err.message ? err.message : '目录不存在' } }
|
|
3053
|
+
}
|
|
3054
|
+
let entries = []
|
|
3055
|
+
try {
|
|
3056
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
3057
|
+
.filter((entry) => {
|
|
3058
|
+
try {
|
|
3059
|
+
return entry.isDirectory() || (entry.isSymbolicLink() && statSync(join(dir, entry.name)).isDirectory())
|
|
3060
|
+
} catch {
|
|
3061
|
+
return false
|
|
3062
|
+
}
|
|
3063
|
+
})
|
|
3064
|
+
.map((entry) => ({
|
|
3065
|
+
name: entry.name,
|
|
3066
|
+
path: join(dir, entry.name),
|
|
3067
|
+
hidden: entry.name.startsWith('.'),
|
|
3068
|
+
}))
|
|
3069
|
+
.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
|
|
3070
|
+
} catch (err) {
|
|
3071
|
+
return { ok: false, error: { code: 'list-failed', message: err && err.message ? err.message : '读取目录失败' } }
|
|
3072
|
+
}
|
|
3073
|
+
const MAX = 500
|
|
3074
|
+
const truncated = entries.length > MAX
|
|
3075
|
+
if (truncated) entries = entries.slice(0, MAX)
|
|
3076
|
+
// 面包屑:从盘符/根开始逐段列出。
|
|
3077
|
+
const crumbs = []
|
|
3078
|
+
const parsed = resolvePath(dir)
|
|
3079
|
+
const parts = parsed.split(/[\\/]+/).filter(Boolean)
|
|
3080
|
+
if (parts.length > 0) {
|
|
3081
|
+
const root = parsed.startsWith('/') ? '/' : `${parts[0]}\\`
|
|
3082
|
+
crumbs.push({ name: parts[0], path: root })
|
|
3083
|
+
let acc = root
|
|
3084
|
+
for (let index = 1; index < parts.length; index += 1) {
|
|
3085
|
+
acc = join(acc, parts[index])
|
|
3086
|
+
crumbs.push({ name: parts[index], path: acc })
|
|
3087
|
+
}
|
|
3088
|
+
}
|
|
3089
|
+
return { ok: true, value: { path: dir, home, crumbs, entries, truncated } }
|
|
3090
|
+
}
|
|
3091
|
+
|
|
2950
3092
|
async function dispatchRpc(method, payload, rpcContext) {
|
|
2951
3093
|
const controller = sc()
|
|
2952
3094
|
// 注册表优先:已搬进 src/host/rpc/ 的域在这里命中,还没搬的继续落到下面的 switch。
|
|
@@ -3180,6 +3322,49 @@ export function createHost(ctx, config) {
|
|
|
3180
3322
|
await controller.delete({ workspaceId: payload.workspaceId })
|
|
3181
3323
|
return { ok: true, value: { deleted: true } }
|
|
3182
3324
|
}
|
|
3325
|
+
case 'workspace.purge': {
|
|
3326
|
+
// 工作区级清理:先逐个删会话(受保护/运行中的跳过),再注销工作区;
|
|
3327
|
+
// deleteWorkspaceFiles=true 时把整个目录搬进回收站(必须精确确认路径)。
|
|
3328
|
+
const workspaceId = String(payload.workspaceId || '')
|
|
3329
|
+
const registry = wr()
|
|
3330
|
+
const workspaces = registry && typeof registry.list === 'function' ? registry.list() : []
|
|
3331
|
+
const workspace = workspaces.find((item) => String(item.id) === workspaceId)
|
|
3332
|
+
if (!workspace) return { ok: false, error: { code: 'not-found', message: '工作区不存在' } }
|
|
3333
|
+
const sessionIds = Array.isArray(workspace.sessionIds) ? [...workspace.sessionIds] : []
|
|
3334
|
+
const purged = []
|
|
3335
|
+
const skipped = []
|
|
3336
|
+
const failed = []
|
|
3337
|
+
for (const sessionId of sessionIds) {
|
|
3338
|
+
const result = await purgeSession(sessionId, { hard: payload.hard === true })
|
|
3339
|
+
if (result.ok) purged.push(sessionId)
|
|
3340
|
+
else if (result.error && (result.error.code === 'protected' || result.error.code === 'running')) {
|
|
3341
|
+
skipped.push({ sessionId, reason: result.error.code })
|
|
3342
|
+
} else {
|
|
3343
|
+
failed.push({ sessionId, reason: result.error && result.error.message })
|
|
3344
|
+
}
|
|
3345
|
+
}
|
|
3346
|
+
if (payload.deleteWorkspaceFiles === true) {
|
|
3347
|
+
if (String(payload.confirmPath || '') !== String(workspace.path || '')) {
|
|
3348
|
+
return { ok: false, error: { code: 'confirm-mismatch', message: '确认路径与工作区路径不一致' }, value: { purged, skipped, failed } }
|
|
3349
|
+
}
|
|
3350
|
+
const deleted = purgeWorkspaceDir(workspace.path, { pluginDir: __dirname })
|
|
3351
|
+
if (!deleted.ok) return { ok: false, error: deleted.error, value: { purged, skipped, failed } }
|
|
3352
|
+
}
|
|
3353
|
+
const controller = wc()
|
|
3354
|
+
if (!controller || typeof controller.delete !== 'function') {
|
|
3355
|
+
return { ok: false, error: { code: 'unavailable', message: '工作区服务不可用' }, value: { purged, skipped, failed } }
|
|
3356
|
+
}
|
|
3357
|
+
try {
|
|
3358
|
+
await controller.delete({ workspaceId })
|
|
3359
|
+
} catch (err) {
|
|
3360
|
+
return {
|
|
3361
|
+
ok: false,
|
|
3362
|
+
error: { code: 'workspace-delete-failed', message: err && err.message ? err.message : String(err) },
|
|
3363
|
+
value: { purged, skipped, failed },
|
|
3364
|
+
}
|
|
3365
|
+
}
|
|
3366
|
+
return { ok: true, value: { purged, skipped, failed } }
|
|
3367
|
+
}
|
|
3183
3368
|
case 'workspace.archiveSession': {
|
|
3184
3369
|
// 契约(宿主 api-workspace-controller):archiveSession({ sessionId, stopActivity })
|
|
3185
3370
|
// → { archivedSessionIds }。registry 上这个方法接收 (sessionId, options),
|
|
@@ -3222,6 +3407,26 @@ export function createHost(ctx, config) {
|
|
|
3222
3407
|
}
|
|
3223
3408
|
return { ok: true, value: { archivedSessionIds: archived } }
|
|
3224
3409
|
}
|
|
3410
|
+
case 'session.purge': {
|
|
3411
|
+
// 永久删除会话:归档墓碑 + rm -rf 会话目录(不可恢复)。
|
|
3412
|
+
return await purgeSession(payload.sessionId, {
|
|
3413
|
+
allowProtected: payload.allowProtected === true,
|
|
3414
|
+
force: payload.force === true,
|
|
3415
|
+
})
|
|
3416
|
+
}
|
|
3417
|
+
case 'session.files.list': {
|
|
3418
|
+
const sessionId = String(payload.sessionId || '')
|
|
3419
|
+
if (sessionId === '') return { ok: false, error: { code: 'bad-request', message: '缺少 sessionId' } }
|
|
3420
|
+
const files = listSessionFiles(sessionId, cwdOfSession(sessionId))
|
|
3421
|
+
return { ok: true, value: files }
|
|
3422
|
+
}
|
|
3423
|
+
case 'session.files.delete': {
|
|
3424
|
+
const sessionId = String(payload.sessionId || '')
|
|
3425
|
+
if (sessionId === '') return { ok: false, error: { code: 'bad-request', message: '缺少 sessionId' } }
|
|
3426
|
+
const result = purgeSessionFiles(sessionId, payload.paths, cwdOfSession(sessionId))
|
|
3427
|
+
if (!result.ok) return result
|
|
3428
|
+
return { ok: true, value: result }
|
|
3429
|
+
}
|
|
3225
3430
|
case 'mobile.preferences': {
|
|
3226
3431
|
// 手机 bundle 的启动探针;形态必须匹配 dsh-palm,否则会判定「连不上 host」。
|
|
3227
3432
|
// 复用它做「发送模式」的读+写通道(需求 A):
|
|
@@ -3994,6 +4199,85 @@ export function createHost(ctx, config) {
|
|
|
3994
4199
|
}
|
|
3995
4200
|
return { ok: true, value: { mediaType, dataUrl: `data:${mediaType};base64,${data}` } }
|
|
3996
4201
|
}
|
|
4202
|
+
/**
|
|
4203
|
+
* 手机端「添加文件」:把选中的文件落到会话工作区的 `.dsh-uploads/` 下,
|
|
4204
|
+
* 返回相对路径。手机端拿到路径后写进 prompt 文本,agent 用普通文件工具
|
|
4205
|
+
* 就能读到 —— DSH 的消息协议没有「附件」概念,路径是最稳的载体。
|
|
4206
|
+
*
|
|
4207
|
+
* 边界:必须已配对设备、必须有会话 cwd、文件名去危险字符、大小上限
|
|
4208
|
+
* MOBILE_FILE_MAX_BYTES;只写 `.dsh-uploads/`,不动工作区其它位置。
|
|
4209
|
+
*/
|
|
4210
|
+
case 'mobile.saveFile': {
|
|
4211
|
+
const sessionId = typeof payload?.sessionId === 'string' ? payload.sessionId : ''
|
|
4212
|
+
const rawName = typeof payload?.name === 'string' ? payload.name : ''
|
|
4213
|
+
const dataBase64 = typeof payload?.dataBase64 === 'string' ? payload.dataBase64 : ''
|
|
4214
|
+
if (sessionId === '' || rawName === '' || dataBase64 === '') {
|
|
4215
|
+
return { ok: false, error: { code: 'invalid-request', message: '缺少会话、文件名或内容' } }
|
|
4216
|
+
}
|
|
4217
|
+
const cwd = sessionCwd(sessionId)
|
|
4218
|
+
if (cwd === '') {
|
|
4219
|
+
return { ok: false, error: { code: 'no-workspace', message: '当前会话没有工作目录,无法保存文件' } }
|
|
4220
|
+
}
|
|
4221
|
+
const safeName = rawName
|
|
4222
|
+
.replace(/[\/:*?"<>|]/g, '_')
|
|
4223
|
+
.replace(/[\u0000-\u001f]/g, '_')
|
|
4224
|
+
.replace(/^\.+/, '')
|
|
4225
|
+
.slice(-120) || 'file'
|
|
4226
|
+
let buffer
|
|
4227
|
+
try {
|
|
4228
|
+
buffer = Buffer.from(dataBase64, 'base64')
|
|
4229
|
+
} catch {
|
|
4230
|
+
return { ok: false, error: { code: 'bad-request', message: '文件内容不是合法的 base64' } }
|
|
4231
|
+
}
|
|
4232
|
+
if (buffer.length === 0) {
|
|
4233
|
+
return { ok: false, error: { code: 'bad-request', message: '文件是空的' } }
|
|
4234
|
+
}
|
|
4235
|
+
if (buffer.length > MOBILE_FILE_MAX_BYTES) {
|
|
4236
|
+
return {
|
|
4237
|
+
ok: false,
|
|
4238
|
+
error: { code: 'too-large', message: `文件超过 ${Math.round(MOBILE_FILE_MAX_BYTES / 1024 / 1024)}MB 上限` },
|
|
4239
|
+
}
|
|
4240
|
+
}
|
|
4241
|
+
try {
|
|
4242
|
+
const dir = join(cwd, '.dsh-uploads')
|
|
4243
|
+
mkdirSync(dir, { recursive: true })
|
|
4244
|
+
const target = join(dir, `${Date.now()}-${safeName}`)
|
|
4245
|
+
writeFileSync(target, buffer)
|
|
4246
|
+
return {
|
|
4247
|
+
ok: true,
|
|
4248
|
+
value: {
|
|
4249
|
+
path: relativePath(cwd, target).split('\\').join('/'),
|
|
4250
|
+
name: safeName,
|
|
4251
|
+
bytes: buffer.length,
|
|
4252
|
+
},
|
|
4253
|
+
}
|
|
4254
|
+
} catch (err) {
|
|
4255
|
+
return { ok: false, error: { code: 'write-failed', message: String(err && err.message ? err.message : err) } }
|
|
4256
|
+
}
|
|
4257
|
+
}
|
|
4258
|
+
/**
|
|
4259
|
+
* 手机端「下载/预览」一个工作区文件:返回短时下载地址。
|
|
4260
|
+
* 只接受会话工作区内的路径(realpath 校验,挡 ../ 与符号链接逃逸)。
|
|
4261
|
+
*/
|
|
4262
|
+
case 'mobile.fileUrl': {
|
|
4263
|
+
const sessionId = typeof payload?.sessionId === 'string' ? payload.sessionId : ''
|
|
4264
|
+
const requested = typeof payload?.path === 'string' ? payload.path.trim() : ''
|
|
4265
|
+
const resolved = resolveInWorkspace(sessionId, requested)
|
|
4266
|
+
if (resolved === null) {
|
|
4267
|
+
return { ok: false, error: { code: 'forbidden', message: '只能下载工作区内的文件' } }
|
|
4268
|
+
}
|
|
4269
|
+
const token = randomBytes(16).toString('hex')
|
|
4270
|
+
fileTokens.set(token, { realPath: resolved.realPath, expiresAt: Date.now() + FILE_TOKEN_TTL_MS })
|
|
4271
|
+
// 顺手清掉过期令牌,避免长驻内存。
|
|
4272
|
+
for (const [key, value] of fileTokens) if (value.expiresAt < Date.now()) fileTokens.delete(key)
|
|
4273
|
+
return {
|
|
4274
|
+
ok: true,
|
|
4275
|
+
value: {
|
|
4276
|
+
url: `${FILE_PREFIX}/${token}`,
|
|
4277
|
+
name: basename(resolved.realPath),
|
|
4278
|
+
},
|
|
4279
|
+
}
|
|
4280
|
+
}
|
|
3997
4281
|
case 'mobile.readFile': {
|
|
3998
4282
|
// 手机端 markdown 里的本地图片(``)走这里:
|
|
3999
4283
|
// bundle 只把路径写进 <img data-local-src>,随后用 mobile.readFile
|
|
@@ -4031,9 +4315,6 @@ export function createHost(ctx, config) {
|
|
|
4031
4315
|
return { ok: false, error: { code: 'forbidden', message: '只能读取工作区内的文件' } }
|
|
4032
4316
|
}
|
|
4033
4317
|
const mediaType = PREVIEW_IMAGE_MIME[extname(realPath).toLowerCase()]
|
|
4034
|
-
if (mediaType === undefined) {
|
|
4035
|
-
return { ok: false, error: { code: 'unsupported', message: '只支持图片预览' } }
|
|
4036
|
-
}
|
|
4037
4318
|
let stat
|
|
4038
4319
|
try {
|
|
4039
4320
|
stat = statSync(realPath)
|
|
@@ -4043,21 +4324,49 @@ export function createHost(ctx, config) {
|
|
|
4043
4324
|
if (!stat.isFile()) {
|
|
4044
4325
|
return { ok: false, error: { code: 'invalid-request', message: '不是文件' } }
|
|
4045
4326
|
}
|
|
4046
|
-
|
|
4047
|
-
|
|
4327
|
+
// 图片:直接给 data URL(聊天里的本地图片也走这条)。
|
|
4328
|
+
if (mediaType !== undefined) {
|
|
4329
|
+
if (stat.size > PREVIEW_IMAGE_MAX_BYTES) {
|
|
4330
|
+
return { ok: false, error: { code: 'too-large', message: '图片太大(上限 8MB)' } }
|
|
4331
|
+
}
|
|
4332
|
+
let base64
|
|
4333
|
+
try {
|
|
4334
|
+
base64 = readFileSync(realPath).toString('base64')
|
|
4335
|
+
} catch {
|
|
4336
|
+
return { ok: false, error: { code: 'internal', message: '图片读取失败' } }
|
|
4337
|
+
}
|
|
4338
|
+
return {
|
|
4339
|
+
ok: true,
|
|
4340
|
+
value: {
|
|
4341
|
+
kind: 'image',
|
|
4342
|
+
mediaType,
|
|
4343
|
+
dataUrl: `data:${mediaType};base64,${base64}`,
|
|
4344
|
+
name: basename(realPath),
|
|
4345
|
+
path: realPath,
|
|
4346
|
+
},
|
|
4347
|
+
}
|
|
4348
|
+
}
|
|
4349
|
+
// 非图片:给**文本预览**(源码里的 FilePreviewSheet 早就支持 {kind:'text'},
|
|
4350
|
+
// 之前宿主这里只放行图片,于是 txt/md/json 全被判成「只支持图片预览」)。
|
|
4351
|
+
// 判据:≤256KiB 且前 4KB 里没有 NUL(二进制直接拒绝,客户端会改给「下载」)。
|
|
4352
|
+
if (stat.size > PREVIEW_TEXT_MAX_BYTES) {
|
|
4353
|
+
return { ok: false, error: { code: 'too-large', message: '文件太大,无法预览,可下载查看' } }
|
|
4048
4354
|
}
|
|
4049
|
-
let
|
|
4355
|
+
let raw
|
|
4050
4356
|
try {
|
|
4051
|
-
|
|
4357
|
+
raw = readFileSync(realPath)
|
|
4052
4358
|
} catch {
|
|
4053
|
-
return { ok: false, error: { code: 'internal', message: '
|
|
4359
|
+
return { ok: false, error: { code: 'internal', message: '文件读取失败' } }
|
|
4360
|
+
}
|
|
4361
|
+
const head = raw.subarray(0, 4096)
|
|
4362
|
+
if (head.includes(0)) {
|
|
4363
|
+
return { ok: false, error: { code: 'unsupported', message: '该文件是二进制,无法预览,可下载查看' } }
|
|
4054
4364
|
}
|
|
4055
4365
|
return {
|
|
4056
4366
|
ok: true,
|
|
4057
4367
|
value: {
|
|
4058
|
-
kind: '
|
|
4059
|
-
|
|
4060
|
-
dataUrl: `data:${mediaType};base64,${base64}`,
|
|
4368
|
+
kind: 'text',
|
|
4369
|
+
text: raw.toString('utf8'),
|
|
4061
4370
|
name: basename(realPath),
|
|
4062
4371
|
path: realPath,
|
|
4063
4372
|
},
|
|
@@ -4075,10 +4384,10 @@ export function createHost(ctx, config) {
|
|
|
4075
4384
|
// home/crumbs/truncated),绝不用裸 fs.readdir 打开任意路径。
|
|
4076
4385
|
//
|
|
4077
4386
|
const picker = dp()
|
|
4387
|
+
const requested = typeof payload?.path === 'string' && payload.path !== '' ? payload.path : undefined
|
|
4078
4388
|
if (!picker || typeof picker.list !== 'function') {
|
|
4079
|
-
return
|
|
4389
|
+
return listDirectoryWithFs(requested)
|
|
4080
4390
|
}
|
|
4081
|
-
const requested = typeof payload?.path === 'string' && payload.path !== '' ? payload.path : undefined
|
|
4082
4391
|
try {
|
|
4083
4392
|
const listing = await picker.list(requested, new AbortController().signal)
|
|
4084
4393
|
// 契约(@deepseek-ai/dsh-host-directory-picker/types#DirectoryListing):
|
|
@@ -4098,6 +4407,9 @@ export function createHost(ctx, config) {
|
|
|
4098
4407
|
},
|
|
4099
4408
|
}
|
|
4100
4409
|
} catch (err) {
|
|
4410
|
+
// native 模式的 picker 没有 list 能力:手机端退回自己的 fs 列目录。
|
|
4411
|
+
const fallback = listDirectoryWithFs(requested)
|
|
4412
|
+
if (fallback.ok === true) return fallback
|
|
4101
4413
|
return {
|
|
4102
4414
|
ok: false,
|
|
4103
4415
|
error: {
|
|
@@ -4107,6 +4419,64 @@ export function createHost(ctx, config) {
|
|
|
4107
4419
|
}
|
|
4108
4420
|
}
|
|
4109
4421
|
}
|
|
4422
|
+
case 'mobile.roots': {
|
|
4423
|
+
// 手机端目录浏览的「盘符」列表:Windows 下逐盘符探测可访问的根目录,
|
|
4424
|
+
// 其它平台只给 /。仅返回目录根,不暴露文件。
|
|
4425
|
+
const roots = []
|
|
4426
|
+
if (process.platform === 'win32') {
|
|
4427
|
+
for (let code = 65; code <= 90; code += 1) {
|
|
4428
|
+
const letter = String.fromCharCode(code)
|
|
4429
|
+
const path = `${letter}:\\`
|
|
4430
|
+
try {
|
|
4431
|
+
if (statSync(path).isDirectory()) roots.push({ name: `${letter}:`, path })
|
|
4432
|
+
} catch {}
|
|
4433
|
+
}
|
|
4434
|
+
} else {
|
|
4435
|
+
roots.push({ name: '/', path: '/' })
|
|
4436
|
+
}
|
|
4437
|
+
return { ok: true, value: { roots, home: homedir() } }
|
|
4438
|
+
}
|
|
4439
|
+
case 'mobile.mkdir': {
|
|
4440
|
+
// 「新建工作区 → 新建文件夹」:宿主的 workspace.create 只接受**已存在**的
|
|
4441
|
+
// 目录(realpath 必须成功),所以手机端要先在这里建目录,再走创建。
|
|
4442
|
+
// 只允许在已存在的父目录下建**一段**名称,不接受多级路径/盘符。
|
|
4443
|
+
const parent = typeof payload?.parent === 'string' ? payload.parent.trim() : ''
|
|
4444
|
+
const name = typeof payload?.name === 'string' ? payload.name.trim() : ''
|
|
4445
|
+
if (parent === '' || !isAbsolutePath(parent)) {
|
|
4446
|
+
return { ok: false, error: { code: 'bad-request', message: '缺少有效的父目录' } }
|
|
4447
|
+
}
|
|
4448
|
+
if (name === '' || name === '.' || name === '..' || /[\\/]/.test(name)) {
|
|
4449
|
+
return { ok: false, error: { code: 'bad-request', message: '文件夹名不能为空、不能包含路径分隔符' } }
|
|
4450
|
+
}
|
|
4451
|
+
if (/[<>:"|?*]/.test(name) || /[. ]$/.test(name)) {
|
|
4452
|
+
return { ok: false, error: { code: 'bad-request', message: '文件夹名含有 Windows 不允许的字符' } }
|
|
4453
|
+
}
|
|
4454
|
+
if (name.length > 80) {
|
|
4455
|
+
return { ok: false, error: { code: 'bad-request', message: '文件夹名太长(最多 80 个字符)' } }
|
|
4456
|
+
}
|
|
4457
|
+
let parentStat
|
|
4458
|
+
try {
|
|
4459
|
+
parentStat = statSync(parent)
|
|
4460
|
+
} catch {
|
|
4461
|
+
return { ok: false, error: { code: 'not-found', message: '父目录不存在' } }
|
|
4462
|
+
}
|
|
4463
|
+
if (!parentStat.isDirectory()) {
|
|
4464
|
+
return { ok: false, error: { code: 'bad-request', message: '父路径不是目录' } }
|
|
4465
|
+
}
|
|
4466
|
+
const target = resolvePath(parent, name)
|
|
4467
|
+
if (basename(target) !== name) {
|
|
4468
|
+
return { ok: false, error: { code: 'bad-request', message: '非法的文件夹名' } }
|
|
4469
|
+
}
|
|
4470
|
+
if (existsSync(target)) {
|
|
4471
|
+
return { ok: false, error: { code: 'exists', message: '同名文件或文件夹已存在' } }
|
|
4472
|
+
}
|
|
4473
|
+
try {
|
|
4474
|
+
mkdirSync(target)
|
|
4475
|
+
} catch (err) {
|
|
4476
|
+
return { ok: false, error: { code: 'mkdir-failed', message: err && err.message ? err.message : String(err) } }
|
|
4477
|
+
}
|
|
4478
|
+
return { ok: true, value: { path: target, name } }
|
|
4479
|
+
}
|
|
4110
4480
|
case 'mobile.previews': {
|
|
4111
4481
|
// bundle 的调用点是 `(await m('mobile.previews', {sessionIds})).items`,
|
|
4112
4482
|
// 然后对结果直接 `for (const x of items)`。这里返回空对象 `{}` 时
|
|
@@ -4288,7 +4658,9 @@ export function createHost(ctx, config) {
|
|
|
4288
4658
|
try {
|
|
4289
4659
|
const budget = method === 'session.prompt'
|
|
4290
4660
|
? PROMPT_BODY
|
|
4291
|
-
: method === 'mobile.transcribe' ? 12 * 1024 * 1024
|
|
4661
|
+
: method === 'mobile.transcribe' ? 12 * 1024 * 1024
|
|
4662
|
+
: method === 'mobile.saveFile' ? MOBILE_FILE_MAX_BYTES + 4 * 1024 * 1024
|
|
4663
|
+
: SMALL_BODY
|
|
4292
4664
|
envelope = await readJsonBody(req, budget)
|
|
4293
4665
|
} catch {
|
|
4294
4666
|
sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'invalid json body' } })
|
|
@@ -4701,6 +5073,56 @@ export function createHost(ctx, config) {
|
|
|
4701
5073
|
// 3. 显式拒绝 "."、"..";
|
|
4702
5074
|
// 4. resolve(join(SHOT_DIR, name)) 后再用 relative(SHOT_DIR, full) 复核,
|
|
4703
5075
|
// 落点不在目录内(以 .. 开头或成绝对路径)再 400。
|
|
5076
|
+
// ── 文件下载:GET /m/file/<token> ──────────────────────────────────────────
|
|
5077
|
+
// 手机端点击聊天里的文件路径时,先调 mobile.fileUrl 换一个短时令牌,再用它下载。
|
|
5078
|
+
// 为什么不是直接把路径放 URL:路径里可能有中文/空格,更重要的是不想给出一个
|
|
5079
|
+
// 「任意读工作区文件」的稳定地址;令牌 10 分钟后失效,且只映射一个已校验的绝对路径。
|
|
5080
|
+
const fileTokens = new Map()
|
|
5081
|
+
|
|
5082
|
+
/** 校验一个路径必须落在会话工作区内,返回 realpath 或 null。 */
|
|
5083
|
+
function resolveInWorkspace(sessionId, requested) {
|
|
5084
|
+
const cwd = sessionCwd(sessionId)
|
|
5085
|
+
if (cwd === '' || requested === '') return null
|
|
5086
|
+
try {
|
|
5087
|
+
const realRoot = realpathSync(cwd)
|
|
5088
|
+
const realPath = realpathSync(resolvePath(realRoot, requested))
|
|
5089
|
+
const rel = relativePath(realRoot, realPath)
|
|
5090
|
+
const inside = rel === '' || (!isAbsolutePath(rel) && rel.split(/[\/]/)[0] !== '..')
|
|
5091
|
+
return inside ? { realRoot, realPath } : null
|
|
5092
|
+
} catch {
|
|
5093
|
+
return null
|
|
5094
|
+
}
|
|
5095
|
+
}
|
|
5096
|
+
|
|
5097
|
+
function serveFileToken(req, res, path) {
|
|
5098
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
5099
|
+
return sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
5100
|
+
}
|
|
5101
|
+
const token = path.length > FILE_PREFIX.length ? path.slice(FILE_PREFIX.length + 1) : ''
|
|
5102
|
+
const hit = fileTokens.get(token)
|
|
5103
|
+
if (hit === undefined || hit.expiresAt < Date.now()) {
|
|
5104
|
+
fileTokens.delete(token)
|
|
5105
|
+
return sendJson(res, 404, { ok: false, error: 'not found' })
|
|
5106
|
+
}
|
|
5107
|
+
let data
|
|
5108
|
+
try {
|
|
5109
|
+
data = readFileSync(hit.realPath)
|
|
5110
|
+
} catch {
|
|
5111
|
+
return sendJson(res, 404, { ok: false, error: 'not found' })
|
|
5112
|
+
}
|
|
5113
|
+
const name = basename(hit.realPath)
|
|
5114
|
+
const ascii = name.replace(/[^ -~]/g, '_').replace(/[\\"]/g, '_')
|
|
5115
|
+
try {
|
|
5116
|
+
res.writeHead(200, {
|
|
5117
|
+
'Content-Type': 'application/octet-stream',
|
|
5118
|
+
'Content-Length': String(data.length),
|
|
5119
|
+
'Content-Disposition': `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`,
|
|
5120
|
+
'Cache-Control': 'no-store',
|
|
5121
|
+
})
|
|
5122
|
+
res.end(req.method === 'HEAD' ? undefined : data)
|
|
5123
|
+
} catch {}
|
|
5124
|
+
}
|
|
5125
|
+
|
|
4704
5126
|
function serveShot(req, res, path) {
|
|
4705
5127
|
const method = (req.method || 'GET').toUpperCase()
|
|
4706
5128
|
if (method !== 'GET' && method !== 'HEAD') {
|
|
@@ -4814,6 +5236,7 @@ export function createHost(ctx, config) {
|
|
|
4814
5236
|
// 只读截图:/m/shot/<file>。放在 /m/api 之后是因为前缀互不重叠,放哪都一样;
|
|
4815
5237
|
// 单独一个分支,绝不影响上面任何既有路由的行为。
|
|
4816
5238
|
if (path === SHOT_PREFIX || path.startsWith(SHOT_PREFIX + '/')) return serveShot(req, res, path)
|
|
5239
|
+
if (path === FILE_PREFIX || path.startsWith(FILE_PREFIX + '/')) return serveFileToken(req, res, path)
|
|
4817
5240
|
sendJson(res, 404, { ok: false, error: 'not found' })
|
|
4818
5241
|
}
|
|
4819
5242
|
|
|
@@ -4833,6 +5256,10 @@ export function createHost(ctx, config) {
|
|
|
4833
5256
|
{ kind: 'exact', path: WHALE_URL, handler: (req, res) => routeRequest(req, res) },
|
|
4834
5257
|
{ kind: 'exact', path: THEME_URL, handler: (req, res) => routeRequest(req, res) },
|
|
4835
5258
|
{ kind: 'prefix', path: API_PREFIX, handler: (req, res) => routeRequest(req, res) },
|
|
5259
|
+
// 截图路由也挂到 GUI 上:桌面聊天里内嵌图片必须与页面同源,否则会被
|
|
5260
|
+
// CSP(img-src 'self')/跨源策略拦掉,浏览器里就是一张裂图。
|
|
5261
|
+
{ kind: 'prefix', path: SHOT_PREFIX, handler: (req, res) => routeRequest(req, res) },
|
|
5262
|
+
{ kind: 'prefix', path: FILE_PREFIX, handler: (req, res) => routeRequest(req, res) },
|
|
4836
5263
|
]
|
|
4837
5264
|
const disposers = routes.map((r) => ctx.webServer.register(r))
|
|
4838
5265
|
return () => {
|