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
1 change: 1 addition & 0 deletions docs/PROVIDERS_TRAE_SOLO.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,7 @@ ProviderDescriptor{
- [x] `credential.go`:Validate / Encode / Decode(nested + flat)、持久化 device
- [x] `ExchangeToken` + 提前刷新 + 刷新失败标未就绪
- [x] `StartLogin`:持久化或复用 device、起 `127.0.0.1` callback、返回授权 URL
- [x] `CompleteLogin`:浏览器和服务不在同一台时,可粘贴完整 `127.0.0.1/authorize?...` URL
- [x] `PollLogin`:pending → ExchangeToken → GetUserInfo → SaveCredentialPayload
- [x] Import/Export 兼容 `trae-{uid}.json`
- [x] 不绑公网、不提供 CODING_TOKEN、不每轮换设备
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/api/overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ export function fetchLoginStatus(accountId?: string) {
return api<{ login?: any }>(`/api/accounts/${encodeURIComponent(accountId)}/login/status`)
}

export function completeLoginCallback(accountId: string, callbackUrl: string) {
if (!accountId) throw new Error('account id required')
return api(`/api/accounts/${encodeURIComponent(accountId)}/login/callback`, {
method: 'POST',
body: JSON.stringify({ callback_url: callbackUrl }),
})
}

export function loginWithPat(pat: string, accountId?: string) {
if (!accountId) throw new Error('account id required')
return api(`/api/accounts/${encodeURIComponent(accountId)}/login/pat`, {
Expand Down
41 changes: 41 additions & 0 deletions frontend/src/components/AddAccountModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
fetchProviders,
importAccount,
loginWithPat,
completeLoginCallback,
startDeviceLogin,
type ProviderDescriptor,
} from '@/api/overview'
Expand Down Expand Up @@ -132,6 +133,7 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
const [phase, setPhase] = useState<Phase>('idle')
const [message, setMessage] = useState('')
const [authUrl, setAuthUrl] = useState('')
const [callbackUrl, setCallbackUrl] = useState('')
const createdId = useRef<string>('')
const pollTimer = useRef<number | null>(null)
const fileInput = useRef<HTMLInputElement>(null)
Expand Down Expand Up @@ -173,6 +175,7 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
const showPatTab = activeOption?.descriptor.capabilities?.pat_login !== false
const showImportTab = activeOption?.descriptor.capabilities?.import_export !== false
const showDropSystem = activeOption?.provider === 'workbuddy'
const showCallbackPaste = activeOption?.provider === 'trae'
const busy = phase === 'busy' || phase === 'polling'
const settingsLocked = Boolean(createdId.current) || busy
const isDone = phase === 'done'
Expand Down Expand Up @@ -220,6 +223,7 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
setPhase('idle')
setMessage('')
setAuthUrl('')
setCallbackUrl('')
createdId.current = ''
}

Expand Down Expand Up @@ -280,6 +284,27 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
}
}

async function runCallback() {
const pasted = callbackUrl.trim()
if (!pasted) {
setMessage(t('wizardCallbackPh'))
return
}
try {
setPhase('busy')
const id = await ensureAccount()
await completeLoginCallback(id, pasted)
stopPolling()
setPhase('done')
setMessage(t('wizardAccountReady'))
onAdded()
window.setTimeout(close, 900)
} catch (error) {
setPhase('polling')
setMessage(error instanceof Error ? error.message : String(error))
}
}

