源码
packages/desktop/src/renderer/ResourceSettings.tsx
import * as Dialog from "@radix-ui/react-dialog";
import {
ChevronDown,
FilePenLine,
FilePlus2,
FileSearch,
RefreshCcw,
SquareTerminal,
WandSparkles,
Wrench,
X,
} from "lucide-react";
import { useEffect, useState } from "react";
import type { PromptPreview, SkillSettings, ToolInfo } from "../shared/bridge.ts";
import { SettingsHelp } from "./SettingsHelp.tsx";
import { refresh, useStore } from "./store.ts";
import { unwrap } from "./unwrap.ts";
import { userErrorMessage } from "./user-error.ts";
export function ResourceSettings({ page }: { page: "prompt" | "tools" | "skills" }) {
const settings = useStore((s) => s.settings);
const [preview, setPreview] = useState<PromptPreview>();
const [tools, setTools] = useState<ToolInfo[]>([]),
[toolName, setToolName] = useState("");
const [skills, setSkills] = useState<SkillSettings>(),
[skillPath, setSkillPath] = useState(""),
[body, setBody] = useState("");
const [error, setError] = useState(""),
[notice, setNotice] = useState(""),
[saving, setSaving] = useState(false),
[revision, setRevision] = useState(0);
useEffect(() => {
let active = true;
setError("");
if (page === "prompt") {
void window.zpi
.previewPrompt()
.then(unwrap)
.then((value) => {
if (active) setPreview(value);
})
.catch((error) => {
if (active) {
setPreview(undefined);
setError(userErrorMessage(error));
}
});
}
if (page === "tools")
void window.zpi
.listTools()
.then(unwrap)
.then((v) => {
if (active) setTools(v);
})
.catch((e) => {
if (active) setError(userErrorMessage(e));
});
if (page === "skills")
void window.zpi
.getSkillSettings()
.then(unwrap)
.then((v) => {
if (active) setSkills(v);
})
.catch((e) => {
if (active) setError(userErrorMessage(e));
});
return () => {
active = false;
};
}, [page, revision, settings]);
useEffect(() => {
let active = true;
setBody("");
if (skillPath)
void window.zpi
.readSkill(skillPath)
.then(unwrap)
.then((v) => {
if (active) setBody(v);
})
.catch((e) => {
if (active) setError(userErrorMessage(e));
});
return () => {
active = false;
};
}, [skillPath, revision]);
const action = async (work: () => Promise<void>) => {
setError("");
setNotice("");
setSaving(true);
try {
await work();
setRevision((r) => r + 1);
} catch (e) {
setError(userErrorMessage(e));
} finally {
setSaving(false);
}
};
const adopt = (value: NonNullable<typeof settings>) => {
useStore.setState({ settings: value });
};
return (
<div className="resource-settings">
{error && (
<div className="run-error" role="alert">
{error}
</div>
)}
{notice && <p role="status">{notice}</p>}
{page === "prompt" && (
<>
<h2 className="preview-heading" aria-label="基础系统提示词 只读">
<span className="settings-label">
基础系统提示词{" "}
<SettingsHelp
label="基础系统提示词"
text={`默认预览工作目录:${preview?.cwd ?? "正在读取…"}。新任务使用其绑定的工作目录和项目指令;此处只读。`}
/>
</span>{" "}
<small>只读</small>
</h2>
<pre className="prompt-preview">{preview?.basePrompt ?? "正在生成预览…"}</pre>
<h2 className="preview-heading" aria-label="追加指令 只读">
<span className="settings-label">
追加指令{" "}
<SettingsHelp
label="追加指令"
text="按启用的插件追加规则,可在插件页调整。新对话首次发送时生效,已有对话保留原配置;此处只读。"
/>
</span>{" "}
<small>只读</small>
</h2>
<pre className="custom-instructions-preview">
{preview ? preview.customInstructions || "未启用追加指令。" : "正在生成预览…"}
</pre>
</>
)}
{page === "tools" && (
<>
<div className="resource-group-heading">
<h3>
<span className="settings-label">
内置工具{" "}
<SettingsHelp
label="内置工具"
text="查看工具能力、参数和启用状态。点击工具展开只读定义;列表按当前全局配置生成,已有对话保留其配置。"
/>
</span>{" "}
<span>{tools.length}</span>
</h3>
</div>
<div className="tool-resource-list">
{tools.map((tool) => {
const Icon =
{ read: FileSearch, bash: SquareTerminal, edit: FilePenLine, write: FilePlus2 }[tool.name] ??
Wrench;
const expanded = toolName === tool.name;
return (
<div className="tool-resource-item" key={tool.name}>
<button
className="tool-resource-row"
type="button"
aria-label={`${tool.name} ${tool.enabled ? "已启用" : "未启用"}`}
aria-expanded={expanded}
aria-controls={`tool-detail-${tool.name}`}
onClick={() => setToolName(expanded ? "" : tool.name)}
>
<span className="resource-avatar" aria-hidden="true">
<Icon size={16} />
</span>
<span className="tool-resource-summary">
<span>{tool.name}</span>
<small>{tool.promptSnippet ?? tool.description}</small>
</span>
<span className="tool-resource-status">{tool.enabled ? "已启用" : "未启用"}</span>
<ChevronDown size={16} className={expanded ? "rotated" : ""} aria-hidden="true" />
</button>
{expanded && (
<section
className="tool-resource-detail"
id={`tool-detail-${tool.name}`}
aria-label={`${tool.name} 工具定义`}
>
<p>{tool.description}</p>
<h4>
参数 Schema <small>只读</small>
</h4>
<pre>{JSON.stringify(tool.parameters, null, 2)}</pre>
{tool.promptSnippet && (
<>
<h4>工具简介</h4>
<pre>{tool.promptSnippet}</pre>
</>
)}
{!!tool.promptGuidelines?.length && (
<>
<h4>使用规则</h4>
<pre>{tool.promptGuidelines.join("\n")}</pre>
</>
)}
</section>
)}
</div>
);
})}
</div>
</>
)}
{page === "skills" && (
<>
<div className="resource-group-heading">
<h3>
<span className="settings-label">
已安装{" "}
<SettingsHelp
label="技能"
text={`技能开关在新任务首次发送时生效,已有任务保留原配置。\n全局技能目录:${skills?.directories.join("、") || "正在读取…"}`}
/>
</span>{" "}
<span>{skills?.skills.length ?? 0}</span>
</h3>
<button
className="resource-icon-button"
type="button"
aria-label="刷新技能"
title="刷新技能"
onClick={() => setRevision((r) => r + 1)}
>
<RefreshCcw size={16} />
</button>
</div>
{skills?.diagnostics.map((d) => (
<p className="run-error" key={d.path}>
{d.path}: {userErrorMessage(d.message)}
</p>
))}
<div className="skill-resource-list">
{skills?.skills.map((s) => (
<div className="skill-resource-row" key={s.path}>
<div className="resource-avatar" aria-hidden="true">
<WandSparkles size={16} />
</div>
<button
className="skill-resource-name"
aria-label={`${s.name} ${s.source} ${s.description}`}
type="button"
title={s.path}
onClick={() => setSkillPath(s.path)}
>
<span>{s.name}</span>
<small>{s.description || "暂无描述"}</small>
{s.overriddenBy && <small>已被覆盖:{s.overriddenBy}</small>}
</button>
<label className="settings-switch">
<input
type="checkbox"
aria-label={`启用技能 ${s.name} ${s.source}`}
checked={s.enabled}
disabled={saving}
onChange={(e) => {
const enabled = e.target.checked;
void action(async () => {
adopt(unwrap(await window.zpi.setSkillEnabled(s.path, enabled)));
await refresh();
});
}}
/>
<span aria-hidden="true" />
</label>
</div>
))}
{skills?.skills.length === 0 && <p className="resource-empty">暂无技能</p>}
</div>
<Dialog.Root
open={!!skillPath}
onOpenChange={(open) => {
if (!open) setSkillPath("");
}}
>
<Dialog.Portal>
<Dialog.Overlay className="modal-backdrop skill-detail-backdrop" />
<Dialog.Content
className="skill-detail-dialog"
aria-describedby={undefined}
onEscapeKeyDown={(event) => event.stopPropagation()}
>
<header>
<Dialog.Title>
{skills?.skills.find((s) => s.path === skillPath)?.name ?? "技能详情"}
</Dialog.Title>
<Dialog.Close aria-label="关闭技能详情">
<X size={16} />
</Dialog.Close>
</header>
<div className="skill-detail-body">
<p>{skills?.skills.find((s) => s.path === skillPath)?.description}</p>
<dl>
<div>
<dt>来源</dt>
<dd>{skills?.skills.find((s) => s.path === skillPath)?.source}</dd>
</div>
<div>
<dt>状态</dt>
<dd>
{skills?.skills.find((s) => s.path === skillPath)?.enabled ? "已启用" : "未启用"}
</dd>
</div>
</dl>
<div className="skill-detail-path">
<span>路径</span>
<code>{skillPath}</code>
</div>
<h3>SKILL.md(只读)</h3>
{error && <p role="alert">{error}</p>}
<pre>{body || "正在读取…"}</pre>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
</>
)}
</div>
);
}