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
3 changes: 3 additions & 0 deletions internal/domain/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ type Provider struct {
// 展示的名称
Name string `json:"name"`

// Logo URL 或 data URI
Logo string `json:"logo,omitempty"`
Comment on lines +91 to +92
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

find . -type f \( -name "*migration*" -o -name "*migrations*" -o -name "*schema*" \) | head -20

Repository: awsl-project/maxx

Length of output: 188


🏁 Script executed:

rg -l "migrations|migration" --type go --type sql | head -20

Repository: awsl-project/maxx

Length of output: 171


🏁 Script executed:

rg -n "logo" -i -t go -t sql . 2>/dev/null | head -40

Repository: awsl-project/maxx

Length of output: 181


🏁 Script executed:

rg -n "type Provider struct" -A 50 internal/domain/model.go

Repository: awsl-project/maxx

Length of output: 1409


🏁 Script executed:

cat -n internal/repository/sqlite/migrations.go

Repository: awsl-project/maxx

Length of output: 5457


🏁 Script executed:

cat -n internal/repository/sqlite/models.go | head -100

Repository: awsl-project/maxx

Length of output: 2771


🏁 Script executed:

rg -n "validate|Validate" internal/domain/model.go

Repository: awsl-project/maxx

Length of output: 43


🏁 Script executed:

cat -n internal/repository/sqlite/models.go | grep -A 30 "type Provider struct"

Repository: awsl-project/maxx

Length of output: 1135


🏁 Script executed:

rg -n "Logo" internal/repository/sqlite/models.go

Repository: awsl-project/maxx

Length of output: 43


🏁 Script executed:

cat -n docs/database-migrations.md

Repository: awsl-project/maxx

Length of output: 6890


Logo 字段缺失 GORM 模型定义,导致数据无法持久化

Logo 字段已添加到域模型 (internal/domain/model.go:91-92),但在 GORM 模型 (internal/repository/sqlite/models.go:115-121) 中缺失。由于 AutoMigrate 仅处理 GORM 模型中定义的字段,缺少该定义会导致数据库列不被创建,Logo 数据将被丢弃。

必须在 Provider 结构体中补充 Logo 字段定义,建议添加长度限制以防止超长 data URI 导致存储膨胀。

🤖 Prompt for AI Agents
In `@internal/domain/model.go` around lines 91 - 92, The Provider GORM model is
missing the Logo field added to the domain model; update the Provider struct in
internal/repository/sqlite/models.go to include a Logo string field mapped to
the JSON/tag and GORM column (e.g., Logo string `json:"logo,omitempty"
gorm:"column:logo;size:2048"`), adding a size limit (suggest ~2048) to prevent
oversized data URIs and ensure AutoMigrate creates the logo column; keep the
JSON tag consistent with the domain model.


// 配置
Config *ProviderConfig `json:"config"`

Expand Down
4 changes: 4 additions & 0 deletions web/src/assets/icons/88code.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added web/src/assets/icons/aicodemirror.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions web/src/assets/icons/nvidia.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
30 changes: 26 additions & 4 deletions web/src/components/routes/ClientTypeRoutesContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,13 @@ import { AntigravityQuotasProvider } from '@/contexts/antigravity-quotas-context
interface ClientTypeRoutesContentProps {
clientType: ClientType;
projectID: number; // 0 for global routes
searchQuery?: string; // Optional search query from parent
}

export function ClientTypeRoutesContent({
clientType,
projectID,
searchQuery = '',
}: ClientTypeRoutesContentProps) {
const [activeId, setActiveId] = useState<string | null>(null)
const { data: providerStats = {} } = useProviderStats(clientType, projectID || undefined)
Expand Down Expand Up @@ -100,7 +102,16 @@ export function ClientTypeRoutesContent({
});

// Only show providers that have routes
const filteredItems = allItems.filter((item) => item.route);
let filteredItems = allItems.filter((item) => item.route);

// Apply search filter
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
filteredItems = filteredItems.filter((item) =>
item.provider.name.toLowerCase().includes(query) ||
item.provider.type.toLowerCase().includes(query)
);
}

return filteredItems.sort((a, b) => {
if (a.route && b.route) return a.route.position - b.route.position;
Expand All @@ -110,15 +121,26 @@ export function ClientTypeRoutesContent({
if (!a.isNative && b.isNative) return 1;
return a.provider.name.localeCompare(b.provider.name);
});
}, [providers, clientRoutes, clientType]);
}, [providers, clientRoutes, clientType, searchQuery]);

// Get available providers (without routes yet)
const availableProviders = useMemo((): Provider[] => {
return providers.filter((p) => {
let available = providers.filter((p) => {
const hasRoute = clientRoutes.some((r) => Number(r.providerID) === Number(p.id));
return !hasRoute;
});
}, [providers, clientRoutes]);

// Apply search filter
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
available = available.filter((p) =>
p.name.toLowerCase().includes(query) ||
p.type.toLowerCase().includes(query)
);
}

return available;
}, [providers, clientRoutes, searchQuery]);

