@raingor/pi-web-switch 0.7.1 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,222 @@
1
+ import { useState, useEffect, useCallback, useRef } from "react";
2
+ import { Search, Plus, Check, Loader2, ExternalLink, Sparkles } from "lucide-react";
3
+ import { Modal } from "@/components/ui/Modal";
4
+ import { useTranslation } from "@/lib/i18n";
5
+ import { RECOMMENDED_PACKAGES } from "@/data/recommended-packages";
6
+
7
+ interface PackageSearchResult {
8
+ name: string;
9
+ description: string;
10
+ version: string;
11
+ downloads: number;
12
+ link: string;
13
+ }
14
+
15
+ type PackageFilter = "all" | "installed" | "available";
16
+ type Tab = "recommended" | "search";
17
+
18
+ interface PackageBrowserProps {
19
+ open: boolean;
20
+ onClose: () => void;
21
+ installed: Set<string>; // ids like "npm:pkg-name"
22
+ onInstall: (id: string) => void;
23
+ }
24
+
25
+ /** One package row with an install / installed control. */
26
+ function PackageRow({
27
+ name,
28
+ description,
29
+ link,
30
+ installed,
31
+ onInstall,
32
+ }: {
33
+ name: string;
34
+ description: string;
35
+ link?: string;
36
+ installed: boolean;
37
+ onInstall: () => void;
38
+ }) {
39
+ const { t } = useTranslation();
40
+ return (
41
+ <div
42
+ className="flex items-center gap-3 rounded-lg border px-3 py-2"
43
+ style={{ borderColor: "var(--card-border)", backgroundColor: "var(--card-bg)" }}
44
+ >
45
+ <div className="min-w-0 flex-1">
46
+ <div className="flex items-center gap-2">
47
+ <span className="truncate text-sm font-medium" style={{ color: "var(--page-text)" }}>{name}</span>
48
+ <a
49
+ href={link ?? `https://www.npmjs.com/package/${name}`}
50
+ target="_blank"
51
+ rel="noreferrer"
52
+ className="shrink-0"
53
+ style={{ color: "var(--subtle-text)" }}
54
+ title={t("settings.view_on_npm")}
55
+ >
56
+ <ExternalLink className="h-3 w-3" />
57
+ </a>
58
+ </div>
59
+ {description && (
60
+ <p className="truncate text-xs" style={{ color: "var(--muted-text)" }}>{description}</p>
61
+ )}
62
+ </div>
63
+ {installed ? (
64
+ <span className="flex shrink-0 items-center gap-1 rounded-lg border px-3 py-1.5 text-xs font-medium"
65
+ style={{ borderColor: "rgba(16,185,129,0.4)", color: "#10b981" }}>
66
+ <Check className="h-3.5 w-3.5" />
67
+ {t("settings.installed")}
68
+ </span>
69
+ ) : (
70
+ <button
71
+ onClick={onInstall}
72
+ className="flex shrink-0 items-center gap-1 rounded-lg border border-blue-600/50 bg-blue-600/10 px-3 py-1.5 text-xs font-medium text-blue-400 hover:bg-blue-600/20"
73
+ >
74
+ <Plus className="h-3.5 w-3.5" />
75
+ {t("settings.install")}
76
+ </button>
77
+ )}
78
+ </div>
79
+ );
80
+ }
81
+
82
+ /**
83
+ * Package browser modal with two tabs:
84
+ * - Recommended: the curated list from src/data/recommended-packages.ts
85
+ * - Search: fuzzy npm-registry search for any pi package
86
+ * Installing writes "npm:<name>" into settings.packages.
87
+ */
88
+ export function PackageBrowser({ open, onClose, installed, onInstall }: PackageBrowserProps) {
89
+ const { t } = useTranslation();
90
+ const [tab, setTab] = useState<Tab>("recommended");
91
+ const [query, setQuery] = useState("");
92
+ const [results, setResults] = useState<PackageSearchResult[]>([]);
93
+ const [loading, setLoading] = useState(false);
94
+ const [justAdded, setJustAdded] = useState<Set<string>>(new Set());
95
+ const [filter, setFilter] = useState<PackageFilter>("all");
96
+ const debounceRef = useRef<number | undefined>(undefined);
97
+
98
+ const runSearch = useCallback((q: string) => {
99
+ setLoading(true);
100
+ fetch(`/api/pi/packages/search?q=${encodeURIComponent(q)}`)
101
+ .then((r) => r.json())
102
+ .then((d: { results?: PackageSearchResult[] }) => setResults(d.results ?? []))
103
+ .catch(() => setResults([]))
104
+ .finally(() => setLoading(false));
105
+ }, []);
106
+
107
+ // Load popular packages the first time the Search tab is opened; debounce typing.
108
+ useEffect(() => {
109
+ if (!open || tab !== "search") return;
110
+ window.clearTimeout(debounceRef.current);
111
+ debounceRef.current = window.setTimeout(() => runSearch(query), 350);
112
+ return () => window.clearTimeout(debounceRef.current);
113
+ }, [query, open, tab, runSearch]);
114
+
115
+ const isInstalledId = (name: string) => {
116
+ const id = `npm:${name}`;
117
+ return installed.has(id) || justAdded.has(id);
118
+ };
119
+
120
+ const handleInstall = (id: string) => {
121
+ onInstall(id);
122
+ setJustAdded((prev) => new Set(prev).add(id));
123
+ };
124
+
125
+ // Search-tab results after the installed/available filter.
126
+ const filtered = results.filter((pkg) => {
127
+ if (filter === "installed") return isInstalledId(pkg.name);
128
+ if (filter === "available") return !isInstalledId(pkg.name);
129
+ return true;
130
+ });
131
+ const installedCount = results.filter((p) => isInstalledId(p.name)).length;
132
+
133
+ return (
134
+ <Modal open={open} onClose={onClose} title={t("settings.browse_packages")} size="lg">
135
+ <div className="space-y-4">
136
+ {/* Tabs */}
137
+ <div className="flex gap-1 rounded-lg p-1" style={{ backgroundColor: "var(--card-bg)" }}>
138
+ {(["recommended", "search"] as Tab[]).map((tKey) => (
139
+ <button
140
+ key={tKey}
141
+ onClick={() => setTab(tKey)}
142
+ className={`flex flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${tab === tKey ? "bg-blue-600/15 text-blue-400" : "text-gray-400 hover:text-gray-200"}`}
143
+ >
144
+ {tKey === "recommended" ? <Sparkles className="h-3.5 w-3.5" /> : <Search className="h-3.5 w-3.5" />}
145
+ {t(`settings.tab_${tKey}`)}
146
+ </button>
147
+ ))}
148
+ </div>
149
+
150
+ {tab === "recommended" ? (
151
+ <div className="max-h-[55vh] space-y-1.5 overflow-y-auto">
152
+ {RECOMMENDED_PACKAGES.map((pkg) => (
153
+ <PackageRow
154
+ key={pkg.id}
155
+ name={pkg.name}
156
+ description={t(pkg.descKey)}
157
+ installed={installed.has(pkg.id) || justAdded.has(pkg.id)}
158
+ onInstall={() => handleInstall(pkg.id)}
159
+ />
160
+ ))}
161
+ </div>
162
+ ) : (
163
+ <>
164
+ {/* Search box */}
165
+ <div className="relative">
166
+ <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" style={{ color: "var(--subtle-text)" }} />
167
+ <input
168
+ type="text"
169
+ autoFocus
170
+ value={query}
171
+ onChange={(e) => setQuery(e.target.value)}
172
+ placeholder={t("settings.search_packages_placeholder")}
173
+ className="w-full rounded-lg border py-2 pl-9 pr-3 text-sm outline-none focus:ring-1 focus:ring-blue-500"
174
+ style={{ backgroundColor: "var(--card-bg)", borderColor: "var(--card-border)", color: "var(--page-text)" }}
175
+ />
176
+ </div>
177
+
178
+ {/* Installed / available filter */}
179
+ <div className="flex gap-1 rounded-lg p-1" style={{ backgroundColor: "var(--card-bg)" }}>
180
+ {(["all", "available", "installed"] as PackageFilter[]).map((f) => {
181
+ const count = f === "all" ? results.length : f === "installed" ? installedCount : results.length - installedCount;
182
+ return (
183
+ <button
184
+ key={f}
185
+ onClick={() => setFilter(f)}
186
+ className={`flex-1 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${filter === f ? "bg-blue-600/15 text-blue-400" : "text-gray-400 hover:text-gray-200"}`}
187
+ >
188
+ {t(`settings.filter_${f}`)} ({count})
189
+ </button>
190
+ );
191
+ })}
192
+ </div>
193
+
194
+ {/* Results */}
195
+ <div className="max-h-[42vh] space-y-1.5 overflow-y-auto">
196
+ {loading && results.length === 0 ? (
197
+ <div className="flex items-center justify-center py-10">
198
+ <Loader2 className="h-5 w-5 animate-spin" style={{ color: "var(--muted-text)" }} />
199
+ </div>
200
+ ) : filtered.length === 0 ? (
201
+ <p className="py-10 text-center text-sm" style={{ color: "var(--muted-text)" }}>
202
+ {t("settings.no_packages_found")}
203
+ </p>
204
+ ) : (
205
+ filtered.map((pkg) => (
206
+ <PackageRow
207
+ key={pkg.name}
208
+ name={pkg.name}
209
+ description={pkg.description}
210
+ link={pkg.link}
211
+ installed={isInstalledId(pkg.name)}
212
+ onInstall={() => handleInstall(`npm:${pkg.name}`)}
213
+ />
214
+ ))
215
+ )}
216
+ </div>
217
+ </>
218
+ )}
219
+ </div>
220
+ </Modal>
221
+ );
222
+ }
@@ -11,6 +11,8 @@ import {
11
11
  } from "@/lib/config";
