mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 19:45:34 +08:00
420 lines
13 KiB
TypeScript
420 lines
13 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { FilePlus2, Loader2, RefreshCw, Trash2 } from "lucide-react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { toast } from "sonner";
|
|
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import {
|
|
promptsApi,
|
|
type PiPromptFileKind,
|
|
type PiPromptFileSnapshot,
|
|
type PiPromptTemplate,
|
|
} from "@/lib/api/prompts";
|
|
import { isValidPiPromptTemplateSlug } from "@/lib/piPromptSlug";
|
|
import { extractErrorMessage } from "@/utils/errorUtils";
|
|
|
|
const EDITABLE_FILES: Array<{
|
|
kind: Exclude<PiPromptFileKind, "global_context">;
|
|
filename: string;
|
|
titleKey: string;
|
|
descriptionKey: string;
|
|
}> = [
|
|
{
|
|
kind: "system_override",
|
|
filename: "SYSTEM.md",
|
|
titleKey: "pi.prompts.systemOverride",
|
|
descriptionKey: "pi.prompts.systemOverrideDescription",
|
|
},
|
|
{
|
|
kind: "system_append",
|
|
filename: "APPEND_SYSTEM.md",
|
|
titleKey: "pi.prompts.systemAppend",
|
|
descriptionKey: "pi.prompts.systemAppendDescription",
|
|
},
|
|
];
|
|
|
|
function mutationError(error: unknown, fallback: string) {
|
|
toast.error(extractErrorMessage(error) || fallback);
|
|
}
|
|
|
|
function PiInstructionFileEditor({
|
|
kind,
|
|
filename,
|
|
titleKey,
|
|
descriptionKey,
|
|
}: (typeof EDITABLE_FILES)[number]) {
|
|
const { t } = useTranslation();
|
|
const queryClient = useQueryClient();
|
|
const [draft, setDraft] = useState("");
|
|
const [confirmCreate, setConfirmCreate] = useState(false);
|
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
const queryKey = ["pi", "promptFile", kind] as const;
|
|
const query = useQuery({
|
|
queryKey,
|
|
queryFn: () => promptsApi.getPiPromptFile(kind),
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (query.data) setDraft(query.data.content);
|
|
}, [query.data?.revision]);
|
|
|
|
const save = useMutation({
|
|
mutationFn: () => {
|
|
const snapshot = query.data;
|
|
if (!snapshot) throw new Error(t("pi.prompts.loadFirst"));
|
|
return promptsApi.replacePiPromptFile(kind, snapshot.revision, draft);
|
|
},
|
|
onSuccess: (snapshot) => {
|
|
queryClient.setQueryData<PiPromptFileSnapshot>(queryKey, snapshot);
|
|
setConfirmCreate(false);
|
|
toast.success(t("pi.prompts.fileSaved", { filename }));
|
|
},
|
|
onError: (error) => {
|
|
mutationError(error, t("pi.prompts.saveFailed"));
|
|
void query.refetch();
|
|
},
|
|
});
|
|
const remove = useMutation({
|
|
mutationFn: async () => {
|
|
const snapshot = query.data;
|
|
if (!snapshot) throw new Error(t("pi.prompts.loadFirst"));
|
|
await promptsApi.deletePiPromptFile(kind, snapshot.revision);
|
|
return promptsApi.getPiPromptFile(kind);
|
|
},
|
|
onSuccess: (snapshot) => {
|
|
queryClient.setQueryData<PiPromptFileSnapshot>(queryKey, snapshot);
|
|
setDraft("");
|
|
setConfirmDelete(false);
|
|
toast.success(t("pi.prompts.fileDeactivated", { filename }));
|
|
},
|
|
onError: (error) => {
|
|
mutationError(error, t("pi.prompts.deleteFailed"));
|
|
void query.refetch();
|
|
},
|
|
});
|
|
|
|
const busy = save.isPending || remove.isPending;
|
|
const changed = Boolean(query.data && draft !== query.data.content);
|
|
const blank = !draft.trim();
|
|
const requestSave = () => {
|
|
if (kind === "system_override" && query.data && !query.data.exists) {
|
|
setConfirmCreate(true);
|
|
return;
|
|
}
|
|
save.mutate();
|
|
};
|
|
|
|
return (
|
|
<div className="rounded-lg border border-border bg-background/60 p-4">
|
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<h4 className="text-sm font-medium">{t(titleKey)}</h4>
|
|
<Badge variant={query.data?.exists ? "default" : "outline"}>
|
|
{query.data?.exists
|
|
? t("pi.prompts.active")
|
|
: t("pi.prompts.inactive")}
|
|
</Badge>
|
|
</div>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
{t(descriptionKey)}
|
|
</p>
|
|
{query.data?.path && (
|
|
<code className="mt-1 block break-all text-[10px] text-muted-foreground">
|
|
{query.data.path}
|
|
</code>
|
|
)}
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => void query.refetch()}
|
|
disabled={query.isFetching || busy}
|
|
title={t("common.refresh")}
|
|
>
|
|
<RefreshCw
|
|
className={`h-4 w-4 ${query.isFetching ? "animate-spin" : ""}`}
|
|
/>
|
|
</Button>
|
|
</div>
|
|
|
|
{query.isLoading ? (
|
|
<div className="flex items-center gap-2 py-6 text-xs text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
{t("common.loading")}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<Textarea
|
|
value={draft}
|
|
onChange={(event) => setDraft(event.target.value)}
|
|
className="mt-3 min-h-28 font-mono text-xs"
|
|
placeholder={t("pi.prompts.instructionPlaceholder")}
|
|
spellCheck={false}
|
|
/>
|
|
{blank && (
|
|
<p className="mt-1 text-xs text-destructive">
|
|
{t("pi.prompts.blankInstruction")}
|
|
</p>
|
|
)}
|
|
<div className="mt-3 flex justify-end gap-2">
|
|
{query.data?.exists && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => setConfirmDelete(true)}
|
|
disabled={busy}
|
|
>
|
|
{t("pi.prompts.deactivate")}
|
|
</Button>
|
|
)}
|
|
<Button
|
|
type="button"
|
|
onClick={requestSave}
|
|
disabled={!query.data || !changed || blank || busy}
|
|
>
|
|
{save.isPending && (
|
|
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
|
)}
|
|
{t("common.save")}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<ConfirmDialog
|
|
isOpen={confirmCreate}
|
|
title={t("pi.prompts.activateOverrideTitle", { filename })}
|
|
message={t("pi.prompts.activateOverrideMessage", { filename })}
|
|
confirmText={t("pi.prompts.activateOverride")}
|
|
onConfirm={() => save.mutate()}
|
|
onCancel={() => setConfirmCreate(false)}
|
|
/>
|
|
<ConfirmDialog
|
|
isOpen={confirmDelete}
|
|
title={t("pi.prompts.deactivateTitle", { filename })}
|
|
message={t("pi.prompts.deactivateMessage", { filename })}
|
|
confirmText={t("pi.prompts.deactivate")}
|
|
onConfirm={() => remove.mutate()}
|
|
onCancel={() => setConfirmDelete(false)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PiTemplateEditor({
|
|
template,
|
|
onChanged,
|
|
}: {
|
|
template: PiPromptTemplate;
|
|
onChanged: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [draft, setDraft] = useState(template.content);
|
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
|
|
useEffect(
|
|
() => setDraft(template.content),
|
|
[template.content, template.revision],
|
|
);
|
|
|
|
const save = useMutation({
|
|
mutationFn: () =>
|
|
promptsApi.upsertPiPromptTemplate(
|
|
template.slug,
|
|
template.revision,
|
|
draft,
|
|
),
|
|
onSuccess: () => {
|
|
toast.success(t("pi.prompts.templateSaved", { slug: template.slug }));
|
|
onChanged();
|
|
},
|
|
onError: (error) =>
|
|
mutationError(error, t("pi.prompts.templateSaveFailed")),
|
|
});
|
|
const remove = useMutation({
|
|
mutationFn: () =>
|
|
promptsApi.deletePiPromptTemplate(template.slug, template.revision),
|
|
onSuccess: () => {
|
|
setConfirmDelete(false);
|
|
toast.success(t("pi.prompts.templateDeleted", { slug: template.slug }));
|
|
onChanged();
|
|
},
|
|
onError: (error) =>
|
|
mutationError(error, t("pi.prompts.templateDeleteFailed")),
|
|
});
|
|
|
|
const busy = save.isPending || remove.isPending;
|
|
return (
|
|
<div className="rounded-lg border border-border bg-background/60 p-3">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<code className="text-xs font-medium">/{template.slug}</code>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => setConfirmDelete(true)}
|
|
disabled={busy}
|
|
title={t("common.delete")}
|
|
>
|
|
<Trash2 className="h-4 w-4 text-destructive" />
|
|
</Button>
|
|
</div>
|
|
<Textarea
|
|
value={draft}
|
|
onChange={(event) => setDraft(event.target.value)}
|
|
className="mt-2 min-h-24 font-mono text-xs"
|
|
spellCheck={false}
|
|
/>
|
|
<div className="mt-2 flex justify-end">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
onClick={() => save.mutate()}
|
|
disabled={draft === template.content || busy}
|
|
>
|
|
{save.isPending && (
|
|
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
|
)}
|
|
{t("common.save")}
|
|
</Button>
|
|
</div>
|
|
<ConfirmDialog
|
|
isOpen={confirmDelete}
|
|
title={t("pi.prompts.deleteTemplateTitle", {
|
|
slug: template.slug,
|
|
})}
|
|
message={t("pi.prompts.deleteTemplateMessage", {
|
|
slug: template.slug,
|
|
})}
|
|
confirmText={t("common.delete")}
|
|
onConfirm={() => remove.mutate()}
|
|
onCancel={() => setConfirmDelete(false)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function PiNativePromptResources() {
|
|
const { t } = useTranslation();
|
|
const queryClient = useQueryClient();
|
|
const [slug, setSlug] = useState("");
|
|
const [content, setContent] = useState("");
|
|
const slugIsValid = isValidPiPromptTemplateSlug(slug);
|
|
const templates = useQuery({
|
|
queryKey: ["pi", "promptTemplates"],
|
|
queryFn: () => promptsApi.listPiPromptTemplates(),
|
|
});
|
|
const createTemplate = useMutation({
|
|
mutationFn: () =>
|
|
promptsApi.upsertPiPromptTemplate(slug, "missing", content),
|
|
onSuccess: async () => {
|
|
setSlug("");
|
|
setContent("");
|
|
await queryClient.invalidateQueries({
|
|
queryKey: ["pi", "promptTemplates"],
|
|
});
|
|
toast.success(t("pi.prompts.templateCreated"));
|
|
},
|
|
onError: (error) =>
|
|
mutationError(error, t("pi.prompts.templateSaveFailed")),
|
|
});
|
|
const refreshTemplates = () =>
|
|
queryClient.invalidateQueries({ queryKey: ["pi", "promptTemplates"] });
|
|
|
|
return (
|
|
<section className="mb-5 space-y-4 rounded-xl border border-border bg-muted/20 p-4">
|
|
<div>
|
|
<h3 className="text-sm font-semibold">{t("pi.prompts.nativeTitle")}</h3>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
{t("pi.prompts.nativeDescription")}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid gap-3 lg:grid-cols-2">
|
|
{EDITABLE_FILES.map((file) => (
|
|
<PiInstructionFileEditor key={file.kind} {...file} />
|
|
))}
|
|
</div>
|
|
|
|
<div className="rounded-lg border border-border bg-background/40 p-4">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div>
|
|
<h4 className="text-sm font-medium">{t("pi.prompts.templates")}</h4>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
{t("pi.prompts.templatesDescription")}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => void templates.refetch()}
|
|
disabled={templates.isFetching}
|
|
title={t("common.refresh")}
|
|
>
|
|
<RefreshCw
|
|
className={`h-4 w-4 ${
|
|
templates.isFetching ? "animate-spin" : ""
|
|
}`}
|
|
/>
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="mt-3 grid gap-3 lg:grid-cols-2">
|
|
{(templates.data ?? []).map((template) => (
|
|
<PiTemplateEditor
|
|
key={template.slug}
|
|
template={template}
|
|
onChanged={() => void refreshTemplates()}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
<div className="mt-4 rounded-lg border border-dashed p-3">
|
|
<div className="mb-2 flex items-center gap-2 text-xs font-medium">
|
|
<FilePlus2 className="h-4 w-4" />
|
|
{t("pi.prompts.newTemplate")}
|
|
</div>
|
|
<Input
|
|
value={slug}
|
|
onChange={(event) => setSlug(event.target.value)}
|
|
placeholder={t("pi.prompts.templateSlug")}
|
|
aria-invalid={slug.length > 0 && !slugIsValid}
|
|
/>
|
|
{slug.length > 0 && !slugIsValid && (
|
|
<p className="mt-1 text-xs text-destructive">
|
|
{t("pi.prompts.templateSlugInvalid")}
|
|
</p>
|
|
)}
|
|
<Textarea
|
|
value={content}
|
|
onChange={(event) => setContent(event.target.value)}
|
|
className="mt-2 min-h-24 font-mono text-xs"
|
|
placeholder={t("pi.prompts.templateContent")}
|
|
spellCheck={false}
|
|
/>
|
|
<div className="mt-2 flex justify-end">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
onClick={() => createTemplate.mutate()}
|
|
disabled={!slugIsValid || createTemplate.isPending}
|
|
>
|
|
{createTemplate.isPending && (
|
|
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
|
)}
|
|
{t("pi.prompts.createTemplate")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|