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/zhipu-glm-proxy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### English

- Add a Zhipu GLM account. Paste an API key to proxy the official OpenAI-compatible endpoint, with Coding Plan quota on coding keys.

### 中文

- 新增智谱 GLM 账号。粘贴 API Key 即可反向代理官方 OpenAI 兼容端点,Coding Plan 密钥会显示套餐额度。
190 changes: 142 additions & 48 deletions frontend/src/components/AddAccountModal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import gsap from 'gsap'
import { Button, Input, Modal, NumberField, Skeleton, TextArea } from '@heroui/react'
import { ArrowSquareOut, CaretLeft, CaretRight, CheckCircle, FileCode, Key, ShieldCheck, X } from '@phosphor-icons/react'
import { BrandMark } from '@/components/BrandMark'
Expand Down Expand Up @@ -53,6 +54,7 @@ const labelKeys: Record<string, string> = {
'devin-global': 'accountTypeDevinGlobal',
'command-global': 'accountTypeCommandGlobal',
'codex-global': 'accountTypeCodexGlobal',
'zhipu-cn': 'accountTypeZhipuCN',
}

const hintKeys: Record<string, string> = {
Expand All @@ -64,15 +66,16 @@ const hintKeys: Record<string, string> = {
'devin-global': 'accountTypeDevinGlobalHint',
'command-global': 'accountTypeCommandGlobalHint',
'codex-global': 'accountTypeCodexGlobalHint',
'zhipu-cn': 'accountTypeZhipuCNHint',
}

function AccountTypeSkeleton({ ariaLabel }: { ariaLabel: string }) {
return (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2" aria-busy="true" aria-label={ariaLabel}>
{Array.from({ length: 4 }, (_, index) => (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2" aria-busy="true" aria-label={ariaLabel}>
{Array.from({ length: 6 }, (_, index) => (
<div key={index} className="flex items-center gap-2.5 rounded-xl border border-separator px-3 py-2.5">
<Skeleton className="size-[18px] shrink-0 rounded-lg" />
<Skeleton className="h-4 w-28 rounded-lg" />
<Skeleton className="size-7 shrink-0 rounded-lg" />
<Skeleton className="h-4 w-20 rounded-lg" />
</div>
))}
</div>
Expand Down Expand Up @@ -136,10 +139,15 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
const [proxyUrl, setProxyUrl] = useState('')
const [pat, setPat] = useState('')
const [json, setJson] = useState('')
const [zhipuMode, setZhipuMode] = useState<'payg' | 'coding'>('payg')
const [zhipuKey, setZhipuKey] = useState('')
const [zhipuOrganization, setZhipuOrganization] = useState('')
const [zhipuProject, setZhipuProject] = useState('')
const [phase, setPhase] = useState<Phase>('idle')
const [message, setMessage] = useState('')
const [authUrl, setAuthUrl] = useState('')
const [callbackUrl, setCallbackUrl] = useState('')
const stepBody = useRef<HTMLDivElement>(null)
const createdId = useRef<string>('')
const pollTimer = useRef<number | null>(null)
const fileInput = useRef<HTMLInputElement>(null)
Expand Down Expand Up @@ -236,6 +244,10 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
setProxyUrl('')
setPat('')
setJson('')
setZhipuMode('payg')
setZhipuKey('')
setZhipuOrganization('')
setZhipuProject('')
setAdvancedOpen(false)
setPhase('idle')
setMessage('')
Expand All @@ -255,9 +267,30 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
onClose()
}

function goLogin(next: TabKey) {
useLayoutEffect(() => {
const body = stepBody.current
if (!body || !isOpen) return
const context = gsap.context(() => {
const media = gsap.matchMedia()
media.add('(prefers-reduced-motion: reduce)', () => {
gsap.set(body, { autoAlpha: 1, y: 0 })
})
media.add('(prefers-reduced-motion: no-preference)', () => {
gsap.fromTo(
body,
{ autoAlpha: 0, y: 12 },
{ autoAlpha: 1, y: 0, duration: 0.34, ease: 'power3.out', overwrite: true },
)
})
}, body)
return () => context.revert()
}, [isOpen, step])

function chooseProvider(next: string) {
if (busy || !typesReady) return
setTab(next)
const option = providerOptions.find((item) => item.id === next)
setAccountType(next)
setTab(option?.provider === 'zhipu' ? 'pat' : 'browser')
setMessage('')
setStep('login')
}
Expand Down Expand Up @@ -327,6 +360,39 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
}
}

async function runZhipu() {
const key = zhipuKey.trim()
if (!key) { setMessage(t('zhipuKeyRequired')); return }
if (!activeOption) { setMessage(t('accountTypeHint')); return }
setMessage('')
try {
setPhase('busy')
const options = accountOptions()
await importAccount({
format: 'zhipu-key-v1',
api_key: key,
mode: zhipuMode,
organization: zhipuMode === 'coding' ? zhipuOrganization.trim() : '',
project: zhipuMode === 'coding' ? zhipuProject.trim() : '',
name: name.trim() || t('accountTypeZhipuCN'),
enabled: true,
provider: activeOption.provider,
region: activeOption.region,
max_inflight: options.max_inflight,
priority: options.priority,
drop_system_prompt: options.drop_system_prompt,
proxy_url: options.proxy_url,
})
setPhase('done')
setMessage(t('wizardAccountReady'))
onAdded()
window.setTimeout(finishAndClose, 700)
} catch (error) {
setPhase('idle')
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 @@ -416,6 +482,7 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
disabled: settingsLocked,
}))

const isZhipu = activeOption?.provider === 'zhipu'
const methodOptions = [
activeOption?.descriptor.capabilities?.browser_login !== false
? { value: 'browser' as const, label: t('tabBrowser'), icon: <ShieldCheck size={16} /> }
Expand All @@ -437,7 +504,7 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
return (
<Modal.Root isOpen={isOpen} onOpenChange={(next: boolean) => { if (!next) close() }}>
<Modal.Backdrop variant="blur" isDismissable={!busy}>
<Modal.Container size="lg" scroll="inside" className="sm:max-w-3xl">
<Modal.Container size="lg" scroll="inside" className="sm:max-w-4xl">
<Modal.Dialog>
<Modal.Header className="relative items-center justify-center px-12 pt-5 text-center">
<div className="min-w-0">
Expand All @@ -448,43 +515,47 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
</div>
<Modal.CloseTrigger aria-label={t('close')} className="absolute right-4 top-4 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="px-5 pb-2">
<Modal.Body className="px-6 pb-2">
<div ref={stepBody}>
{step === 'method' ? (
<>
<section className="space-y-2.5">
{typesLoading ? (
<>
<span className="text-sm font-medium text-muted">{t('accountType')}</span>
<AccountTypeSkeleton ariaLabel={t('accountType')} />
</>
<AccountTypeSkeleton ariaLabel={t('accountType')} />
) : !typesReady ? (
<>
<span className="text-sm font-medium text-muted">{t('accountType')}</span>
<p className="rounded-lg border border-separator bg-surface-secondary/45 px-3.5 py-3 text-xs leading-5 text-muted">{t('accountTypeHint')}</p>
</>
<p className="rounded-lg border border-separator bg-surface-secondary/45 px-3.5 py-3 text-xs leading-5 text-muted">{t('accountTypeHint')}</p>
) : (
<>
<span className="text-sm font-medium text-muted">{t('accountType')}</span>
<OptionTiles
ariaLabel={t('accountType')}
columns={2}
value={accountType}
onChange={(next) => { if (!settingsLocked) setAccountType(next) }}
options={typeOptions}
className="max-h-72 overflow-y-auto overscroll-contain pr-1"
/>
</>
<OptionTiles
ariaLabel={t('accountType')}
columns={2}
value=""
onChange={chooseProvider}
options={typeOptions}
/>
)}
</section>

<section className="mt-5 space-y-3">
</>
) : (
<>
<div className="flex items-center gap-3 rounded-lg border border-separator bg-surface-secondary/45 px-3 py-2.5">
<span className="shrink-0"><ProviderMark provider={activeOption?.provider} size={18} /></span>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">{activeOption ? optionLabel(activeOption, t) : t('accountType')}</div>
<div className="truncate text-[11px] text-muted">{hint}</div>
</div>
<Button size="sm" variant="ghost" onPress={() => setStep('method')} isDisabled={busy || Boolean(createdId.current)}>
<CaretLeft size={12} />{t('wizardBack')}
</Button>
</div>
<div className="mt-4 space-y-3">
<FormRow label={t('accountName')}>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder={t('wizardNamePh')}
aria-label={t('accountName')}
disabled={settingsLocked}
autoFocus
/>
</FormRow>
<button
Expand Down Expand Up @@ -555,27 +626,47 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
) : null}
</div>
) : null}
</section>

<div className="mt-5">
<Button className="w-full" isDisabled={!typesReady} onPress={() => goLogin(tab)}>
{t('wizardContinue')}
</Button>
</div>
</>
) : (
<>
<div className="flex items-center gap-3 rounded-lg border border-separator bg-surface-secondary/45 px-3 py-2.5">
<span className="shrink-0"><ProviderMark provider={activeOption?.provider} size={18} /></span>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">{activeOption ? optionLabel(activeOption, t) : t('accountType')}</div>
<div className="truncate text-[11px] text-muted">{name.trim() || t('account')}</div>

{isZhipu ? (
<div className="mt-4 flex flex-col gap-4">
<div className="space-y-2">
<span className="text-sm font-medium text-muted">{t('zhipuAccountMode')}</span>
<OptionTiles
ariaLabel={t('zhipuAccountMode')}
columns={2}
value={zhipuMode}
onChange={(next) => { if (!busy) setZhipuMode(next) }}
options={[
{ value: 'payg', label: t('zhipuModePayg'), hint: t('zhipuModePaygHint'), disabled: busy },
{ value: 'coding', label: t('zhipuModeCoding'), hint: t('zhipuModeCodingHint'), disabled: busy },
]}
/>
</div>
<FormRow label={t('zhipuApiKey')}>
<Input type="password" value={zhipuKey} onChange={(event) => setZhipuKey(event.target.value)} placeholder={t('zhipuApiKeyPh')} aria-label={t('zhipuApiKey')} disabled={busy} />
</FormRow>
{zhipuMode === 'coding' ? (
<>
<FormRow label={t('zhipuOrganization')}>
<Input value={zhipuOrganization} onChange={(event) => setZhipuOrganization(event.target.value)} placeholder={t('zhipuOrganizationPh')} aria-label={t('zhipuOrganization')} disabled={busy} />
</FormRow>
<FormRow label={t('zhipuProject')}>
<Input value={zhipuProject} onChange={(event) => setZhipuProject(event.target.value)} placeholder={t('zhipuProjectPh')} aria-label={t('zhipuProject')} disabled={busy} />
</FormRow>
</>
) : null}
{message ? (
<p className="flex items-center gap-2 rounded-lg border border-separator bg-surface-secondary px-3 py-2 text-xs">{isDone ? <CheckCircle size={14} className="shrink-0 text-success" /> : null}<span className={isDone ? 'font-medium text-foreground' : 'text-muted'}>{message}</span></p>
) : null}
<Button className="w-full" isPending={phase === 'busy'} onPress={() => void runZhipu()} isDisabled={isDone}>
{isDone ? <><CheckCircle size={15} />{t('wizardAccountReady')}</> : <><Key size={15} />{t('zhipuCreate')}</>}
</Button>
</div>
<Button size="sm" variant="ghost" onPress={() => setStep('method')} isDisabled={busy || Boolean(createdId.current)}>
<CaretLeft size={12} />{t('wizardBack')}
</Button>
</div>
) : null}

{isZhipu ? null : (
<>
<FilterToggle
className="mt-3"
value={tab}
Expand Down Expand Up @@ -662,8 +753,11 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
</>
)}
</div>
</>
)}
</>
)}
</div>
</Modal.Body>
<Modal.Footer className="justify-end px-5 pb-5">
<Button variant="ghost" onPress={close} isDisabled={busy}>{t('cancel')}</Button>
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/components/ProviderMark.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { DevinMark } from '@/components/DevinMark'
import { QoderMark } from '@/components/QoderMark'
import { TraeMark } from '@/components/TraeMark'
import { WorkBuddyMark } from '@/components/WorkBuddyMark'
import { ZhipuMark } from '@/components/ZhipuMark'

