feat(frontend): 编辑器内联工具箱——就地调生成器(含金手指),结果一键插入正文
Phase 1(写作工作台重构):编辑器「工具箱」按钮打开内联面板,不离开写作界面即可调用 全部生成器(含金手指——后端 golden-finger 早已齐全,此处首次露出)。复用 GeneratorRunner, 为其加可选 onInsertText:文本类产物(开篇/简介/续写/扩写等)点「插入正文」直接追加到章末, 可入库类仍走既有 ingest。useToolboxTools 客户端拉描述符(已单测)。润色/续写/工具箱三卡互斥。
This commit is contained in:
@@ -30,6 +30,8 @@ interface GeneratorRunnerProps {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
tool: ToolDescriptorView;
|
tool: ToolDescriptorView;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
// 可选:把某条预览文本插入正文(编辑器内联工具箱场景)。工具箱页不传 → 不显示「插入正文」。
|
||||||
|
onInsertText?: (text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 声明驱动的通用生成器(核心):按 descriptor.input_fields 渲染表单 → 调通用 generate →
|
// 声明驱动的通用生成器(核心):按 descriptor.input_fields 渲染表单 → 调通用 generate →
|
||||||
@@ -39,6 +41,7 @@ export function GeneratorRunner({
|
|||||||
projectId,
|
projectId,
|
||||||
tool,
|
tool,
|
||||||
onClose,
|
onClose,
|
||||||
|
onInsertText,
|
||||||
}: GeneratorRunnerProps) {
|
}: GeneratorRunnerProps) {
|
||||||
const gen = useGenerator();
|
const gen = useGenerator();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -202,6 +205,11 @@ export function GeneratorRunner({
|
|||||||
selectable={canIngest && !singleObject}
|
selectable={canIngest && !singleObject}
|
||||||
checked={selected.has(i)}
|
checked={selected.has(i)}
|
||||||
onToggle={toggle}
|
onToggle={toggle}
|
||||||
|
onInsert={
|
||||||
|
onInsertText
|
||||||
|
? () => onInsertText(item.body ?? item.heading ?? "")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -276,6 +284,8 @@ interface PreviewRowProps {
|
|||||||
selectable: boolean;
|
selectable: boolean;
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
onToggle: (index: number) => void;
|
onToggle: (index: number) => void;
|
||||||
|
// 可选:把本条插入正文(编辑器内联工具箱)。有可插入文本时才渲染按钮。
|
||||||
|
onInsert?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 统一预览行渲染(heading + 次要字段 + 正文段),由 mapPreview 归一,跨工具复用。
|
// 统一预览行渲染(heading + 次要字段 + 正文段),由 mapPreview 归一,跨工具复用。
|
||||||
@@ -285,7 +295,9 @@ function PreviewRow({
|
|||||||
selectable,
|
selectable,
|
||||||
checked,
|
checked,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
onInsert,
|
||||||
}: PreviewRowProps) {
|
}: PreviewRowProps) {
|
||||||
|
const canInsert = onInsert && (item.body || item.heading);
|
||||||
return (
|
return (
|
||||||
<li className="flex gap-2 rounded border border-line bg-bg p-3 text-sm">
|
<li className="flex gap-2 rounded border border-line bg-bg p-3 text-sm">
|
||||||
{selectable ? (
|
{selectable ? (
|
||||||
@@ -310,6 +322,17 @@ function PreviewRow({
|
|||||||
{item.body ? (
|
{item.body ? (
|
||||||
<p className="whitespace-pre-wrap text-ink">{item.body}</p>
|
<p className="whitespace-pre-wrap text-ink">{item.body}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{canInsert ? (
|
||||||
|
<Button
|
||||||
|
onClick={onInsert}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mt-2"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4 rotate-90" aria-hidden="true" />
|
||||||
|
插入正文
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
85
apps/web/components/workbench/InlineToolbox.tsx
Normal file
85
apps/web/components/workbench/InlineToolbox.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
|
import { ThinkingIndicator } from "@/components/ThinkingIndicator";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { SectionHeader } from "@/components/ui/SectionHeader";
|
||||||
|
import { StatusNote } from "@/components/ui/StatusNote";
|
||||||
|
import { GeneratorRunner } from "@/components/toolbox/GeneratorRunner";
|
||||||
|
import type { ToolDescriptorView } from "@/lib/api/types";
|
||||||
|
import { isLegacyTool } from "@/lib/toolbox/toolbox";
|
||||||
|
import { useToolboxTools } from "@/lib/toolbox/useToolboxTools";
|
||||||
|
|
||||||
|
interface InlineToolboxProps {
|
||||||
|
projectId: string;
|
||||||
|
// 把生成结果插入正文(追加到章末)。
|
||||||
|
onInsertText: (text: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑器内联工具箱(不离开写作界面):列出生成器(含金手指),就地生成结构化预览,
|
||||||
|
// 文本类产物点「插入正文」直接填入写作处,可入库类走既有 ingest。legacy 生成器(世界观/
|
||||||
|
// 人设/大纲)仍走各自更丰富的独立页,不塞进内联面板。
|
||||||
|
export function InlineToolbox({
|
||||||
|
projectId,
|
||||||
|
onInsertText,
|
||||||
|
onClose,
|
||||||
|
}: InlineToolboxProps) {
|
||||||
|
const { status, tools } = useToolboxTools();
|
||||||
|
const [active, setActive] = useState<ToolDescriptorView | null>(null);
|
||||||
|
const openable = tools.filter((tool) => !isLegacyTool(tool));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-h-[70vh] overflow-auto border-b border-line bg-panel px-4 py-3 sm:px-6">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<SectionHeader
|
||||||
|
title="工具箱"
|
||||||
|
description="就地生成,点「插入正文」直接填入写作处;可入库内容经一致性预检。"
|
||||||
|
/>
|
||||||
|
<Button onClick={onClose} variant="ghost" size="icon" aria-label="关闭工具箱">
|
||||||
|
<X className="h-4 w-4" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{active ? (
|
||||||
|
<div className="mt-3">
|
||||||
|
<GeneratorRunner
|
||||||
|
projectId={projectId}
|
||||||
|
tool={active}
|
||||||
|
onClose={() => setActive(null)}
|
||||||
|
onInsertText={(text) => {
|
||||||
|
if (text.trim().length > 0) onInsertText(text);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : status === "loading" ? (
|
||||||
|
<div className="mt-3">
|
||||||
|
<ThinkingIndicator label="加载工具箱" className="text-xs text-cinnabar" />
|
||||||
|
</div>
|
||||||
|
) : status === "error" ? (
|
||||||
|
<StatusNote variant="danger" className="mt-3 text-xs" title="工具箱加载失败">
|
||||||
|
<p>请检查后端连接后重开。</p>
|
||||||
|
</StatusNote>
|
||||||
|
) : openable.length === 0 ? (
|
||||||
|
<p className="mt-3 text-xs text-ink-soft">暂无可内联调用的生成器。</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
|
{openable.map((tool) => (
|
||||||
|
<li key={tool.key}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActive(tool)}
|
||||||
|
className="w-full rounded border border-line bg-bg p-3 text-left transition-colors hover:border-cinnabar"
|
||||||
|
>
|
||||||
|
<p className="text-sm text-ink">{tool.title}</p>
|
||||||
|
<p className="mt-0.5 text-xs text-ink-soft">{tool.subtitle}</p>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
PanelLeft,
|
PanelLeft,
|
||||||
PanelRight,
|
PanelRight,
|
||||||
PenLine,
|
PenLine,
|
||||||
|
Sparkles,
|
||||||
Square,
|
Square,
|
||||||
Wand2,
|
Wand2,
|
||||||
WrapText,
|
WrapText,
|
||||||
@@ -40,6 +41,7 @@ import { ChapterAssistant, AssistantContent } from "./ChapterAssistant";
|
|||||||
import { Editor, type EditorSelection } from "./Editor";
|
import { Editor, type EditorSelection } from "./Editor";
|
||||||
import { RefinePanel } from "./RefinePanel";
|
import { RefinePanel } from "./RefinePanel";
|
||||||
import { ContinuePanel } from "./ContinuePanel";
|
import { ContinuePanel } from "./ContinuePanel";
|
||||||
|
import { InlineToolbox } from "./InlineToolbox";
|
||||||
|
|
||||||
interface WorkbenchProps {
|
interface WorkbenchProps {
|
||||||
project: ProjectResponse;
|
project: ProjectResponse;
|
||||||
@@ -74,6 +76,7 @@ export function Workbench({
|
|||||||
const [selection, setSelection] = useState<EditorSelection | null>(null);
|
const [selection, setSelection] = useState<EditorSelection | null>(null);
|
||||||
const [refineOpen, setRefineOpen] = useState(false);
|
const [refineOpen, setRefineOpen] = useState(false);
|
||||||
const [continueOpen, setContinueOpen] = useState(false);
|
const [continueOpen, setContinueOpen] = useState(false);
|
||||||
|
const [toolboxOpen, setToolboxOpen] = useState(false);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const autosave = useAutosave(project.id, chapterNo, initialText);
|
const autosave = useAutosave(project.id, chapterNo, initialText);
|
||||||
const stream = useDraftStream();
|
const stream = useDraftStream();
|
||||||
@@ -134,9 +137,10 @@ export function Workbench({
|
|||||||
setSelection(null);
|
setSelection(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 打开润色/续写互斥,避免两张结果卡同时占位。
|
// 三张 AI 结果卡(润色/续写/工具箱)互斥,避免同时占位。
|
||||||
const openRefine = (): void => {
|
const openRefine = (): void => {
|
||||||
setContinueOpen(false);
|
setContinueOpen(false);
|
||||||
|
setToolboxOpen(false);
|
||||||
setRefineOpen(true);
|
setRefineOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -144,15 +148,26 @@ export function Workbench({
|
|||||||
const openContinue = (): void => {
|
const openContinue = (): void => {
|
||||||
autosave.flush();
|
autosave.flush();
|
||||||
setRefineOpen(false);
|
setRefineOpen(false);
|
||||||
|
setToolboxOpen(false);
|
||||||
setContinueOpen(true);
|
setContinueOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 插入续写候选:追加到章末(续写语义),落回草稿。
|
const openToolbox = (): void => {
|
||||||
const onInsertContinuation = (candidate: string): void => {
|
setRefineOpen(false);
|
||||||
|
setContinueOpen(false);
|
||||||
|
setToolboxOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 追加一段文本到章末并落草稿(续写候选 / 工具箱产物共用)。
|
||||||
|
const appendToDraft = (chunk: string): void => {
|
||||||
const base = text.trimEnd();
|
const base = text.trimEnd();
|
||||||
const next = base.length > 0 ? `${base}\n\n${candidate}` : candidate;
|
const next = base.length > 0 ? `${base}\n\n${chunk}` : chunk;
|
||||||
setText(next);
|
setText(next);
|
||||||
autosave.onChange(next);
|
autosave.onChange(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onInsertContinuation = (candidate: string): void => {
|
||||||
|
appendToDraft(candidate);
|
||||||
setContinueOpen(false);
|
setContinueOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -222,6 +237,16 @@ export function Workbench({
|
|||||||
onClose={() => setContinueOpen(false)}
|
onClose={() => setContinueOpen(false)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{toolboxOpen ? (
|
||||||
|
<InlineToolbox
|
||||||
|
projectId={project.id}
|
||||||
|
onInsertText={(chunk) => {
|
||||||
|
appendToDraft(chunk);
|
||||||
|
setToolboxOpen(false);
|
||||||
|
}}
|
||||||
|
onClose={() => setToolboxOpen(false)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<div className="flex-1 overflow-auto px-6 py-8">
|
<div className="flex-1 overflow-auto px-6 py-8">
|
||||||
<Editor
|
<Editor
|
||||||
value={text}
|
value={text}
|
||||||
@@ -244,6 +269,7 @@ export function Workbench({
|
|||||||
canRefine={canRefine}
|
canRefine={canRefine}
|
||||||
onRefineSelection={openRefine}
|
onRefineSelection={openRefine}
|
||||||
onContinue={openContinue}
|
onContinue={openContinue}
|
||||||
|
onToolbox={openToolbox}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -436,6 +462,8 @@ interface ToolbarProps {
|
|||||||
onRefineSelection: () => void;
|
onRefineSelection: () => void;
|
||||||
// 续写:读本章前文续写候选。
|
// 续写:读本章前文续写候选。
|
||||||
onContinue: () => void;
|
onContinue: () => void;
|
||||||
|
// 工具箱:编辑器内联调用生成器,结果回填正文。
|
||||||
|
onToolbox: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Toolbar({
|
function Toolbar({
|
||||||
@@ -452,6 +480,7 @@ function Toolbar({
|
|||||||
canRefine,
|
canRefine,
|
||||||
onRefineSelection,
|
onRefineSelection,
|
||||||
onContinue,
|
onContinue,
|
||||||
|
onToolbox,
|
||||||
}: ToolbarProps) {
|
}: ToolbarProps) {
|
||||||
return (
|
return (
|
||||||
<div className="sticky bottom-0 z-10 border-t border-line bg-panel/95 px-4 py-2 backdrop-blur sm:px-6 sm:py-3">
|
<div className="sticky bottom-0 z-10 border-t border-line bg-panel/95 px-4 py-2 backdrop-blur sm:px-6 sm:py-3">
|
||||||
@@ -485,6 +514,10 @@ function Toolbar({
|
|||||||
<Wand2 className="h-4 w-4" aria-hidden="true" />
|
<Wand2 className="h-4 w-4" aria-hidden="true" />
|
||||||
润色选段
|
润色选段
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button onClick={onToolbox} variant="secondary" size="sm">
|
||||||
|
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||||
|
工具箱
|
||||||
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{streaming ? (
|
{streaming ? (
|
||||||
|
|||||||
52
apps/web/lib/toolbox/useToolboxTools.test.ts
Normal file
52
apps/web/lib/toolbox/useToolboxTools.test.ts
Normal file
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
48
apps/web/lib/toolbox/useToolboxTools.ts
Normal file
48
apps/web/lib/toolbox/useToolboxTools.ts
Normal file
@@ -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<ToolboxStatus>("loading");
|
||||||
|
const [tools, setTools] = useState<ToolDescriptorView[]>([]);
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
void (async (): Promise<void> => {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user