File size: 11,756 Bytes
4a940a5
b7d4394
c52388f
7824fcd
b7d4394
 
 
 
 
 
 
7824fcd
 
 
5416ffb
 
b7d4394
 
7824fcd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5416ffb
 
7824fcd
b7d4394
 
 
 
 
c52388f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7824fcd
 
 
 
c52388f
7824fcd
 
 
 
 
 
 
 
 
 
 
 
 
 
b7d4394
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7824fcd
b7d4394
 
7824fcd
c52388f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7824fcd
c52388f
7824fcd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5416ffb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b7d4394
7824fcd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b7d4394
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
import { useT } from "../../../shared/i18n/context";
import { CopyButton } from "./CopyButton";
import { useCallback, useState, useEffect, useRef } from "preact/hooks";
import type { ModelFamily } from "../../../shared/hooks/use-status";

interface ApiConfigProps {
  baseUrl: string;
  apiKey: string;
  models: string[];
  selectedModel: string;
  onModelChange: (model: string) => void;
  modelFamilies: ModelFamily[];
  selectedEffort: string;
  onEffortChange: (effort: string) => void;
  selectedSpeed: string | null;
  onSpeedChange: (speed: string | null) => void;
}

const EFFORT_LABELS: Record<string, string> = {
  none: "None",
  minimal: "Minimal",
  low: "Low",
  medium: "Medium",
  high: "High",
  xhigh: "XHigh",
};