type Props = {
provider?: string
Expand All @@ -29,5 +30,8 @@ export function ProviderMark({ provider, size = 16, className = '' }: Props) {
if (id === 'codex') {
return <CodexMark size={size} className={className} />
}
if (id === 'zhipu') {
return <ZhipuMark size={size} className={className} />
}
return <QoderMark size={size} className={className} />
}
23 changes: 23 additions & 0 deletions frontend/src/components/ZhipuMark.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
type Props = {
size?: number
className?: string
}

// Zhipu particle-constellation mark, the same outline used by the upstream console.
export function ZhipuMark({ size = 16, className = '' }: Props) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
className={`block shrink-0 text-foreground ${className}`.trim()}
aria-hidden="true"
>
<path
fill="currentColor"
fillRule="evenodd"
d="M11.991 23.503a.24.24 0 0 0-.244.248a.24.24 0 0 0 .244.249a.24.24 0 0 0 .245-.249a.24.24 0 0 0-.22-.247zM9.671 5.365a1.697 1.697 0 0 1 1.099 2.132l-.071.172l-.016.04l-.018.054c-.07.16-.104.32-.104.498c-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523c-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368c0 .124.018.23.053.337c.209.373.54.658.96.8c.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711c.854.284 1.378 1.226 1.099 2.167a1.66 1.66 0 0 1-2.077 1.102a1.7 1.7 0 0 1-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 0 0-1.954.746a1.66 1.66 0 0 1-1.065.764a1.677 1.677 0 0 1-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.5 1.5 0 0 1 .296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.3 1.3 0 0 0-.227-.693a2.5 2.5 0 0 1-.366-1.403a2.4 2.4 0 0 1 .366-1.208c.14-.195.21-.444.227-.693c.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.4 1.4 0 0 1-.299-.07l-.05-.019a1.7 1.7 0 0 1-1.047-2.114a1.68 1.68 0 0 1 2.094-1.101m-5.575 10.11c.26-.264.639-.367.994-.27s.633.379.728.74c.095.362-.007.748-.267 1.013c-.402.41-1.053.41-1.455 0a1.06 1.06 0 0 1 0-1.482zm14.845-.294c.359-.09.738.024.992.297c.254.274.344.665.237 1.025s-.396.634-.756.718c-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 0 1 .757-1.26zm-.064-4.39c.314.32.49.753.49 1.206s-.176.886-.49 1.206c-.315.32-.74.5-1.185.5c-.444 0-.87-.18-1.184-.5a1.727 1.727 0 0 1 0-2.412a1.654 1.654 0 0 1 2.369 0m-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 0 1-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 0 1 1.33-1.038a1.66 1.66 0 0 1 1.547.652m11.545-4.221c.368 0 .708.2.892.524s.184.724 0 1.048a1.03 1.03 0 0 1-.892.524a1.04 1.04 0 0 1-1.03-1.048a1.04 1.04 0 0 1 1.03-1.048m-14.358 0c.368 0 .707.2.891.524s.184.724 0 1.048a1.03 1.03 0 0 1-.891.524a1.04 1.04 0 0 1-1.03-1.048c0-.579.461-1.048 1.03-1.048m10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705s-1.674-.763-1.674-1.705s.75-1.706 1.674-1.706m-2.626-.684c.362-.082.653-.356.761-.718a1.06 1.06 0 0 0-.238-1.028a1.02 1.02 0 0 0-.996-.294c-.547.14-.881.7-.752 1.257c.13.558.675.907 1.225.783m0 16.876c.359-.087.644-.36.75-.72a1.06 1.06 0 0 0-.237-1.019a1.02 1.02 0 0 0-.985-.301a1.04 1.04 0 0 0-.762.717c-.108.361-.017.754.239 1.028c.245.263.606.377.953.305zM17.19 3.5a.63.63 0 0 0 .628-.64a.63.63 0 0 0-.628-.64a.63.63 0 0 0-.628.64c0 .355.28.64.628.64m-10.38 0a.63.63 0 0 0 .628-.64c0-.355-.28-.64-.628-.64a.63.63 0 0 0-.628.64c0 .355.279.64.628.64m-5.182 7.852a.63.63 0 0 0-.628.64c0 .354.28.639.628.639a.63.63 0 0 0 .627-.606l.001-.034a.62.62 0 0 0-.628-.64zm5.182 9.13a.63.63 0 0 0-.628.64c0 .355.279.64.628.64a.63.63 0 0 0 .628-.64c0-.355-.28-.64-.628-.64m10.38.018a.63.63 0 0 0-.628.64c0 .355.28.64.628.64a.63.63 0 0 0 .628-.64a.63.63 0 0 0-.628-.64m5.182-9.148a.63.63 0 0 0-.628.64c0 .354.279.639.628.639a.63.63 0 0 0 .628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 0 0 .244-.249a.24.24 0 0 0-.244-.249a.24.24 0 0 0-.244.249c0 .142.122.249.244.249M11.991.497a.24.24 0 0 0 .245-.248A.24.24 0 0 0 11.99 0a.24.24 0 0 0-.244.249c0 .133.108.236.223.247zM2.011 6.36a.24.24 0 0 0 .245-.249a.24.24 0 0 0-.244-.249a.24.24 0 0 0-.244.249a.24.24 0 0 0 .244.249zm0 11.263a.24.24 0 0 0-.243.248a.24.24 0 0 0 .244.249a.24.24 0 0 0 .244-.249a.25.25 0 0 0-.244-.248zm19.995-.018a.24.24 0 0 0-.245.248a.24.24 0 0 0 .245.25a.24.24 0 0 0 .244-.25a.25.25 0 0 0-.244-.248z"
/>
</svg>
)
}
2 changes: 1 addition & 1 deletion frontend/src/components/ui/FormRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function FormRow({ label, hint, labelWidthClass = defaultLabelWidthClass,
<div>
<div className="flex items-center gap-3">
<Label htmlFor={htmlFor} className={`${labelWidthClass} shrink-0 text-sm font-medium text-foreground`}>{label}</Label>
<div className="min-w-0 flex-1">{children}</div>
<div className="min-w-0 flex-1 [&_[data-slot=input]]:w-full [&_[data-slot=textarea]]:w-full">{children}</div>
</div>
{hint ? <p className="mt-1.5 pl-[7.75rem] text-xs leading-5 text-muted">{hint}</p> : null}
</div>
Expand Down
Loading
Loading