✨ feat(vscode): 会话交接改走阿里云 OSS 收件箱、换 xz 压缩,另加从 env 文件配置移交存储的 URI

- 会话包传到 OSS 上收件人的收件箱 spx-handoff/<owner>/<repo>/session/<收件人>/<sid>!<发送人>!<会话名>.tar.xz,对象私有;owner/repo/收件人转小写
- 待接收列表就是列自己的收件箱;同一会话再交给同一人先删旧包;接收后删包,包已被别处收走时提示并刷新
- 打包换成 xz 1 级,和工单移交共用 packHandoff / unpackHandoff,删掉 gzip 版
- 删掉邮箱工单整套(mailboxIssue、MAILBOX_*、isMailboxIssue 及看板 / webhook 里的过滤、附件名编解码)和不再用的 Gitea 附件、标签、建工单接口
- session-handoff/accept 与待接收项改用 OSS 对象 key 标识
- 没配移交存储时发送、接收、刷新都提示去配,刷新回空列表
- 进度提示照工单移交:同一条提示依次显示打包、上传、下载多少 MB、解包、装会话
- 新增 URI configure-handoff-store?envFile=<绝对路径>:读 OSS_ENDPOINT / OSS_BUCKET_NAME / OSS_ACCESS_KEY / OSS_SECRET_KEY 保存,和命令共用 saveHandoffStore;URI 入口按 path 统一分发,未知 path 报错
This commit is contained in:
cruldra committed 2026-10-08 07:17:26 +08:00
1 parent 81fe14f6dc
commit fcee4143d4
26 files changed
+542 -515

No files matched your search