export function ApiConfig({
  baseUrl,
  apiKey,
  models,
  selectedModel,
  onModelChange,
  modelFamilies,
  selectedEffort,
  onEffortChange,
  selectedSpeed,
  onSpeedChange,
}: ApiConfigProps) {
  const t = useT();

  const getBaseUrl = useCallback(() => baseUrl, [baseUrl]);
  const getApiKey = useCallback(() => apiKey, [apiKey]);

  const [open, setOpen] = useState(false);
  const dropdownRef = useRef<HTMLDivElement>(null);

  // Close dropdown on click outside
  useEffect(() => {
    if (!open) return;
    const handler = (e: MouseEvent) => {
      if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
        setOpen(false);
      }
    };
    document.addEventListener("mousedown", handler);
    return () => document.removeEventListener("mousedown", handler);
  }, [open]);

  // When a family is selected, update model + snap effort to default if current effort is unsupported
  const handleFamilySelect = useCallback(
    (family: ModelFamily) => {
      onModelChange(family.id);
      setOpen(false);
      const supportedEfforts = family.efforts.map((e) => e.reasoningEffort);
      if (!supportedEfforts.includes(selectedEffort)) {
        onEffortChange(family.defaultEffort);
      }
    },
    [onModelChange, onEffortChange, selectedEffort],
  );

  // Find the currently selected family's supported efforts
  const currentFamily = modelFamilies.find((f) => f.id === selectedModel);
  const currentEfforts = currentFamily?.efforts ?? [];

  const showMatrix = modelFamilies.length > 0;

  return (
    <section class="bg-white dark:bg-card-dark border border-gray-200 dark:border-border-dark rounded-xl p-5 shadow-sm transition-colors">
      <div class="flex items-center justify-between mb-6 border-b border-slate-100 dark:border-border-dark pb-4">
        <div class="flex items-center gap-2">
          <svg class="size-5 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
            <path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z" />
            <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
          </svg>
          <h2 class="text-[0.95rem] font-bold">{t("apiConfig")}</h2>
        </div>
      </div>
      <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
        {/* Base URL */}
        <div class="space-y-1.5">
          <label class="text-xs font-semibold text-slate-700 dark:text-text-main">{t("baseProxyUrl")}</label>
          <div class="relative flex items-center">
            <input
              class="w-full pl-3 pr-10 py-2.5 bg-slate-100 dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-[0.78rem] font-mono text-slate-500 dark:text-text-dim outline-none cursor-default select-all"
              type="text"
              value={baseUrl}
              readOnly
            />
            <CopyButton getText={getBaseUrl} class="absolute right-2" titleKey="copyUrl" />
          </div>
        </div>
        {/* Model selector — matrix or flat fallback */}
        <div class="space-y-1.5">
          <label class="text-xs font-semibold text-slate-700 dark:text-text-main">{t("defaultModel")}</label>
          {showMatrix ? (
            <div ref={dropdownRef} class="relative">
              {/* Trigger button */}
              <button
                onClick={() => setOpen(!open)}
                class="w-full flex items-center justify-between px-3 py-2.5 bg-white dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-[0.78rem] text-slate-700 dark:text-text-main font-medium focus:ring-1 focus:ring-primary focus:border-primary outline-none cursor-pointer transition-colors"
              >
                <span>{currentFamily?.displayName ?? selectedModel}</span>
                <svg class={`size-[18px] text-slate-500 dark:text-text-dim transition-transform ${open ? "rotate-180" : ""}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                  <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
                </svg>
              </button>
              {/* Dropdown list */}
              {open && (
                <div class="absolute z-10 mt-1 w-full border border-gray-200 dark:border-border-dark rounded-lg overflow-hidden bg-white dark:bg-card-dark shadow-lg">
                  <div class="max-h-[200px] overflow-y-auto">
                    {modelFamilies.map((f) => (
                      <button
                        key={f.id}
                        onClick={() => handleFamilySelect(f)}
                        class={`w-full text-left px-3 py-2 text-[0.78rem] font-medium border-b border-gray-100 dark:border-border-dark last:border-b-0 transition-colors ${
                          selectedModel === f.id
                            ? "bg-primary/10 text-primary dark:bg-primary/20"
                            : "text-slate-700 dark:text-text-main hover:bg-slate-50 dark:hover:bg-[#21262d]"
                        }`}
                      >
                        {f.displayName}
                      </button>
                    ))}
                  </div>
                </div>
              )}
              {/* Reasoning effort buttons — always visible */}
              {currentEfforts.length > 1 && (
                <div class="flex gap-1.5 mt-2 flex-wrap">
                  {currentEfforts.map((e) => (
                    <button
                      key={e.reasoningEffort}
                      onClick={() => onEffortChange(e.reasoningEffort)}
                      title={e.description}
                      class={`px-2.5 py-1 text-[0.7rem] font-semibold rounded transition-all ${
                        selectedEffort === e.reasoningEffort
                          ? "bg-primary text-white shadow-sm"
                          : "bg-white dark:bg-[#21262d] text-slate-600 dark:text-text-dim border border-gray-200 dark:border-border-dark hover:border-primary/50"
                      }`}
                    >
                      {EFFORT_LABELS[e.reasoningEffort] ?? e.reasoningEffort}
                    </button>
                  ))}
                </div>
              )}
              {/* Speed toggle — Standard / Fast */}
              <div class="flex items-center gap-1.5 mt-2">
                <span class="text-[0.68rem] font-medium text-slate-500 dark:text-text-dim mr-1">{t("speed")}</span>
                <button
                  onClick={() => onSpeedChange(null)}
                  class={`px-2.5 py-1 text-[0.7rem] font-semibold rounded transition-all ${
                    selectedSpeed === null
                      ? "bg-primary text-white shadow-sm"
                      : "bg-white dark:bg-[#21262d] text-slate-600 dark:text-text-dim border border-gray-200 dark:border-border-dark hover:border-primary/50"
                  }`}
                >
                  {t("speedStandard")}
                </button>
                <button
                  onClick={() => onSpeedChange("fast")}
                  class={`px-2.5 py-1 text-[0.7rem] font-semibold rounded transition-all ${
                    selectedSpeed === "fast"
                      ? "bg-primary text-white shadow-sm"
                      : "bg-white dark:bg-[#21262d] text-slate-600 dark:text-text-dim border border-gray-200 dark:border-border-dark hover:border-primary/50"
                  }`}
                >
                  {t("speedFast")}
                </button>
              </div>
            </div>
          ) : (
            <div class="relative">
              <select
                class="w-full appearance-none pl-3 pr-10 py-2.5 bg-white dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-[0.78rem] text-slate-700 dark:text-text-main font-medium focus:ring-1 focus:ring-primary focus:border-primary outline-none cursor-pointer transition-colors"
                value={selectedModel}
                onChange={(e) => onModelChange((e.target as HTMLSelectElement).value)}
              >
                {models.map((m) => (
                  <option key={m} value={m}>{m}</option>
                ))}
              </select>
              <div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-slate-500 dark:text-text-dim">
                <svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
                  <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
                </svg>
              </div>
            </div>
          )}
        </div>
        {/* API Key */}
        <div class="space-y-1.5 md:col-span-2">
          <label class="text-xs font-semibold text-slate-700 dark:text-text-main">{t("yourApiKey")}</label>
          <div class="relative flex items-center">
            <div class="absolute left-3 text-slate-400 dark:text-text-dim">
              <svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
                <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
              </svg>
            </div>
            <input
              class="w-full pl-10 pr-10 py-2.5 bg-slate-100 dark:bg-bg-dark border border-gray-200 dark:border-border-dark rounded-lg text-[0.78rem] font-mono text-slate-500 dark:text-text-dim outline-none cursor-default select-all tracking-wider"
              type="text"
              value={apiKey}
              readOnly
            />
            <CopyButton getText={getApiKey} class="absolute right-2" titleKey="copyApiKey" />
          </div>
          <p class="text-xs text-slate-400 dark:text-text-dim mt-1">{t("apiKeyHint")}</p>
        </div>
      </div>
    </section>
  );
}