@raingor/pi-web-switch 0.3.1 → 0.3.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.
- package/README.md +0 -1
- package/README.zh-CN.md +0 -1
- package/package.json +1 -1
- package/server/pi-reader.ts +403 -4
- package/src/App.tsx +3 -0
- package/src/components/dashboard/DashboardPage.tsx +275 -117
- package/src/components/layout/Sidebar.tsx +2 -0
- package/src/components/providers/ProvidersModelsPage.tsx +1387 -0
- package/src/components/sessions/MemoryPage.tsx +480 -85
- package/src/components/sessions/SessionsPage.tsx +473 -89
- package/src/components/settings/SettingsPage.tsx +555 -243
- package/src/data/builtin-providers.ts +0 -12
- package/src/data/mock-config.ts +1 -15
- package/src/data/mock-usage.ts +0 -2
- package/src/lib/translations/en.ts +114 -23
- package/src/lib/translations/ja.ts +114 -23
- package/src/lib/translations/zh-CN.ts +114 -23
- package/src/lib/translations/zh-TW.ts +114 -23
- package/src/main.tsx +9 -0
- package/src/store/config-store.ts +18 -12
- package/src/types/index.ts +14 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vite.config.ts +115 -3
- package/src/components/models/ModelsPage.tsx +0 -570
- package/src/components/providers/ProvidersPage.tsx +0 -466
package/README.md
CHANGED
|
@@ -100,7 +100,6 @@ The app ships with definitions for **11 built-in providers** and **26 models** (
|
|
|
100
100
|
| DeepSeek | DeepSeek V3, DeepSeek R1 |
|
|
101
101
|
| OpenCode | DeepSeek V4 Flash (Free), DeepSeek V4 Flash |
|
|
102
102
|
| OpenCode Go | DeepSeek V4 Flash, V4 Pro, GLM 5.1, Qwen 3.7 Max, MiMo V2.5 |
|
|
103
|
-
| SenseNova | GLM 5.2, DeepSeek V4 Flash |
|
|
104
103
|
| Google Gemini | Gemini 2.5 Flash, Gemini 2.5 Pro |
|
|
105
104
|
| OpenRouter | Claude Sonnet 4, DeepSeek R1 |
|
|
106
105
|
| Mistral | Mistral Large |
|
package/README.zh-CN.md
CHANGED
|
@@ -99,7 +99,6 @@
|
|
|
99
99
|
| DeepSeek | DeepSeek V3, DeepSeek R1 |
|
|
100
100
|
| OpenCode | DeepSeek V4 Flash (免费), DeepSeek V4 Flash |
|
|
101
101
|
| OpenCode Go | DeepSeek V4 Flash, V4 Pro, GLM 5.1, Qwen 3.7 Max, MiMo V2.5 |
|
|
102
|
-
| SenseNova | GLM 5.2, DeepSeek V4 Flash |
|
|
103
102
|
| Google Gemini | Gemini 2.5 Flash, Gemini 2.5 Pro |
|
|
104
103
|
| OpenRouter | Claude Sonnet 4, DeepSeek R1 |
|
|
105
104
|
| Mistral | Mistral Large |
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raingor/pi-web-switch",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.2",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "pi-package/index.ts",
|
|
7
7
|
"description": "Web UI for pi coding agent — live configuration management, session browser, and memory viewer",
|
package/server/pi-reader.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { readFileSync, readdirSync, existsSync, statSync, unlinkSync, writeFileSync } from "fs";
|
|
1
|
+
import { readFileSync, readdirSync, existsSync, statSync, unlinkSync, writeFileSync, mkdirSync, renameSync } from "fs";
|
|
2
2
|
import { homedir } from "os";
|
|
3
|
-
import { join, resolve } from "path";
|
|
3
|
+
import { join, resolve, dirname, relative, sep } from "path";
|
|
4
|
+
import { spawnSync } from "child_process";
|
|
4
5
|
|
|
5
6
|
const PI_DIR = join(homedir(), ".pi", "agent");
|
|
6
7
|
|
|
@@ -39,8 +40,6 @@ export function writeSettings(settings: any): boolean {
|
|
|
39
40
|
}
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
import { writeFileSync } from "fs";
|
|
43
|
-
|
|
44
43
|
// ─── Auth ───────────────────────────────────────────────
|
|
45
44
|
|
|
46
45
|
export function readAuth() {
|
|
@@ -676,3 +675,403 @@ export function deleteSessionFile(filePath: string): boolean {
|
|
|
676
675
|
return false;
|
|
677
676
|
}
|
|
678
677
|
}
|
|
678
|
+
|
|
679
|
+
// ─── Session Trash (shared with pi-desktop: ~/.pi/agent/.trash) ───
|
|
680
|
+
|
|
681
|
+
const SESSIONS_DIR = join(PI_DIR, "sessions");
|
|
682
|
+
const TRASH_DIR = join(PI_DIR, ".trash");
|
|
683
|
+
|
|
684
|
+
export interface TrashEntry {
|
|
685
|
+
trashPath: string;
|
|
686
|
+
originalPath: string;
|
|
687
|
+
fileName: string;
|
|
688
|
+
trashedAt: string;
|
|
689
|
+
sessionId: string;
|
|
690
|
+
sessionName: string;
|
|
691
|
+
lastActive: string;
|
|
692
|
+
messageCount: number;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function walkJsonl(dir: string, out: string[]): void {
|
|
696
|
+
let entries: string[];
|
|
697
|
+
try {
|
|
698
|
+
entries = readdirSync(dir);
|
|
699
|
+
} catch {
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
for (const name of entries) {
|
|
703
|
+
const p = join(dir, name);
|
|
704
|
+
try {
|
|
705
|
+
if (statSync(p).isDirectory()) walkJsonl(p, out);
|
|
706
|
+
else if (name.endsWith(".jsonl")) out.push(p);
|
|
707
|
+
} catch {
|
|
708
|
+
// skip
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/** Move a session file into the trash, preserving its path relative to the sessions dir. */
|
|
714
|
+
export function trashSessionFile(filePath: string): boolean {
|
|
715
|
+
try {
|
|
716
|
+
const resolved = resolve(filePath);
|
|
717
|
+
if (!resolved.startsWith(SESSIONS_DIR + sep)) return false;
|
|
718
|
+
if (!resolved.endsWith(".jsonl") || !existsSync(resolved)) return false;
|
|
719
|
+
const rel = relative(SESSIONS_DIR, resolved);
|
|
720
|
+
const trashPath = join(TRASH_DIR, rel);
|
|
721
|
+
mkdirSync(dirname(trashPath), { recursive: true });
|
|
722
|
+
renameSync(resolved, trashPath);
|
|
723
|
+
return true;
|
|
724
|
+
} catch {
|
|
725
|
+
return false;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
export function listTrash(): TrashEntry[] {
|
|
730
|
+
const files: string[] = [];
|
|
731
|
+
walkJsonl(TRASH_DIR, files);
|
|
732
|
+
const entries: TrashEntry[] = [];
|
|
733
|
+
for (const trashPath of files) {
|
|
734
|
+
const info = parseSessionFileInfo(trashPath);
|
|
735
|
+
let trashedAt = "";
|
|
736
|
+
try {
|
|
737
|
+
// ctime updates on rename — reflects when the file entered the trash
|
|
738
|
+
trashedAt = statSync(trashPath).ctime.toISOString();
|
|
739
|
+
} catch {
|
|
740
|
+
// keep empty
|
|
741
|
+
}
|
|
742
|
+
const rel = relative(TRASH_DIR, trashPath);
|
|
743
|
+
entries.push({
|
|
744
|
+
trashPath,
|
|
745
|
+
originalPath: join(SESSIONS_DIR, rel),
|
|
746
|
+
fileName: trashPath.split("/").pop() || trashPath,
|
|
747
|
+
trashedAt,
|
|
748
|
+
sessionId: info?.id || "",
|
|
749
|
+
sessionName: info?.name || "",
|
|
750
|
+
lastActive: info?.lastActive || "",
|
|
751
|
+
messageCount: info?.messageCount || 0,
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
return entries.sort((a, b) => b.trashedAt.localeCompare(a.trashedAt));
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
export function restoreFromTrash(trashPath: string): boolean {
|
|
758
|
+
try {
|
|
759
|
+
const resolved = resolve(trashPath);
|
|
760
|
+
if (!resolved.startsWith(TRASH_DIR + sep) || !existsSync(resolved)) return false;
|
|
761
|
+
const rel = relative(TRASH_DIR, resolved);
|
|
762
|
+
const original = join(SESSIONS_DIR, rel);
|
|
763
|
+
mkdirSync(dirname(original), { recursive: true });
|
|
764
|
+
renameSync(resolved, original);
|
|
765
|
+
return true;
|
|
766
|
+
} catch {
|
|
767
|
+
return false;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
export function permanentlyDeleteTrash(trashPath: string): boolean {
|
|
772
|
+
try {
|
|
773
|
+
const resolved = resolve(trashPath);
|
|
774
|
+
if (!resolved.startsWith(TRASH_DIR + sep) || !existsSync(resolved)) return false;
|
|
775
|
+
unlinkSync(resolved);
|
|
776
|
+
return true;
|
|
777
|
+
} catch {
|
|
778
|
+
return false;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// ─── Session Preview ────────────────────────────────
|
|
783
|
+
|
|
784
|
+
export interface SessionPreviewMessage {
|
|
785
|
+
role: string;
|
|
786
|
+
text: string;
|
|
787
|
+
timestamp: string;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/** Read the first user/assistant messages of a session file (text parts only). */
|
|
791
|
+
export function readSessionPreview(filePath: string, limit = 20): { messages: SessionPreviewMessage[]; total: number } | null {
|
|
792
|
+
try {
|
|
793
|
+
const resolved = resolve(filePath);
|
|
794
|
+
const inSessions = resolved.startsWith(SESSIONS_DIR + sep);
|
|
795
|
+
const inTrash = resolved.startsWith(TRASH_DIR + sep);
|
|
796
|
+
if ((!inSessions && !inTrash) || !resolved.endsWith(".jsonl") || !existsSync(resolved)) return null;
|
|
797
|
+
|
|
798
|
+
const lines = readFileSync(resolved, "utf-8").split("\n").filter((l) => l.trim());
|
|
799
|
+
const messages: SessionPreviewMessage[] = [];
|
|
800
|
+
let total = 0;
|
|
801
|
+
for (const line of lines) {
|
|
802
|
+
try {
|
|
803
|
+
const obj = JSON.parse(line);
|
|
804
|
+
if (obj.type !== "message") continue;
|
|
805
|
+
const msg = obj.message || {};
|
|
806
|
+
const role = msg.role || "";
|
|
807
|
+
if (role !== "user" && role !== "assistant") continue;
|
|
808
|
+
total++;
|
|
809
|
+
if (messages.length >= limit) continue;
|
|
810
|
+
// Extract text parts; fall back to a tool-call marker for pure tool turns
|
|
811
|
+
let text = "";
|
|
812
|
+
if (typeof msg.content === "string") {
|
|
813
|
+
text = msg.content;
|
|
814
|
+
} else if (Array.isArray(msg.content)) {
|
|
815
|
+
text = msg.content
|
|
816
|
+
.filter((c: any) => c?.type === "text" && c.text)
|
|
817
|
+
.map((c: any) => c.text)
|
|
818
|
+
.join("\n");
|
|
819
|
+
if (!text) {
|
|
820
|
+
const tools = msg.content.filter((c: any) => c?.type === "toolCall").length;
|
|
821
|
+
if (tools > 0) text = `[${tools} tool call${tools > 1 ? "s" : ""}]`;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
text = text.trim();
|
|
825
|
+
if (text.length > 400) text = text.slice(0, 400) + "…";
|
|
826
|
+
messages.push({ role, text, timestamp: obj.timestamp || "" });
|
|
827
|
+
} catch {
|
|
828
|
+
// skip
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
return { messages, total };
|
|
832
|
+
} catch {
|
|
833
|
+
return null;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// ─── Memory Entry Deletion ───────────────────────────
|
|
838
|
+
|
|
839
|
+
const MEMORY_FILENAMES = ["MEMORY.md", "USER.md", "failures.md"];
|
|
840
|
+
|
|
841
|
+
/** Strip the trailing `<!-- created=..., last=... -->` marker from a § section. */
|
|
842
|
+
function sectionText(section: string): string {
|
|
843
|
+
return section.replace(/<!--\s*created\s*=[^>]*-->\s*$/, "").trim();
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** Delete one §-separated entry (matched by its text) and write the file back. */
|
|
847
|
+
export function deleteMemoryEntry(filename: string, entryText: string): boolean {
|
|
848
|
+
try {
|
|
849
|
+
if (!MEMORY_FILENAMES.includes(filename)) return false;
|
|
850
|
+
const filePath = join(HERMES_DIR, filename);
|
|
851
|
+
if (!existsSync(filePath)) return false;
|
|
852
|
+
|
|
853
|
+
const content = readFileSync(filePath, "utf-8");
|
|
854
|
+
const sections = content.split("§");
|
|
855
|
+
const target = entryText.trim();
|
|
856
|
+
const idx = sections.findIndex((s) => s.trim().length > 0 && sectionText(s) === target);
|
|
857
|
+
if (idx === -1) return false;
|
|
858
|
+
|
|
859
|
+
sections.splice(idx, 1);
|
|
860
|
+
writeFileSync(filePath, sections.join("§"));
|
|
861
|
+
return true;
|
|
862
|
+
} catch {
|
|
863
|
+
return false;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// ─── Update Check (npm registry) ────────────────────────
|
|
868
|
+
|
|
869
|
+
/** npm package that ships the pi binary (bin/pi → its dist/cli.js). */
|
|
870
|
+
const PI_CORE_PACKAGE = "@earendil-works/pi-coding-agent";
|
|
871
|
+
|
|
872
|
+
const REGISTRY_TIMEOUT_MS = 8000;
|
|
873
|
+
|
|
874
|
+
export interface UpdateItem {
|
|
875
|
+
name: string;
|
|
876
|
+
installed: string;
|
|
877
|
+
latest: string | null; // null → registry lookup failed
|
|
878
|
+
hasUpdate: boolean;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
export interface UpdateCheckResult {
|
|
882
|
+
pi: UpdateItem | null; // null → pi version unknown (binary missing)
|
|
883
|
+
extensions: UpdateItem[];
|
|
884
|
+
checkedAt: number;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/** Discover the installed pi version: PI_BINARY env → PATH → known locations. */
|
|
888
|
+
function getPiVersion(): string | null {
|
|
889
|
+
const home = homedir();
|
|
890
|
+
const candidates = [
|
|
891
|
+
process.env.PI_BINARY,
|
|
892
|
+
"pi",
|
|
893
|
+
`${home}/.npm-global/bin/pi`,
|
|
894
|
+
`${home}/.npm-packages/bin/pi`,
|
|
895
|
+
`${home}/.config/yarn/global/node_modules/.bin/pi`,
|
|
896
|
+
`${home}/.local/share/pnpm/pi`,
|
|
897
|
+
].filter(Boolean) as string[];
|
|
898
|
+
|
|
899
|
+
for (const bin of candidates) {
|
|
900
|
+
try {
|
|
901
|
+
const out = spawnSync(bin, ["--version"], { encoding: "utf8", timeout: 15000 });
|
|
902
|
+
if (out.status === 0) {
|
|
903
|
+
const v = out.stdout.trim();
|
|
904
|
+
if (v) return v;
|
|
905
|
+
}
|
|
906
|
+
} catch {
|
|
907
|
+
// try next candidate
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
return null;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function readJsonFile<T>(filePath: string): T | null {
|
|
914
|
+
try {
|
|
915
|
+
return JSON.parse(readFileSync(filePath, "utf-8")) as T;
|
|
916
|
+
} catch {
|
|
917
|
+
return null;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/** Numeric segment-wise semver comparison; returns true when latest > installed. */
|
|
922
|
+
function isNewerVersion(installed: string, latest: string): boolean {
|
|
923
|
+
const parse = (v: string) =>
|
|
924
|
+
(v.replace(/^v/, "").split("-")[0] ?? "").split(".").map((s) => parseInt(s, 10) || 0);
|
|
925
|
+
const a = parse(installed);
|
|
926
|
+
const b = parse(latest);
|
|
927
|
+
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
928
|
+
const ai = a[i] ?? 0;
|
|
929
|
+
const bi = b[i] ?? 0;
|
|
930
|
+
if (bi > ai) return true;
|
|
931
|
+
if (bi < ai) return false;
|
|
932
|
+
}
|
|
933
|
+
return false;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
async function fetchLatestVersion(pkgName: string): Promise<string | null> {
|
|
937
|
+
try {
|
|
938
|
+
const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(pkgName)}/latest`, {
|
|
939
|
+
signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS),
|
|
940
|
+
headers: { accept: "application/json" },
|
|
941
|
+
});
|
|
942
|
+
if (!res.ok) return null;
|
|
943
|
+
const data = (await res.json()) as { version?: string };
|
|
944
|
+
return typeof data.version === "string" ? data.version : null;
|
|
945
|
+
} catch {
|
|
946
|
+
return null;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/** Installed extensions: names from ~/.pi/agent/npm/package.json deps, versions from node_modules. */
|
|
951
|
+
function listInstalledExtensions(): { name: string; installed: string }[] {
|
|
952
|
+
const dir = join(PI_DIR, "npm");
|
|
953
|
+
const manifest = readJsonFile<{ dependencies?: Record<string, string> }>(join(dir, "package.json"));
|
|
954
|
+
if (!manifest?.dependencies) return [];
|
|
955
|
+
return Object.keys(manifest.dependencies).map((name) => {
|
|
956
|
+
const pkg = readJsonFile<{ version?: string }>(join(dir, "node_modules", name, "package.json"));
|
|
957
|
+
return { name, installed: pkg?.version ?? "unknown" };
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Check pi core and every installed extension against the npm registry.
|
|
963
|
+
* Registry lookups run in parallel; individual failures degrade to latest=null
|
|
964
|
+
* instead of failing the whole check.
|
|
965
|
+
*/
|
|
966
|
+
export async function checkUpdates(): Promise<UpdateCheckResult> {
|
|
967
|
+
const piVersion = getPiVersion();
|
|
968
|
+
const extensions = listInstalledExtensions();
|
|
969
|
+
|
|
970
|
+
const toItem = async (name: string, installed: string): Promise<UpdateItem> => {
|
|
971
|
+
const latest = await fetchLatestVersion(name);
|
|
972
|
+
return {
|
|
973
|
+
name,
|
|
974
|
+
installed,
|
|
975
|
+
latest,
|
|
976
|
+
hasUpdate: latest !== null && installed !== "unknown" && isNewerVersion(installed, latest),
|
|
977
|
+
};
|
|
978
|
+
};
|
|
979
|
+
|
|
980
|
+
const [piItem, ...extItems] = await Promise.all([
|
|
981
|
+
piVersion ? toItem(PI_CORE_PACKAGE, piVersion) : Promise.resolve(null),
|
|
982
|
+
...extensions.map((e) => toItem(e.name, e.installed)),
|
|
983
|
+
]);
|
|
984
|
+
|
|
985
|
+
return { pi: piItem as UpdateItem | null, extensions: extItems as UpdateItem[], checkedAt: Date.now() };
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
export interface ApplyUpdateResult {
|
|
989
|
+
name: string;
|
|
990
|
+
success: boolean;
|
|
991
|
+
message?: string;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* One-click update: npm install <name>@latest inside ~/.pi/agent/npm.
|
|
996
|
+
* Only packages already installed there are accepted (pi core is excluded —
|
|
997
|
+
* its install method is unknown, so it must be updated via its installer).
|
|
998
|
+
*/
|
|
999
|
+
export function applyExtensionUpdates(names: string[]): ApplyUpdateResult[] {
|
|
1000
|
+
const dir = join(PI_DIR, "npm");
|
|
1001
|
+
const installed = new Set(listInstalledExtensions().map((e) => e.name));
|
|
1002
|
+
|
|
1003
|
+
return names.map((name) => {
|
|
1004
|
+
if (!installed.has(name)) {
|
|
1005
|
+
return { name, success: false, message: "not an installed extension" };
|
|
1006
|
+
}
|
|
1007
|
+
try {
|
|
1008
|
+
// --legacy-peer-deps: peer deps (e.g. pi core) are provided by the pi host,
|
|
1009
|
+
// not installed here — strict resolution would fail with ERESOLVE.
|
|
1010
|
+
const out = spawnSync(
|
|
1011
|
+
"npm",
|
|
1012
|
+
["install", `${name}@latest`, "--no-audit", "--no-fund", "--legacy-peer-deps"],
|
|
1013
|
+
{
|
|
1014
|
+
cwd: dir,
|
|
1015
|
+
encoding: "utf8",
|
|
1016
|
+
timeout: 120000,
|
|
1017
|
+
}
|
|
1018
|
+
);
|
|
1019
|
+
if (out.status === 0) return { name, success: true };
|
|
1020
|
+
const stderr = (out.stderr || "").trim().split("\n").slice(-3).join(" ");
|
|
1021
|
+
return { name, success: false, message: stderr || `npm exited with ${out.status}` };
|
|
1022
|
+
} catch (e) {
|
|
1023
|
+
return { name, success: false, message: String(e) };
|
|
1024
|
+
}
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// ─── Provider Connection Test ────────────────────────────
|
|
1029
|
+
|
|
1030
|
+
export interface ProviderTestResult {
|
|
1031
|
+
success: boolean;
|
|
1032
|
+
status?: number;
|
|
1033
|
+
latencyMs?: number;
|
|
1034
|
+
message?: string;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* Test connectivity of a provider endpoint server-side (avoids browser CORS).
|
|
1039
|
+
* Requests GET {baseUrl}/models with an optional Bearer key; any HTTP response
|
|
1040
|
+
* counts as reachable — 2xx additionally means the key was accepted.
|
|
1041
|
+
*/
|
|
1042
|
+
export async function testProviderConnection(
|
|
1043
|
+
baseUrl: string,
|
|
1044
|
+
apiKey?: string
|
|
1045
|
+
): Promise<ProviderTestResult> {
|
|
1046
|
+
let url: URL;
|
|
1047
|
+
try {
|
|
1048
|
+
url = new URL(baseUrl.replace(/\/+$/, "") + "/models");
|
|
1049
|
+
} catch {
|
|
1050
|
+
return { success: false, message: "invalid URL" };
|
|
1051
|
+
}
|
|
1052
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
1053
|
+
return { success: false, message: "invalid URL" };
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// Resolve $ENV_VAR style keys the same way pi does
|
|
1057
|
+
let key = apiKey ?? "";
|
|
1058
|
+
if (key.startsWith("$")) key = process.env[key.slice(1)] ?? "";
|
|
1059
|
+
|
|
1060
|
+
const headers: Record<string, string> = {};
|
|
1061
|
+
if (key) headers["Authorization"] = `Bearer ${key}`;
|
|
1062
|
+
|
|
1063
|
+
const started = Date.now();
|
|
1064
|
+
try {
|
|
1065
|
+
const res = await fetch(url, {
|
|
1066
|
+
headers,
|
|
1067
|
+
signal: AbortSignal.timeout(10000),
|
|
1068
|
+
});
|
|
1069
|
+
const latencyMs = Date.now() - started;
|
|
1070
|
+
if (res.ok) return { success: true, status: res.status, latencyMs };
|
|
1071
|
+
return { success: false, status: res.status, latencyMs, message: `HTTP ${res.status}` };
|
|
1072
|
+
} catch (e: any) {
|
|
1073
|
+
const latencyMs = Date.now() - started;
|
|
1074
|
+
const msg = e?.name === "TimeoutError" ? "timeout" : e?.cause?.code || e?.message || String(e);
|
|
1075
|
+
return { success: false, latencyMs, message: msg };
|
|
1076
|
+
}
|
|
1077
|
+
}
|
package/src/App.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { AppShell } from "@/components/layout/AppShell";
|
|
|
3
3
|
import { DashboardPage } from "@/components/dashboard/DashboardPage";
|
|
4
4
|
import { SessionsPage } from "@/components/sessions/SessionsPage";
|
|
5
5
|
import { MemoryPage } from "@/components/sessions/MemoryPage";
|
|
6
|
+
import { ProvidersModelsPage } from "@/components/providers/ProvidersModelsPage";
|
|
6
7
|
import { SettingsPage } from "@/components/settings/SettingsPage";
|
|
7
8
|
|
|
8
9
|
export default function App() {
|
|
@@ -13,6 +14,8 @@ export default function App() {
|
|
|
13
14
|
<Route path="/" element={<DashboardPage />} />
|
|
14
15
|
<Route path="/sessions" element={<SessionsPage />} />
|
|
15
16
|
<Route path="/memory" element={<MemoryPage />} />
|
|
17
|
+
<Route path="/providers" element={<ProvidersModelsPage />} />
|
|
18
|
+
<Route path="/models" element={<ProvidersModelsPage />} />
|
|
16
19
|
<Route path="/settings" element={<SettingsPage />} />
|
|
17
20
|
</Route>
|
|
18
21
|
</Routes>
|