const activeItem = activeId ? items.find((item) => item.id === activeId) : null;

Expand Down
12 changes: 12 additions & 0 deletions web/src/components/ui/model-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ const COMMON_MODELS = [
{ id: '*gpt*', name: 'All GPT models', provider: 'OpenAI' },
{ id: '*o1*', name: 'All o1 models', provider: 'OpenAI' },
{ id: '*o3*', name: 'All o3 models', provider: 'OpenAI' },
// NVIDIA/Meta wildcards
{ id: '*llama*', name: 'All Llama models', provider: 'NVIDIA' },
{ id: 'meta/*', name: 'All Meta models', provider: 'NVIDIA' },
// OpenAI models
{ id: 'gpt-4o', name: 'GPT-4o', provider: 'OpenAI' },
{ id: 'gpt-4o-mini', name: 'GPT-4o Mini', provider: 'OpenAI' },
Expand All @@ -58,6 +61,15 @@ const COMMON_MODELS = [
{ id: 'o1-mini', name: 'o1 Mini', provider: 'OpenAI' },
{ id: 'o1-pro', name: 'o1 Pro', provider: 'OpenAI' },
{ id: 'o3-mini', name: 'o3 Mini', provider: 'OpenAI' },
// NVIDIA models
{ id: 'minimaxai/minimax-m2.1', name: 'MiniMax M2.1', provider: 'NVIDIA' },
{ id: 'z-ai/glm4.7', name: 'GLM 4.7', provider: 'NVIDIA' },
{ id: 'deepseek-ai/deepseek-rl', name: 'DeepSeek RL', provider: 'NVIDIA' },
{ id: 'qwen/qwen2.5-coder-32b-instruct', name: 'Qwen 2.5 Coder 32B', provider: 'NVIDIA' },
{ id: 'openai/gpt-oss-120b', name: 'GPT OSS 120B', provider: 'NVIDIA' },
{ id: 'google/gemma-3-27b-it', name: 'Gemma 3 27B', provider: 'NVIDIA' },
{ id: 'meta/llama-4-maverick-17b-128e-instruct', name: 'Llama 4 Maverick 17B', provider: 'NVIDIA' },
{ id: 'mistralai/devstral-2-123b-instruct-2512', name: 'Devstral 2 123B', provider: 'NVIDIA' },
// Antigravity supported target models (use these as mapping targets)
{
id: 'claude-opus-4-5-thinking',
Expand Down
1 change: 1 addition & 0 deletions web/src/lib/transport/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export interface Provider {
updatedAt: string;
type: string;
name: string;
logo?: string; // Logo URL or data URI
config: ProviderConfig | null;
supportedClientTypes: ClientType[];
}
Expand Down
8 changes: 7 additions & 1 deletion web/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@
"days": "days",
"hours": "hours",
"global": "Global",
"initFailed": "Failed to Initialize"
"initFailed": "Failed to Initialize",
"search": "Search",
"searchProviders": "Search providers..."
},
"nav": {
"dashboard": "Dashboard",
Expand Down Expand Up @@ -568,6 +570,10 @@
"freeduck": {
"name": "Free Duck",
"description": "Free site · Claude Code only"
},
"nvidia": {
"name": "NVIDIA NIM",
"description": "NVIDIA NIM · OpenAI Compatible"
}
}
}
Expand Down
8 changes: 7 additions & 1 deletion web/src/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@
"days": "天",
"hours": "小时",
"global": "全局",
"initFailed": "初始化失败"
"initFailed": "初始化失败",
"search": "搜索",
"searchProviders": "搜索 Provider..."
},
"nav": {
"dashboard": "仪表板",
Expand Down Expand Up @@ -567,6 +569,10 @@
"freeduck": {
"name": "Free Duck",
"description": "免费站点 · 只有 Claude Code"
},
"nvidia": {
"name": "NVIDIA NIM",
"description": "NVIDIA NIM · OpenAI 兼容"
}
}
}
Expand Down
24 changes: 23 additions & 1 deletion web/src/pages/client-routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,20 @@
* 全局路由配置页面 - 显示当前 ClientType 的路由
*/

import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Search } from 'lucide-react';
import { ClientIcon, getClientName } from '@/components/icons/client-icons';
import type { ClientType } from '@/lib/transport';
import { ClientTypeRoutesContent } from '@/components/routes/ClientTypeRoutesContent';
import { Input } from '@/components/ui/input';