12
12
  import type { PiConfig, UpdateCheckResult } from "@/types";
13
13
  import { cn } from "@/lib/utils";
14
+ import { RECOMMENDED_PACKAGES } from "@/data/recommended-packages";
15
+ import { PackageBrowser } from "./PackageBrowser";
14
16
  import {
15
17
  Download,
16
18
  Upload,
@@ -89,7 +91,6 @@ export function SettingsPage() {
89
91
  auth,
90
92
  modelsJson,
91
93
  allProviders,
92
- allModels,
93
94
  updateSettings,
94
95
  setTheme,
95
96
  addPackage,
@@ -102,6 +103,7 @@ export function SettingsPage() {
102
103
  const [newPackage, setNewPackage] = useState("");
103
104
  const [importError, setImportError] = useState("");
104
105
  const [showResetConfirm, setShowResetConfirm] = useState(false);
106
+ const [showPackageBrowser, setShowPackageBrowser] = useState(false);
105
107
  const [fontSize, setFontSize] = useState(() => {
106
108
  const saved = Number(localStorage.getItem(FONT_SIZE_KEY));
107
109
  return saved >= 12 && saved <= 24 ? saved : 16;
@@ -132,10 +134,16 @@ export function SettingsPage() {
132
134
  }
133
135
  };
134
136
 
135
- // One-click update: npm install <name>@latest for every updatable extension,
136
- // then re-check so the list reflects the new installed versions.
137
+ // Names of everything that can be updated: pi core (routed to `pi update`
138
+ // server-side) plus every outdated extension.
139
+ const updatableNames = [
140
+ ...(updateResult?.pi?.hasUpdate ? [updateResult.pi.name] : []),
141
+ ...(updateResult?.extensions ?? []).filter((e) => e.hasUpdate).map((e) => e.name),
142
+ ];
143
+
144
+ // One-click update, then re-check so the list reflects the new installed versions.
137
145
  const handleApplyUpdates = async () => {
138
- const names = (updateResult?.extensions ?? []).filter((e) => e.hasUpdate).map((e) => e.name);
146
+ const names = updatableNames;
139
147
  if (names.length === 0) return;
140
148
  setApplying(true);
141
149
  setApplyMessage(null);
@@ -221,7 +229,18 @@ export function SettingsPage() {
221
229
  // ── Default model select: composite `providerId/modelId` values, but the
222
230
  // settings file stores the bare model id (that's what pi expects on disk).
223
231
  const modelValue = (providerId: string, modelId: string) => `${providerId}/${modelId}`;
224
- const modelProviders = allProviders.filter((p) => p.models.length > 0);
232
+ // Only providers that are actually usable belong in the defaults lists: a saved
233
+ // API key (auth.json / models.json override) or a custom provider. A stale
234
+ // saved value is kept so the selects don't render blank.
235
+ const providerOptions = allProviders.filter(
236
+ (p) =>
237
+ p.type === "custom" ||
238
+ p.hasAuth ||
239
+ !!p.apiKey ||
240
+ !!auth?.[p.id]?.key ||
241
+ p.id === settings?.defaultProvider
242
+ );
243
+ const modelProviders = providerOptions.filter((p) => p.models.length > 0);
225
244
  const scopedId = settings?.defaultProvider;
226
245
  const groupedModelOptions = scopedId
227
246
  ? [...modelProviders.filter((p) => p.id === scopedId), ...modelProviders.filter((p) => p.id !== scopedId)]
@@ -262,13 +281,6 @@ export function SettingsPage() {
262
281
  updateSettings(clearModel ? { defaultProvider: v, defaultModel: undefined } : { defaultProvider: v || undefined });
263
282
  };
264
283
 
265
- const enabledModels = settings?.enabledModels ?? [];
266
- const themeLabelKey: Record<string, string> = {
267
- light: "settings.light",
268
- dark: "settings.dark",
269
- "light/dark": "settings.system",
270
- };
271
-
272
284
  const tabs: { key: SettingsTab; icon: typeof Palette; label: string }[] = [
273
285
  { key: "appearance", icon: Palette, label: t("settings.appearance") },
274
286
  { key: "models", icon: LayoutGrid, label: t("settings.tab_models") },
@@ -287,25 +299,6 @@ export function SettingsPage() {
287
299
  </div>
288
300
  <h1 className="mt-1 text-2xl font-bold text-white">{t("settings.title")}</h1>
289
301
  <p className="mt-1 text-sm text-gray-400">{t("settings.subtitle")}</p>
290
- <div className="mt-4 grid max-w-lg grid-cols-3 gap-3">
291
- <div className="rounded-xl border border-gray-800 bg-gray-900/50 px-4 py-3">
292
- <div className="text-xl font-bold text-white">{allProviders.length}</div>
293
- <div className="text-xs text-gray-500">{t("settings.stat_providers")}</div>
294
- </div>
295
- <div className="rounded-xl border border-gray-800 bg-gray-900/50 px-4 py-3">
296
- <div className="text-xl font-bold text-white">
297
- {enabledModels.length}
298
- <small className="ml-0.5 text-xs font-normal text-gray-500">/ {allModels.length}</small>
299
- </div>
300
- <div className="text-xs text-gray-500">{t("settings.stat_enabled")}</div>
301
- </div>
302
- <div className="rounded-xl border border-gray-800 bg-gray-900/50 px-4 py-3">
303
- <div className="pt-1 text-sm font-bold text-white">
304
- {t(themeLabelKey[settings?.theme ?? "light/dark"] ?? "settings.system")}
305
- </div>
306
- <div className="mt-0.5 text-xs text-gray-500">{t("settings.stat_theme")}</div>
307
- </div>
308
- </div>
309
302
  </header>
310
303
 
311
304
  {/* ── Tab nav ──────────────────────────────────────── */}
@@ -428,7 +421,7 @@ export function SettingsPage() {
428
421
  className={cn(selectCls, "w-56")}
429
422
  >
430
423
  <option value="">{t("settings.none")}</option>
431
- {allProviders.map((p) => (
424
+ {providerOptions.map((p) => (
432
425
  <option key={p.id} value={p.id}>{p.name}</option>
433
426
  ))}
434
427
  </select>
@@ -490,7 +483,7 @@ export function SettingsPage() {
490
483
  <RefreshCw className={cn("h-4 w-4", checkingUpdates && "animate-spin")} />
491
484
  {t("settings.check_updates")}
492
485
  </button>
493
- {(updateResult?.extensions ?? []).some((e) => e.hasUpdate) && (
486
+ {updatableNames.length > 0 && (
494
487
  <button
495
488
  onClick={handleApplyUpdates}
496
489
  disabled={applying || checkingUpdates}
@@ -569,6 +562,15 @@ export function SettingsPage() {
569
562
  </Card>
570
563
 
571
564
  <Card icon={Package} title={t("settings.packages")}>
565
+ <div className="mb-4">
566
+ <button
567
+ onClick={() => setShowPackageBrowser(true)}
568
+ className="flex items-center gap-1.5 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500"
569
+ >
570
+ <Plus className="h-4 w-4" />
571
+ {t("settings.browse_packages")}
572
+ </button>
573
+ </div>
572
574
  {(settings?.packages ?? []).length > 0 && (
573
575
  <div className="mb-4 flex flex-wrap gap-1.5">
574
576
  {(settings?.packages ?? []).map((pkg) => (
@@ -590,33 +592,84 @@ export function SettingsPage() {
590
592
  {(settings?.packages ?? []).length === 0 && (
591
593
  <p className="mb-4 text-sm text-gray-500">{t("settings.no_packages")}</p>
592
594
  )}
593
- <div className="flex max-w-md gap-2">
594
- <input
595
- type="text"
596
- value={newPackage}
597
- onChange={(e) => setNewPackage(e.target.value)}
598
- placeholder={t("settings.package_placeholder")}
599
- className="flex-1 rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-white placeholder-gray-500"
600
- onKeyDown={(e) => {
601
- if (e.key === "Enter" && newPackage.trim()) {
602
- addPackage(newPackage.trim());
603
- setNewPackage("");
604
- }
605
- }}
606
- />
607
- <button
608
- onClick={() => {
609
- if (newPackage.trim()) {
610
- addPackage(newPackage.trim());
611
- setNewPackage("");
612
- }
613
- }}
614
- className="flex items-center gap-1 rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-300 hover:bg-gray-700"
615
- >
616
- <Plus className="h-3.5 w-3.5" />
617
- {t("settings.add")}
618
- </button>
619
- </div>
595
+
596
+ {/* Recommended packages — one-click install */}
597
+ {(() => {
598
+ const installed = new Set(settings?.packages ?? []);
599
+ const recommended = RECOMMENDED_PACKAGES.filter((p) => !installed.has(p.id));
600
+ if (recommended.length === 0) return null;
601
+ return (
602
+ <div className="mb-4">
603
+ <p className="mb-2 text-xs font-medium" style={{ color: "var(--muted-text)" }}>
604
+ {t("settings.recommended_packages")}
605
+ </p>
606
+ <div className="space-y-1.5">
607
+ {recommended.map((pkg) => (
608
+ <div
609
+ key={pkg.id}
610
+ className="flex items-center gap-3 rounded-lg border border-gray-700 bg-gray-800/50 px-3 py-2"
611
+ >
612
+ <div className="min-w-0 flex-1">
613
+ <p className="truncate text-sm text-gray-200">{pkg.name}</p>
614
+ <p className="truncate text-xs text-gray-500">{t(pkg.descKey)}</p>
615
+ </div>
616
+ <button
617
+ onClick={() => addPackage(pkg.id)}
618
+ className="flex shrink-0 items-center gap-1 rounded-lg border border-blue-600/50 bg-blue-600/10 px-3 py-1.5 text-xs font-medium text-blue-400 hover:bg-blue-600/20"
619
+ >
620
+ <Plus className="h-3.5 w-3.5" />
621
+ {t("settings.install")}
622
+ </button>
623
+ </div>
624
+ ))}
625
+ </div>
626
+ <button
627
+ onClick={() => {
628
+ const list = settings?.packages ?? [];
629
+ const toAdd = recommended.map((p) => p.id);
630
+ updateSettings({ packages: [...list, ...toAdd] });
631
+ }}
632
+ className="mt-2 flex items-center gap-1.5 rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-xs text-gray-300 hover:bg-gray-700"
633
+ >
634
+ <Plus className="h-3.5 w-3.5" />
635
+ {t("settings.install_all_recommended")}
636
+ </button>
637
+ </div>
638
+ );
639
+ })()}
640
+
641
+ <details className="mb-1">
642
+ <summary className="cursor-pointer text-xs text-gray-500 hover:text-gray-300">
643
+ {t("settings.add_custom_package")}
644
+ </summary>
645
+ <div className="mt-2 flex max-w-md gap-2">
646
+ <input
647
+ type="text"
648
+ value={newPackage}
649
+ onChange={(e) => setNewPackage(e.target.value)}
650
+ placeholder={t("settings.package_placeholder")}
651
+ className="flex-1 rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-white placeholder-gray-500"
652
+ onKeyDown={(e) => {
653
+ if (e.key === "Enter" && newPackage.trim()) {
654
+ addPackage(newPackage.trim());
655
+ setNewPackage("");
656
+ }
657
+ }}
658
+ />
659
+ <button
660
+ onClick={() => {
661
+ if (newPackage.trim()) {
662
+ addPackage(newPackage.trim());
663
+ setNewPackage("");
664
+ }
665
+ }}
666
+ className="flex items-center gap-1 rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-300 hover:bg-gray-700"
667
+ >
668
+ <Plus className="h-3.5 w-3.5" />
669
+ {t("settings.add")}
670
+ </button>
671
+ </div>
672
+ </details>
620
673
  </Card>
621
674
 
622
675
  <Card icon={Download} title={t("settings.import_export")}>
@@ -655,6 +708,13 @@ export function SettingsPage() {
655
708
  </div>
656
709
  )}
657
710
 
711
+ <PackageBrowser
712
+ open={showPackageBrowser}
713
+ onClose={() => setShowPackageBrowser(false)}
714
+ installed={new Set(settings?.packages ?? [])}
715
+ onInstall={(id) => addPackage(id)}
716
+ />
717
+
658
718
  {/* Reset Confirm */}
659
719
  <Modal
660
720
  open={showResetConfirm}