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
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
### English

- Console copy buttons now fall back to a hidden-textarea copy when the async Clipboard API is unavailable, so copying works when the console is served over plain http (a non-secure context), not just https/localhost.
- Trae account quota now counts only the General credit bucket; the Work-only bucket (parsed separately) is excluded so it is not summed into the General figure.

### 中文

- 控制台复制按钮在异步剪贴板 API 不可用时回退到隐藏文本框 + execCommand,因此在纯 http(非安全上下文)下打开控制台时复制也能生效,不再只支持 https/localhost。
- Trae 账号额度只统计「通用积分」桶;「Work 专属积分」桶(单独解析)不计入,避免混入通用额度。
7 changes: 7 additions & 0 deletions changelog/unreleased/devin-quota-window-layout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### English

- Stretch Devin daily and weekly quota meters across the account card, with the title, used percent, bar, and reset time in one compact stack.

### 中文

- Devin 日额度和周额度条铺满账号卡片,标题、已用百分比、进度条和重置时间落在同一组紧凑块里。
9 changes: 9 additions & 0 deletions changelog/unreleased/trae-max-mode-tiers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
### English

- Trae max mode is now only offered on models that declare a real second tier. Models upstream tags for max mode without a larger window or larger ceilings no longer show a toggle that changes nothing (affects Doubao-Seed-2.1-Turbo, kimi-k2.6, kimi-k2.7-code).
- Turning Trae max mode off now restores the default-tier prompt/output ceilings immediately. The toggle only switches the context window server-side; the console derives the shown ceilings from the Max tier at render time, so the previously stuck Max values (e.g. 936k prompt / 64k output) no longer linger until a full catalog refresh.

### 中文

- Trae 的「更大上下文」开关现在只在**确有第二档**的模型上出现。上游标了 max mode 但没有更大窗口、也没有更大上限的模型,不再显示一个点了没反应的开关(涉及 Doubao-Seed-2.1-Turbo、kimi-k2.6、kimi-k2.7-code)。
- 关掉 Trae max mode 后,输入/输出上限**立即**回到默认档。开关在服务端只切上下文窗口;控制台展示层在渲染时从 Max 档取值,因此之前会残留的最大值(如 936k 输入 / 64k 输出)不再需要整表刷新才恢复。
9 changes: 9 additions & 0 deletions changelog/unreleased/trae-pkce-and-scene-merge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
### English

- Trae login now uses the IDE (PKCE) authorization-code flow: the login URL carries a S256 `code_challenge`, and the pasted callback's `authCodeInfo` code is exchanged at `/trae/api/v3/oauth/ExchangeToken` with the matching verifier and a device public key (EC P-256). Accounts created before the switch keep refreshing with the OAuth client that minted their token (`refresh_client_id`).
- The Trae model catalog now also fetches a second scene (`chat_v3`) and merges it into the primary scene, so models the primary catalog hides as invisible become selectable. Duplicates prefer the entry carrying a credit rate.

### 中文

- Trae 登录改用 IDE(PKCE)授权码流程:登录链接带 S256 `code_challenge`,粘贴回调里的 `authCodeInfo` code 连同 verifier 与设备公钥(EC P-256)交到 `/trae/api/v3/oauth/ExchangeToken` 换取令牌。切换前创建的账号继续用签发其 refresh token 的 OAuth client 刷新(`refresh_client_id`)。
- Trae 模型目录额外拉取 `chat_v3` 场景并与主场景合并,使主目录里被标为 invisible 的模型变为可选;重复项优先保留带倍率的条目。
7 changes: 7 additions & 0 deletions changelog/unreleased/trae-scene-routing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### English

- Trae chats are now routed to the catalog scene that actually serves each model. The whole catalog is served through `chat_v3` (which also carries the Max-mode tiers), and only models that scene does not list fall back to `solo_work_lite`. Previously every chat went through `solo_work_lite`, so the six models that scene does not carry (Doubao-Seed-Code, deepseek-v4.1-flash, glm-5.3-flash, glm-5.3-flashx, kimi-k2.8-preview, qwen3.8-flash) failed with a param error, and Max mode was sent to a scene without Max tiers. This is derived from catalog membership, so new models route correctly on the next refresh without changes.

### 中文

- Trae 聊天现在按「实际提供该模型的目录场景」路由。整个目录默认走 `chat_v3`(该场景也是唯一带 Max 档的),仅当 `chat_v3` 不收录某模型时才回退到 `solo_work_lite`。此前所有聊天一律走 `solo_work_lite`,导致该场景没有的 6 个模型(Doubao-Seed-Code、deepseek-v4.1-flash、glm-5.3-flash、glm-5.3-flashx、kimi-k2.8-preview、qwen3.8-flash)报参数错误,且 Max 模式被发到了没有 Max 档的场景。该路由由目录收录关系推导,新模型下次刷新即自动归位,无需改码。
7 changes: 7 additions & 0 deletions changelog/unreleased/workbuddy-checkin-processing-retry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
### English

