Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions changelog/unreleased/accounts-batch-import-workbuddy-cn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### English

- Accounts page: batch-import WorkBuddy hub packs with auto-detect (format / provider / region) and a confirm step to choose WorkBuddy CN or Global; other providers are detected but not imported yet. Reuses the existing import API.

### 中文

- 账号页批量导入:自动识别凭证 format / 供应商 / 区,确认后可选择 WorkBuddy 国内版或国际版导入;其它供应商仅识别、暂不导入。复用现有导入接口。
109 changes: 109 additions & 0 deletions frontend/scripts/accountImportDetect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import assert from 'node:assert/strict'
import { detectImportBundle, parseImportBundleText } from '../src/lib/accountImportDetect.ts'

function run(name: string, fn: () => void) {
try {
fn()
console.log(`ok ${name}`)
} catch (error) {
console.error(`FAIL ${name}`)
throw error
}
}

run('format workbuddy-accounts → workbuddy cn', () => {
const result = detectImportBundle({
format: 'workbuddy-accounts',
accounts: [{ accessToken: 'a', domain: 'https://www.codebuddy.cn' }],
})
assert.equal(result.provider, 'workbuddy')
assert.equal(result.format, 'workbuddy-accounts')
assert.equal(result.region, 'cn')
assert.equal(result.regionAmbiguous, false)
assert.equal(result.batchImportSupported, true)
assert.equal(result.confidence, 'high')
})

run('format workbuddy-oauth-v1 global domain', () => {
const result = detectImportBundle({
format: 'workbuddy-oauth-v1',
accessToken: 'a',
domain: 'https://www.workbuddy.ai',
})
assert.equal(result.provider, 'workbuddy')
assert.equal(result.region, 'global')
assert.equal(result.batchImportSupported, true)
})

run('mixed CN+Global → ambiguous', () => {
const result = detectImportBundle({
format: 'workbuddy-accounts',
accounts: [
{ accessToken: 'a', domain: 'codebuddy.cn' },
{ accessToken: 'b', domain: 'workbuddy.ai' },
],
})
assert.equal(result.region, null)
assert.equal(result.regionAmbiguous, true)
assert.ok(result.warnings.includes('region_mixed'))
})

run('no domain → default cn + region_unknown', () => {
const result = detectImportBundle({
format: 'workbuddy-accounts',
accounts: [{ accessToken: 'a' }],
})
assert.equal(result.region, 'cn')
assert.equal(result.regionAmbiguous, true)
assert.ok(result.warnings.includes('region_unknown'))
})

run('shape accounts[] without format', () => {
const result = detectImportBundle({
accounts: [{ refreshToken: 'r', domain: 'codebuddy.cn' }],
})
assert.equal(result.provider, 'workbuddy')
assert.equal(result.format, 'workbuddy-accounts')
assert.equal(result.confidence, 'medium')
})

run('qoder shape', () => {
const result = detectImportBundle({
user_blob: 'x',
machine_id: 'm',
})
assert.equal(result.provider, 'qoder')
assert.equal(result.format, 'qoder-native-v1')
assert.equal(result.batchImportSupported, false)
})

run('known other formats unsupported for batch', () => {
for (const format of [
'trae-oauth-v1',
'devin-session-v1',
'command-key-v1',
'codex-oauth-v1',
'zhipu-key-v1',
'qwenwork-oauth-v1',
]) {
const result = detectImportBundle({ format })
assert.equal(result.batchImportSupported, false, format)
assert.ok(result.provider, format)
}
})

run('bad json throws', () => {
assert.throws(() => parseImportBundleText('{'), /bad_json/)
assert.throws(() => parseImportBundleText('[]'), /bad_json/)
})

run('unknown format field still shape-detects', () => {
const result = detectImportBundle({
format: 'mystery-v9',
accounts: [{ accessToken: 'a', domain: 'codebuddy.cn' }],
})
assert.equal(result.provider, 'workbuddy')
assert.ok(result.warnings.includes('unknown_format'))
})

console.log('all detect tests passed')
80 changes: 33 additions & 47 deletions frontend/src/components/AddAccountModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ import {
startDeviceLogin,
type ProviderDescriptor,
} from '@/api/overview'
import {
normalizeWorkbuddyImportBundle,
summarizeWorkbuddyImportResult,
type WorkbuddyImportResult,
} from '@/lib/workbuddyImport'

