feat(providers): add group-level priority override#641
feat(providers): add group-level priority override#641NieiR wants to merge 11 commits intoding113:devfrom
Conversation
- Add group_priorities JSONB field for per-group priority settings - Update provider selection algorithm to use effective priority based on user group - Add GroupPriorityPopover component for editing priorities on multi-group providers - Add GroupTagEditPopover component for inline group tag editing - Full i18n support (zh-CN, zh-TW, en, ja, ru)
|
Note Other AI code review bot(s) detectedCodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review. 📝 WalkthroughWalkthrough新增 Changes
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
| try { | ||
| const newPriority = Number(draft.default); | ||
|
|
||
| // Build new groupPriorities | ||
| let newGroupPriorities: Record<string, number> | null = null; | ||
| for (const group of groups) { | ||
| const value = draft.groups[group]?.trim(); | ||
| if (value && value.length > 0) { | ||
| if (!newGroupPriorities) newGroupPriorities = {}; | ||
| newGroupPriorities[group] = Number(value); |
There was a problem hiding this comment.
Empty string values could bypass validation. When a user clears an input field (empty string), the validation passes on line 36 (if (value.length === 0) return true), but here on line 124 the check if (value && value.length > 0) filters it out correctly. However, the logic is slightly inconsistent - value.length > 0 already implies truthiness. Consider simplifying to if (value.length > 0) for clarity.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/[locale]/settings/providers/_components/group-priority-popover.tsx
Line: 119:128
Comment:
Empty string values could bypass validation. When a user clears an input field (empty string), the validation passes on line 36 (`if (value.length === 0) return true`), but here on line 124 the check `if (value && value.length > 0)` filters it out correctly. However, the logic is slightly inconsistent - `value.length > 0` already implies truthiness. Consider simplifying to `if (value.length > 0)` for clarity.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Thanks for the feedback! The validation logic works correctly:
- Line 36 (
if (value.length === 0) return true) allows empty input during typing - Line 124 filters out empty values before submission
I've simplified the condition as suggested. See commit 8abd137.
| // Return first matching group's priority | ||
| for (const group of userGroups) { | ||
| if (group in provider.groupPriorities) { | ||
| return provider.groupPriorities[group]; | ||
| } |
There was a problem hiding this comment.
First-match priority resolution may cause unexpected behavior. If a user belongs to groups ["cli", "chat"] and both have overrides, only cli's priority is used. Consider documenting this behavior or implementing a "lowest priority wins" strategy.
For example, with user groups "cli,chat":
- If
clipriority = 1 andchatpriority = 5 - Current: returns 1 (first match)
- Alternative:
Math.min(1, 5) = 1(highest priority across all groups)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/v1/_lib/proxy/provider-selector.ts
Line: 74:78
Comment:
First-match priority resolution may cause unexpected behavior. If a user belongs to groups `["cli", "chat"]` and both have overrides, only `cli`'s priority is used. Consider documenting this behavior or implementing a "lowest priority wins" strategy.
For example, with user groups `"cli,chat"`:
- If `cli` priority = 1 and `chat` priority = 5
- Current: returns 1 (first match)
- Alternative: `Math.min(1, 5) = 1` (highest priority across all groups)
<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
This is intentional behavior - first-match priority is documented in the function's JSDoc comments:
// Return first matching group's priority
for (const group of userGroups) {
if (group in provider.groupPriorities) {
return provider.groupPriorities[group];
}
}The rationale: user groups are ordered by preference (e.g., "cli,chat" means cli is primary). First-match respects this ordering.
Added unit tests to document this behavior. See commit 8abd137.
| // Calculate display priority based on selected group filter | ||
| const displayPriority = (() => { | ||
| if (!selectedGroup || selectedGroup === "default") { | ||
| return provider.priority; | ||
| } | ||
| if (provider.groupPriorities?.[selectedGroup] !== undefined) { | ||
| return provider.groupPriorities[selectedGroup]; | ||
| } | ||
| return provider.priority; |
There was a problem hiding this comment.
Display priority calculation mirrors backend first-match logic - if selected group filter has no override, falls back to default priority.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx
Line: 414:422
Comment:
Display priority calculation mirrors backend first-match logic - if selected group filter has no override, falls back to default priority.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Thanks for confirming! Yes, the frontend display logic intentionally mirrors the backend first-match behavior for consistency.
There was a problem hiding this comment.
Code Review Summary
This PR adds a group-level priority override feature for providers, allowing different priorities for different user groups. The implementation is well-structured with proper i18n support and UI components. However, there is one critical issue that must be addressed.
PR Size: XL
- Lines changed: 3739 (3711 additions + 28 deletions)
- Files changed: 30
Split Suggestions for XL PR:
This PR could be split into smaller, more reviewable PRs:
- Database schema + types (migration, schema.ts, types, repository layer)
- Core business logic (provider-selector.ts with getEffectivePriority)
- UI components (group-priority-popover, group-tag-edit-popover)
- i18n translations (all 5 language files)
Issues Found
| Category | Critical | High | Medium | Low |
|---|---|---|---|---|
| Logic/Bugs | 0 | 0 | 0 | 0 |
| Security | 0 | 0 | 0 | 0 |
| Error Handling | 0 | 0 | 0 | 0 |
| Types | 0 | 0 | 0 | 0 |
| Comments/Docs | 0 | 0 | 0 | 0 |
| Tests | 1 | 0 | 0 | 0 |
| Simplification | 0 | 0 | 0 | 0 |
Critical Issues (Must Fix)
1. [TEST-MISSING-CRITICAL] No test coverage for group-level priority override feature
Location: src/app/v1/_lib/proxy/provider-selector.ts - getEffectivePriority() function and selectTopPriority() modifications
Why this is a problem:
This PR introduces a new group-level priority override feature that modifies critical provider selection logic. The getEffectivePriority() function and selectTopPriority() changes directly affect which provider is selected for each request, which is a critical code path.
Violated guideline: CLAUDE.md line 12 states: "Test Coverage - All new features must have unit test coverage of at least 80%"
Without tests, there is no verification that:
- Group-specific priorities correctly override default priorities
- The first matching group is used when a user belongs to multiple groups
- Default priority is used when no group override exists
- Edge cases are handled (null groups, empty groupPriorities, invalid data)
Suggested fix:
Add unit tests in tests/unit/ or source-adjacent tests. Minimum test cases:
// tests/unit/provider-selector.test.ts or src/app/v1/_lib/proxy/provider-selector.test.ts
describe('getEffectivePriority', () => {
it('should return default priority when user has no group', () => {
const provider = { priority: 5, groupPriorities: { cli: 1 } };
expect(getEffectivePriority(provider, null)).toBe(5);
});
it('should return group-specific priority when user group matches', () => {
const provider = { priority: 5, groupPriorities: { cli: 1, chat: 3 } };
expect(getEffectivePriority(provider, 'cli')).toBe(1);
});
it('should return first matching group priority for comma-separated groups', () => {
const provider = { priority: 5, groupPriorities: { cli: 1, chat: 3 } };
expect(getEffectivePriority(provider, 'chat,cli')).toBe(3);
});
it('should return default priority when no group override exists', () => {
const provider = { priority: 5, groupPriorities: { cli: 1 } };
expect(getEffectivePriority(provider, 'web')).toBe(5);
});
it('should return default priority when groupPriorities is null', () => {
const provider = { priority: 5, groupPriorities: null };
expect(getEffectivePriority(provider, 'cli')).toBe(5);
});
});
describe('selectTopPriority with group overrides', () => {
it('should select providers with lowest effective priority for user group', () => {
const providers = [
{ id: 1, priority: 5, groupPriorities: { cli: 1 } },
{ id: 2, priority: 3, groupPriorities: { cli: 2 } },
{ id: 3, priority: 1, groupPriorities: null }
];
const result = selectTopPriority(providers, 'cli');
expect(result).toEqual([providers[0]]); // cli group priority 1 is lowest
});
});Review Coverage
- Logic and correctness - Clean
- Security (OWASP Top 10) - Clean
- Error handling - Clean (proper try/finally patterns, errors propagate correctly)
- Type safety - Clean (proper TypeScript types, validation schemas)
- Documentation accuracy - Clean (comments match implementation)
- Test coverage - CRITICAL: Missing tests for new feature
- Code clarity - Good (well-structured, clear naming)
Positive Observations
- Proper i18n implementation across all 5 languages
- Good validation logic in UI components (validatePriority function)
- Proper error handling with try/finally blocks
- Clean separation of concerns (UI, business logic, data layer)
- Well-documented code with clear comments
- Follows existing code patterns and conventions
Automated review by Claude AI
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Fix all issues with AI agents
In `@src/app/`[locale]/settings/providers/_components/group-tag-edit-popover.tsx:
- Around line 141-144: Replace the hard-coded English label
PROVIDER_GROUP.DEFAULT used inside the Badge in group-tag-edit-popover.tsx with
a next-intl translation key (e.g. use intl.formatMessage or the
<FormattedMessage> from next-intl) and add the corresponding translation entries
for that key in the five locale JSON/TS files; update the Badge rendering to
call the intl lookup (keeping the same Badge variant and classes) so the
default-group badge text is localized across all supported languages.
- Around line 90-96: The handleAddGroup function should validate newGroup before
adding: trim newGroup, reject values containing commas and the reserved names
"default" and "*" (case-insensitive), and prevent adding empty or duplicate
entries to selectedGroups; on validation failure surface a user-facing message
(e.g., via an existing error state or toast) and do not call setSelectedGroups
or clear the input. Update handleAddGroup to perform these checks (referencing
newGroup, selectedGroups, setSelectedGroups, setNewGroup) and return early on
invalid input.
In `@src/lib/validation/schemas.ts`:
- Around line 556-560: CreateProviderSchema is missing the group_priorities
field that UpdateProviderSchema already allows; add a group_priorities property
to CreateProviderSchema using the same shape as in UpdateProviderSchema but
constrain the record keys to match group_tag (max 50 chars). Specifically, in
CreateProviderSchema add group_priorities: z.record(z.string().max(50),
z.number().int().min(0).max(2147483647)).nullable().optional(), matching the
UpdateProviderSchema definition and keeping the same null/optional semantics.
🧹 Nitpick comments (2)
drizzle/0057_jazzy_blink.sql (1)
2-2:DEFAULT 'null'::jsonb可能导致查询混淆当前默认值设置为 JSON
null字面量,而非 SQLNULL。这会导致:
group_priorities IS NULL查询不会匹配这些行- 需要用
group_priorities = 'null'::jsonb或group_priorities::text = 'null'来检测建议改为
DEFAULT NULL或直接不设置默认值,让列自然为 SQL NULL。建议修改
-ALTER TABLE "providers" ADD COLUMN "group_priorities" jsonb DEFAULT 'null'::jsonb; +ALTER TABLE "providers" ADD COLUMN "group_priorities" jsonb DEFAULT NULL;src/types/provider.ts (1)
55-56: 建议明确 groupPriorities 的 null/undefined 语义。
可在注释中约定:null表示显式清空/无覆盖,undefined(在 Create/Update 中)表示不修改或继承默认,以保持与其它额度字段的语义一致。Based on learnings, 建议明确 null 与 undefined 的语义边界。
Also applies to: 160-161, 252-253, 323-324
src/app/[locale]/settings/providers/_components/group-tag-edit-popover.tsx
Show resolved
Hide resolved
src/app/[locale]/settings/providers/_components/group-tag-edit-popover.tsx
Show resolved
Hide resolved
- Fix lint errors: remove unused imports (Power, cn) and params (allowedProviderTypes) - Add i18n for default group badge text in group-tag-edit-popover - Add group name validation: reject commas and reserved names (default, *) - Add unit tests for getEffectivePriority() function (12 test cases) - Export getEffectivePriority for testing
Addressed Code Review FeedbackAll critical and high-priority issues from the code review bots have been addressed in commit 8abd137: Fixed Issues
Intentional Design Decisions
Test Results
|
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Fix all issues with AI agents
In `@src/app/`[locale]/settings/providers/_components/group-tag-edit-popover.tsx:
- Around line 130-132: allDisplayGroups currently only filters out "default"
literally, which allows reserved names like "*" or case variants (e.g.
"DEFAULT", "*") from availableGroups/selectedGroups to be shown and selected,
bypassing handleAddGroup validation; update the creation of allDisplayGroups to
normalize group names to a consistent case and filter against a constant set of
reserved names (e.g., RESERVED_GROUPS = new Set(["default","*"]) ) using
case-insensitive comparison (toLowerCase) so any variant is excluded before
rendering/selection and cannot bypass handleAddGroup.
- Around line 221-230: The icon-only Add Group Button (Button with
onClick={handleAddGroup}, disabled={saving || !newGroup.trim()}) lacks an
accessible name; add an aria-label that uses the i18n translation key (e.g.
"settings.group.add") instead of hardcoding text, wire it to the app's
translation function (e.g. t('settings.group.add')) and ensure the new key is
added to the translations for all five locales; keep the visible UI unchanged
(icon only) and pass the aria-label prop to the Button component.
In `@src/app/v1/_lib/proxy/provider-selector.ts`:
- Around line 65-79: Replace the prototype-sensitive "in" check inside
getEffectivePriority with an own-property check (e.g.,
Object.hasOwn(provider.groupPriorities, group) or
provider.groupPriorities.hasOwnProperty(group)) so only direct keys are matched,
and ensure getEffectivePriority falls back to returning provider.priority if no
group match is found; update the loop that iterates userGroups and the
function's final return accordingly.
In `@tests/unit/proxy/provider-selector-group-priority.test.ts`:
- Around line 5-31: The test helper createProvider returns an object that no
longer matches the Provider interface (uses old fields like apiKey/enabled and
omits required fields such as key, isEnabled, and other mandatory properties);
update createProvider (or reuse the existing Provider test factory) to construct
and return a fully compliant Provider object: rename/remove deprecated fields
(apiKey → key, enabled → isEnabled), add all required properties from the
current Provider type (including key, isEnabled, providerVendorId, limits,
timestamps, and any other non-optional fields), and ensure groupPriorities and
other provided params are applied; verify the returned shape satisfies the
Provider interface used by the tests.
| const allDisplayGroups = Array.from(new Set([...availableGroups, ...selectedGroups])).filter( | ||
| (g) => g !== "default" | ||
| ); |
There was a problem hiding this comment.
避免保留分组通过列表选择绕过校验
allDisplayGroups 只排除了 "default",若 availableGroups 含 "*" 或大小写变体,会被渲染并可勾选保存,绕过 handleAddGroup 的保留名校验。建议用常量并做大小写无关过滤。
Proposed fix
- const allDisplayGroups = Array.from(new Set([...availableGroups, ...selectedGroups])).filter(
- (g) => g !== "default"
- );
+ const allDisplayGroups = Array.from(new Set([...availableGroups, ...selectedGroups])).filter(
+ (g) => {
+ const normalized = g.trim().toLowerCase();
+ return (
+ normalized !== PROVIDER_GROUP.DEFAULT &&
+ normalized !== PROVIDER_GROUP.ALL
+ );
+ }
+ );📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const allDisplayGroups = Array.from(new Set([...availableGroups, ...selectedGroups])).filter( | |
| (g) => g !== "default" | |
| ); | |
| const allDisplayGroups = Array.from(new Set([...availableGroups, ...selectedGroups])).filter( | |
| (g) => { | |
| const normalized = g.trim().toLowerCase(); | |
| return ( | |
| normalized !== PROVIDER_GROUP.DEFAULT && | |
| normalized !== PROVIDER_GROUP.ALL | |
| ); | |
| } | |
| ); |
🤖 Prompt for AI Agents
In `@src/app/`[locale]/settings/providers/_components/group-tag-edit-popover.tsx
around lines 130 - 132, allDisplayGroups currently only filters out "default"
literally, which allows reserved names like "*" or case variants (e.g.
"DEFAULT", "*") from availableGroups/selectedGroups to be shown and selected,
bypassing handleAddGroup validation; update the creation of allDisplayGroups to
normalize group names to a consistent case and filter against a constant set of
reserved names (e.g., RESERVED_GROUPS = new Set(["default","*"]) ) using
case-insensitive comparison (toLowerCase) so any variant is excluded before
rendering/selection and cannot bypass handleAddGroup.
| <Button | ||
| type="button" | ||
| size="sm" | ||
| variant="outline" | ||
| onClick={handleAddGroup} | ||
| disabled={saving || !newGroup.trim()} | ||
| className="h-8 px-2" | ||
| > | ||
| <Plus className="h-4 w-4" /> | ||
| </Button> |
There was a problem hiding this comment.
为仅图标按钮补充可访问名称并走 i18n
当前按钮只有图标,屏幕阅读器无法获得名称。建议添加 aria-label 并新增对应翻译键,补齐 5 语种。
Proposed fix
<Button
type="button"
size="sm"
variant="outline"
onClick={handleAddGroup}
disabled={saving || !newGroup.trim()}
className="h-8 px-2"
+ aria-label={t("addGroup")}
>
<Plus className="h-4 w-4" />
</Button>🤖 Prompt for AI Agents
In `@src/app/`[locale]/settings/providers/_components/group-tag-edit-popover.tsx
around lines 221 - 230, The icon-only Add Group Button (Button with
onClick={handleAddGroup}, disabled={saving || !newGroup.trim()}) lacks an
accessible name; add an aria-label that uses the i18n translation key (e.g.
"settings.group.add") instead of hardcoding text, wire it to the app's
translation function (e.g. t('settings.group.add')) and ensure the new key is
added to the translations for all five locales; keep the visible UI unchanged
(icon only) and pass the aria-label prop to the Button component.
| function getEffectivePriority(provider: Provider, userGroup: string | null): number { | ||
| // No group or no overrides: use global priority | ||
| if (!userGroup || !provider.groupPriorities) { | ||
| return provider.priority; | ||
| } | ||
|
|
||
| // Parse user groups (may be comma-separated) | ||
| const userGroups = parseGroupString(userGroup); | ||
|
|
||
| // Return first matching group's priority | ||
| for (const group of userGroups) { | ||
| if (group in provider.groupPriorities) { | ||
| return provider.groupPriorities[group]; | ||
| } | ||
| } |
There was a problem hiding this comment.
避免使用 in 触发原型链命中
in 会命中原型链键(如 toString),在少数分组名场景下可能返回 undefined,进而影响后续 Math.min 计算。建议用 Object.hasOwn(或 hasOwnProperty)确保只匹配自有属性。
可选修正
- for (const group of userGroups) {
- if (group in provider.groupPriorities) {
- return provider.groupPriorities[group];
- }
- }
+ for (const group of userGroups) {
+ if (Object.hasOwn(provider.groupPriorities, group)) {
+ return provider.groupPriorities[group];
+ }
+ }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| function getEffectivePriority(provider: Provider, userGroup: string | null): number { | |
| // No group or no overrides: use global priority | |
| if (!userGroup || !provider.groupPriorities) { | |
| return provider.priority; | |
| } | |
| // Parse user groups (may be comma-separated) | |
| const userGroups = parseGroupString(userGroup); | |
| // Return first matching group's priority | |
| for (const group of userGroups) { | |
| if (group in provider.groupPriorities) { | |
| return provider.groupPriorities[group]; | |
| } | |
| } | |
| function getEffectivePriority(provider: Provider, userGroup: string | null): number { | |
| // No group or no overrides: use global priority | |
| if (!userGroup || !provider.groupPriorities) { | |
| return provider.priority; | |
| } | |
| // Parse user groups (may be comma-separated) | |
| const userGroups = parseGroupString(userGroup); | |
| // Return first matching group's priority | |
| for (const group of userGroups) { | |
| if (Object.hasOwn(provider.groupPriorities, group)) { | |
| return provider.groupPriorities[group]; | |
| } | |
| } |
🤖 Prompt for AI Agents
In `@src/app/v1/_lib/proxy/provider-selector.ts` around lines 65 - 79, Replace the
prototype-sensitive "in" check inside getEffectivePriority with an own-property
check (e.g., Object.hasOwn(provider.groupPriorities, group) or
provider.groupPriorities.hasOwnProperty(group)) so only direct keys are matched,
and ensure getEffectivePriority falls back to returning provider.priority if no
group match is found; update the loop that iterates userGroups and the
function's final return accordingly.
| // Helper to create a minimal Provider for testing | ||
| function createProvider( | ||
| priority: number, | ||
| groupPriorities: Record<string, number> | null = null | ||
| ): Provider { | ||
| return { | ||
| id: 1, | ||
| name: "test-provider", | ||
| providerType: "claude", | ||
| url: "https://api.anthropic.com", | ||
| apiKey: "test-key", | ||
| enabled: true, | ||
| priority, | ||
| weight: 1, | ||
| costMultiplier: 1, | ||
| groupTag: null, | ||
| allowedModels: null, | ||
| modelRedirects: null, | ||
| joinClaudePool: false, | ||
| groupPriorities, | ||
| providerVendorId: null, | ||
| limitTotalUsd: null, | ||
| usedTotalUsd: 0, | ||
| createdAt: new Date(), | ||
| updatedAt: new Date(), | ||
| }; | ||
| } |
There was a problem hiding this comment.
createProvider 未符合 Provider 接口字段,可能导致类型检查失败
当前对象使用了 apiKey/enabled 等旧字段名,并缺少 key、isEnabled 等必填字段(以及多项必需属性)。如果未来把 tests 目录纳入 TS 检查,这里会直接报错,也会降低测试数据的代表性。建议对齐 Provider 接口(或复用现有的 Provider 测试工厂/构造器)。
🤖 Prompt for AI Agents
In `@tests/unit/proxy/provider-selector-group-priority.test.ts` around lines 5 -
31, The test helper createProvider returns an object that no longer matches the
Provider interface (uses old fields like apiKey/enabled and omits required
fields such as key, isEnabled, and other mandatory properties); update
createProvider (or reuse the existing Provider test factory) to construct and
return a fully compliant Provider object: rename/remove deprecated fields
(apiKey → key, enabled → isEnabled), add all required properties from the
current Provider type (including key, isEnabled, providerVendorId, limits,
timestamps, and any other non-optional fields), and ensure groupPriorities and
other provided params are applied; verify the returned shape satisfies the
Provider interface used by the tests.
Transform the group selection dropdown from vertical list to horizontal flow layout (tag cloud style) for better space utilization and easier selection when there are many groups. Changes: - TagInput: suggestions panel uses flex-wrap horizontal layout - TagInput: click toggles selection instead of add-only - GroupTagEditPopover: checkbox labels use horizontal flow layout
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@src/components/ui/tag-input.tsx`:
- Around line 308-322: handleSuggestionClick currently calls onChangeCommit when
removing a tag but not when adding one (it calls addTag(suggestionValue, true)
which updates state via onChange only), causing inconsistent commit behavior;
fix by invoking onChangeCommit for the add-path as well: either have
addTag(suggestionValue, true) return or accept a callback/flag that triggers
onChangeCommit internally, or after calling addTag compute the new tags array
and call onChangeCommit(newTags); update handleSuggestionClick to call
onChangeCommit with the same signature used in the removal branch so both
branches commit consistently (references: handleSuggestionClick, addTag,
onChange, onChangeCommit).
♻️ Duplicate comments (2)
src/app/[locale]/settings/providers/_components/group-tag-edit-popover.tsx (2)
130-132: 改进分组列表过滤以避免保留名绕过Line 130-132 仅过滤了字面量
"default","*"或大小写/空白变体仍可能被渲染并选中,绕过保留名校验。建议规范化并过滤PROVIDER_GROUP.DEFAULT/PROVIDER_GROUP.ALL。建议修改
- const allDisplayGroups = Array.from(new Set([...availableGroups, ...selectedGroups])).filter( - (g) => g !== "default" - ); + const allDisplayGroups = Array.from(new Set([...availableGroups, ...selectedGroups])).filter( + (g) => { + const normalized = g.trim().toLowerCase(); + return normalized !== PROVIDER_GROUP.DEFAULT && normalized !== PROVIDER_GROUP.ALL; + } + );
226-235: 为仅图标按钮补充可访问名称Line 226-235 的按钮仅图标显示,缺少可访问名称。建议添加
aria-label并通过 i18n 文案提供,同时补齐 5 语种翻译。As per coding guidelines, 请确保新增文案走 i18n 并补齐 5 语种。建议修改
<Button type="button" size="sm" variant="outline" onClick={handleAddGroup} disabled={saving || !newGroup.trim()} className="h-8 px-2" + aria-label={t("addGroup")} > <Plus className="h-4 w-4" /> </Button>
🧹 Nitpick comments (1)
src/components/ui/tag-input.tsx (1)
103-112: 注释已过时,需更新。第 103 行的注释
// 过滤建议列表:匹配输入值且未被选中与当前实现不符。代码已改为仅按搜索词过滤,不再排除已选中项(选中状态通过视觉样式展示)。建议更新注释以准确描述当前行为:
建议修改
- // 过滤建议列表:匹配输入值且未被选中 + // 过滤建议列表:按输入值匹配(已选中项通过样式区分) const filteredSuggestions = React.useMemo(() => {
| const handleSuggestionClick = React.useCallback( | ||
| (suggestionValue: string) => { | ||
| addTag(suggestionValue, true); // keepOpen=true 保持下拉展开 | ||
| if (value.includes(suggestionValue)) { | ||
| // Already selected -> deselect (remove tag) | ||
| const nextTags = value.filter((v) => v !== suggestionValue); | ||
| onChange(nextTags); | ||
| onChangeCommit?.(nextTags); | ||
| } else { | ||
| // Not selected -> select (add tag) | ||
| addTag(suggestionValue, true); | ||
| } | ||
| inputRef.current?.focus(); | ||
| }, | ||
| [addTag] | ||
| [value, onChange, onChangeCommit, addTag] | ||
| ); |
There was a problem hiding this comment.
onChangeCommit 调用行为不一致。
切换逻辑中,取消选择时调用了 onChangeCommit(第 314 行),但选择时通过 addTag 仅调用 onChange,未触发 onChangeCommit。这可能导致依赖 onChangeCommit 进行持久化的调用方出现不一致行为。
建议在选择路径中也触发 onChangeCommit,或在 addTag 中增加可选的 commit 回调:
建议修改
if (value.includes(suggestionValue)) {
// Already selected -> deselect (remove tag)
const nextTags = value.filter((v) => v !== suggestionValue);
onChange(nextTags);
onChangeCommit?.(nextTags);
} else {
// Not selected -> select (add tag)
- addTag(suggestionValue, true);
+ const nextTags = [...value, suggestionValue];
+ onChange(nextTags);
+ onChangeCommit?.(nextTags);
}🤖 Prompt for AI Agents
In `@src/components/ui/tag-input.tsx` around lines 308 - 322,
handleSuggestionClick currently calls onChangeCommit when removing a tag but not
when adding one (it calls addTag(suggestionValue, true) which updates state via
onChange only), causing inconsistent commit behavior; fix by invoking
onChangeCommit for the add-path as well: either have addTag(suggestionValue,
true) return or accept a callback/flag that triggers onChangeCommit internally,
or after calling addTag compute the new tags array and call
onChangeCommit(newTags); update handleSuggestionClick to call onChangeCommit
with the same signature used in the removal branch so both branches commit
consistently (references: handleSuggestionClick, addTag, onChange,
onChangeCommit).
| providerType: "claude", | ||
| url: "https://api.anthropic.com", | ||
| apiKey: "test-key", | ||
| enabled: true, |
There was a problem hiding this comment.
enabled field doesn't exist in the Provider type. Should be isEnabled instead.
| enabled: true, | |
| isEnabled: true, |
Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/unit/proxy/provider-selector-group-priority.test.ts
Line: 16:16
Comment:
`enabled` field doesn't exist in the Provider type. Should be `isEnabled` instead.
```suggestion
isEnabled: true,
```
How can I resolve this? If you propose a fix, please make it concise.| name: "test-provider", | ||
| providerType: "claude", | ||
| url: "https://api.anthropic.com", | ||
| apiKey: "test-key", |
There was a problem hiding this comment.
Property name mismatch with Provider interface - should be key not apiKey
Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/unit/proxy/provider-selector-group-priority.test.ts
Line: 15:15
Comment:
Property name mismatch with Provider interface - should be `key` not `apiKey`
How can I resolve this? If you propose a fix, please make it concise.… key Address CodeRabbit feedback: constrain group_priorities keys to max 50 chars, consistent with group_tag field validation.
| url: "https://api.anthropic.com", | ||
| apiKey: "test-key", | ||
| enabled: true, |
There was a problem hiding this comment.
Property names don't match the Provider interface
The test uses apiKey and enabled, but the Provider interface expects key and isEnabled.
Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/unit/proxy/provider-selector-group-priority.test.ts
Line: 14:16
Comment:
Property names don't match the Provider interface
The test uses `apiKey` and `enabled`, but the Provider interface expects `key` and `isEnabled`.
How can I resolve this? If you propose a fix, please make it concise.Design document for optimizing usage logs display on mobile devices by switching from table to card-based layout.
Design plan for improving leaderboard usability on mobile: - Simplify tab labels (e.g., "缓存率" instead of "供应商缓存命中率排行") - Replace table with expandable card layout - Stack filter inputs vertically
…ty-override # Conflicts: # src/components/ui/tag-input.tsx
…ard, and logs pages - Add useIsMobile hook for viewport detection - Provider management: mobile filter sheet, group quick-filter bar, card layout with dropdown actions - Leaderboard: mobile card view with expandable details, shortened tab labels - Usage logs: mobile card list replacing virtualized table - Full i18n support (zh-CN, zh-TW, en, ja, ru)
| providerType: "claude", | ||
| url: "https://api.anthropic.com", | ||
| apiKey: "test-key", | ||
| enabled: true, |
There was a problem hiding this comment.
enabled doesn't exist in the Provider interface - should be isEnabled
| enabled: true, | |
| isEnabled: true, |
Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/unit/proxy/provider-selector-group-priority.test.ts
Line: 16:16
Comment:
`enabled` doesn't exist in the Provider interface - should be `isEnabled`
```suggestion
isEnabled: true,
```
How can I resolve this? If you propose a fix, please make it concise.| limitTotalUsd: null, | ||
| usedTotalUsd: 0, |
There was a problem hiding this comment.
These fields don't exist in the Provider type - remove them
| limitTotalUsd: null, | |
| usedTotalUsd: 0, | |
| limitTotalUsd: null, |
Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/unit/proxy/provider-selector-group-priority.test.ts
Line: 26:27
Comment:
These fields don't exist in the Provider type - remove them
```suggestion
limitTotalUsd: null,
```
How can I resolve this? If you propose a fix, please make it concise.- Remove unused imports and variables - Fix useEffect dependency array - Prefix unused parameter with underscore
There was a problem hiding this comment.
Actionable comments posted: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx (1)
84-211: 移动端会触发双份日志请求与渲染Line 84–211:
VirtualizedLogsTable在isMobile分支返回MobileLogsList前已执行useInfiniteQuery和虚拟化逻辑,移动端会同时跑两套查询/轮询与滚动监听,导致重复请求与资源浪费。建议将移动端分流提升到组件最外层,桌面逻辑拆成独立组件,避免双请求。建议的重构方向(将桌面实现下沉)
-export function VirtualizedLogsTable({ - filters, - currencyCode = "USD", - billingModelSource = "original", - autoRefreshEnabled = true, - autoRefreshIntervalMs = 5000, - hideStatusBar = false, - hideScrollToTop = false, - hiddenColumns, - bodyClassName, -}: VirtualizedLogsTableProps) { - const t = useTranslations("dashboard"); - const tChain = useTranslations("provider-chain"); - const parentRef = useRef<HTMLDivElement>(null); - const [showScrollToTop, setShowScrollToTop] = useState(false); - const isMobile = useIsMobile(); - ... - if (isMobile) { - return ( - <MobileLogsList - filters={filters} - currencyCode={currencyCode} - billingModelSource={billingModelSource} - autoRefreshEnabled={autoRefreshEnabled} - autoRefreshIntervalMs={autoRefreshIntervalMs} - hideStatusBar={hideStatusBar} - hideScrollToTop={hideScrollToTop} - bodyClassName={bodyClassName} - /> - ); - } - return (...桌面实现...); -} +export function VirtualizedLogsTable(props: VirtualizedLogsTableProps) { + const isMobile = useIsMobile(); + if (isMobile) { + const { + filters, + currencyCode = "USD", + billingModelSource = "original", + autoRefreshEnabled = true, + autoRefreshIntervalMs = 5000, + hideStatusBar = false, + hideScrollToTop = false, + bodyClassName, + } = props; + return ( + <MobileLogsList + filters={filters} + currencyCode={currencyCode} + billingModelSource={billingModelSource} + autoRefreshEnabled={autoRefreshEnabled} + autoRefreshIntervalMs={autoRefreshIntervalMs} + hideStatusBar={hideStatusBar} + hideScrollToTop={hideScrollToTop} + bodyClassName={bodyClassName} + /> + ); + } + return <VirtualizedLogsTableDesktop {...props} />; +} + +function VirtualizedLogsTableDesktop({ + filters, + currencyCode = "USD", + billingModelSource = "original", + autoRefreshEnabled = true, + autoRefreshIntervalMs = 5000, + hideStatusBar = false, + hideScrollToTop = false, + hiddenColumns, + bodyClassName, +}: VirtualizedLogsTableProps) { + const t = useTranslations("dashboard"); + const tChain = useTranslations("provider-chain"); + const parentRef = useRef<HTMLDivElement>(null); + const [showScrollToTop, setShowScrollToTop] = useState(false); + // 现有桌面实现移动到这里 +}src/app/[locale]/dashboard/leaderboard/_components/leaderboard-view.tsx (1)
55-156: 切换榜单范围/周期后移动端展开索引可能错位Line 66–156:
expandedIndex在 scope/period/dateRange/筛选变化后不重置,移动端可能默认展开与新数据不匹配的条目。建议在关键依赖变化时清空展开状态。建议补充重置逻辑
+ useEffect(() => { + if (!isMobile) return; + setExpandedIndex(null); + }, [isMobile, scope, period, dateRange, providerTypeFilter, userTagFilters, userGroupFilters]);
🤖 Fix all issues with AI agents
In `@docs/plans/2026-01-24-mobile-usage-logs-design.md`:
- Around line 21-39: The fenced code blocks in the design doc are missing a
language identifier; update each markdown fence (``` ... ```) shown in the diff
to include the "text" language tag (i.e. change ``` to ```text) for the block
that starts with the ASCII layout diagram and the other two occurrences
referenced (the blocks around the other ASCII tables), so all three fenced code
blocks are ```text to satisfy markdownlint.
In
`@src/app/`[locale]/dashboard/leaderboard/_components/mobile-leaderboard-card.tsx:
- Around line 59-193: The UI hardcodes "$", "ms", and "tok/s" in
getPrimaryMetric and renderExpandedContent; update these to use the existing
formatted fields (e.g., entry.totalCostFormatted) or a currency-aware formatter
(Intl.NumberFormat with a provided currency/locale prop) instead of "$", and
replace "ms" and "tok/s" with i18n strings via t(...) or use
Intl.NumberFormat/unit formatting for milliseconds and tokens-per-second; touch
functions getPrimaryMetric and renderExpandedContent, ensure the component
accepts a currency and locale prop (or uses context) and use those when
formatting numbers and units so all cost and unit displays respect localization
and currency settings.
In `@src/app/`[locale]/dashboard/logs/_components/mobile-log-card.tsx:
- Around line 186-201: The hardcoded labels "In:", "Out:", "W:", and "R:" in
mobile-log-card.tsx should be replaced with i18n calls like the existing t(...)
usage; update the JSX where those strings appear (inside the divs rendering
token and cache counts) to use t(...) keys (e.g., t("logs.tokens.in"),
t("logs.tokens.out"), t("logs.cache.write"), t("logs.cache.read")) while keeping
formatTokenAmount(log.*) unchanged, and add those keys to your locale resource
files so translations are available.
- Around line 78-79: Replace the hard-coded "OK" prefix in the status string
(the return that currently does `OK ${statusCode}` in
src/app/[locale]/dashboard/logs/_components/mobile-log-card.tsx) with an i18n
lookup (use the project's translate function, e.g. t('dashboard/logs.statusOk',
{ code: statusCode }) or equivalent) and add the corresponding translation key
("dashboard/logs.statusOk") to the dashboard/logs.json files for all five
locales with the appropriate localized templates (e.g. "OK {code}" or localized
equivalent).
♻️ Duplicate comments (1)
src/components/ui/tag-input.tsx (1)
373-386: 新增分支仍未触发 onChangeCommit,提交语义不一致。Line 381-383 仅调用 addTag,未同步触发 onChangeCommit,与移除分支不一致,可能导致依赖持久化回调的调用方漏提交。建议在 addTag 成功后统一触发 onChangeCommit(例如让 addTag 返回 nextTags 或接收回调)。
🧹 Nitpick comments (4)
src/app/v1/_lib/proxy/provider-selector.ts (1)
74-79: 使用Object.hasOwn替代in运算符以避免原型链命中
in运算符会检查原型链上的属性(如toString、constructor等)。虽然 Zod 验证会拒绝default和*,但无法阻止所有原型链键名。建议使用Object.hasOwn确保只匹配自有属性。建议修复
// Return first matching group's priority for (const group of userGroups) { - if (group in provider.groupPriorities) { + if (Object.hasOwn(provider.groupPriorities, group)) { return provider.groupPriorities[group]; } }src/app/[locale]/settings/providers/_components/provider-manager.tsx (1)
376-512: 移动端筛选 Sheet 中分组筛选逻辑重复分组筛选按钮的渲染逻辑在快速访问区(lines 357-372)和 Sheet 内部(lines 456-472)基本相同。考虑提取为共享组件以减少重复。
可选:提取分组筛选按钮组件
// 可以创建一个 GroupFilterButtons 组件 function GroupFilterButtons({ groups, selectedGroups, onToggle, onClear, disabled, allLabel, }: { groups: string[]; selectedGroups: string[]; onToggle: (group: string) => void; onClear: () => void; disabled?: boolean; allLabel: string; }) { return ( <> <Button variant={selectedGroups.length === 0 ? "default" : "outline"} size="sm" onClick={onClear} disabled={disabled} > {allLabel} </Button> {groups.map((group) => ( <Button key={group} variant={selectedGroups.includes(group) ? "default" : "outline"} size="sm" onClick={() => onToggle(group)} disabled={disabled} > {group} </Button> ))} </> ); }src/app/[locale]/settings/providers/_components/provider-rich-list-item.tsx (1)
709-745: 条件渲染逻辑可简化当前的条件渲染在
canEdit为 false 时有些冗余:先检查groupTags.length > 0,然后在渲染时又重新解析provider.groupTag。可以直接复用已计算的groupTags变量。建议简化
) : (provider.groupTag - ? provider.groupTag - .split(",") - .map((t) => t.trim()) - .filter(Boolean) - : [] - ).length > 0 ? ( - provider.groupTag - ?.split(",") - .map((t) => t.trim()) - .filter(Boolean) - .map((tag, index) => { + ) : groupTags.length > 0 ? ( + groupTags.map((tag, index) => { const bgColor = getGroupColor(tag); return ( <Badgesrc/app/[locale]/dashboard/logs/_components/mobile-log-card.tsx (1)
220-224: 性能指标标签应使用 i18n"TTFB" 和 "tok/s" 是硬编码的技术缩写,建议使用 i18n 以保持一致性并支持本地化。
建议的修复
<div className="flex items-center justify-between text-xs text-muted-foreground font-mono"> <span>{formatDuration(log.durationMs)}</span> - {log.ttfbMs != null && log.ttfbMs > 0 && <span>TTFB {formatDuration(log.ttfbMs)}</span>} - {rate !== null && <span>{rate.toFixed(0)} tok/s</span>} + {log.ttfbMs != null && log.ttfbMs > 0 && <span>{t("logs.mobile.ttfb")} {formatDuration(log.ttfbMs)}</span>} + {rate !== null && <span>{t("logs.mobile.tokensPerSecond", { rate: rate.toFixed(0) })}</span>} </div>
| ``` | ||
| +-------------------------------------+ | ||
| | [Header] Time + Status Badge | | ||
| | Left: Relative time (3s ago) | | ||
| | Right: Status badge (OK 200) | | ||
| +-------------------------------------+ | ||
| | [Identity] User + Provider + Model | | ||
| | Username - Provider name | | ||
| | Model name (with redirect arrow) | | ||
| +-------------------------------------+ | ||
| | [Data] Tokens + Cache + Cost | | ||
| | Col1: Input/Output tokens | | ||
| | Col2: Cache write/read | | ||
| | Col3: Cost amount | | ||
| +-------------------------------------+ | ||
| | [Performance] Duration + TTFB + Rate| | ||
| | Total time - TTFB - Output rate | | ||
| +-------------------------------------+ | ||
| ``` |
There was a problem hiding this comment.
为代码块补充语言标识以通过 markdownlint。
目前的围栏代码块缺少语言标识,建议统一标注为 text。
修改建议
-```
+```text
+-------------------------------------+
...
-```
+```text
+-----------+-----------+---------+
...
-```
+```text
+src/app/[locale]/dashboard/logs/_components/Also applies to: 66-72, 80-85
🧰 Tools
🪛 markdownlint-cli2 (0.18.1)
21-21: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
🤖 Prompt for AI Agents
In `@docs/plans/2026-01-24-mobile-usage-logs-design.md` around lines 21 - 39, The
fenced code blocks in the design doc are missing a language identifier; update
each markdown fence (``` ... ```) shown in the diff to include the "text"
language tag (i.e. change ``` to ```text) for the block that starts with the
ASCII layout diagram and the other two occurrences referenced (the blocks around
the other ASCII tables), so all three fenced code blocks are ```text to satisfy
markdownlint.
| const getPrimaryMetric = (): string => { | ||
| switch (scope) { | ||
| case "user": { | ||
| const entry = data as UserEntry; | ||
| return entry.totalCostFormatted ?? `$${Number(entry.totalCost).toFixed(2)}`; | ||
| } | ||
| case "provider": { | ||
| const entry = data as ProviderEntry; | ||
| return entry.totalCostFormatted ?? `$${Number(entry.totalCost).toFixed(2)}`; | ||
| } | ||
| case "providerCacheHitRate": { | ||
| const entry = data as ProviderCacheHitRateEntry; | ||
| return `${(Number(entry.cacheHitRate || 0) * 100).toFixed(1)}%`; | ||
| } | ||
| case "model": { | ||
| const entry = data as ModelEntry; | ||
| return entry.totalCostFormatted ?? `$${Number(entry.totalCost).toFixed(2)}`; | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| const renderExpandedContent = () => { | ||
| switch (scope) { | ||
| case "user": { | ||
| const entry = data as UserEntry; | ||
| return ( | ||
| <div className="grid grid-cols-3 gap-2 text-xs"> | ||
| <div> | ||
| <div className="text-muted-foreground">{t("columns.requests")}</div> | ||
| <div className="font-mono">{entry.totalRequests.toLocaleString()}</div> | ||
| </div> | ||
| <div> | ||
| <div className="text-muted-foreground">{t("columns.tokens")}</div> | ||
| <div className="font-mono">{formatTokenAmount(entry.totalTokens)}</div> | ||
| </div> | ||
| <div> | ||
| <div className="text-muted-foreground">{t("columns.cost")}</div> | ||
| <div className="font-mono"> | ||
| {entry.totalCostFormatted ?? `$${Number(entry.totalCost).toFixed(2)}`} | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
| case "provider": { | ||
| const entry = data as ProviderEntry; | ||
| return ( | ||
| <div className="grid grid-cols-3 gap-2 text-xs"> | ||
| <div> | ||
| <div className="text-muted-foreground">{t("columns.requests")}</div> | ||
| <div className="font-mono">{entry.totalRequests.toLocaleString()}</div> | ||
| </div> | ||
| <div> | ||
| <div className="text-muted-foreground">{t("columns.cost")}</div> | ||
| <div className="font-mono"> | ||
| {entry.totalCostFormatted ?? `$${Number(entry.totalCost).toFixed(2)}`} | ||
| </div> | ||
| </div> | ||
| <div> | ||
| <div className="text-muted-foreground">{t("columns.tokens")}</div> | ||
| <div className="font-mono">{formatTokenAmount(entry.totalTokens)}</div> | ||
| </div> | ||
| <div> | ||
| <div className="text-muted-foreground">{t("columns.successRate")}</div> | ||
| <div className="font-mono"> | ||
| {`${(Number(entry.successRate || 0) * 100).toFixed(1)}%`} | ||
| </div> | ||
| </div> | ||
| <div> | ||
| <div className="text-muted-foreground">{t("columns.avgTtfbMs")}</div> | ||
| <div className="font-mono"> | ||
| {entry.avgTtfbMs && entry.avgTtfbMs > 0 | ||
| ? `${Math.round(entry.avgTtfbMs).toLocaleString()} ms` | ||
| : "-"} | ||
| </div> | ||
| </div> | ||
| <div> | ||
| <div className="text-muted-foreground">{t("columns.avgTokensPerSecond")}</div> | ||
| <div className="font-mono"> | ||
| {entry.avgTokensPerSecond && entry.avgTokensPerSecond > 0 | ||
| ? `${entry.avgTokensPerSecond.toFixed(1)} tok/s` | ||
| : "-"} | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
| case "providerCacheHitRate": { | ||
| const entry = data as ProviderCacheHitRateEntry; | ||
| return ( | ||
| <div className="grid grid-cols-3 gap-2 text-xs"> | ||
| <div> | ||
| <div className="text-muted-foreground">{t("columns.cacheHitRequests")}</div> | ||
| <div className="font-mono">{entry.totalRequests.toLocaleString()}</div> | ||
| </div> | ||
| <div> | ||
| <div className="text-muted-foreground">{t("columns.cacheHitRate")}</div> | ||
| <div className="font-mono"> | ||
| {`${(Number(entry.cacheHitRate || 0) * 100).toFixed(1)}%`} | ||
| </div> | ||
| </div> | ||
| <div> | ||
| <div className="text-muted-foreground">{t("columns.cacheReadTokens")}</div> | ||
| <div className="font-mono">{formatTokenAmount(entry.cacheReadTokens)}</div> | ||
| </div> | ||
| <div className="col-span-3"> | ||
| <div className="text-muted-foreground">{t("columns.totalTokens")}</div> | ||
| <div className="font-mono">{formatTokenAmount(entry.totalInputTokens)}</div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
| case "model": { | ||
| const entry = data as ModelEntry; | ||
| return ( | ||
| <div className="grid grid-cols-3 gap-2 text-xs"> | ||
| <div> | ||
| <div className="text-muted-foreground">{t("columns.requests")}</div> | ||
| <div className="font-mono">{entry.totalRequests.toLocaleString()}</div> | ||
| </div> | ||
| <div> | ||
| <div className="text-muted-foreground">{t("columns.tokens")}</div> | ||
| <div className="font-mono">{formatTokenAmount(entry.totalTokens)}</div> | ||
| </div> | ||
| <div> | ||
| <div className="text-muted-foreground">{t("columns.cost")}</div> | ||
| <div className="font-mono"> | ||
| {entry.totalCostFormatted ?? `$${Number(entry.totalCost).toFixed(2)}`} | ||
| </div> | ||
| </div> | ||
| <div className="col-span-3"> | ||
| <div className="text-muted-foreground">{t("columns.successRate")}</div> | ||
| <div className="font-mono"> | ||
| {`${(Number(entry.successRate || 0) * 100).toFixed(1)}%`} | ||
| </div> |
There was a problem hiding this comment.
成本与单位硬编码,可能与币种/本地化不一致
Line 63–187:$、ms、tok/s 为硬编码展示文本,既不符合 i18n 规范,也可能在非 USD 场景误导用户。建议成本优先使用已格式化字段或按系统币种格式化;单位建议用 i18n 或 Intl.NumberFormat 的单位格式化。
成本字段的最小修正示例(先移除硬编码美元符号)
- return entry.totalCostFormatted ?? `$${Number(entry.totalCost).toFixed(2)}`;
+ return entry.totalCostFormatted ?? Number(entry.totalCost).toLocaleString();
...
- {entry.totalCostFormatted ?? `$${Number(entry.totalCost).toFixed(2)}`}
+ {entry.totalCostFormatted ?? Number(entry.totalCost).toLocaleString()}
...
- return entry.totalCostFormatted ?? `$${Number(entry.totalCost).toFixed(2)}`;
+ return entry.totalCostFormatted ?? Number(entry.totalCost).toLocaleString();
...
- {entry.totalCostFormatted ?? `$${Number(entry.totalCost).toFixed(2)}`}
+ {entry.totalCostFormatted ?? Number(entry.totalCost).toLocaleString()}如需保留币种符号,建议在父组件传入币种并用统一的货币格式化工具处理。单位(ms/tok/s)也建议提取为 i18n 或使用单位格式化。
🤖 Prompt for AI Agents
In
`@src/app/`[locale]/dashboard/leaderboard/_components/mobile-leaderboard-card.tsx
around lines 59 - 193, The UI hardcodes "$", "ms", and "tok/s" in
getPrimaryMetric and renderExpandedContent; update these to use the existing
formatted fields (e.g., entry.totalCostFormatted) or a currency-aware formatter
(Intl.NumberFormat with a provided currency/locale prop) instead of "$", and
replace "ms" and "tok/s" with i18n strings via t(...) or use
Intl.NumberFormat/unit formatting for milliseconds and tokens-per-second; touch
functions getPrimaryMetric and renderExpandedContent, ensure the component
accepts a currency and locale prop (or uses context) and use those when
formatting numbers and units so all cost and unit displays respect localization
and currency settings.
| if (statusCode >= 200 && statusCode < 300) return `OK ${statusCode}`; | ||
| return `${statusCode}`; |
There was a problem hiding this comment.
硬编码的用户可见字符串应使用 i18n
状态文本中的 "OK" 前缀是硬编码的英文,应使用 i18n 翻译。
建议的修复
- if (statusCode >= 200 && statusCode < 300) return `OK ${statusCode}`;
+ if (statusCode >= 200 && statusCode < 300) return t("logs.status.ok", { code: statusCode });并在 5 种语言的 dashboard/logs.json 中添加对应的翻译键。
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (statusCode >= 200 && statusCode < 300) return `OK ${statusCode}`; | |
| return `${statusCode}`; | |
| if (statusCode >= 200 && statusCode < 300) return t("logs.status.ok", { code: statusCode }); | |
| return `${statusCode}`; |
🤖 Prompt for AI Agents
In `@src/app/`[locale]/dashboard/logs/_components/mobile-log-card.tsx around lines
78 - 79, Replace the hard-coded "OK" prefix in the status string (the return
that currently does `OK ${statusCode}` in
src/app/[locale]/dashboard/logs/_components/mobile-log-card.tsx) with an i18n
lookup (use the project's translate function, e.g. t('dashboard/logs.statusOk',
{ code: statusCode }) or equivalent) and add the corresponding translation key
("dashboard/logs.statusOk") to the dashboard/logs.json files for all five
locales with the appropriate localized templates (e.g. "OK {code}" or localized
equivalent).
| <div className="text-muted-foreground text-[10px]">{t("logs.columns.tokens")}</div> | ||
| <div className="font-mono tabular-nums"> | ||
| <div>In: {formatTokenAmount(log.inputTokens)}</div> | ||
| <div className="text-muted-foreground">Out: {formatTokenAmount(log.outputTokens)}</div> | ||
| </div> | ||
| </div> | ||
|
|
||
| {/* Cache */} | ||
| <div className="space-y-0.5"> | ||
| <div className="text-muted-foreground text-[10px]">{t("logs.columns.cache")}</div> | ||
| <div className="font-mono tabular-nums"> | ||
| <div>W: {formatTokenAmount(log.cacheCreationInputTokens)}</div> | ||
| <div className="text-muted-foreground"> | ||
| R: {formatTokenAmount(log.cacheReadInputTokens)} | ||
| </div> | ||
| </div> |
There was a problem hiding this comment.
Token 和 Cache 标签应使用 i18n
"In:"、"Out:"、"W:"、"R:" 这些标签是硬编码的英文缩写,应使用 i18n 以支持多语言显示。根据编码规范,所有用户可见字符串必须使用 i18n。
建议的修复
<div className="font-mono tabular-nums">
- <div>In: {formatTokenAmount(log.inputTokens)}</div>
- <div className="text-muted-foreground">Out: {formatTokenAmount(log.outputTokens)}</div>
+ <div>{t("logs.mobile.inputShort")}: {formatTokenAmount(log.inputTokens)}</div>
+ <div className="text-muted-foreground">{t("logs.mobile.outputShort")}: {formatTokenAmount(log.outputTokens)}</div>
</div>
</div>
{/* Cache */}
<div className="space-y-0.5">
<div className="text-muted-foreground text-[10px]">{t("logs.columns.cache")}</div>
<div className="font-mono tabular-nums">
- <div>W: {formatTokenAmount(log.cacheCreationInputTokens)}</div>
+ <div>{t("logs.mobile.cacheWriteShort")}: {formatTokenAmount(log.cacheCreationInputTokens)}</div>
<div className="text-muted-foreground">
- R: {formatTokenAmount(log.cacheReadInputTokens)}
+ {t("logs.mobile.cacheReadShort")}: {formatTokenAmount(log.cacheReadInputTokens)}
</div>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <div className="text-muted-foreground text-[10px]">{t("logs.columns.tokens")}</div> | |
| <div className="font-mono tabular-nums"> | |
| <div>In: {formatTokenAmount(log.inputTokens)}</div> | |
| <div className="text-muted-foreground">Out: {formatTokenAmount(log.outputTokens)}</div> | |
| </div> | |
| </div> | |
| {/* Cache */} | |
| <div className="space-y-0.5"> | |
| <div className="text-muted-foreground text-[10px]">{t("logs.columns.cache")}</div> | |
| <div className="font-mono tabular-nums"> | |
| <div>W: {formatTokenAmount(log.cacheCreationInputTokens)}</div> | |
| <div className="text-muted-foreground"> | |
| R: {formatTokenAmount(log.cacheReadInputTokens)} | |
| </div> | |
| </div> | |
| <div className="text-muted-foreground text-[10px]">{t("logs.columns.tokens")}</div> | |
| <div className="font-mono tabular-nums"> | |
| <div>{t("logs.mobile.inputShort")}: {formatTokenAmount(log.inputTokens)}</div> | |
| <div className="text-muted-foreground">{t("logs.mobile.outputShort")}: {formatTokenAmount(log.outputTokens)}</div> | |
| </div> | |
| </div> | |
| {/* Cache */} | |
| <div className="space-y-0.5"> | |
| <div className="text-muted-foreground text-[10px]">{t("logs.columns.cache")}</div> | |
| <div className="font-mono tabular-nums"> | |
| <div>{t("logs.mobile.cacheWriteShort")}: {formatTokenAmount(log.cacheCreationInputTokens)}</div> | |
| <div className="text-muted-foreground"> | |
| {t("logs.mobile.cacheReadShort")}: {formatTokenAmount(log.cacheReadInputTokens)} | |
| </div> | |
| </div> |
🤖 Prompt for AI Agents
In `@src/app/`[locale]/dashboard/logs/_components/mobile-log-card.tsx around lines
186 - 201, The hardcoded labels "In:", "Out:", "W:", and "R:" in
mobile-log-card.tsx should be replaced with i18n calls like the existing t(...)
usage; update the JSX where those strings appear (inside the divs rendering
token and cache counts) to use t(...) keys (e.g., t("logs.tokens.in"),
t("logs.tokens.out"), t("logs.cache.write"), t("logs.cache.read")) while keeping
formatTokenAmount(log.*) unchanged, and add those keys to your locale resource
files so translations are available.
| limitTotalUsd: null, | ||
| usedTotalUsd: 0, |
There was a problem hiding this comment.
Remove unused test properties - usedTotalUsd doesn't exist in Provider type
Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/unit/proxy/provider-selector-group-priority.test.ts
Line: 26:27
Comment:
Remove unused test properties - `usedTotalUsd` doesn't exist in Provider type
How can I resolve this? If you propose a fix, please make it concise.| apiKey: "test-key", | ||
| enabled: true, |
There was a problem hiding this comment.
Property name mismatch - Provider interface uses key not apiKey, and isEnabled not enabled
Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/unit/proxy/provider-selector-group-priority.test.ts
Line: 15:16
Comment:
Property name mismatch - Provider interface uses `key` not `apiKey`, and `isEnabled` not `enabled`
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Actionable comments posted: 0
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/app/[locale]/dashboard/availability/_components/endpoint/probe-terminal.tsx (1)
76-80: 自动滚动依赖缺少日志变化,功能回退Line 76-80:
useEffect不再依赖日志变化,导致新日志到达时不会触发滚动,和注释“新日志到达时自动滚动”的行为不一致。建议把日志变化加入依赖,至少用logs.length触发。建议修改
- }, [autoScroll, userScrolled]); + }, [autoScroll, userScrolled, logs.length]);
Update selectors and assertions to match the new horizontal flow layout where suggestions use inline-flex buttons with accent hover for unselected and primary background for selected items.
| apiKey: "test-key", | ||
| enabled: true, |
There was a problem hiding this comment.
Property names don't match Provider interface - change apiKey to key and enabled to isEnabled per the interface definition in src/types/provider.ts:39-56
Prompt To Fix With AI
This is a comment left during a code review.
Path: tests/unit/proxy/provider-selector-group-priority.test.ts
Line: 15:16
Comment:
Property names don't match Provider interface - change `apiKey` to `key` and `enabled` to `isEnabled` per the interface definition in `src/types/provider.ts:39-56`
How can I resolve this? If you propose a fix, please make it concise.
Summary
Add group-level priority override feature for providers, allowing different priorities for different user groups.
Changes:
group_prioritiesJSONB field for per-group priority settingsGroupPriorityPopovercomponent for editing priorities on multi-group providersGroupTagEditPopovercomponent for inline group tag editingUse Case
When a provider serves multiple user groups, administrators can configure different priorities per group. For example:
cligroup: priority 1 (highest)chatgroup: priority 5 (lower)Test Plan
bun run build)bun run typecheck)Screenshots
N/A (backend + existing UI patterns)
Greptile Overview
Greptile Summary
This PR adds group-level priority override functionality, allowing providers to have different priorities for different user groups. The implementation is comprehensive and well-structured:
Core Changes:
group_prioritiesJSONB field to database schema and all provider interfacesgetEffectivePriority()function with first-match resolution for comma-separated groupsGroupPriorityPopoverandGroupTagEditPopoverUI components for editingImplementation Quality:
Issues Found:
apiKey/enabledinstead ofkey/isEnabled) - this will cause TypeScript compilation errorsConfidence Score: 4/5
Important Files Changed
apiKey,enabled) instead of interface properties (key,isEnabled)group_prioritiesJSONB column with proper type annotation and null defaultgroupPrioritiesfield to all provider interfaces with proper typingSequence Diagram
sequenceDiagram participant Client participant UI as Provider List UI participant Popover as GroupPriorityPopover participant Action as editProvider Action participant Repo as Provider Repository participant DB as Database participant Selector as ProviderSelector Note over Client,Selector: Setup: Provider with group priorities configured Client->>UI: View providers list UI->>UI: Calculate displayPriority<br/>based on selectedGroup filter UI-->>Client: Show provider with priority<br/>(may show group override) Client->>Popover: Click priority to edit Popover->>Popover: Load current priority<br/>and groupPriorities Popover-->>Client: Show edit form with<br/>default + per-group inputs Client->>Popover: Edit priorities<br/>(default: 5, cli: 1, chat: 3) Popover->>Popover: Validate inputs<br/>(int, 0-2147483647) Popover->>Action: onSave(priority, groupPriorities) Action->>Repo: updateProvider(id, data) Repo->>DB: UPDATE providers SET<br/>priority=5, group_priorities={cli:1, chat:3} DB-->>Repo: Success Repo-->>Action: Updated provider Action->>Action: publishProviderCacheInvalidation() Action-->>Popover: Success Popover-->>Client: Close popover, refresh list Note over Client,Selector: Runtime: Provider selection with group override Client->>Selector: API request with user group "cli" Selector->>Selector: getEffectivePriority(provider, "cli") Selector->>Selector: Check groupPriorities["cli"] Selector->>Selector: Return 1 (override) Selector->>Selector: selectTopPriority()<br/>filters to priority=1 Selector-->>Client: Select provider with<br/>effective priority 1 Client->>Selector: API request with user group "web" Selector->>Selector: getEffectivePriority(provider, "web") Selector->>Selector: Check groupPriorities["web"] Selector->>Selector: No match, return 5 (default) Selector->>Selector: selectTopPriority()<br/>filters to priority=5 Selector-->>Client: Select provider with<br/>effective priority 5