- Retry WorkBuddy daily check-ins when the upstream temporarily reports that a request is still being processed.

### 中文

- 当 WorkBuddy 上游暂时返回“请求处理中”时,自动重试每日签到。
2 changes: 2 additions & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ export type ModelInfo = {
max_mode?: boolean
max_output_tokens?: number
prompt_max_tokens?: number
max_output_tokens_max?: number
prompt_max_tokens_max?: number
reasoning_options?: string[]
reasoning_default?: string
reasoning_effort?: string
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/EndpointList.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { copyText } from '@/lib/clipboard'
import { useMemo, useState } from 'react'
import { Button, Card, Chip } from '@heroui/react'
import { ArrowSquareOut, BracketsCurly, Check, Copy, Heartbeat, ListBullets, PaperPlaneTilt } from '@phosphor-icons/react'
Expand Down Expand Up @@ -50,7 +51,7 @@ export function EndpointList({ access }: EndpointListProps) {
<div className="mt-2 flex items-center justify-between gap-2 border-t border-separator pt-2">
<span className="text-[10px] font-medium text-muted">{item.method === 'BASE' ? t('endpointBaseLabel') : t('endpointAuthLabel')}</span>
<div className="flex gap-1">
<Button isIconOnly size="sm" variant="ghost" aria-label={t('copy')} onPress={() => { void navigator.clipboard.writeText(item.url); setCopiedEndpoint(item.name); window.setTimeout(() => setCopiedEndpoint(''), 1100) }}>
<Button isIconOnly size="sm" variant="ghost" aria-label={t('copy')} onPress={() => { void copyText(item.url); setCopiedEndpoint(item.name); window.setTimeout(() => setCopiedEndpoint(''), 1100) }}>
{copiedEndpoint === item.name ? <Check size={14} className="text-success" /> : <Copy size={14} />}
</Button>
<Button isIconOnly size="sm" variant="ghost" aria-label={t('open')} onPress={() => window.open(item.url, '_blank', 'noopener,noreferrer')}>
Expand Down
13 changes: 10 additions & 3 deletions frontend/src/components/ModelDetailsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Chip, Modal } from '@heroui/react'
import { X } from '@phosphor-icons/react'
import type { ModelInfo } from '@/api/types'
import { modelCreditsText, modelIsFree } from '@/lib/format'
import { accountProviderLabel } from '@/lib/provider'
import { accountProviderLabel, tieredTraeCaps } from '@/lib/provider'

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

Expand All @@ -29,6 +29,9 @@ export function ModelDetailsModal({ model, t, onClose }: Props) {
const provider = String(model.provider || model.owned_by || 'qoder').trim().toLowerCase()
const region = String(model.region || model.regions?.[0] || '').trim().toLowerCase()
const providerLabel = accountProviderLabel(provider, region || undefined, t)
// Resolve the tier shown for the current max-mode toggle so the ceilings match
// the selected window instead of lingering on the Max tier after a toggle-off.
const traeTier = provider === 'trae' ? tieredTraeCaps(model) : undefined
return (
<Modal.Root isOpen onOpenChange={(next: boolean) => { if (!next) onClose() }}>
<Modal.Backdrop variant="blur">
Expand All @@ -55,8 +58,12 @@ export function ModelDetailsModal({ model, t, onClose }: Props) {
<div className="mt-1 text-sm">{formatTokens(windowDev)}{windowMax && windowMax !== windowDev ? ` → ${formatTokens(windowMax)}` : ''}</div>
{model.supports_max_mode ? <p className="mt-1 text-[11px] leading-5 text-muted">{t('maxModeHint')}</p> : null}
{!model.supports_max_mode && windowMax && windowMax !== windowDev ? <p className="mt-1 text-[11px] leading-5 text-muted">{t('workbuddyContextHint')}</p> : null}
{model.prompt_max_tokens ? <div className="mt-1 text-[11px] text-muted">{t('promptMaxTokens')}: {formatTokens(model.prompt_max_tokens)}</div> : null}
{model.max_output_tokens ? <div className="text-[11px] text-muted">{t('maxOutputTokens')}: {formatTokens(model.max_output_tokens)}</div> : null}
{traeTier
? (traeTier.prompt_max_tokens ? <div className="mt-1 text-[11px] text-muted">{t('promptMaxTokens')}: {formatTokens(traeTier.prompt_max_tokens)}</div> : null)
: (model.prompt_max_tokens ? <div className="mt-1 text-[11px] text-muted">{t('promptMaxTokens')}: {formatTokens(model.prompt_max_tokens)}</div> : null)}
{traeTier
? (traeTier.max_output_tokens ? <div className="text-[11px] text-muted">{t('maxOutputTokens')}: {formatTokens(traeTier.max_output_tokens)}</div> : null)
: (model.max_output_tokens ? <div className="text-[11px] text-muted">{t('maxOutputTokens')}: {formatTokens(model.max_output_tokens)}</div> : null)}
</section>
<section>
<div className="text-xs font-medium text-muted">{t('reasoningLevels')}</div>
Expand Down
128 changes: 60 additions & 68 deletions frontend/src/components/account/QuotaMeter.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { ReactNode } from 'react'
import { Meter, Tooltip } from '@heroui/react'
import type { AccountQuota, AccountQuotaWindow } from '@/api/types'
import {
Expand Down Expand Up @@ -33,33 +32,30 @@ function extraQuotaLines(quota: AccountQuota, remainingLabel: string, addOnLabel
return [addOn, resourcePackage].filter(Boolean)
}

function QuotaBar({
quota,
label,
left,
right,
valueLabel,
function WindowMeter({
window,
t,
extra,
compact,
}: {
quota: Pick<AccountQuota, 'percentage' | 'exceeded'>
label: string
left: ReactNode
right: ReactNode
valueLabel: string
window: AccountQuotaWindow
t: Translate
extra?: string[]
compact?: boolean
}) {
const ratio = quotaUsedRatio(quota)
const tone = quotaTone(quota)
const percent = Math.round(window.percentage ?? 0)
const label = quotaWindowLabel(window, t)
const usedText = t('quotaUsedPercent', { n: percent })
const reset = quotaResetLabel(window.reset_at, t)
const valueLabel = `${label} · ${usedText}${reset ? ` · ${reset}` : ''}`
const ratio = quotaUsedRatio(window)
const tone = quotaTone(window)
const color = tone === 'danger' ? 'danger' : tone === 'warn' ? 'warning' : 'success'

return (
<Tooltip>
<Tooltip.Trigger>
<div className="cursor-help">
<div className="w-full cursor-help">
<Meter
className={compact ? 'account-meter account-meter--window' : 'account-meter'}
className="account-meter account-meter--window w-full"
color={color}
size="md"
minValue={0}
Expand All @@ -68,14 +64,15 @@ function QuotaBar({
aria-label={label}
valueLabel={valueLabel}
>
<Meter.Output className="flex w-full items-baseline justify-between text-[10px]">
{left}
{right}
<Meter.Output className="flex w-full items-baseline justify-between gap-3 text-[11px]">
<span className="min-w-0 truncate font-medium text-foreground">{label}</span>
<span className="mono shrink-0 text-foreground/70">{usedText}</span>
</Meter.Output>
<Meter.Track>
<Meter.Fill />
</Meter.Track>
</Meter>
{reset ? <div className="mt-1 text-[10px] leading-4 text-muted">{reset}</div> : null}
</div>
</Tooltip.Trigger>
<Tooltip.Content>
Expand All @@ -88,50 +85,24 @@ function QuotaBar({
)
}

function WindowMeter({
window,
t,
extra,
}: {
window: AccountQuotaWindow
t: Translate
extra?: string[]
}) {
const percent = Math.round(window.percentage ?? 0)
const label = quotaWindowLabel(window, t)
const usedText = t('quotaUsedPercent', { n: percent })
const reset = quotaResetLabel(window.reset_at, t)
const valueLabel = `${label} · ${usedText}${reset ? ` · ${reset}` : ''}`

return (
<div className="space-y-1">
<QuotaBar
quota={window}
label={label}
left={<span className="text-[11px] font-medium text-foreground">{label}</span>}
right={<span className="mono text-foreground/55">{usedText}</span>}
valueLabel={valueLabel}
extra={extra}
compact
/>
{reset ? <div className="text-[10px] text-muted">{reset}</div> : null}
</div>
)
}

// Trae-style quota block: the remaining count is the headline number; the
// used / total pair plus unit sits underneath as a secondary mono line.
// Devin daily/weekly windows render as stacked compact bars with reset copy.
// Devin daily/weekly windows keep a titled bar with used percent and reset
// copy in one compact stack.
export function QuotaMeter({ quota, t, label, usedLabel, remainingLabel, addOnLabel, resourcePackageLabel, exceededLabel }: Props) {
const unit = quota.unit || 'credits'
const used = `${formatQuotaAmount(quota.used)} / ${formatQuotaAmount(quota.total)}`
const remaining = `${formatQuotaAmount(quota.remaining)}`
const extra = extraQuotaLines(quota, remainingLabel, addOnLabel, resourcePackageLabel)
const windows = quotaWindows(quota)
const ratio = quotaUsedRatio(quota)
const tone = quotaTone(quota)
const color = tone === 'danger' ? 'danger' : tone === 'warn' ? 'warning' : 'success'
const valueLabel = `${usedLabel} ${used} ${unit} · ${remainingLabel} ${remaining} ${unit}${extra.length ? ` · ${extra.join(' · ')}` : ''}`

if (windows.length > 0) {
return (
<div className="space-y-2.5">
<div className="w-full space-y-3">
{windows.map((window, index) => (
<WindowMeter
key={window.id || String(index)}
Expand All @@ -145,19 +116,40 @@ export function QuotaMeter({ quota, t, label, usedLabel, remainingLabel, addOnLa
}

return (
<QuotaBar
quota={quota}
label={label}
left={(
<span className="text-[13px] font-semibold leading-5 text-foreground tabular-nums">
{quota.exceeded ? <span className="mr-1.5 text-danger">{exceededLabel}</span> : null}
{remaining}
<span className="ml-1 text-[10px] font-normal text-foreground/60">{unit}</span>
</span>
)}
right={<span className="mono text-foreground/55">{usedLabel} {used}</span>}
valueLabel={`${usedLabel} ${used} ${unit} · ${remainingLabel} ${remaining} ${unit}${extra.length ? ` · ${extra.join(' · ')}` : ''}`}
extra={extra}
/>
<Tooltip>
<Tooltip.Trigger>
<div className="w-full cursor-help">
<Meter
className="account-meter w-full"
color={color}
size="md"
minValue={0}
maxValue={100}
value={Math.round(ratio * 100)}
aria-label={label}
valueLabel={valueLabel}
>
<Meter.Output className="flex w-full items-baseline justify-between text-[10px]">
<span className="text-[13px] font-semibold leading-5 text-foreground tabular-nums">
{quota.exceeded ? <span className="mr-1.5 text-danger">{exceededLabel}</span> : null}
{remaining}
<span className="ml-1 text-[10px] font-normal text-foreground/60">{unit}</span>
</span>
<span className="mono text-foreground/55">{usedLabel} {used}</span>
</Meter.Output>
<Meter.Track>
<Meter.Fill />
</Meter.Track>
</Meter>
</div>
</Tooltip.Trigger>
<Tooltip.Content>
<div className="space-y-0.5">
<div>{usedLabel} {used} {unit}</div>
<div>{remainingLabel} {remaining} {unit}</div>
{extra.map((line) => <div key={line}>{line}</div>)}
</div>
</Tooltip.Content>
</Tooltip>
)
}
2 changes: 1 addition & 1 deletion frontend/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1162,7 +1162,7 @@ export const messages: Record<Lang, Dict> = {
quotaRemaining: '剩余',
quotaDaily: '日额度',
quotaWeekly: '周额度',
quotaUsedPercent: '已用 {n}%',
quotaUsedPercent: '{n}%',
quotaResetsSoon: '即将重置',
quotaResetsInMinutes: '{n} 分钟后重置',
quotaResetsInHours: '{n} 小时后重置',
Expand Down
17 changes: 13 additions & 4 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -158,10 +158,19 @@ body {

.account-meter {
display: grid;
width: 100%;
min-width: 0;
gap: 0.4rem;
grid-template-columns: minmax(0, 1fr);
grid-template-areas:
"output output"
"track track";
"output"
"track";
}

.account-meter .meter__output,
.account-meter .meter__track {
width: 100%;
min-width: 0;
}

.account-meter .meter__track {
Expand All @@ -176,11 +185,11 @@ body {
}

.account-meter--window {
gap: 0.25rem;
gap: 0.3rem;
}

.account-meter--window .meter__track {
height: 0.375rem;
height: 0.5rem;
}

.runtime-meter {
Expand Down
31 changes: 31 additions & 0 deletions frontend/src/lib/clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// copyText copies `value` to the clipboard, falling back to a hidden textarea
// + execCommand when the async Clipboard API is unavailable. navigator.clipboard
// is only defined in secure contexts, so the console served over plain http
// (e.g. http://192.168.101.1:3010) would otherwise silently fail to copy.
export async function copyText(value: string): Promise<boolean> {
if (!value) return false
try {
if (typeof navigator !== 'undefined' && navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(value)
return true
}
} catch {
// fall through to the legacy path
}
try {
const area = document.createElement('textarea')
area.value = value
area.setAttribute('readonly', '')
area.style.position = 'fixed'
area.style.top = '-1000px'
area.style.opacity = '0'
document.body.appendChild(area)
area.focus()
area.select()
const ok = document.execCommand('copy')
document.body.removeChild(area)
return ok
} catch {
return false
}
}
Loading
Loading