@@ -436,6 +462,8 @@ interface ToolbarProps {
onRefineSelection: () => void;
// 续写:读本章前文续写候选。
onContinue: () => void;
+ // 工具箱:编辑器内联调用生成器,结果回填正文。
+ onToolbox: () => void;
}
function Toolbar({
@@ -452,6 +480,7 @@ function Toolbar({
canRefine,
onRefineSelection,
onContinue,
+ onToolbox,
}: ToolbarProps) {
return (
@@ -485,6 +514,10 @@ function Toolbar({
润色选段
+
>
) : null}
{streaming ? (
diff --git a/apps/web/lib/toolbox/useToolboxTools.test.ts b/apps/web/lib/toolbox/useToolboxTools.test.ts
new file mode 100644
index 0000000..ead5c24
--- /dev/null
+++ b/apps/web/lib/toolbox/useToolboxTools.test.ts
@@ -0,0 +1,52 @@
+// @vitest-environment jsdom
+import { renderHook, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { useToolboxTools } from "./useToolboxTools";
+
+const get = vi.fn();
+const toast = vi.fn();
+vi.mock("@/lib/api/client", () => ({
+ api: { GET: (...a: unknown[]) => get(...a) },
+}));
+vi.mock("@/components/Toast", () => ({ useToast: () => toast }));
+
+describe("useToolboxTools", () => {
+ beforeEach(() => {
+ get.mockReset();
+ toast.mockReset();
+ });
+ afterEach(() => vi.clearAllMocks());
+
+ it("加载成功:拉全量描述符,status=ready", async () => {
+ const tools = [{ key: "golden-finger", title: "金手指生成器", is_legacy: false }];
+ get.mockResolvedValue({ data: { tools }, error: null });
+
+ const { result } = renderHook(() => useToolboxTools());
+ await waitFor(() => expect(result.current.status).toBe("ready"));
+ expect(result.current.tools).toEqual(tools);
+ expect(get).toHaveBeenCalledWith("/skills/toolbox", {});
+ expect(toast).not.toHaveBeenCalled();
+ });
+
+ it("data.tools 缺省时回退空数组", async () => {
+ get.mockResolvedValue({ data: { tools: null }, error: null });
+ const { result } = renderHook(() => useToolboxTools());
+ await waitFor(() => expect(result.current.status).toBe("ready"));
+ expect(result.current.tools).toEqual([]);
+ });
+
+ it("后端 error:status=error、弹错误 toast", async () => {
+ get.mockResolvedValue({ data: null, error: { detail: "boom" } });
+ const { result } = renderHook(() => useToolboxTools());
+ await waitFor(() => expect(result.current.status).toBe("error"));
+ expect(toast).toHaveBeenCalledWith(expect.any(String), "error");
+ });
+
+ it("请求抛异常:status=error、弹网络异常 toast", async () => {
+ get.mockRejectedValue(new Error("network down"));
+ const { result } = renderHook(() => useToolboxTools());
+ await waitFor(() => expect(result.current.status).toBe("error"));
+ expect(toast).toHaveBeenCalledWith("工具箱加载异常,请检查网络。", "error");
+ });
+});
diff --git a/apps/web/lib/toolbox/useToolboxTools.ts b/apps/web/lib/toolbox/useToolboxTools.ts
new file mode 100644
index 0000000..b5209ef
--- /dev/null
+++ b/apps/web/lib/toolbox/useToolboxTools.ts
@@ -0,0 +1,48 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+import { api } from "@/lib/api/client";
+import { useToast } from "@/components/Toast";
+import type { ToolDescriptorView } from "@/lib/api/types";
+
+// 客户端拉取创作工具箱全量描述符(GET /skills/toolbox),供编辑器内联工具箱调用。
+// 描述符只读、少变,进面板时拉一次即可。
+export type ToolboxStatus = "loading" | "ready" | "error";
+
+export interface UseToolboxTools {
+ status: ToolboxStatus;
+ tools: ToolDescriptorView[];
+}
+
+export function useToolboxTools(): UseToolboxTools {
+ const [status, setStatus] = useState("loading");
+ const [tools, setTools] = useState([]);
+ const toast = useToast();
+
+ useEffect(() => {
+ let cancelled = false;
+ void (async (): Promise => {
+ try {
+ const { data, error } = await api.GET("/skills/toolbox", {});
+ if (cancelled) return;
+ if (error || !data) {
+ setStatus("error");
+ toast("工具箱加载失败,请重试。", "error");
+ return;
+ }
+ setTools(data.tools ?? []);
+ setStatus("ready");
+ } catch {
+ if (cancelled) return;
+ setStatus("error");
+ toast("工具箱加载异常,请检查网络。", "error");
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [toast]);
+
+ return { status, tools };
+}