export function ClientRoutesPage() {
const { t } = useTranslation();
const { clientType } = useParams<{ clientType: string }>();
const activeClientType = (clientType as ClientType) || 'claude';
const [searchQuery, setSearchQuery] = useState('');

return (
<div className="flex flex-col h-full bg-background">
Expand All @@ -29,11 +35,27 @@ export function ClientRoutesPage() {
</p>
</div>
</div>
<div className="relative">
<Search
size={14}
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
placeholder={t('common.searchProviders')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 w-48"
/>
Comment on lines +38 to +48
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

为搜索输入补充可访问性标签。
仅依赖 placeholder 不利于读屏器识别,建议添加 aria-label 或显式 label。

🧩 建议修复
-          <Input
-            placeholder={t('common.searchProviders')}
-            value={searchQuery}
-            onChange={(e) => setSearchQuery(e.target.value)}
-            className="pl-9 w-48"
-          />
+          <Input
+            type="search"
+            aria-label={t('common.searchProviders')}
+            placeholder={t('common.searchProviders')}
+            value={searchQuery}
+            onChange={(e) => setSearchQuery(e.target.value)}
+            className="pl-9 w-48"
+          />
📝 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.

Suggested change
<div className="relative">
<Search
size={14}
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
placeholder={t('common.searchProviders')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 w-48"
/>
<div className="relative">
<Search
size={14}
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
type="search"
aria-label={t('common.searchProviders')}
placeholder={t('common.searchProviders')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 w-48"
/>
🤖 Prompt for AI Agents
In `@web/src/pages/client-routes/index.tsx` around lines 38 - 48,
为搜索输入补充无障碍标签:在使用的 Input 组件(在这段代码块中引用的 Input)上添加 aria-label 或为其生成关联的
<label>(使用相同的翻译文本 t('common.searchProviders') 以保持一致),确保 Input 保留现有的
placeholder、value 和 onChange;如果添加 <label>,给 Input 一个唯一 id 并将 <label
htmlFor={id}> 包裹或放在相应位置;同时保留 Search 图标不影响可访问性(图标保持装饰性,确保其 role 或 aria-hidden 设为
true 如有必要)。

</div>
</div>

{/* Content */}
<div className="flex-1 min-w-0 overflow-hidden">
<ClientTypeRoutesContent clientType={activeClientType} projectID={0} />
<ClientTypeRoutesContent
clientType={activeClientType}
projectID={0}
searchQuery={searchQuery}
/>
</div>
</div>
);
Expand Down
97 changes: 94 additions & 3 deletions web/src/pages/providers/components/custom-config-step.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Globe, ChevronLeft, Key, Check } from 'lucide-react';
import { Globe, ChevronLeft, Key, Check, Plus, Trash2, ArrowRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useCreateProvider } from '@/hooks/queries';
import { useCreateProvider, useCreateModelMapping } from '@/hooks/queries';
import type { ClientType, CreateProviderData } from '@/lib/transport';
import { ClientsConfigSection } from './clients-config-section';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ModelInput } from '@/components/ui/model-input';
import { useProviderForm } from '../context/provider-form-context';
import { useProviderNavigation } from '../hooks/use-provider-navigation';

Expand All @@ -13,6 +14,7 @@ export function CustomConfigStep() {
const { formData, updateFormData, updateClient, isValid, isSaving, setSaving, saveStatus, setSaveStatus } = useProviderForm();
const { goToSelectType, goToProviders } = useProviderNavigation();
const createProvider = useCreateProvider();
const createModelMapping = useCreateModelMapping();

const handleSave = async () => {
if (!isValid()) return;
Expand All @@ -32,6 +34,7 @@ export function CustomConfigStep() {
const data: CreateProviderData = {
type: 'custom',
name: formData.name,
logo: formData.logo,
config: {
custom: {
baseURL: formData.baseURL,
Expand All @@ -42,7 +45,20 @@ export function CustomConfigStep() {
supportedClientTypes,
};

await createProvider.mutateAsync(data);
const provider = await createProvider.mutateAsync(data);

// Create model mappings if template has any
if (formData.modelMappings && formData.modelMappings.length > 0) {
for (const mapping of formData.modelMappings) {
await createModelMapping.mutateAsync({
scope: 'provider',
providerID: provider.id,
pattern: mapping.pattern,
target: mapping.target,
});
}
}

setSaveStatus('success');
setTimeout(() => goToProviders(), 500);
} catch (error) {
Expand Down Expand Up @@ -153,6 +169,81 @@ export function CustomConfigStep() {
<ClientsConfigSection clients={formData.clients} onUpdateClient={updateClient} />
</div>

{/* Model Mapping Section */}
<div className="space-y-6">
<div className="flex items-center justify-between border-b border-border pb-2">
<h3 className="text-lg font-semibold text-text-primary">
{t('modelMappings.title')}
</h3>
<Button
variant="outline"
size="sm"
onClick={() => {
const newMappings = [...(formData.modelMappings || []), { pattern: '', target: '' }];
updateFormData({ modelMappings: newMappings });
}}
>
<Plus size={14} />
{t('routes.modelMapping.addMapping')}
</Button>
</div>

{formData.modelMappings && formData.modelMappings.length > 0 ? (
<div className="space-y-3">
{formData.modelMappings.map((mapping, index) => (
<div key={index} className="flex items-center gap-3 p-3 bg-muted/50 rounded-lg">
<div className="flex-1">
<label className="text-xs text-muted-foreground mb-1 block">
{t('settings.matchPattern')}
</label>
<Input
type="text"
value={mapping.pattern}
onChange={(e) => {
const newMappings = [...(formData.modelMappings || [])];
newMappings[index] = { ...newMappings[index], pattern: e.target.value };
updateFormData({ modelMappings: newMappings });
}}
placeholder="*claude*, *sonnet*, *"
className="font-mono text-sm"
/>
</div>
<ArrowRight size={16} className="text-muted-foreground shrink-0 mt-5" />
<div className="flex-1">
<label className="text-xs text-muted-foreground mb-1 block">
{t('settings.targetModel')}
</label>
<ModelInput
value={mapping.target}
onChange={(value) => {
const newMappings = [...(formData.modelMappings || [])];
newMappings[index] = { ...newMappings[index], target: value };
updateFormData({ modelMappings: newMappings });
}}
placeholder={t('modelInput.selectOrEnter')}
/>
</div>
<Button
variant="ghost"
size="icon"
className="shrink-0 mt-5 text-muted-foreground hover:text-destructive"
onClick={() => {
const newMappings = (formData.modelMappings || []).filter((_, i) => i !== index);
updateFormData({ modelMappings: newMappings });
}}
>
<Trash2 size={14} />
</Button>
</div>
))}
</div>
) : (
<div className="text-sm text-muted-foreground p-4 bg-muted/30 rounded-lg text-center">
{t('modelMappings.noMappings')}
</div>
)}
</div>
Comment on lines +172 to +245
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

删除按钮为纯图标,建议补充可访问性文本。
为图标按钮添加 aria-label/title 可提升可访问性与可用性。

🧩 建议修复
-                    <Button
+                    <Button
                       variant="ghost"
                       size="icon"
                       className="shrink-0 mt-5 text-muted-foreground hover:text-destructive"
+                      aria-label={t('common.delete')}
+                      title={t('common.delete')}
                       onClick={() => {
                         const newMappings = (formData.modelMappings || []).filter((_, i) => i !== index);
                         updateFormData({ modelMappings: newMappings });
                       }}
                     >
🤖 Prompt for AI Agents
In `@web/src/pages/providers/components/custom-config-step.tsx` around lines 172 -
245, The delete Button that renders only the Trash2 icon in the modelMappings
list lacks accessible text—update the Button (the one with variant="ghost"
size="icon" wrapping <Trash2 /> inside the map iteration) to include an
aria-label and title (e.g. aria-label={t('routes.modelMapping.removeMapping')}
title={t('routes.modelMapping.removeMapping')}) and mark the <Trash2 /> icon
aria-hidden="true" to avoid duplicate announcements; ensure the i-th removal
onClick logic remains unchanged so accessibility is added without affecting
behavior.


{saveStatus === 'error' && (
<div className="p-4 bg-error/10 border border-error/30 rounded-lg text-sm text-error flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-error" />
Expand Down
2 changes: 2 additions & 0 deletions web/src/pages/providers/components/select-type-step.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export function SelectTypeStep() {
selectedTemplate: templateId,
name: template.name,
clients: updatedClients,
modelMappings: template.modelMappings,
logo: template.logoUrl,
Comment on lines +44 to +45
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

避免直接复用模板的 modelMappings 引用
如果后续允许在表单中编辑映射,直接复用模板数组/对象可能污染模板数据,影响再次选择模板的初始值。建议做浅/深拷贝后再写入表单状态。

🛠 建议修复
-        modelMappings: template.modelMappings,
+        modelMappings: template.modelMappings?.map((m) => ({ ...m })),
📝 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.

Suggested change
modelMappings: template.modelMappings,
logo: template.logoUrl,
modelMappings: template.modelMappings?.map((m) => ({ ...m })),
logo: template.logoUrl,
🤖 Prompt for AI Agents
In `@web/src/pages/providers/components/select-type-step.tsx` around lines 44 -
45, The template’s modelMappings reference is being assigned directly
(modelMappings: template.modelMappings) which can mutate the original template
if the form allows edits; in the SelectTypeStep component, replace the direct
reference with a copy of template.modelMappings (use a shallow copy for
arrays/objects via spread or a deep copy via JSON.parse(JSON.stringify(...)) or
structuredClone) so the form state owns its own data and does not mutate
template.modelMappings; keep logo: template.logoUrl as-is.

});

goToCustomConfig();
Expand Down
Loading