type Props = {
isOpen: boolean
Expand Down Expand Up @@ -455,26 +460,28 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
setMessage(t('wizardBadJson'))
return
}
// Hub multi-account packages keep workbuddy-accounts. Do not rewrite them
// to oauth-v1 or only the first entry would import.
const isWorkbuddyAccounts =
activeOption.provider === 'workbuddy' &&
(bundle.format === 'workbuddy-accounts' || Array.isArray(bundle.accounts))
if (!bundle.format) {
if (isWorkbuddyAccounts) bundle.format = 'workbuddy-accounts'
else if (activeOption.descriptor.id === 'workbuddy') bundle.format = 'workbuddy-oauth-v1'
else if (activeOption.descriptor.id === 'trae') bundle.format = 'trae-oauth-v1'
else if (activeOption.descriptor.id === 'devin') bundle.format = 'devin-session-v1'
else if (activeOption.descriptor.id === 'command') bundle.format = 'command-key-v1'
else if (activeOption.descriptor.id === 'qwenwork') bundle.format = 'qwenwork-oauth-v1'
else bundle.format = 'qoder-native-v1'
} else if (isWorkbuddyAccounts) {
bundle.format = 'workbuddy-accounts'
}
try {
setPhase('busy')
const options = accountOptions()
const result = await importAccount({
const options = accountOptions()
let payload: Record<string, unknown>
if (activeOption.provider === 'workbuddy') {
// Shared normalize keeps hub workbuddy-accounts packs intact (same as batch import).
payload = normalizeWorkbuddyImportBundle(bundle, {
region: activeOption.region === 'global' ? 'global' : 'cn',
name: name.trim() || undefined,
enabled: true,
max_inflight: options.max_inflight,
priority: options.priority,
drop_system_prompt: options.drop_system_prompt,
proxy_url: options.proxy_url,
})
} else {
if (!bundle.format) {
if (activeOption.descriptor.id === 'trae') bundle.format = 'trae-oauth-v1'
else if (activeOption.descriptor.id === 'devin') bundle.format = 'devin-session-v1'
else if (activeOption.descriptor.id === 'command') bundle.format = 'command-key-v1'
else if (activeOption.descriptor.id === 'qwenwork') bundle.format = 'qwenwork-oauth-v1'
else bundle.format = 'qoder-native-v1'
}
payload = {
...bundle,
name: name.trim() || bundle.name,
enabled: true,
Expand All @@ -483,35 +490,14 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
max_inflight: options.max_inflight,
priority: options.priority,
drop_system_prompt: options.drop_system_prompt,
}) as {
object?: string
succeeded?: number
failed?: number
errors?: Array<{ index?: number; name?: string; message?: string }>
id?: string
}
const succeeded = typeof result?.succeeded === 'number'
? result.succeeded
: (result?.id || result ? 1 : 0)
const failed = typeof result?.failed === 'number' ? result.failed : 0
const failureSummary = (result?.errors || [])
.slice(0, 3)
.map((item) => item.message || `index ${item.index}`)
.filter(Boolean)
.join('; ')
}
try {
setPhase('busy')
const result = await importAccount(payload) as WorkbuddyImportResult
const { failed, message } = summarizeWorkbuddyImportResult(result, t)
setPhase('done')
if (failed > 0) {
setMessage(
t('accountImportPartial')
.replace('{ok}', String(succeeded))
.replace('{fail}', String(failed))
+ (failureSummary ? ` — ${failureSummary}` : ''),
)
} else if (succeeded > 1) {
setMessage(t('accountImportBatch').replace('{n}', String(succeeded)))
} else {
setMessage(t('accountImported'))
}
setMessage(message)
onAdded()
window.setTimeout(finishAndClose, failed > 0 ? 1600 : 700)
} catch (error) {
Expand Down
144 changes: 144 additions & 0 deletions frontend/src/components/account/BatchImportConfirmModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { useEffect, useState } from 'react'
import { Button, Modal } from '@heroui/react'
import { X } from '@phosphor-icons/react'
import { FilterToggle } from '@/components/ui/FilterToggle'
import { ProviderMark } from '@/components/ProviderMark'
import type { ImportDetectResult, ImportRegion } from '@/lib/accountImportDetect'
import { accountProviderFamilyLabel } from '@/lib/provider'

type Props = {
isOpen: boolean
detection: ImportDetectResult | null
fileName?: string
isPending?: boolean
t: (key: string, vars?: Record<string, string | number>) => string
onClose: () => void
onConfirm: (region: ImportRegion) => void
}

function warningText(code: string, t: Props['t']) {
if (code === 'region_mixed') return t('batchImportRegionMixed')
if (code === 'region_unknown') return t('batchImportRegionUnknown')
if (code === 'unknown_format') return t('batchImportUnknownFormat')
return ''
}

export function BatchImportConfirmModal({
isOpen,
detection,
fileName,
isPending,
t,
onClose,
onConfirm,
}: Props) {
const [region, setRegion] = useState<ImportRegion>('cn')

useEffect(() => {
if (!detection) return
if (detection.region === 'cn' || detection.region === 'global') {
setRegion(detection.region)
} else {
setRegion('cn')
}
}, [detection])

const supported = Boolean(detection?.batchImportSupported)
const provider = detection?.provider
const formatLabel = detection?.format || t('batchImportFormatUnknown')
const providerLabel = provider
? accountProviderFamilyLabel(provider, t)
: t('batchImportProviderUnknown')

const warnings = (detection?.warnings || [])
.map((code) => warningText(code, t))
.filter(Boolean)

return (
<Modal.Root
isOpen={isOpen}
onOpenChange={(open: boolean) => {
if (!open && !isPending) onClose()
}}
>
<Modal.Backdrop variant="blur" isDismissable={!isPending}>
<Modal.Container placement="center" size="sm">
<Modal.Dialog>
<Modal.Header className="items-start justify-between gap-4 px-5 pt-5">
<div className="min-w-0">
<Modal.Heading className="text-section font-semibold">{t('batchImportConfirmTitle')}</Modal.Heading>
<p className="mt-1 text-caption leading-5 text-muted">
{fileName
? t('batchImportConfirmHint', { file: fileName })
: t('batchImportConfirmHintNoFile')}
</p>
</div>
<Modal.CloseTrigger
isDisabled={isPending}
aria-label={t('close')}
className="grid size-8 shrink-0 place-items-center rounded-lg text-muted transition-colors hover:bg-surface-secondary hover:text-foreground"
>
<X size={16} />
</Modal.CloseTrigger>
</Modal.Header>
<Modal.Body className="space-y-4 px-5 pb-2">
<div className="rounded-xl bg-surface-secondary px-3.5 py-3">
<div className="flex items-center gap-2.5">
{provider ? <ProviderMark provider={provider} size={18} /> : null}
<div className="min-w-0">
<div className="text-body font-medium text-foreground">{providerLabel}</div>
<div className="mono mt-0.5 truncate text-caption text-muted">{formatLabel}</div>
</div>
</div>
</div>

{supported ? (
<div className="space-y-2">
<div className="text-body font-medium text-muted">{t('batchImportRegion')}</div>
<FilterToggle
value={region}
onChange={(next) => {
if (next === 'cn' || next === 'global') setRegion(next)
}}
ariaLabel={t('batchImportRegion')}
options={[
{ id: 'cn', label: t('accountTypeWorkBuddyCN') },
{ id: 'global', label: t('accountTypeWorkBuddyGlobal') },
]}
/>
{detection?.regionAmbiguous ? (
<p className="text-caption leading-5 text-muted">{t('batchImportRegionPickHint')}</p>
) : null}
</div>
) : (
<p className="text-body leading-6 text-muted">{t('batchImportProviderUnsupported')}</p>
)}

{warnings.length ? (
<ul className="space-y-1 text-caption leading-5 text-muted">
{warnings.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
) : null}
</Modal.Body>
<Modal.Footer className="justify-end gap-2 px-5 pb-5">
<Button size="sm" variant="ghost" isDisabled={isPending} onPress={onClose}>
{supported ? t('cancel') : t('close')}
</Button>
{supported ? (
<Button
size="sm"
isPending={isPending}
onPress={() => onConfirm(region)}
>
{t('batchImportConfirmAction')}
</Button>
) : null}
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal.Root>
)
}
Loading
Loading