+27 -1
View File
@@ -1,5 +1,5 @@
/**
* 工单移交的会话包放在阿里云 OSS 上:发送方上传,接管方下载后删掉。
* 工单移交和会话交接的会话包放在阿里云 OSS 上:发送方上传,接收方下载后删掉。
*
* 这里只管传文件,不碰 VS Code;配置从哪来见 `src/oss/handoffStoreSetup.ts`。
*/
@@ -107,6 +107,32 @@ export async function downloadHandoffBundle(
throw new Error(`下载不完整:收到 ${received} 字节,应为 ${total} 字节`)
}
/** `err` 是不是「对象不存在」:会话包已经被别人收走或删掉了。 */
export function isHandoffBundleMissing(err: unknown): boolean {
return typeof err === 'object' && err !== null && (err as { code?: unknown }).code === 'NoSuchKey'
}
export async function deleteHandoffBundle(store: HandoffStore, key: string): Promise<void> {
await client(store).delete(key)
}
/** OSS 上的一个会话包。 */
export interface HandoffBundleObject {
key: string
size: number
/** ISO 时间,如 `2026-10-08T01:02:03.000Z` */
lastModified: string
}
/** 列出 key 以 `prefix` 开头的全部对象。OSS 一页最多给 1000 个,这里翻页直到列完。 */
export async function listHandoffBundles(store: HandoffStore, prefix: string): Promise<HandoffBundleObject[]> {
const oss = client(store)
const out: HandoffBundleObject[] = []
let token: string | undefined
do {
const page = await oss.listV2({ 'prefix': prefix, 'max-keys': 1000, ...(token ? { 'continuation-token': token } : {}) })
out.push(...page.objects.map(o => ({ key: o.name, size: o.size, lastModified: o.lastModified })))
token = page.isTruncated ? page.nextContinuationToken : undefined
} while (token)
return out
}
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import { parseHandoffStoreEnv } from './handoffStoreEnv'
const STORE = {
endpoint: 'oss-cn-beijing.aliyuncs.com',
bucket: 'luffy-agent-platform',
accessKeyId: 'AK-ID',
accessKeySecret: 'AK-SECRET',
}
describe('parseHandoffStoreEnv', () => {
it('luffy 后端 .env 的写法:跳过注释、空行和别的变量', () => {
const text = [
'# 数据库',
'DATABASE_URL=postgres://x',
'',
'OSS_ENDPOINT=oss-cn-beijing.aliyuncs.com',
'OSS_ACCESS_KEY=AK-ID',
'OSS_SECRET_KEY=AK-SECRET',
'OSS_BUCKET_NAME=luffy-agent-platform',
'OSS_PUBLIC_BASE=https://cdn.example.com',
].join('\n')
expect(parseHandoffStoreEnv(text)).toEqual({ ok: true, store: STORE })
})
it('认 CRLF 换行、export 前缀、等号两边空格、成对引号和行尾注释', () => {
const text = [
'export OSS_ENDPOINT = "oss-cn-beijing.aliyuncs.com"',
'OSS_BUCKET_NAME=luffy-agent-platform # 正式服',
'OSS_ACCESS_KEY=\'AK-ID\'',
'OSS_SECRET_KEY=AK-SECRET',
].join('\r\n')
expect(parseHandoffStoreEnv(text)).toEqual({ ok: true, store: STORE })
})
it('值里的 # 不挨着空格就不算注释', () => {
const text = 'OSS_ENDPOINT=e\nOSS_BUCKET_NAME=b\nOSS_ACCESS_KEY=a\nOSS_SECRET_KEY=s#ec+ret/=='
const result = parseHandoffStoreEnv(text)
expect(result.ok && result.store.accessKeySecret).toBe('s#ec+ret/==')
})
it('endpoint 去掉协议和结尾的 /', () => {
const text = 'OSS_ENDPOINT=https://oss-cn-beijing.aliyuncs.com/\nOSS_BUCKET_NAME=b\nOSS_ACCESS_KEY=a\nOSS_SECRET_KEY=s'
const result = parseHandoffStoreEnv(text)
expect(result.ok && result.store.endpoint).toBe('oss-cn-beijing.aliyuncs.com')
})
it('同一个变量写两遍以后一遍为准', () => {
const text = 'OSS_ENDPOINT=old\nOSS_ENDPOINT=new\nOSS_BUCKET_NAME=b\nOSS_ACCESS_KEY=a\nOSS_SECRET_KEY=s'
const result = parseHandoffStoreEnv(text)
expect(result.ok && result.store.endpoint).toBe('new')
})
it('缺项和空值都报出变量名', () => {
const text = 'OSS_ENDPOINT=e\nOSS_ACCESS_KEY=\n# OSS_SECRET_KEY=s\nOSS_BUCKET_NAME=b'
expect(parseHandoffStoreEnv(text)).toEqual({ ok: false, missing: ['OSS_ACCESS_KEY', 'OSS_SECRET_KEY'] })
})
it('空文本四项全缺', () => {
expect(parseHandoffStoreEnv('')).toEqual({ ok: false, missing: ['OSS_ENDPOINT', 'OSS_BUCKET_NAME', 'OSS_ACCESS_KEY', 'OSS_SECRET_KEY'] })
})
})
+57
View File
@@ -0,0 +1,57 @@
/**
* 从 env 文件的文本里读移交存储配置。认的是 luffy-agent-platform 后端 `.env` 的写法:
* `OSS_ENDPOINT`、`OSS_BUCKET_NAME`、`OSS_ACCESS_KEY`、`OSS_SECRET_KEY` 四行 `KEY=value`,
* 空行、`#` 注释和别的变量都跳过。
*/
import type { HandoffStore } from './handoffStore'
const ENV_NAMES = ['OSS_ENDPOINT', 'OSS_BUCKET_NAME', 'OSS_ACCESS_KEY', 'OSS_SECRET_KEY'] as const
type EnvName = typeof ENV_NAMES[number]
const LINE_RE = /^(?:export\s+)?(\w+)\s*=(.*)$/
/** 读出来的配置,或者缺了哪几个变量(只有变量名,不带值)。 */
export type HandoffStoreEnvResult = { ok: true, store: HandoffStore } | { ok: false, missing: EnvName[] }
/** 去掉值两边成对的引号;没加引号的值去掉 ` #` 起的行尾注释。 */
function unquote(raw: string): string {
const v = raw.trim()
const quote = v[0]
if (quote === '"' || quote === '\'') {
const end = v.indexOf(quote, 1)
if (end > 0)
return v.slice(1, end)
}
return v.replace(/\s#.*$/, '').trim()
}
/**
* 同一个变量写了几遍以最后一遍为准,值为空算缺。endpoint 去掉 `https://` 和结尾的 `/`:
* 设置里存的是不带协议的域名,上传下载一律走 https。
*/
export function parseHandoffStoreEnv(text: string): HandoffStoreEnvResult {
const values = new Map<string, string>()
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#'))
continue
const m = LINE_RE.exec(trimmed)
if (m && (ENV_NAMES as readonly string[]).includes(m[1]))
values.set(m[1], unquote(m[2]))
}
const missing = ENV_NAMES.filter(name => !values.get(name))
if (missing.length > 0)
return { ok: false, missing }
const get = (name: EnvName): string => values.get(name) ?? ''
return {
ok: true,
store: {
endpoint: get('OSS_ENDPOINT').replace(/^https?:\/\//i, '').replace(/\/+$/, ''),
bucket: get('OSS_BUCKET_NAME'),
accessKeyId: get('OSS_ACCESS_KEY'),
accessKeySecret: get('OSS_SECRET_KEY'),
},
}
}
+11 -3
View File
@@ -55,7 +55,15 @@ export async function configureHandoffStore(ctx: ExtensionContext): Promise<void
const accessKeySecret = await ask({ title: '配置移交存储(4/4)', prompt: 'AccessKey Secret', value: keys.accessKeySecret, password: true })
if (!accessKeySecret)
return
await saveSettings(ctx, { handoffOssEndpoint: endpoint, handoffOssBucket: bucket })
await setHandoffOssKeys(ctx, { accessKeyId, accessKeySecret })
void window.showInformationMessage(`移交存储已保存:${bucket}(${endpoint})`)
await saveHandoffStore(ctx, { endpoint, bucket, accessKeyId, accessKeySecret })
}
/**
* 保存移交存储配置并提示保存好了:endpoint、bucket 进扩展设置,两个 AccessKey 进密钥存储。
* 「配置移交存储」命令和 `configure-handoff-store` URI 都走这里。
*/
export async function saveHandoffStore(ctx: ExtensionContext, store: HandoffStore): Promise<void> {
await saveSettings(ctx, { handoffOssEndpoint: store.endpoint, handoffOssBucket: store.bucket })
await setHandoffOssKeys(ctx, { accessKeyId: store.accessKeyId, accessKeySecret: store.accessKeySecret })
void window.showInformationMessage(`移交存储已保存:${store.bucket}(${store.endpoint})`)
}