mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-03 02:59:35 +00:00
feat: add vision and extension management to WebUI
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
import { existsSync, readdirSync, statSync } from "fs";
|
||||
import { basename, dirname, extname, join, relative, resolve } from "path";
|
||||
import { DefaultPackageManager, getAgentDir, type ResolvedResource } from "@earendil-works/pi-coding-agent";
|
||||
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
|
||||
import { getManagedRuntimePaths, isManagedRuntime, createAppSettingsManager } from "@/lib/app-runtime";
|
||||
import { getProjectTrustStatus } from "@/lib/project-trust";
|
||||
import type { ExtensionInfo, ExtensionsResponse, PluginDiagnostic } from "@/lib/api-types";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function extensionName(path: string): string {
|
||||
const file = basename(path);
|
||||
const extension = extname(file);
|
||||
if (/^index\.(ts|js)$/.test(file)) return basename(dirname(path));
|
||||
return extension ? file.slice(0, -extension.length) : file;
|
||||
}
|
||||
|
||||
function extensionFiles(root: string): string[] {
|
||||
if (!existsSync(root)) return [];
|
||||
try {
|
||||
const stats = statSync(root);
|
||||
if (stats.isFile()) return /\.(?:ts|js)$/.test(root) ? [root] : [];
|
||||
if (!stats.isDirectory()) return [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files: string[] = [];
|
||||
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
||||
const path = join(root, entry.name);
|
||||
if (entry.isDirectory()) files.push(...extensionFiles(path));
|
||||
else if (entry.isFile() && /\.(?:ts|js)$/.test(entry.name)) files.push(path);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function infoFromResource(resource: ResolvedResource, scopeOverride?: ExtensionInfo["scope"]): ExtensionInfo {
|
||||
const baseDir = resource.metadata.baseDir ?? dirname(resource.path);
|
||||
const rel = relative(baseDir, resource.path);
|
||||
return {
|
||||
name: extensionName(resource.path),
|
||||
path: resource.path,
|
||||
relativePath: rel && !rel.startsWith("..") ? rel : resource.path,
|
||||
source: scopeOverride === "builtin" ? "resources" : resource.metadata.source,
|
||||
scope: scopeOverride ?? (resource.metadata.scope === "project" ? "project" : "global"),
|
||||
status: resource.enabled ? "enabled" : "disabled",
|
||||
};
|
||||
}
|
||||
|
||||
function blockedInfo(path: string): ExtensionInfo {
|
||||
return {
|
||||
name: extensionName(path),
|
||||
path,
|
||||
relativePath: relative(dirname(dirname(path)), path),
|
||||
source: "project",
|
||||
scope: "project",
|
||||
status: "blocked",
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
let cwd: string | null;
|
||||
try {
|
||||
cwd = new URL(req.url).searchParams.get("cwd");
|
||||
} catch {
|
||||
return Response.json({ error: "invalid request URL" }, { status: 400 });
|
||||
}
|
||||
if (!cwd) return Response.json({ error: "cwd required" }, { status: 400 });
|
||||
|
||||
try {
|
||||
const allowedRoots = await getAllowedFileRoots();
|
||||
if (!isExistingFilePathAllowed(cwd, allowedRoots)) {
|
||||
return Response.json({ error: "Access denied" }, { status: 403 });
|
||||
}
|
||||
|
||||
const agentDir = getAgentDir();
|
||||
const trust = getProjectTrustStatus(cwd, agentDir);
|
||||
const settingsManager = createAppSettingsManager(cwd, agentDir, trust.trusted);
|
||||
const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
|
||||
const diagnostics: PluginDiagnostic[] = [];
|
||||
const byPath = new Map<string, ExtensionInfo>();
|
||||
|
||||
try {
|
||||
const resolved = await packageManager.resolve(async () => "skip");
|
||||
for (const resource of resolved.extensions) {
|
||||
// Package-provided extensions belong to the plugin panel, not here.
|
||||
if (resource.metadata.origin !== "top-level") continue;
|
||||
byPath.set(resource.path, infoFromResource(resource));
|
||||
}
|
||||
} catch (error) {
|
||||
diagnostics.push({ type: "error", message: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
|
||||
if (isManagedRuntime()) {
|
||||
const resourcesRoot = resolve(getManagedRuntimePaths().resourcesDir, "extensions");
|
||||
const builtIn = await packageManager.resolveExtensionSources([resourcesRoot], { temporary: true });
|
||||
for (const resource of builtIn.extensions) {
|
||||
byPath.set(resource.path, infoFromResource(resource, "builtin"));
|
||||
}
|
||||
}
|
||||
|
||||
const projectExtensionsRoot = join(cwd, ".pi", "extensions");
|
||||
if (trust.requiresTrust && !trust.trusted) {
|
||||
for (const path of extensionFiles(projectExtensionsRoot)) {
|
||||
byPath.set(path, blockedInfo(path));
|
||||
}
|
||||
if (byPathHasScope(byPath, "project")) {
|
||||
diagnostics.push({ type: "warning", source: "project", message: "Project extensions are blocked until this project is trusted." });
|
||||
}
|
||||
}
|
||||
|
||||
const scopeOrder: Record<ExtensionInfo["scope"], number> = { project: 0, builtin: 1, global: 2 };
|
||||
const extensions = [...byPath.values()].sort((a, b) => (
|
||||
scopeOrder[a.scope] - scopeOrder[b.scope] || a.name.localeCompare(b.name) || a.path.localeCompare(b.path)
|
||||
));
|
||||
return Response.json({ extensions, diagnostics, projectResourcesLoaded: trust.trusted } satisfies ExtensionsResponse);
|
||||
} catch (error) {
|
||||
return Response.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
function byPathHasScope(entries: Map<string, ExtensionInfo>, scope: ExtensionInfo["scope"]): boolean {
|
||||
for (const entry of entries.values()) {
|
||||
if (entry.scope === scope) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
interface VisionConfigFile {
|
||||
backend?: "ollama" | "openai";
|
||||
ollama?: { host?: string; model?: string };
|
||||
openai?: { baseUrl?: string; apiKey?: string; model?: string };
|
||||
}
|
||||
|
||||
function getVisionPath(): string {
|
||||
return join(getAgentDir(), "vision.json");
|
||||
}
|
||||
|
||||
function readVisionJson(): VisionConfigFile {
|
||||
const path = getVisionPath();
|
||||
if (!existsSync(path)) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(path, "utf8")) as VisionConfigFile;
|
||||
if (!parsed || typeof parsed !== "object") return {};
|
||||
return parsed;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeVisionJson(data: VisionConfigFile): void {
|
||||
const path = getVisionPath();
|
||||
const dir = dirname(path);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf8");
|
||||
}
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json({ config: readVisionJson(), path: getVisionPath() });
|
||||
}
|
||||
|
||||
export async function PUT(req: Request) {
|
||||
try {
|
||||
const body = (await req.json()) as VisionConfigFile;
|
||||
if (!body || typeof body !== "object") {
|
||||
return NextResponse.json({ error: "body must be an object" }, { status: 400 });
|
||||
}
|
||||
const out: VisionConfigFile = {};
|
||||
if (body.backend === "ollama" || body.backend === "openai") out.backend = body.backend;
|
||||
if (body.ollama && typeof body.ollama === "object") {
|
||||
out.ollama = {};
|
||||
if (typeof body.ollama.host === "string" && body.ollama.host.trim()) out.ollama.host = body.ollama.host.trim();
|
||||
if (typeof body.ollama.model === "string" && body.ollama.model.trim()) out.ollama.model = body.ollama.model.trim();
|
||||
}
|
||||
if (body.openai && typeof body.openai === "object") {
|
||||
out.openai = {};
|
||||
if (typeof body.openai.baseUrl === "string" && body.openai.baseUrl.trim()) out.openai.baseUrl = body.openai.baseUrl.trim();
|
||||
if (typeof body.openai.apiKey === "string" && body.openai.apiKey.trim()) out.openai.apiKey = body.openai.apiKey.trim();
|
||||
if (typeof body.openai.model === "string" && body.openai.model.trim()) out.openai.model = body.openai.model.trim();
|
||||
}
|
||||
writeVisionJson(out);
|
||||
return NextResponse.json({ success: true, path: getVisionPath() });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: String(error) }, { status: 500 });
|
||||
}
|
||||
}
|
||||
+2463
-1394
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import type { ExtensionInfo, ExtensionsResponse } from "@/lib/api-types";
|
||||
|
||||
function shortenPath(path: string): string {
|
||||
return path.replace(/^\/(?:Users|home)\/[^/]+/, "~");
|
||||
}
|
||||
|
||||
function statusColor(status: ExtensionInfo["status"]): string {
|
||||
if (status === "enabled") return "var(--accent)";
|
||||
if (status === "blocked") return "#d97706";
|
||||
return "var(--text-dim)";
|
||||
}
|
||||
|
||||
function statusLabel(status: ExtensionInfo["status"], t: ReturnType<typeof useI18n>["t"]): string {
|
||||
if (status === "enabled") return t("extensions.enabled");
|
||||
if (status === "blocked") return t("extensions.blocked");
|
||||
return t("extensions.disabled");
|
||||
}
|
||||
|
||||
function scopeLabel(scope: ExtensionInfo["scope"], t: ReturnType<typeof useI18n>["t"]): string {
|
||||
if (scope === "project") return t("extensions.project");
|
||||
if (scope === "builtin") return t("extensions.builtin");
|
||||
return t("extensions.app");
|
||||
}
|
||||
|
||||
function extensionKey(extension: ExtensionInfo): string {
|
||||
return `${extension.scope}:${extension.path}`;
|
||||
}
|
||||
|
||||
export function ExtensionsConfig({ cwd, onCloseAction }: { cwd: string; onCloseAction: () => void }) {
|
||||
const isMobile = useIsMobile();
|
||||
const { t } = useI18n();
|
||||
const [data, setData] = useState<ExtensionsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
|
||||
const loadExtensions = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/extensions?cwd=${encodeURIComponent(cwd)}`);
|
||||
const next = (await response.json()) as ExtensionsResponse & { error?: string };
|
||||
if (!response.ok || next.error) throw new Error(next.error ?? `HTTP ${response.status}`);
|
||||
setData(next);
|
||||
setSelected((current) => (
|
||||
current && next.extensions.some((extension) => extensionKey(extension) === current)
|
||||
? current
|
||||
: next.extensions[0] ? extensionKey(next.extensions[0]) : null
|
||||
));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [cwd]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadExtensions();
|
||||
}, [loadExtensions]);
|
||||
|
||||
const extensions = useMemo(() => data?.extensions ?? [], [data?.extensions]);
|
||||
const selectedExtension = extensions.find((extension) => extensionKey(extension) === selected) ?? null;
|
||||
const enabledCount = extensions.filter((extension) => extension.status === "enabled").length;
|
||||
const blockedCount = extensions.filter((extension) => extension.status === "blocked").length;
|
||||
const disabledCount = extensions.filter((extension) => extension.status === "disabled").length;
|
||||
const hasBlockedProjectExtensions = blockedCount > 0 && !data?.projectResourcesLoaded;
|
||||
const groupedExtensions = useMemo(() => {
|
||||
const groups: Array<{ scope: ExtensionInfo["scope"]; extensions: ExtensionInfo[] }> = [];
|
||||
for (const scope of ["project", "builtin", "global"] as const) {
|
||||
const scoped = extensions.filter((extension) => extension.scope === scope);
|
||||
if (scoped.length > 0) groups.push({ scope, extensions: scoped });
|
||||
}
|
||||
return groups;
|
||||
}, [extensions]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ position: "fixed", inset: 0, zIndex: 1000, background: "rgba(0,0,0,0.35)", display: "flex", alignItems: "center", justifyContent: "center" }}
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) onCloseAction();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: isMobile ? "calc(100vw - 16px)" : 860,
|
||||
maxWidth: "calc(100vw - 16px)",
|
||||
height: isMobile ? "calc(100dvh - 16px)" : "76vh",
|
||||
maxHeight: "calc(100dvh - 16px)",
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 10,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
boxShadow: "0 8px 32px rgba(0,0,0,0.18)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "12px 18px", borderBottom: "1px solid var(--border)", flexShrink: 0 }}>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 10, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 15, fontWeight: 700, color: "var(--text)" }}>{t("common.extensions")}</span>
|
||||
<code style={{ fontSize: 11, color: "var(--text-muted)", fontFamily: "var(--font-mono)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{shortenPath(cwd)}
|
||||
</code>
|
||||
</div>
|
||||
<button type="button" onClick={onCloseAction} aria-label={t("i18n.close")} style={{ background: "none", border: "none", color: "var(--text-muted)", cursor: "pointer", fontSize: 20, lineHeight: 1, padding: "2px 6px" }}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hasBlockedProjectExtensions && (
|
||||
<div style={{ padding: "8px 18px", borderBottom: "1px solid var(--border)", color: "#d97706", fontSize: 11 }}>
|
||||
{t("extensions.projectResourcesBlocked")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: isMobile ? "column" : "row", overflow: "hidden" }}>
|
||||
<div style={{ width: isMobile ? "100%" : 245, maxHeight: isMobile ? "40vh" : undefined, borderRight: isMobile ? "none" : "1px solid var(--border)", borderBottom: isMobile ? "1px solid var(--border)" : "none", display: "flex", flexDirection: "column", flexShrink: 0, background: "var(--bg-panel)" }}>
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: "8px 6px" }}>
|
||||
{loading ? (
|
||||
<div style={{ padding: "10px 8px", fontSize: 12, color: "var(--text-muted)" }}>{t("i18n.loading")}</div>
|
||||
) : error ? (
|
||||
<div style={{ padding: "10px 8px", fontSize: 11, color: "#ef4444" }}>{error}</div>
|
||||
) : groupedExtensions.length === 0 ? (
|
||||
<div style={{ padding: "10px 8px", fontSize: 11, color: "var(--text-dim)" }}>{t("extensions.noExtensions")}</div>
|
||||
) : (
|
||||
groupedExtensions.map((group) => (
|
||||
<div key={group.scope} style={{ marginBottom: 6 }}>
|
||||
<div style={{ padding: "4px 8px 3px", fontSize: 10, fontWeight: 600, color: "var(--text-dim)", textTransform: "uppercase" }}>
|
||||
{scopeLabel(group.scope, t)}
|
||||
</div>
|
||||
{group.extensions.map((extension) => {
|
||||
const key = extensionKey(extension);
|
||||
const isSelected = selected === key;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
onClick={() => setSelected(key)}
|
||||
style={{ display: "flex", alignItems: "center", gap: 7, padding: "8px", borderRadius: 5, cursor: "pointer", background: isSelected ? "var(--bg-selected)" : "none" }}
|
||||
onMouseEnter={(event) => { if (!isSelected) event.currentTarget.style.background = "var(--bg-hover)"; }}
|
||||
onMouseLeave={(event) => { if (!isSelected) event.currentTarget.style.background = "none"; }}
|
||||
>
|
||||
<span style={{ flexShrink: 0, width: 7, height: 7, borderRadius: "50%", background: statusColor(extension.status) }} />
|
||||
<span style={{ minWidth: 0, flex: 1, fontSize: 12, fontWeight: isSelected ? 600 : 400, color: extension.status === "disabled" ? "var(--text-dim)" : "var(--text)", fontFamily: "var(--font-mono)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{extension.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: 20 }}>
|
||||
{selectedExtension ? (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 20, maxWidth: 680 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: "50%", background: statusColor(selectedExtension.status) }} />
|
||||
<span style={{ fontSize: 14, fontWeight: 700, color: "var(--text)", fontFamily: "var(--font-mono)" }}>{selectedExtension.name}</span>
|
||||
<span style={{ fontSize: 10, padding: "1px 5px", borderRadius: 3, background: "rgba(120,120,120,0.12)", color: "var(--text-dim)" }}>{scopeLabel(selectedExtension.scope, t)}</span>
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "minmax(100px, 130px) minmax(0, 1fr)", gap: "9px 14px", fontSize: 12, lineHeight: 1.45 }}>
|
||||
<div style={{ color: "var(--text-dim)" }}>{t("extensions.status")}</div>
|
||||
<div style={{ color: statusColor(selectedExtension.status) }}>{statusLabel(selectedExtension.status, t)}</div>
|
||||
<div style={{ color: "var(--text-dim)" }}>{t("extensions.source")}</div>
|
||||
<div style={{ color: "var(--text-muted)", fontFamily: "var(--font-mono)" }}>{selectedExtension.source}</div>
|
||||
<div style={{ color: "var(--text-dim)" }}>{t("extensions.path")}</div>
|
||||
<div style={{ color: "var(--text-muted)", fontFamily: "var(--font-mono)", overflowWrap: "anywhere" }}>{shortenPath(selectedExtension.path)}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : !loading && !error ? (
|
||||
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", color: "var(--text-dim)", fontSize: 13 }}>{t("extensions.noExtensions")}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "10px 18px", borderTop: "1px solid var(--border)", flexShrink: 0 }}>
|
||||
<div style={{ minWidth: 0, flex: 1, fontSize: 11, color: "var(--text-dim)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{data ? `${extensions.length} ${t("extensions.total")} · ${enabledCount} ${t("extensions.enabled")} · ${disabledCount} ${t("extensions.disabled")} · ${blockedCount} ${t("extensions.blocked")}${data.diagnostics.length ? ` · ${data.diagnostics.length} ${t("extensions.diagnostics")}` : ""}` : ""}
|
||||
</div>
|
||||
<button type="button" onClick={() => void loadExtensions()} disabled={loading} style={{ padding: "6px 12px", background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text-muted)", cursor: loading ? "not-allowed" : "pointer", opacity: loading ? 0.5 : 1, fontSize: 12 }}>{t("i18n.refresh")}</button>
|
||||
<button type="button" onClick={onCloseAction} style={{ padding: "6px 12px", background: "none", border: "1px solid var(--border)", borderRadius: 6, color: "var(--text-muted)", cursor: "pointer", fontSize: 12 }}>{t("i18n.close")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+3491
-1783
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,424 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
|
||||
interface VisionConfigFile {
|
||||
backend?: "ollama" | "openai";
|
||||
ollama?: { host?: string; model?: string };
|
||||
openai?: { baseUrl?: string; apiKey?: string; model?: string };
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
padding: "6px 9px",
|
||||
background: "var(--bg-panel)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 5,
|
||||
color: "var(--text)",
|
||||
fontSize: 12,
|
||||
outline: "none",
|
||||
width: "100%",
|
||||
boxSizing: "border-box",
|
||||
};
|
||||
|
||||
function Field({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<label
|
||||
style={{ fontSize: 11, color: "var(--text-muted)", fontWeight: 500 }}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
mono,
|
||||
type,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
mono?: boolean;
|
||||
type?: string;
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
type={type ?? "text"}
|
||||
style={{
|
||||
...inputStyle,
|
||||
fontFamily: mono ? "var(--font-mono)" : "inherit",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const saveButtonStyle = (primary: boolean): React.CSSProperties => ({
|
||||
padding: "6px 14px",
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
border: "1px solid var(--border)",
|
||||
background: primary ? "var(--accent)" : "var(--bg-panel)",
|
||||
color: primary ? "#fff" : "var(--text)",
|
||||
});
|
||||
|
||||
/**
|
||||
* Vision backend settings (backend picker + fields + save), without modal
|
||||
* chrome. Embedded in the Models config panel and reused by the standalone
|
||||
* modal below.
|
||||
*/
|
||||
export function VisionConfigContent() {
|
||||
const { t } = useI18n();
|
||||
const [config, setConfig] = useState<VisionConfigFile>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [savedOk, setSavedOk] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const res = await fetch("/api/vision-config");
|
||||
const data = (await res.json()) as {
|
||||
config?: VisionConfigFile;
|
||||
error?: string;
|
||||
};
|
||||
if (!res.ok || data.error)
|
||||
throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||
setConfig(data.config ?? {});
|
||||
} catch (e) {
|
||||
setSaveError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
setSavedOk(false);
|
||||
try {
|
||||
const res = await fetch("/api/vision-config", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
const data = (await res.json()) as { success?: boolean; error?: string };
|
||||
if (!res.ok || !data.success)
|
||||
throw new Error(data.error ?? `HTTP ${res.status}`);
|
||||
setSavedOk(true);
|
||||
} catch (e) {
|
||||
setSaveError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const backend = config.backend ?? "ollama";
|
||||
const ollama = config.ollama ?? {};
|
||||
const openai = config.openai ?? {};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 14,
|
||||
padding: "14px 18px",
|
||||
flex: 1,
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<div style={{ fontSize: 12, color: "var(--text-muted)" }}>
|
||||
{t("vision.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Backend picker */}
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
{(["ollama", "openai"] as const).map((b) => (
|
||||
<button
|
||||
key={b}
|
||||
onClick={() => setConfig((prev) => ({ ...prev, backend: b }))}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "10px 12px",
|
||||
borderRadius: 8,
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
border: `1px solid ${backend === b ? "var(--accent)" : "var(--border)"}`,
|
||||
background:
|
||||
backend === b
|
||||
? "color-mix(in srgb, var(--accent) 8%, var(--bg-panel))"
|
||||
: "var(--bg-panel)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "var(--text)",
|
||||
}}
|
||||
>
|
||||
{b === "ollama"
|
||||
? t("vision.backend.ollama")
|
||||
: t("vision.backend.openai")}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-muted)",
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{b === "ollama"
|
||||
? t("vision.backend.ollamaHint")
|
||||
: t("vision.backend.openaiHint")}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Ollama settings */}
|
||||
{backend === "ollama" && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<Field label={t("vision.ollama.host")}>
|
||||
<TextInput
|
||||
value={ollama.host ?? ""}
|
||||
onChange={(v) =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
ollama: { ...prev.ollama, host: v },
|
||||
}))
|
||||
}
|
||||
placeholder={t("vision.ollama.hostPlaceholder")}
|
||||
mono
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("vision.ollama.model")}>
|
||||
<TextInput
|
||||
value={ollama.model ?? ""}
|
||||
onChange={(v) =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
ollama: { ...prev.ollama, model: v },
|
||||
}))
|
||||
}
|
||||
placeholder={t("vision.ollama.modelPlaceholder")}
|
||||
mono
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OpenAI-compatible settings */}
|
||||
{backend === "openai" && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<Field label={t("vision.openai.baseUrl")}>
|
||||
<TextInput
|
||||
value={openai.baseUrl ?? ""}
|
||||
onChange={(v) =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
openai: { ...prev.openai, baseUrl: v },
|
||||
}))
|
||||
}
|
||||
placeholder={t("vision.openai.baseUrlPlaceholder")}
|
||||
mono
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("vision.openai.apiKey")}>
|
||||
<TextInput
|
||||
value={openai.apiKey ?? ""}
|
||||
onChange={(v) =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
openai: { ...prev.openai, apiKey: v },
|
||||
}))
|
||||
}
|
||||
type="password"
|
||||
mono
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("vision.openai.model")}>
|
||||
<TextInput
|
||||
value={openai.model ?? ""}
|
||||
onChange={(v) =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
openai: { ...prev.openai, model: v },
|
||||
}))
|
||||
}
|
||||
placeholder={t("vision.openai.modelPlaceholder")}
|
||||
mono
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-muted)",
|
||||
lineHeight: 1.5,
|
||||
borderTop: "1px solid var(--border)",
|
||||
paddingTop: 10,
|
||||
}}
|
||||
>
|
||||
{t("vision.effective")}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{saveError && (
|
||||
<div style={{ fontSize: 11, color: "var(--danger, #e5484d)" }}>
|
||||
{t("vision.loadFailed")}: {saveError}
|
||||
</div>
|
||||
)}
|
||||
{savedOk && (
|
||||
<div style={{ fontSize: 11, color: "var(--success, #30a46c)" }}>
|
||||
{t("vision.saved")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: 8,
|
||||
marginTop: "auto",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={save}
|
||||
disabled={saving || loading}
|
||||
style={{
|
||||
...saveButtonStyle(true),
|
||||
opacity: saving || loading ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{t("vision.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Standalone modal wrapper (kept for backward compatibility). */
|
||||
export function VisionConfig({ onClose }: { onClose: () => void }) {
|
||||
const isMobile = useIsMobile();
|
||||
const { t } = useI18n();
|
||||
const [configPath, setConfigPath] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/vision-config")
|
||||
.then((r) => r.json())
|
||||
.then((d: { path?: string }) => setConfigPath(d.path ?? ""))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.45)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 1000,
|
||||
padding: 8,
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: isMobile ? "calc(100vw - 16px)" : 560,
|
||||
maxWidth: "calc(100vw - 16px)",
|
||||
maxHeight: "calc(100dvh - 16px)",
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 10,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
boxShadow: "0 8px 32px rgba(0,0,0,0.18)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "12px 18px",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
|
||||
<span
|
||||
style={{ fontSize: 15, fontWeight: 700, color: "var(--text)" }}
|
||||
>
|
||||
{t("vision.title")}
|
||||
</span>
|
||||
<code
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "var(--text-muted)",
|
||||
fontFamily: "var(--font-mono)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{configPath || "data/agent/vision.json"}
|
||||
</code>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--text-muted)",
|
||||
cursor: "pointer",
|
||||
fontSize: 20,
|
||||
lineHeight: 1,
|
||||
padding: "2px 6px",
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<VisionConfigContent />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -103,3 +103,21 @@ export interface PluginsResponse {
|
||||
diagnostics: PluginDiagnostic[];
|
||||
projectResourcesLoaded: boolean;
|
||||
}
|
||||
|
||||
export type ExtensionScope = "global" | "project" | "builtin";
|
||||
export type ExtensionStatus = "enabled" | "disabled" | "blocked";
|
||||
|
||||
export interface ExtensionInfo {
|
||||
name: string;
|
||||
path: string;
|
||||
relativePath: string;
|
||||
source: string;
|
||||
scope: ExtensionScope;
|
||||
status: ExtensionStatus;
|
||||
}
|
||||
|
||||
export interface ExtensionsResponse {
|
||||
extensions: ExtensionInfo[];
|
||||
diagnostics: PluginDiagnostic[];
|
||||
projectResourcesLoaded: boolean;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ export const enLocale: LocalePlugin = {
|
||||
"common.mcp": "MCP",
|
||||
"common.skills": "Skills",
|
||||
"common.plugins": "Plugins",
|
||||
"common.extensions": "Extensions",
|
||||
"common.vision": "Vision",
|
||||
"sidebar.hide": "Hide sidebar",
|
||||
"sidebar.show": "Show sidebar",
|
||||
"theme.light": "Switch to light mode",
|
||||
@@ -43,7 +45,7 @@ export const enLocale: LocalePlugin = {
|
||||
"session.output": "Output",
|
||||
"session.cacheRead": "Cache Read",
|
||||
"session.cacheWrite": "Cache Write",
|
||||
"session.cacheHitRate": "Cache Hit Rate",
|
||||
"session.cacheHitRate": "Cache Hit Rate (Main Session)",
|
||||
"session.cost": "Cost",
|
||||
"session.context": "Context",
|
||||
"session.copy": "Copy {value}",
|
||||
@@ -259,6 +261,25 @@ export const enLocale: LocalePlugin = {
|
||||
"mcp.noServers": "No MCP servers configured",
|
||||
"mcp.selectServer": "Select a server to edit",
|
||||
"mcp.restartHint": "Saved changes apply after restarting pi (or /reload).",
|
||||
"vision.title": "Vision config",
|
||||
"vision.loading": "Loading…",
|
||||
"vision.backend.ollama": "Local Ollama",
|
||||
"vision.backend.ollamaHint": "Free and private — needs a local Ollama with a vision model pulled",
|
||||
"vision.backend.openai": "OpenAI-compatible API",
|
||||
"vision.backend.openaiHint": "Any OpenAI-compatible vision endpoint (cloud or self-hosted)",
|
||||
"vision.ollama.host": "Ollama address",
|
||||
"vision.ollama.hostPlaceholder": "e.g. http://localhost:11434",
|
||||
"vision.ollama.model": "Vision model",
|
||||
"vision.ollama.modelPlaceholder": "e.g. qwen3-vl:8b",
|
||||
"vision.openai.baseUrl": "Base URL",
|
||||
"vision.openai.baseUrlPlaceholder": "e.g. https://api.openai.com/v1",
|
||||
"vision.openai.apiKey": "API Key",
|
||||
"vision.openai.model": "Model",
|
||||
"vision.openai.modelPlaceholder": "e.g. gpt-4o-mini / glm-4.5v",
|
||||
"vision.effective": "Saved config takes effect on the next image request — no restart needed. Uploaded images are auto-transcribed into text for text-only main models (e.g. DeepSeek).",
|
||||
"vision.loadFailed": "Failed to load / save config",
|
||||
"vision.save": "Save",
|
||||
"vision.saved": "Saved — takes effect on the next image request",
|
||||
"i18n.close": "Close",
|
||||
"i18n.copy": "Copy",
|
||||
"i18n.copied": "Copied",
|
||||
@@ -389,6 +410,19 @@ export const enLocale: LocalePlugin = {
|
||||
"i18n.configuredVersion": "configured {version}",
|
||||
"i18n.extensions": "Extensions",
|
||||
"i18n.prompts": "Prompts",
|
||||
"extensions.app": "App",
|
||||
"extensions.project": "Project",
|
||||
"extensions.builtin": "Built-in",
|
||||
"extensions.enabled": "enabled",
|
||||
"extensions.disabled": "disabled",
|
||||
"extensions.blocked": "blocked",
|
||||
"extensions.total": "extensions",
|
||||
"extensions.status": "Status",
|
||||
"extensions.source": "Source",
|
||||
"extensions.path": "Path",
|
||||
"extensions.noExtensions": "No direct extensions found",
|
||||
"extensions.projectResourcesBlocked": "Project extensions are blocked until this project is trusted.",
|
||||
"extensions.diagnostics": "diagnostics",
|
||||
"i18n.themes": "Themes",
|
||||
"i18n.resourceCount": "{count} {label}",
|
||||
"i18n.extensionShort": "ext",
|
||||
|
||||
@@ -11,6 +11,8 @@ export const zhCNLocale: LocalePlugin = {
|
||||
"common.mcp": "MCP",
|
||||
"common.skills": "技能",
|
||||
"common.plugins": "插件",
|
||||
"common.extensions": "扩展",
|
||||
"common.vision": "视觉",
|
||||
"sidebar.hide": "隐藏侧边栏",
|
||||
"sidebar.show": "显示侧边栏",
|
||||
"theme.light": "切换到浅色模式",
|
||||
@@ -43,7 +45,7 @@ export const zhCNLocale: LocalePlugin = {
|
||||
"session.output": "输出",
|
||||
"session.cacheRead": "缓存读取",
|
||||
"session.cacheWrite": "缓存写入",
|
||||
"session.cacheHitRate": "缓存命中率",
|
||||
"session.cacheHitRate": "缓存命中率(主会话)",
|
||||
"session.cost": "费用",
|
||||
"session.context": "上下文",
|
||||
"session.copy": "复制{value}",
|
||||
@@ -259,6 +261,25 @@ export const zhCNLocale: LocalePlugin = {
|
||||
"mcp.noServers": "未配置 MCP 服务器",
|
||||
"mcp.selectServer": "选择一个服务器进行编辑",
|
||||
"mcp.restartHint": "保存的更改在重启 pi(或 /reload)后生效。",
|
||||
"vision.title": "视觉配置",
|
||||
"vision.loading": "加载中…",
|
||||
"vision.backend.ollama": "本地 Ollama",
|
||||
"vision.backend.ollamaHint": "免费、私密——本机 Ollama 需已拉取视觉模型",
|
||||
"vision.backend.openai": "OpenAI 兼容 API",
|
||||
"vision.backend.openaiHint": "任意 OpenAI 兼容的视觉端点(云端或自托管)",
|
||||
"vision.ollama.host": "Ollama 地址",
|
||||
"vision.ollama.hostPlaceholder": "如 http://localhost:11434",
|
||||
"vision.ollama.model": "视觉模型",
|
||||
"vision.ollama.modelPlaceholder": "如 qwen3-vl:8b",
|
||||
"vision.openai.baseUrl": "接口地址",
|
||||
"vision.openai.baseUrlPlaceholder": "如 https://api.openai.com/v1",
|
||||
"vision.openai.apiKey": "API 密钥",
|
||||
"vision.openai.model": "模型",
|
||||
"vision.openai.modelPlaceholder": "如 gpt-4o-mini / glm-4.5v",
|
||||
"vision.effective": "保存后下次识图请求立即生效,无需重启。上传图片会自动转录为文本,供纯文本主模型(如 DeepSeek)使用。",
|
||||
"vision.loadFailed": "配置加载/保存失败",
|
||||
"vision.save": "保存",
|
||||
"vision.saved": "已保存——下次识图请求生效",
|
||||
"i18n.close": "关闭",
|
||||
"i18n.copy": "复制",
|
||||
"i18n.copied": "已复制",
|
||||
@@ -389,6 +410,19 @@ export const zhCNLocale: LocalePlugin = {
|
||||
"i18n.configuredVersion": "已配置 {version}",
|
||||
"i18n.extensions": "扩展",
|
||||
"i18n.prompts": "提示词",
|
||||
"extensions.app": "应用",
|
||||
"extensions.project": "项目",
|
||||
"extensions.builtin": "内置",
|
||||
"extensions.enabled": "已启用",
|
||||
"extensions.disabled": "已禁用",
|
||||
"extensions.blocked": "已阻止",
|
||||
"extensions.total": "个扩展",
|
||||
"extensions.status": "状态",
|
||||
"extensions.source": "来源",
|
||||
"extensions.path": "路径",
|
||||
"extensions.noExtensions": "未找到直接扩展",
|
||||
"extensions.projectResourcesBlocked": "项目尚未受信任,项目扩展已被阻止。",
|
||||
"extensions.diagnostics": "条诊断信息",
|
||||
"i18n.themes": "主题",
|
||||
"i18n.resourceCount": "{count}{label}",
|
||||
"i18n.extensionShort": "扩展",
|
||||
|
||||
Reference in New Issue
Block a user