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
6 changes: 6 additions & 0 deletions scripts/build/assembleStandalone.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,8 @@ async function syncNativeAssetsToDir(projectRoot, outDir, fsImpl, log) {
if (!(await exists(sourcePath))) continue;

const destinationPath = path.join(outDir, ...entry.dest);
if (path.resolve(sourcePath) === path.resolve(destinationPath)) continue;

const mkdir =
typeof fsImpl.mkdir === "function" ? fsImpl.mkdir.bind(fsImpl) : fs.mkdir.bind(fs);
await mkdir(path.dirname(destinationPath), { recursive: true });
Expand Down Expand Up @@ -306,6 +308,8 @@ async function syncExtraModulesToDir(projectRoot, outDir, fsImpl, log) {
if (!(await exists(sourcePath))) continue;

const destPath = path.join(outDir, ...entry.dest);
if (path.resolve(sourcePath) === path.resolve(destPath)) continue;

const mkdir =
typeof fsImpl.mkdir === "function" ? fsImpl.mkdir.bind(fsImpl) : fs.mkdir.bind(fs);
await mkdir(path.dirname(destPath), { recursive: true });
Expand Down Expand Up @@ -466,6 +470,7 @@ function copyNativeAssetsAndExtraModules(projectRoot, resolvedOutDir) {
const src = path.join(projectRoot, ...asset.src);
if (!fsSync.existsSync(src)) continue;
const dest = path.join(resolvedOutDir, ...asset.dest);
if (path.resolve(src) === path.resolve(dest)) continue;
fsSync.mkdirSync(path.dirname(dest), { recursive: true });
fsSync.cpSync(src, dest, { recursive: true, force: true });
console.log(`[assembleStandalone] Copied native asset: ${asset.label}`);
Expand All @@ -475,6 +480,7 @@ function copyNativeAssetsAndExtraModules(projectRoot, resolvedOutDir) {
const src = path.join(projectRoot, ...mod.src);
if (!fsSync.existsSync(src)) continue;
const dest = path.join(resolvedOutDir, ...mod.dest);
if (path.resolve(src) === path.resolve(dest)) continue;
fsSync.mkdirSync(path.dirname(dest), { recursive: true });
fsSync.cpSync(src, dest, { recursive: true, force: true });
console.log(`[assembleStandalone] Synced module: ${mod.label}`);
Expand Down
195 changes: 195 additions & 0 deletions src/app/(dashboard)/dashboard/combos/GlobalModelSearchPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import Button from "@/shared/components/Button";
import { hasExactModelStepDuplicate, type ComboBuilderGlobalModelEntry } from "@/lib/combos/builderDraft";

type TranslationFn = {
(key: string, values?: Record<string, unknown>): string;
has?: (key: string) => boolean;
};

const QUICK_PRESETS = ["Opus", "Sonnet", "DeepSeek", "Kimi", "Qwen", "Gemini Pro", "Flash"];

type Props = {
builderSelectionMode: "step" | "global";
onSelectionModeChange: (mode: "step" | "global") => void;
globalSearchQuery: string;
onGlobalSearchQueryChange: (query: string) => void;
filteredGlobalModels: ComboBuilderGlobalModelEntry[];
models: unknown[];
onAddOne: (step: unknown) => void;
onAddAll: () => void;
t: TranslationFn;
};

function getI18nOrFallback(
t: TranslationFn,
key: string,
fallback: string,
values?: Record<string, unknown>
): string {
try {
if (typeof t.has === "function" && t.has(key)) return t(key, values);
} catch {}
return fallback;
}

/**
* The combo builder's "Step by step" / "Global model search" mode toggle plus
* the global-search panel itself (search box, quick presets, add-all, result
* list). Extracted from ComboFormModal (#8285) to keep the mode toggle and
* search UI cohesive and out of the already-frozen page.tsx.
*/
export default function GlobalModelSearchPanel({
builderSelectionMode,
onSelectionModeChange,
globalSearchQuery,
onGlobalSearchQueryChange,
filteredGlobalModels,
models,
onAddOne,
onAddAll,
t,
}: Props) {
return (
<>
<div className="flex items-center gap-1.5 mt-2.5 mb-2 p-1 bg-black/5 dark:bg-white/5 rounded-lg">
<button
type="button"
onClick={() => onSelectionModeChange("step")}
className={`flex-1 py-1.5 px-2 rounded-md text-xs font-medium transition-all text-center flex items-center justify-center gap-1 ${
builderSelectionMode === "step"
? "bg-white dark:bg-white/10 shadow-sm text-primary font-semibold"
: "text-text-muted hover:text-text-main"
}`}
>
<span className="material-symbols-outlined text-[14px]">schema</span>
{getI18nOrFallback(t, "builderModeStep", "Step by step (Provider → Model)")}
</button>
<button
type="button"
onClick={() => onSelectionModeChange("global")}
className={`flex-1 py-1.5 px-2 rounded-md text-xs font-medium transition-all text-center flex items-center justify-center gap-1 ${
builderSelectionMode === "global"
? "bg-white dark:bg-white/10 shadow-sm text-primary font-semibold"
: "text-text-muted hover:text-text-main"
}`}
>
<span className="material-symbols-outlined text-[14px]">search</span>
{getI18nOrFallback(t, "builderModeGlobal", "Global model search (Custom combo)")}
</button>
</div>

{builderSelectionMode === "global" && (
<div className="mt-3 space-y-2.5 rounded-md border border-black/8 dark:border-white/8 bg-white/70 dark:bg-white/[0.03] p-3">
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative flex-1">
<input
type="text"
value={globalSearchQuery}
onChange={(e) => onGlobalSearchQueryChange(e.target.value)}
placeholder={getI18nOrFallback(
t,
"builderGlobalSearchPlaceholder",
"Search models across all providers (e.g. opus, sonnet, deepseek, kimi, qwen)..."
)}
className="w-full text-xs py-2 pl-3 pr-7 rounded border border-black/10 dark:border-white/10 bg-white dark:bg-white/5 text-text-main focus:border-primary focus:outline-none"
/>
{globalSearchQuery && (
<button
type="button"
onClick={() => onGlobalSearchQueryChange("")}
className="absolute right-2.5 top-2 text-text-muted hover:text-text-main text-xs"
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
)}
</div>
{filteredGlobalModels.length > 0 && (
<Button
type="button"
onClick={onAddAll}
variant="secondary"
size="sm"
className="shrink-0 text-xs"
>
<span className="material-symbols-outlined text-[14px] mr-1">playlist_add</span>
{getI18nOrFallback(t, "builderGlobalAddAll", "Add all")} ({filteredGlobalModels.length})
</Button>
)}
</div>

{/* Quick Presets */}
<div className="flex flex-wrap items-center gap-1">
<span className="text-[10px] text-text-muted uppercase font-semibold mr-1">
{getI18nOrFallback(t, "builderGlobalShortcuts", "Shortcuts:")}
</span>
{QUICK_PRESETS.map((preset) => (
<button
key={preset}
type="button"
onClick={() => onGlobalSearchQueryChange(preset.toLowerCase())}
className={`text-[10px] px-2 py-0.5 rounded-full border transition-colors ${
globalSearchQuery.toLowerCase() === preset.toLowerCase()
? "border-primary bg-primary/10 text-primary font-bold"
: "border-black/10 dark:border-white/10 text-text-muted hover:border-primary/40 hover:text-text-main"
}`}
>
{preset}
</button>
))}
</div>

{/* Results List */}
<div className="max-h-[220px] overflow-y-auto rounded border border-black/10 dark:border-white/10 bg-white dark:bg-white/5 divide-y divide-black/5 dark:divide-white/5">
{filteredGlobalModels.length === 0 ? (
<div className="p-4 text-center text-xs text-text-muted">
{getI18nOrFallback(t, "builderGlobalNoResults", `No model found for "${globalSearchQuery}".`, {
query: globalSearchQuery,
})}
</div>
) : (
filteredGlobalModels.map((item) => {
const isAdded = hasExactModelStepDuplicate(models, item.step);
return (
<div
key={`${item.providerId}-${item.modelId}`}
className="flex items-center justify-between px-3 py-2 text-xs hover:bg-black/[0.02] dark:hover:bg-white/[0.03]"
>
<div className="flex flex-col min-w-0 pr-2">
<span className="font-semibold text-text-main truncate">{item.modelName}</span>
<span className="text-[10px] text-text-muted truncate">
{getI18nOrFallback(t, "builderGlobalProviderLabel", "Provider:")}{" "}
<strong className="text-text-main">{item.providerName}</strong> (
{getI18nOrFallback(
t,
"builderGlobalAccountCount",
`${item.connectionCount} account${item.connectionCount === 1 ? "" : "s"}`,
{ count: item.connectionCount }
)}
)
</span>
</div>
<button
type="button"
onClick={() => onAddOne(item.step)}
disabled={isAdded}
className={`text-[11px] px-2.5 py-1 rounded transition-all shrink-0 flex items-center gap-1 ${
isAdded
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 opacity-70 cursor-default"
: "bg-primary/10 text-primary hover:bg-primary/20 font-medium"
}`}
>
<span className="material-symbols-outlined text-[13px]">{isAdded ? "check" : "add"}</span>
{isAdded
? getI18nOrFallback(t, "builderGlobalAdded", "Added")
: getI18nOrFallback(t, "builderGlobalAdd", "Add")}
</button>
</div>
);
})
)}
</div>
</div>
)}
</>
);
}
85 changes: 40 additions & 45 deletions src/app/(dashboard)/dashboard/combos/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import dynamic from "next/dynamic";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import Button from "@/shared/components/Button";
import GlobalModelSearchPanel from "./GlobalModelSearchPanel";
import Card from "@/shared/components/Card";
import { CardSkeleton } from "@/shared/components/Loading";
import EmptyState from "@/shared/components/EmptyState";
Expand All @@ -25,11 +26,15 @@ import { ROUTING_STRATEGIES } from "@/shared/constants/routingStrategies";
import {
COMBO_BUILDER_AUTO_CONNECTION,
COMBO_BUILDER_STAGES,
addAllGlobalSearchMatches,
addGlobalModelStep,
buildGlobalModelList,
buildManualComboModelStep,
buildPrecisionComboModelStep,
canAccessComboBuilderStage,
computeBatchAddModelSteps,
computeBatchDeselectModelSteps,
filterGlobalModelList,
findNextSuggestedConnectionId,
getComboBuilderStageChecks,
getComboBuilderStages,
Expand Down Expand Up @@ -2041,10 +2046,29 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo

const [dragIndex, setDragIndex] = useState(null);
const [dragOverIndex, setDragOverIndex] = useState(null);
const [builderSelectionMode, setBuilderSelectionMode] = useState<"step" | "global">("step");
const [globalSearchQuery, setGlobalSearchQuery] = useState("");

const builderProviders = useMemo(
() => builderOptions.providers || [],
[builderOptions.providers]
);

const allGlobalModels = useMemo(() => buildGlobalModelList(builderProviders), [builderProviders]);

const filteredGlobalModels = useMemo(
() => filterGlobalModelList(allGlobalModels, globalSearchQuery),
[allGlobalModels, globalSearchQuery]
);

const handleAddGlobalModelStep = (step: any) => {
setModels((prev) => addGlobalModelStep(prev, step) as typeof prev);
};

const handleAddAllGlobalSearchMatches = () => {
setModels((prev) => addAllGlobalSearchMatches(prev, filteredGlobalModels) as typeof prev);
};

const builderComboRefs = (builderOptions.comboRefs || []).filter(
(comboRef) => comboRef.name !== combo?.name && comboRef.name !== name.trim()
);
Expand Down Expand Up @@ -3118,52 +3142,21 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
)}
</div>

{isExpertMode && (
<div className="mt-3 rounded-md border border-black/8 dark:border-white/8 bg-white/70 dark:bg-white/[0.03] px-2.5 py-2">
<label className="text-[10px] font-medium uppercase tracking-wide text-text-muted block mb-1">
{getI18nOrFallback(t, "manualModel", "Manual model")}
</label>
<div className="flex flex-col sm:flex-row gap-2">
<input
type="text"
value={manualModelInput}
onChange={(e) => {
setManualModelInput(e.target.value);
setManualModelError("");
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddManualModel();
}
}}
placeholder="provider/model"
data-testid="combo-manual-model-input"
className="flex-1 text-xs py-2 px-2 rounded border border-black/10 dark:border-white/10 bg-white dark:bg-white/5 text-text-main focus:border-primary focus:outline-none font-mono"
/>
<Button
onClick={handleAddManualModel}
size="sm"
disabled={!manualModelInput.trim() || !!manualModelHasDuplicate}
data-testid="combo-manual-model-add"
>
{getI18nOrFallback(t, "addModel", "Add model")}
</Button>
</div>
{(manualModelError || manualModelHasDuplicate) && (
<div className="mt-2 rounded-md border border-amber-500/20 bg-amber-500/10 px-2 py-1.5 text-[10px] text-amber-700 dark:text-amber-300">
{manualModelError ||
getI18nOrFallback(
t,
"builderDuplicateExact",
"This exact provider/model/account step is already in the combo."
)}
</div>
)}
</div>
)}
<GlobalModelSearchPanel
builderSelectionMode={builderSelectionMode}
onSelectionModeChange={setBuilderSelectionMode}
globalSearchQuery={globalSearchQuery}
onGlobalSearchQueryChange={setGlobalSearchQuery}
filteredGlobalModels={filteredGlobalModels}
models={models}
onAddOne={handleAddGlobalModelStep}
onAddAll={handleAddAllGlobalSearchMatches}
t={t}
/>

<div className="grid grid-cols-1 md:grid-cols-3 gap-2 mt-3">
{builderSelectionMode === "step" && (
<>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 mt-3">
<div>
<label className="text-[10px] font-medium uppercase tracking-wide text-text-muted block mb-1">
1. {getI18nOrFallback(t, "builderProvider", "Provider")}
Expand Down Expand Up @@ -3371,6 +3364,8 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
</Button>
</div>
</div>
</>
)}

{builderError && (
<div className="mt-2 rounded-md border border-amber-500/20 bg-amber-500/10 px-2 py-1.5 text-[10px] text-amber-700 dark:text-amber-300">
Expand Down
10 changes: 10 additions & 0 deletions src/i18n/messages/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -3239,6 +3239,16 @@
"routingInputsHint": "تبقى حزمة الوضع والترجيح هنا، وتبقى حالة قاطع الدائرة وقت التشغيل في صفحة الصحة.",
"emailVisibilityHint": "يتبع عرض البريد الإلكتروني للحساب هنا إعداد الخصوصية العام.",
"emailVisibilityTooltip": "استخدم رمز العين لإظهار رسائل البريد الإلكتروني للحسابات أو إخفائها في جميع شاشات المجموعات والمزودين والحصص.",
"builderModeStep": "__MISSING__:Step by step (Provider → Model)",
"builderModeGlobal": "__MISSING__:Global model search (Custom combo)",
"builderGlobalSearchPlaceholder": "__MISSING__:Search models across all providers (e.g. opus, sonnet, deepseek, kimi, qwen)...",
"builderGlobalAddAll": "__MISSING__:Add all",
"builderGlobalShortcuts": "__MISSING__:Shortcuts:",
"builderGlobalNoResults": "__MISSING__:No model found for \"{query}\".",
"builderGlobalProviderLabel": "__MISSING__:Provider:",
"builderGlobalAccountCount": "__MISSING__:{count, plural, one {# account} other {# accounts}}",
"builderGlobalAdded": "__MISSING__:Added",
"builderGlobalAdd": "__MISSING__:Add",
"manualModel": "نموذج يدوي",
"manualModelInvalid": "أدخل النموذج بالصيغة provider/model.",
"manualModelUnknownProvider": "بادئة المزود غير معروفة.",
Expand Down
10 changes: 10 additions & 0 deletions src/i18n/messages/az.json
Original file line number Diff line number Diff line change
Expand Up @@ -3239,6 +3239,16 @@
"routingInputsHint": "Rejim paketi və çəki dərəcəsi burada qalır; breaker iş vaxtı vəziyyəti Sağlamlıq səhifəsində qalır.",
"emailVisibilityHint": "Buradakı hesab e-poçtları qlobal məxfilik keçidini izləyir.",
"emailVisibilityTooltip": "Kombolar, provayderlər və kvota ekranlarında hesab e-poçtlarını qlobal olaraq göstərmək və ya gizlətmək üçün göz ikonundan istifadə edin.",
"builderModeStep": "__MISSING__:Step by step (Provider → Model)",
"builderModeGlobal": "__MISSING__:Global model search (Custom combo)",
"builderGlobalSearchPlaceholder": "__MISSING__:Search models across all providers (e.g. opus, sonnet, deepseek, kimi, qwen)...",
"builderGlobalAddAll": "__MISSING__:Add all",
"builderGlobalShortcuts": "__MISSING__:Shortcuts:",
"builderGlobalNoResults": "__MISSING__:No model found for \"{query}\".",
"builderGlobalProviderLabel": "__MISSING__:Provider:",
"builderGlobalAccountCount": "__MISSING__:{count, plural, one {# account} other {# accounts}}",
"builderGlobalAdded": "__MISSING__:Added",
"builderGlobalAdd": "__MISSING__:Add",
"manualModel": "Mexniki model",
"manualModelInvalid": "Modeli provayder/model kimi daxil edin.",
"manualModelUnknownProvider": "Naməlum provayder prefiksi.",
Expand Down
Loading