async function runPat() {
const token = pat.trim()
if (!token) { setMessage(t('pastePatFirst')); return }
Expand Down Expand Up @@ -586,6 +611,22 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
{message && !authUrl ? (
<p className={`rounded-lg border px-3 py-2 text-xs ${isDone ? 'border-[var(--app-ok-line)] bg-[var(--app-ok-soft)] text-[var(--app-ok-strong)]' : 'border-[var(--app-line)] bg-[var(--app-surface-muted)] text-[var(--app-muted)]'}`}>{message}</p>
) : null}
{showCallbackPaste ? (
<div className="space-y-2">
<p className="text-[11px] leading-4 text-[var(--app-faint)]">{t('wizardCallbackLead')}</p>
<TextArea
className="min-h-20 font-mono text-xs"
value={callbackUrl}
onChange={(event) => setCallbackUrl(event.target.value)}
placeholder={t('wizardCallbackPh')}
aria-label={t('wizardCallbackPh')}
disabled={isDone}
/>
<Button className="w-full" variant="secondary" isPending={phase === 'busy' && Boolean(callbackUrl.trim())} onPress={() => void runCallback()} isDisabled={isDone}>
{t('wizardSubmitCallback')}
</Button>
</div>
) : null}
<Button className="w-full" isPending={tabPending('browser')} onPress={() => void runBrowser()}>
{isDone ? <><CheckCircle size={15} />{t('wizardAccountReady')}</> : <><ShieldCheck size={15} />{t('wizardStartBrowser')}</>}
</Button>
Expand Down
23 changes: 22 additions & 1 deletion frontend/src/components/account/AccountCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const ACCOUNT_ICON_BUTTON_CLASS = 'account-button account-icon-button'
const ACCOUNT_CHIP_CLASS = 'account-chip'
const ACCOUNT_INPUT_CLASS = 'account-input'

export type AccountBusyKind = 'create' | 'import' | 'device' | 'pat' | 'rewarm' | 'toggle' | 'delete' | 'export' | 'settings'
export type AccountBusyKind = 'create' | 'import' | 'device' | 'pat' | 'callback' | 'rewarm' | 'toggle' | 'delete' | 'export' | 'settings'

type Translate = (key: string, vars?: Record<string, string | number>) => string

Expand All @@ -44,6 +44,9 @@ type Props = {
onPatChange: (value: string) => void
onDeviceLogin: () => void
onPatLogin: () => void
callbackUrl?: string
onCallbackChange?: (value: string) => void
onSubmitCallback?: () => void
onExport: () => void
onRewarm: () => void
onDelete: () => void
Expand Down Expand Up @@ -73,6 +76,9 @@ export function AccountCard({
onPatChange,
onDeviceLogin,
onPatLogin,
callbackUrl,
onCallbackChange,
onSubmitCallback,
onExport,
onRewarm,
onDelete,
Expand Down Expand Up @@ -236,6 +242,21 @@ export function AccountCard({
<Button className={ACCOUNT_BUTTON_CLASS} size="sm" isPending={busyKind === 'device'} onPress={onDeviceLogin}><ShieldCheck size={14} />{t('startBrowserLogin')}</Button>
{authUrl ? <Button className={ACCOUNT_BUTTON_CLASS} size="sm" variant="ghost" onPress={() => window.open(authUrl, '_blank', 'noopener,noreferrer')}><ArrowSquareOut size={14} />{t('open')}</Button> : null}
</div>
{account.provider === 'trae' && onSubmitCallback && onCallbackChange ? (
<div className="mt-3 space-y-2">
<p className="text-[11px] leading-4 text-[var(--app-faint)]">{t('wizardCallbackLead')}</p>
<Input
className={ACCOUNT_INPUT_CLASS}
value={callbackUrl || ''}
onChange={(event) => onCallbackChange(event.target.value)}
placeholder={t('wizardCallbackPh')}
aria-label={t('wizardCallbackPh')}
/>
<Button className={ACCOUNT_BUTTON_CLASS} size="sm" variant="secondary" isPending={busyKind === 'callback'} onPress={onSubmitCallback}>
{t('wizardSubmitCallback')}
</Button>
</div>
) : null}
</div>
<div>
<div className="text-[10px] font-semibold tracking-[0.1em] text-[var(--app-faint)] uppercase">{t('patFallback')}</div>
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,9 @@ export const messages: Record<Lang, Dict> = {
wizardOpenBrowser: 'Open authorization page',
wizardStartingSession: 'Starting the account session…',
wizardWaitingBrowser: 'Waiting for authorization in the browser…',
wizardCallbackLead: 'If the browser stays on 127.0.0.1 and this console never finishes, copy the full address-bar URL here.',
wizardCallbackPh: 'Paste the full http://127.0.0.1…/authorize?… URL',
wizardSubmitCallback: 'Submit callback URL',
wizardStartingWorker: 'Authorization received. Starting account session…',
wizardAccountReady: 'Account is ready.',
wizardLoginTimeout: 'Timed out waiting for browser login. You can retry from the account card.',
Expand Down Expand Up @@ -834,6 +837,9 @@ export const messages: Record<Lang, Dict> = {
wizardOpenBrowser: '打开授权页',
wizardStartingSession: '正在启动账号会话…',
wizardWaitingBrowser: '正在等待浏览器完成授权…',
wizardCallbackLead: '如果浏览器停在 127.0.0.1,而控制台一直没有完成,把地址栏完整 URL 粘贴到这里。',
wizardCallbackPh: '粘贴完整的 http://127.0.0.1…/authorize?… 地址',
wizardSubmitCallback: '提交回调地址',
wizardStartingWorker: '已收到授权,正在启动账号会话…',
wizardAccountReady: '账号已就绪。',
wizardLoginTimeout: '等待浏览器登录超时,可在账号卡片重试。',
Expand Down
18 changes: 18 additions & 0 deletions frontend/src/pages/AccountsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { useOverview } from '@/hooks/useOverview'
import {
deleteAccount,
exportAccount,
completeLoginCallback,
fetchLoginStatus,
loginWithPat,
rewarmWorker,
Expand Down Expand Up @@ -45,6 +46,7 @@ export function AccountsPage() {
const [addOpen, setAddOpen] = useState(false)
const [busy, setBusy] = useState<AccountBusy | null>(null)
const [patById, setPatById] = useState<Record<string, string>>({})
const [callbackById, setCallbackById] = useState<Record<string, string>>({})
const [noteById, setNoteById] = useState<Record<string, string>>({})
const [urlById, setUrlById] = useState<Record<string, string>>({})
const [confirmId, setConfirmId] = useState<string | null>(null)
Expand Down Expand Up @@ -130,6 +132,19 @@ export function AccountsPage() {
})
}

async function onCallback(id: string) {
const pasted = (callbackById[id] || '').trim()
if (!pasted) {
setNoteById((current) => ({ ...current, [id]: t('wizardCallbackPh') }))
return
}
await run(id, 'callback', async () => {
await completeLoginCallback(id, pasted)
setCallbackById((current) => ({ ...current, [id]: '' }))
await refresh(undefined, { silent: true })
})
}

async function onPat(id: string) {
const pat = (patById[id] || '').trim()
if (!pat) {
Expand Down Expand Up @@ -332,6 +347,9 @@ export function AccountsPage() {
pat={patById[account.id] || ''}
t={t}
onPatChange={(value) => setPatById((current) => ({ ...current, [account.id]: value }))}
callbackUrl={callbackById[account.id] || ''}
onCallbackChange={(value) => setCallbackById((current) => ({ ...current, [account.id]: value }))}
onSubmitCallback={() => void onCallback(account.id)}
onDeviceLogin={() => void onDeviceLogin(account.id)}
onPatLogin={() => void onPat(account.id)}
onExport={() => void onExport(account.id)}
Expand Down
23 changes: 23 additions & 0 deletions internal/api/accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,29 @@ func (s *Server) handleAccountByID(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, map[string]any{"login": map[string]any{"status": status, "message": message}})
return
case "login/callback":
if r.Method != http.MethodPost {
writeErr(w, http.StatusMethodNotAllowed, "method_not_allowed", "POST only")
return
}
completer, ok := adapter.Login.(providers.LoginCompleter)
if !ok {
writeErr(w, http.StatusBadRequest, "provider_unsupported", "provider does not accept a pasted callback URL")
return
}
var input struct {
CallbackURL string `json:"callback_url"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeErr(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if err := completer.CompleteLogin(r.Context(), accountID, input.CallbackURL); err != nil {
writeErr(w, http.StatusBadGateway, "login_callback_failed", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"login": map[string]any{"status": "ok", "message": "login complete"}})
return
case "export":
if r.Method != http.MethodGet {
writeErr(w, http.StatusMethodNotAllowed, "method_not_allowed", "GET only")
Expand Down
6 changes: 6 additions & 0 deletions internal/providers/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ type LoginSessionProvider interface {
PollLogin(ctx context.Context, accountID string) (done bool, message string, err error)
}

// LoginCompleter accepts a provider callback URL copied from the browser
// when the automatic loopback redirect cannot reach this process.
type LoginCompleter interface {
CompleteLogin(ctx context.Context, accountID, callbackURL string) error
}

// ChatOutcome is the provider-neutral non-stream result.
type ChatOutcome struct {
Model string
Expand Down
64 changes: 51 additions & 13 deletions internal/providers/trae/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,56 @@ func (c *Client) PollLogin(ctx context.Context, accountID string) (bool, string,
if !done {
return false, firstNonEmpty(message, "waiting for authorization"), nil
}
if err := c.finishCredential(ctx, accountID, credential); err != nil {
return false, "", err
}
c.mu.Lock()
delete(c.pending, accountID)
c.mu.Unlock()
return true, "login complete", nil
}

func (c *Client) CompleteLogin(ctx context.Context, accountID, callbackURL string) error {
info, err := ParseCallback(callbackURL)
if err != nil {
return err
}
credential := Credential{
AccessToken: info.AccessToken,
RefreshToken: info.RefreshToken,
ExpiresAt: unixSeconds(info.ExpiresAt),
UID: info.UID,
Nickname: info.Nickname,
EnterpriseID: info.EnterpriseID,
Domain: DomainCN,
APIHost: OAuthHost,
}
c.mu.Lock()
pending := c.pending[accountID]
c.mu.Unlock()
if pending != nil {
credential.MachineID = pending.machineID
credential.DeviceID = pending.deviceID
} else if _, payload, err := c.store.LoadCredentialPayload(ctx, accountID); err == nil {
if decoded, err := DecodeCredential(payload); err == nil {
credential.MachineID = decoded.MachineID
credential.DeviceID = decoded.DeviceID
}
}
if err := c.finishCredential(ctx, accountID, credential); err != nil {
return err
}
c.mu.Lock()
delete(c.pending, accountID)
c.mu.Unlock()
return nil
}

func (c *Client) finishCredential(ctx context.Context, accountID string, credential Credential) error {
if strings.TrimSpace(credential.RefreshToken) != "" {
refreshed, err := c.ExchangeToken(ctx, credential)
if err != nil {
return false, "", err
return err
}
credential = refreshed
}
Expand All @@ -161,24 +207,16 @@ func (c *Client) PollLogin(ctx context.Context, accountID string) (bool, string,
}
}
}
if strings.TrimSpace(credential.MachineID) == "" {
credential.MachineID = pending.machineID
}
if strings.TrimSpace(credential.DeviceID) == "" {
credential.DeviceID = pending.deviceID
}
credential = EnsureDevice(credential)
payload, err := credential.Encode()
if err != nil {
return false, "", err
return err
}
if err := c.store.SaveCredentialPayload(ctx, accountID, CredentialFormat, payload); err != nil {
return false, "", err
return err
}
_ = c.store.Observe(ctx, accountID, credential.UID, "ready", "", "")
c.mu.Lock()
delete(c.pending, accountID)
c.mu.Unlock()
return true, "login complete", nil
return nil
}

func (c *Client) ensureCallback() (string, error) {
Expand Down
36 changes: 36 additions & 0 deletions internal/providers/trae/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,42 @@ func TestLoginCallbackStoresDeviceAndToken(t *testing.T) {
}
}

func TestCompleteLoginAcceptsPastedCallbackURL(t *testing.T) {
client, store := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case pathExchange:
_ = json.NewEncoder(w).Encode(map[string]any{"Result": map[string]any{
"Token": "at", "RefreshToken": "rt2", "TokenExpireAt": time.Now().Add(time.Hour).Unix(),
}})
case pathUserInfo:
_ = json.NewEncoder(w).Encode(map[string]any{"Result": map[string]any{
"UserID": "u1", "ScreenName": "Tester", "EnterpriseID": "e1",
}})
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
if _, err := client.StartLogin(context.Background(), "acc1"); err != nil {
t.Fatal(err)
}
client.mu.Lock()
machine, device := client.pending["acc1"].machineID, client.pending["acc1"].deviceID
client.mu.Unlock()
err := client.CompleteLogin(context.Background(), "acc1",
`http://127.0.0.1:9/authorize?refreshToken=rt&userInfo={"UserID":"u1","ScreenName":"N"}`)
if err != nil {
t.Fatal(err)
}
_, payload, err := store.LoadCredentialPayload(context.Background(), "acc1")
if err != nil {
t.Fatal(err)
}
credential, err := DecodeCredential(payload)
if err != nil || credential.UID != "u1" || credential.AccessToken != "at" || credential.MachineID != machine || credential.DeviceID != device {
t.Fatalf("credential=%+v err=%v want machine=%s device=%s", credential, err, machine, device)
}
}

func TestChatNonStreamAggregatesToolsAndReasoning(t *testing.T) {
payload, _ := Credential{AccessToken: "at", RefreshToken: "rt", UID: "u1", Domain: DomainCN, ExpiresAt: 4102444800, MachineID: "m1", DeviceID: "d1"}.Encode()
store := &memStore{items: map[string][]byte{"acc1": payload}}
Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Loading
Loading